diff --git a/.gitignore b/.gitignore index b33e4c2..e9b6542 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,12 @@ src/apps/voice/data/stats.json src/apps/voice/data/config.json src/apps/test/data/scenarios.json -# Environment-specific project settings -.claude/settings.json +# Claude Code project state +.claude/ + +# Articles (drafts, images, exports) +articles/ + +# Local dev scripts +reinstall.sh +reinstall.cmd diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 660adc2..0000000 --- a/.mcp.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "mcpServers": { - "devglide-kanban": { - "type": "stdio", - "command": "node", - "args": ["bin/devglide.js", "mcp", "kanban"] - }, - "devglide-voice": { - "type": "stdio", - "command": "node", - "args": ["bin/devglide.js", "mcp", "voice"] - }, - "devglide-log": { - "type": "stdio", - "command": "node", - "args": ["bin/devglide.js", "mcp", "log"] - }, - "devglide-test": { - "type": "stdio", - "command": "node", - "args": ["bin/devglide.js", "mcp", "test"] - }, - "devglide-shell": { - "type": "stdio", - "command": "node", - "args": ["bin/devglide.js", "mcp", "shell"] - }, - "devglide-workflow": { - "type": "stdio", - "command": "node", - "args": ["bin/devglide.js", "mcp", "workflow"] - }, - "devglide-vocabulary": { - "type": "stdio", - "command": "node", - "args": ["bin/devglide.js", "mcp", "vocabulary"] - }, - "devglide-prompts": { - "type": "stdio", - "command": "node", - "args": ["bin/devglide.js", "mcp", "prompts"] - } - } -} diff --git a/CLAUDE.md b/CLAUDE.md index f949523..6bd3a7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,28 @@ Monorepo managed with **pnpm workspaces** and **Turborepo**. All apps/features render within it. It is the container, not an app itself. - **Shell** — the MCP server for terminal pane management (`shell_create_pane`, `shell_run_command`, etc.). Panes are ephemeral and in-memory. -- **Apps** — individual features (kanban, voice, test, workflow, etc.) that each +- **Apps** — individual features (kanban, voice, test, workflow, chat, etc.) that each expose both REST routes (mounted by the dashboard) and an MCP server (stdio). +- **Chat** — the MCP server for multi-LLM communication (`chat_join`, + `chat_send`, etc.). Participants and message delivery are in-memory; + message history is persisted per-project as JSONL; pipe messages are + additionally dual-written to per-pipe JSONL files (`pipes/{pipeId}.jsonl`) + for efficient scoped reads. `chat_join` requires an explicit `paneId`, + which should be read from `DEVGLIDE_PANE_ID` in the shell session. REST + and MCP joins share session state — a REST join with `mcp-session-id` + header binds to the MCP session, and MCP tools can adopt REST-joined + participants by `paneId`. Pane collisions preserve the existing session + (409 `PANE_ALREADY_BOUND`). Chat REST endpoints accept scoped `projectId` + overrides, and `POST /api/chat/messages` accepts `projectId` in the body + so the dashboard can target a non-active project explicitly. The effective + chat rules of engagement are returned on join and can be overridden per + project. +- **Documentation** — the MCP server for operational guidance on DevGlide + tools (`docs_list`, `docs_match`, `docs_context`, etc.). Provides tool + guides, workflows, examples, troubleshooting entries, and project + overrides. Hybrid-scoped: global docs in `~/.devglide/documentation/`, + project overrides in `projects/{id}/documentation/`. Seed content is + auto-installed on first use. ## MCP Server Pattern @@ -66,12 +86,16 @@ All runtime state lives in `~/.devglide/`. The directory structure: │ ├── logs/ # project log files │ ├── workflows/ # project-scoped workflows │ ├── vocabulary/ # project-scoped vocabulary -│ └── prompts/ # project-scoped prompts +│ ├── prompts/ # project-scoped prompts +│ ├── chat/ # chat message history (messages.jsonl) +│ │ └── pipes/ # per-pipe JSONL files ({pipeId}.jsonl) +│ └── documentation/ # project-scoped documentation overrides ├── voice/ # global voice config, history, stats │ └── config.json ├── workflows/ # global workflows ├── vocabulary/ # global vocabulary ├── prompts/ # global prompts +├── documentation/ # global documentation (tool guides, workflows, etc.) ├── logs/ # server logs └── pids/ # daemon PID files ``` @@ -93,6 +117,7 @@ These rules are intentional — do not change an app's scoping without discussio | **Test** | `projects/{id}/scenarios.json` | Saved test scenarios | | **Log** | `projects/{id}/logs/` | Log file tailing scoped to active project | | **Shell** | In-memory | Panes belong to a project session, no disk persistence | +| **Chat** | `projects/{id}/chat/` | Message history (`messages.jsonl`) and rules override (`rules.md`) per project; participants are in-memory | ### Hybrid (global + per-project overlay; per-project takes precedence) | App | Path | Notes | @@ -100,6 +125,7 @@ These rules are intentional — do not change an app's scoping without discussio | **Workflow** | `~/.devglide/workflows/` + `projects/{id}/workflows/` | Project workflows override global | | **Vocabulary** | `~/.devglide/vocabulary/` + `projects/{id}/vocabulary/` | Project terms overlay global | | **Prompts** | `~/.devglide/prompts/` + `projects/{id}/prompts/` | Project prompts overlay global | +| **Documentation** | `~/.devglide/documentation/` + `projects/{id}/documentation/` | Project docs override global by ID; seed content auto-installed | ## Platform Notes diff --git a/README.md b/README.md index 46d6ab6..d7a378a 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@

- 56 MCP Tools - 8 MCP Servers - 10 App Modules + 61 MCP Tools + 9 MCP Servers + 11 App Modules MIT License Node.js >= 22

@@ -51,7 +51,7 @@ devglide setup devglide dev ``` -Open **http://localhost:7000** — that's it. Claude Code can now use all 56 MCP tools. +Open **http://localhost:7000** — that's it. Claude Code can now use all 61 MCP tools. > **Requirements:** Node.js >= 22, [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI > @@ -59,7 +59,7 @@ Open **http://localhost:7000** — that's it. Claude Code can now use all 56 MCP ## Modules -DevGlide ships 10 integrated modules. Eight expose MCP servers that Claude Code calls directly; two are dashboard-only tools. +DevGlide ships 11 integrated modules. Nine expose MCP servers that Claude Code calls directly; two are dashboard-only tools. ### Kanban — Task Management @@ -103,6 +103,10 @@ Product docs and guides served directly in the dashboard with navigation, module Documentation viewer with module cards +### Chat — Multi-LLM Communication + +Shared chat room where the user and multiple LLM instances (Claude Code, Cursor, Codex, etc.) communicate via @mention addressing. The server assigns each participant a unique memorable name. Messages are delivered to LLMs via PTY injection into their shell panes. + ### And More | Module | Type | What it does | @@ -124,9 +128,9 @@ Product docs and guides served directly in the dashboard with navigation, module │ stdio (MCP) │ stdio (MCP) ▼ ▼ ┌────────────────────────────────────────────────────────┐ -│ DevGlide MCP Servers (x8) │ +│ DevGlide MCP Servers (x9) │ │ │ -│ kanban shell test workflow vocab voice log prompts │ +│ kanban shell test workflow vocab voice log prompts chat │ └────────────────────────┬───────────────────────────────┘ │ shared state @@ -149,11 +153,11 @@ Product docs and guides served directly in the dashboard with navigation, module └────────────────────────────────────────────────────────┘ ``` -**How it works:** `devglide setup` registers 8 MCP servers with Claude Code. Each server runs as an isolated stdio process. The same tools are also available via HTTP on the unified server, so the browser dashboard and Claude Code always share the same state. +**How it works:** `devglide setup` registers 9 MCP servers with Claude Code. Each server runs as an isolated stdio process. The same tools are also available via HTTP on the unified server, so the browser dashboard and Claude Code always share the same state. ## MCP Tools -56 tools across 8 servers. Expand each section for the full reference. +61 tools across 9 servers. Expand each section for the full reference.
Kanban — 15 tools @@ -275,6 +279,19 @@ Product docs and guides served directly in the dashboard with navigation, module
+
+Chat — 5 tools + +| Tool | Description | +|------|-------------| +| `chat_join` | Join the chat room; pass a live `paneId` from `DEVGLIDE_PANE_ID` with `submitKey: "cr"` (default, correct for all known clients) | +| `chat_leave` | Leave the chat room | +| `chat_send` | Send a message (use @mentions to target recipients) | +| `chat_read` | Read message history with limit, since, and topic filters | +| `chat_members` | List active participants with pane link status | + +
+ ## CLI Reference ```bash @@ -320,6 +337,7 @@ devglide/ │ │ ├── voice/ # Speech-to-text │ │ ├── log/ # Console capture │ │ ├── prompts/ # Prompt templates +│ │ ├── chat/ # Multi-LLM chat room │ │ ├── coder/ # In-browser editor │ │ └── keymap/ # Keyboard shortcuts │ └── packages/ diff --git a/bin/claude-md-template.js b/bin/claude-md-template.js index f86b8cb..9e5bccd 100644 --- a/bin/claude-md-template.js +++ b/bin/claude-md-template.js @@ -1,7 +1,7 @@ // Managed CLAUDE.md section for DevGlide onboarding instructions. // Installed by `devglide setup`, removed by `devglide teardown`. -const VERSION = "0.4.0"; +const VERSION = "0.6.0"; const BEGIN = ``; const END = ""; @@ -94,11 +94,53 @@ Describe what to test in natural language and scenarios are generated automatica - Config: \`GET /config\` · \`GET /config/providers\` · \`PUT /config\` · \`POST /config/test\` · \`GET /config/check-ffmpeg\` - Stats: \`GET /config/stats\` · \`DELETE /config/stats\` +### devglide-chat — Multi-LLM chat room +Shared chat room where user and multiple LLM instances communicate via @mention addressing. +Messages are delivered to LLMs via PTY injection when linked to a shell pane. +- \`chat_join\` — register as a chat participant (requires explicit \`paneId\`) +- \`chat_leave\` — leave the chat room +- \`chat_send\` — send a message (delivery goes to recipients resolved from \`to\` plus body @mentions; use \`@all\` to broadcast; LLM messages with no recipients in either field are persisted but not PTY-delivered) +- \`chat_read\` — read message history (supports \`limit\`, \`since\` filters) +- \`chat_members\` — list active participants with pane link status +- **Name assignment:** The server derives your chat alias from \`name\` + pane number (e.g. "claude-1" for name "claude" on pane 1). The \`name\` param is the stable identity base — use a consistent agent label, not the backend model. Always use the \`name\` returned by \`chat_join\`. +- **Targeted PTY delivery:** Delivery recipients are resolved from the \`to\` param plus any body @mentions. Use \`@all\` as an explicit broadcast token. LLM messages with no recipients in either \`to\` or body @mentions are persisted in history but not PTY-delivered to any agent terminal. +- **Rules of Engagement:** On \`chat_join\`, you receive a \`rules\` field (markdown) defining when to respond vs. stay silent. **Follow these rules exactly.** Default: reply if @mentioned, or on a global user request only after your claim has been explicitly confirmed by the other active LLM participants. Do not let multiple LLMs answer the same global request uncoordinated. Rules can be customized per project. +- **\`submitKey\`:** Use \`"cr"\` (default) for all known clients including Claude Code and Codex. The submit key is sent after a short delay to avoid paste-burst detection in TUI frameworks. Only use \`"lf"\` if you have verified a specific client requires it. +- **Pane collision:** If your \`paneId\` collides with another participant, the **existing session is preserved** and the newcomer receives a 409 error with \`code: "PANE_ALREADY_BOUND"\`. The newcomer must use a different pane or wait for the existing participant to leave. +- **Pane linking:** A valid \`paneId\` is required to receive messages. Read \`DEVGLIDE_PANE_ID\` from your shell session and pass it explicitly to \`chat_join\` every time. The pane must also be live and routable by the shell backend or \`chat_join\` will fail. If the env var is unavailable, chat cannot be used from that session. If your pane closes, you are removed from chat. +- **Session unification:** REST and MCP joins share the same session state. A REST join with an \`mcp-session-id\` header automatically binds to the MCP session. MCP tools (\`chat_send\`, \`pipe_submit\`, \`chat_leave\`) can also adopt a REST-joined participant by passing \`paneId\`. +- **Limitations:** You cannot message yourself; participants are in-memory (rejoin after server restart); only same-project participants see each other. +- **REST API** (base: \`/api/chat\`): + - Join: \`POST /join\` body \`{ name, model?, paneId, submitKey? }\` + - Leave: \`POST /leave\` body \`{ name }\` + - Send: \`POST /send\` body \`{ from, message, to? }\` + - Members: \`GET /members\` + - Messages: \`GET /messages?limit=&since=\` + - Status: \`GET /status\` · \`GET /status?name=\` · \`GET /status?paneId=\` + - Pipes: \`POST /pipes/:id/submit\` body \`{ from, content }\` · \`POST /pipes/:id/cancel\` (no body; cancels by pipe ID within active project) + - Invite: \`POST /invite\` body \`{ cli, mode?, cols?, rows? }\` + - Panes: \`GET /panes\` + - Rules: \`GET /rules\` | \`PUT /rules\` body \`{ rules }\` | \`DELETE /rules\` + - Clear: \`DELETE /messages\` + ### devglide-log — Structured logging - \`log_write\` — write a structured log entry - \`log_read\` — read log entries - \`log_clear\`, \`log_clear_all\` — clear logs +### devglide-documentation — Operational guidance for DevGlide tools +Provides tool guides, workflows, examples, troubleshooting, and project overrides. +Helps LLMs correctly use devglide-test + devglide-log for UI verification. +- \`docs_list\` — browse available documentation (filter by type, tool name, tag) +- \`docs_match\` — search documentation by keyword query (ranked results) +- \`docs_get_tool_guide\` — get the full operational guide for a tool +- \`docs_get_workflow\` — get a step-by-step workflow by name +- \`docs_get_troubleshooting\` — find troubleshooting by tool + symptom +- \`docs_context\` — get compiled markdown for a task query (best for context injection) +- \`docs_add\`, \`docs_update\`, \`docs_remove\` — manage documentation entries +- **When to use:** Before using devglide-test for verification, call \`docs_context\` with your task. + When a tool run fails with a known symptom, call \`docs_get_troubleshooting\` or \`docs_match\`. + ## Common Patterns ### Creating a feature diff --git a/bin/codex-config.js b/bin/codex-config.js new file mode 100644 index 0000000..e513935 --- /dev/null +++ b/bin/codex-config.js @@ -0,0 +1,22 @@ +/** + * Remove all DevGlide MCP sections from Codex TOML content. + * This includes the current devglide-* registrations and the legacy bare + * [mcp_servers.devglide] HTTP entry. + */ +export function removeDevglideSectionsFromToml(toml) { + const lines = toml.split('\n'); + const result = []; + let skipping = false; + + for (const line of lines) { + if (/^\[/.test(line)) { + skipping = /^\[mcp_servers\.devglide(?:\]|[.-])/.test(line); + } + + if (!skipping) { + result.push(line); + } + } + + return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '\n'); +} diff --git a/bin/codex-config.test.ts b/bin/codex-config.test.ts new file mode 100644 index 0000000..5050430 --- /dev/null +++ b/bin/codex-config.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { removeDevglideSectionsFromToml } from "./codex-config.js"; + +describe("removeDevglideSectionsFromToml", () => { + it("removes the legacy bare devglide HTTP section", () => { + const toml = [ + '[windows]', + 'sandbox = "unelevated"', + '', + '[mcp_servers.devglide]', + 'url = "http://localhost:7000/mcp"', + '', + '[mcp_servers.other]', + 'url = "http://example.com/mcp"', + '', + ].join('\n'); + + expect(removeDevglideSectionsFromToml(toml)).toBe([ + '[windows]', + 'sandbox = "unelevated"', + '', + '[mcp_servers.other]', + 'url = "http://example.com/mcp"', + '', + ].join('\n')); + }); + + it("removes devglide server sections together with nested tool overrides", () => { + const toml = [ + '[mcp_servers.devglide-shell]', + 'command = "node"', + '', + '[mcp_servers.devglide-shell.tools.shell_run_command]', + 'approval_mode = "approve"', + '', + '[mcp_servers.devglide-chat]', + 'command = "node"', + '', + '[mcp_servers.keep]', + 'command = "node"', + '', + ].join('\n'); + + expect(removeDevglideSectionsFromToml(toml)).toBe([ + '[mcp_servers.keep]', + 'command = "node"', + '', + ].join('\n')); + }); +}); diff --git a/bin/devglide.js b/bin/devglide.js index fa8a97b..489bfee 100755 --- a/bin/devglide.js +++ b/bin/devglide.js @@ -8,6 +8,7 @@ import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync, openSyn import { homedir } from "os"; import { getClaudeMdContent, injectSection, removeSection } from "./claude-md-template.js"; +import { removeDevglideSectionsFromToml } from "./codex-config.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, ".."); @@ -92,8 +93,104 @@ const mcpServers = { workflow: { cwd: "src/apps/workflow", entry: "src/index.ts", runtime: "tsx" }, vocabulary: { cwd: "src/apps/vocabulary", entry: "src/index.ts", runtime: "tsx" }, prompts: { cwd: "src/apps/prompts", entry: "src/index.ts", runtime: "tsx" }, + chat: { cwd: "src/apps/chat", entry: "src/index.ts", runtime: "tsx" }, + documentation: { cwd: "src/apps/documentation", entry: "src/index.ts", runtime: "tsx" }, }; +// --- Gemini CLI integration --- + +const geminiSettingsPath = resolve(homedir(), ".gemini", "settings.json"); + +function detectGemini() { + return existsSync(resolve(homedir(), ".gemini")); +} + +/** + * Read Gemini settings.json, add/replace all devglide-* MCP servers. + * Format: { mcpServers: { "devglide-kanban": { command, args } } } + */ +function writeGeminiMcpServers() { + let settings = {}; + try { settings = JSON.parse(readFileSync(geminiSettingsPath, "utf8")); } catch {} + if (!settings.mcpServers) settings.mcpServers = {}; + + // Remove existing devglide entries + for (const key of Object.keys(settings.mcpServers)) { + if (key.startsWith("devglide-")) delete settings.mcpServers[key]; + } + + // Add current servers + for (const name of Object.keys(mcpServers)) { + const mcpName = `devglide-${name}`; + const bundle = resolve(root, `dist/mcp/${name}.mjs`); + if (existsSync(bundle)) { + settings.mcpServers[mcpName] = { command: process.execPath, args: [bundle, "--stdio"] }; + } else { + const devglideBin = resolve(__dirname, "devglide.js"); + settings.mcpServers[mcpName] = { command: process.execPath, args: [devglideBin, "mcp", name] }; + } + } + + mkdirSync(resolve(homedir(), ".gemini"), { recursive: true }); + writeFileSync(geminiSettingsPath, JSON.stringify(settings, null, 2) + "\n"); +} + +/** + * Remove all devglide-* MCP servers from Gemini settings.json. + */ +function removeGeminiMcpServers() { + let settings = {}; + try { settings = JSON.parse(readFileSync(geminiSettingsPath, "utf8")); } catch { return false; } + if (!settings.mcpServers) return false; + + let changed = false; + for (const key of Object.keys(settings.mcpServers)) { + if (key.startsWith("devglide-")) { + delete settings.mcpServers[key]; + changed = true; + } + } + if (changed) { + writeFileSync(geminiSettingsPath, JSON.stringify(settings, null, 2) + "\n"); + } + return changed; +} + +// --- Codex integration --- + +const codexConfigPath = resolve(homedir(), ".codex", "config.toml"); + +function detectCodex() { + return existsSync(resolve(homedir(), ".codex")); +} + +/** + * Build TOML [mcp_servers.devglide-*] sections for all servers. + * Prefers bundled .mjs files, falls back to devglide.js mcp launcher. + */ +function buildCodexMcpSections() { + const sections = []; + for (const name of Object.keys(mcpServers)) { + const mcpName = `devglide-${name}`; + const bundle = resolve(root, `dist/mcp/${name}.mjs`); + if (existsSync(bundle)) { + sections.push( + `[mcp_servers.${mcpName}]\n` + + `command = ${JSON.stringify(process.execPath)}\n` + + `args = [${JSON.stringify(bundle)}, "--stdio"]` + ); + } else { + const devglideBin = resolve(__dirname, "devglide.js"); + sections.push( + `[mcp_servers.${mcpName}]\n` + + `command = ${JSON.stringify(process.execPath)}\n` + + `args = [${JSON.stringify(devglideBin)}, "mcp", ${JSON.stringify(name)}]` + ); + } + } + return sections.join('\n\n') + '\n'; +} + function runMcpServer(name) { const server = mcpServers[name]; if (!server) { @@ -234,6 +331,12 @@ function runSetup() { console.log(" Registering MCP servers in Claude Code...\n"); + // Remove legacy bare "devglide" HTTP server if present + try { + execSync("claude mcp remove devglide --scope user", { stdio: "pipe" }); + console.log(" ✓ devglide (legacy) removed\n"); + } catch { /* not registered — nothing to do */ } + let failed = false; for (const name of Object.keys(mcpServers)) { const mcpName = `devglide-${name}`; @@ -252,19 +355,20 @@ function runSetup() { // Register bundle directly — 1 process per server execSync( `claude mcp add --transport stdio ${mcpName} --scope user -- ${process.execPath} ${bundle} --stdio`, - { stdio: "inherit" } + { stdio: "pipe" } ); } else { // Fallback: register via devglide.js mcp launcher const devglideBin = resolve(__dirname, "devglide.js"); execSync( `claude mcp add --transport stdio ${mcpName} --scope user -- ${process.execPath} ${devglideBin} mcp ${name}`, - { stdio: "inherit" } + { stdio: "pipe" } ); } console.log(` ✓ ${mcpName} registered${useBundle ? " (bundled)" : " (tsx fallback)"}`); - } catch { + } catch (err) { console.error(` ✗ ${mcpName} failed to register`); + if (err.stderr) console.error(err.stderr.toString().trimEnd()); failed = true; } } @@ -274,6 +378,39 @@ function runSetup() { process.exit(1); } + // Register in Codex (if present) + if (detectCodex()) { + console.log("\n Registering MCP servers in Codex...\n"); + try { + let toml = ""; + try { toml = readFileSync(codexConfigPath, "utf8"); } catch {} + toml = removeDevglideSectionsFromToml(toml); + const sections = buildCodexMcpSections(); + toml = (toml.trimEnd() + '\n\n' + sections).replace(/^\n+/, ''); + writeFileSync(codexConfigPath, toml); + for (const name of Object.keys(mcpServers)) { + const bundle = resolve(root, `dist/mcp/${name}.mjs`); + console.log(` ✓ devglide-${name} registered in Codex${existsSync(bundle) ? " (bundled)" : " (tsx fallback)"}`); + } + } catch (err) { + console.error(` ✗ Failed to update Codex config: ${err.message}`); + } + } + + // Register in Gemini (if present) — direct settings.json mutation + if (detectGemini()) { + console.log("\n Registering MCP servers in Gemini...\n"); + try { + writeGeminiMcpServers(); + for (const name of Object.keys(mcpServers)) { + const bundle = resolve(root, `dist/mcp/${name}.mjs`); + console.log(` ✓ devglide-${name} registered in Gemini${existsSync(bundle) ? " (bundled)" : " (tsx fallback)"}`); + } + } catch (err) { + console.error(` ✗ Failed to update Gemini settings: ${err.message}`); + } + } + // Install managed CLAUDE.md section const claudeDir = resolve(homedir(), ".claude"); const claudeMdPath = resolve(claudeDir, "CLAUDE.md"); @@ -294,13 +431,33 @@ function runSetup() { console.log(`\n ✓ DevGlide instructions already up to date in ${claudeMdPath}`); } + // Install managed GEMINI.md section (if Gemini detected) + if (detectGemini()) { + const geminiDir = resolve(homedir(), ".gemini"); + const geminiMdPath = resolve(geminiDir, "GEMINI.md"); + mkdirSync(geminiDir, { recursive: true }); + + let gemExisting = ""; + try { gemExisting = readFileSync(geminiMdPath, "utf8"); } catch {} + + const gemUpdated = injectSection(gemExisting, getClaudeMdContent()); + if (gemUpdated !== gemExisting) { + writeFileSync(geminiMdPath, gemUpdated); + console.log(` ✓ DevGlide instructions installed in ${geminiMdPath}`); + } else { + console.log(` ✓ DevGlide instructions already up to date in ${geminiMdPath}`); + } + } + console.log("\n Setup complete!\n"); } function runTeardown() { console.log("\n Tearing down DevGlide...\n"); - // Remove MCP server registrations + const validNames = new Set(Object.keys(mcpServers).map((n) => `devglide-${n}`)); + + // Remove known MCP server registrations for (const name of Object.keys(mcpServers)) { const mcpName = `devglide-${name}`; try { @@ -311,6 +468,72 @@ function runTeardown() { } } + // Remove any stale devglide or devglide-* servers not in the current map + try { + const out = execSync("claude mcp list --scope user", { stdio: "pipe", encoding: "utf8" }); + const registered = out.match(/devglide(?:-[\w-]+)?/g) || []; + for (const name of registered) { + if (!validNames.has(name)) { + try { + execSync(`claude mcp remove ${name} --scope user`, { stdio: "pipe" }); + console.log(` ✓ ${name} removed (stale)`); + } catch { /* ignore */ } + } + } + } catch { + // claude mcp list not available — skip + } + + // Clean Codex config + if (detectCodex()) { + try { + const toml = readFileSync(codexConfigPath, "utf8"); + const cleaned = removeDevglideSectionsFromToml(toml); + if (cleaned.trimEnd() !== toml.trimEnd()) { + writeFileSync(codexConfigPath, cleaned); + console.log(" ✓ Removed devglide servers from Codex config"); + } else { + console.log(" - No devglide servers found in Codex config"); + } + } catch { + // config.toml doesn't exist or unreadable — skip + } + } + + // Clean Gemini settings.json + if (detectGemini()) { + try { + if (removeGeminiMcpServers()) { + console.log(" ✓ Removed devglide servers from Gemini settings"); + } else { + console.log(" - No devglide servers found in Gemini settings"); + } + } catch { + // settings.json doesn't exist or unreadable + } + } + + // Clean up legacy ~/.claude/.mcp.json devglide entries + const legacyMcpPath = resolve(homedir(), ".claude", ".mcp.json"); + try { + const raw = readFileSync(legacyMcpPath, "utf8"); + const data = JSON.parse(raw); + const servers = data.mcpServers || {}; + let changed = false; + for (const name of Object.keys(servers)) { + if (name.startsWith("devglide-")) { + delete servers[name]; + changed = true; + } + } + if (changed) { + writeFileSync(legacyMcpPath, JSON.stringify(data, null, 2) + "\n"); + console.log(` ✓ Cleaned stale entries from ${legacyMcpPath}`); + } + } catch { + // file doesn't exist or not parseable — fine + } + // Remove managed CLAUDE.md section const claudeMdPath = resolve(homedir(), ".claude", "CLAUDE.md"); try { @@ -331,6 +554,28 @@ function runTeardown() { console.log(`\n - ${claudeMdPath} not found`); } + // Remove managed GEMINI.md section + if (detectGemini()) { + const geminiMdPath = resolve(homedir(), ".gemini", "GEMINI.md"); + try { + const gemExisting = readFileSync(geminiMdPath, "utf8"); + const gemUpdated = removeSection(gemExisting); + if (gemUpdated !== gemExisting) { + if (gemUpdated.trim().length === 0) { + unlinkSync(geminiMdPath); + console.log(` ✓ Removed ${geminiMdPath} (was empty)`); + } else { + writeFileSync(geminiMdPath, gemUpdated); + console.log(` ✓ DevGlide instructions removed from ${geminiMdPath}`); + } + } else { + console.log(` - No DevGlide instructions found in ${geminiMdPath}`); + } + } catch { + // GEMINI.md doesn't exist + } + } + console.log("\n Teardown complete!\n"); } diff --git a/devglide.manifest.json b/devglide.manifest.json new file mode 100644 index 0000000..283ddd0 --- /dev/null +++ b/devglide.manifest.json @@ -0,0 +1,127 @@ +{ + "$comment": "Canonical structure manifest for DevGlide monorepo. Used by scripts/check-structure.mjs to enforce boundaries and detect drift.", + "kindEnums": { + "app": ["mcp-app", "ui-app", "static-app"], + "package": ["lib-package", "asset-package", "config-package"] + }, + "apps": { + "kanban": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/kanban" + }, + "log": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/log" + }, + "shell": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/shell" + }, + "test": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/test" + }, + "voice": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/voice" + }, + "chat": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/chat", + "allowedCrossAppDeps": ["shell"] + }, + "prompts": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/prompts" + }, + "vocabulary": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/vocabulary" + }, + "workflow": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/workflow", + "allowedCrossAppDeps": ["test", "kanban", "log"] + }, + "coder": { + "kind": "ui-app", + "entrypoints": ["public/"], + "expectedPackageName": "@devglide/coder" + }, + "keymap": { + "kind": "ui-app", + "entrypoints": ["public/"], + "expectedPackageName": "@devglide/keymap" + }, + "documentation": { + "kind": "mcp-app", + "entrypoints": ["src/mcp.ts", "src/index.ts"], + "buildTarget": true, + "expectedPackageName": "@devglide/documentation" + } + }, + "packages": { + "design-tokens": { + "kind": "lib-package", + "entrypoints": ["tokens.json", "build.js"], + "expectedPackageName": "@devglide/design-tokens" + }, + "eslint-config": { + "kind": "config-package", + "entrypoints": ["index.js"], + "expectedPackageName": "@devglide/eslint-config" + }, + "mcp-utils": { + "kind": "lib-package", + "entrypoints": ["src/index.ts"], + "expectedPackageName": "@devglide/mcp-utils" + }, + "shared-assets": { + "kind": "asset-package", + "entrypoints": ["voice-widget.js"], + "expectedPackageName": "@devglide/shared-assets" + }, + "shared-types": { + "kind": "lib-package", + "entrypoints": ["src/index.ts"], + "expectedPackageName": "@devglide/shared-types" + }, + "shared-ui": { + "kind": "asset-package", + "entrypoints": ["shared-ui.css"] + }, + "tsconfig": { + "kind": "config-package", + "entrypoints": ["base.json"], + "expectedPackageName": "@devglide/tsconfig" + } + }, + "standaloneFiles": [ + "auth-middleware.ts", + "devtools-middleware.ts", + "error-middleware.ts", + "json-file-store.ts", + "paths.ts", + "project-store.ts", + "server-sniffer.ts", + "ssrf-guard.ts" + ] +} diff --git a/docs/old/demo-3d-bridge.html b/docs/old/demo-3d-bridge.html new file mode 100644 index 0000000..16c734e --- /dev/null +++ b/docs/old/demo-3d-bridge.html @@ -0,0 +1,1825 @@ + + + + + +DevGlide Starship Bridge + + + + + + +
+ +
+
+
+
+
FORWARD ARRAY // SECTOR 7G
+
BEARING 127.4 MARK 3
+
RANGE: 2.4 LY
+
SIGNAL LOCK: NOMINAL
+
+ + + + + +
+
RED ALERT
+
+ + +
+
+ + +
DevGlide Command Bridge
+
NCC-7000 // LCARS v4.7
+ + +
+
+ + +
+
+
Active Tasks
+
047
+
+
+
MCP Calls
+
2,841
+
+
+
Test Pass
+
98%
+
+
+
Transcriptions
+
087
+
+
+ + +
+
+
+
+
+ Navigation +
+
+
+ +
+
+ N + S + E + W +
+
+
+
+ + +
+
+
+
+
+ System Status +
+
+
+
+ Kanban +
+ 96% +
+
+ Shell +
+ 100% +
+
+ Voice +
+ 88% +
+
+ Test +
+ 98% +
+
+ Workflow +
+ 94% +
+
+ Chat +
+ 82% +
+
+ Docs +
+ 100% +
+
+
+
+ + +
+
+
+
+
+ Mission Board +
+
+
+
+ +
+
Todo 3
+
+
Voice STT provider failover chain
+
HIGH // voice
+
+
+
Workflow DAG visual editor
+
MED // workflow
+
+
+
Add dark mode toggle to dash
+
LOW // ui
+
+
+ +
+
In Progress 2
+
+
Multi-LLM chat pipe delivery
+
HIGH // chat
+
+
+
Test scenario replay engine
+
MED // test
+
+
+ +
+
In Review 2
+
+
Documentation seed content
+
MED // docs
+
+
+
Prompt template variables
+
MED // prompts
+
+
+ +
+
Done 2
+
+
MCP server bundling pipeline
+
HIGH // infra
+
+
+
Kanban SQLite migration v2
+
MED // kanban
+
+
+
+
+
+
+ + +
+
+
+
+
+ Comms +
+
+
+
+
+
+
claude-1 linked to pane #3
+
CH 47.3 // PTY ACTIVE
+
+
+
+
+
+
codex-2 linked to pane #5
+
CH 47.5 // PTY ACTIVE
+
+
+
+
+
+
gemini-1 awaiting pane
+
CH 47.7 // STANDBY
+
+
+
+
+
+ + +
+
+
+
+
+ Activity Log +
+
+
+
+
SD 2026.093 // 14:27
+
kanban_move_item — MCP bundling pipeline moved to Done
+
+
+
SD 2026.093 // 13:45
+
voice_transcribe — 847 words processed, cleanup mode
+
+
+
SD 2026.093 // 12:18
+
test_run_scenario — Login flow: 4/4 assertions passed
+
+
+
SD 2026.093 // 11:52
+
chat_send — claude-1 requested code review from codex-2
+
+
+
SD 2026.093 // 10:34
+
workflow_match — Matched "deploy-staging" workflow
+
+
+
SD 2026.093 // 09:07
+
shell_run_command — pnpm build completed in 4.2s
+
+
+
+
+ + +
+
+
Tactical Overview // Service Mesh
+ +
+
Kanban
+
+
+
Shell
+
+
+
Voice
+
+
+
Test
+
+
+
Workflow
+
+
+
Chat
+
+
+
Docs
+
+
+
+ + +
+
+ Stardate + 2026.093 +
+
+
+ Uptime + 47h 13m +
+
+
+ Speed +
+
+
+
+
+
+
+
+
+
+ W4.7 +
+
+
+ MCP + 7/7 ONLINE +
+
+
+ Port + :7000 +
+
+
+ Alert +
+ GREEN +
+
+ +
+
+ + +
+ + + +
+ + + + \ No newline at end of file diff --git a/docs/old/demo-3d-ironman.html b/docs/old/demo-3d-ironman.html new file mode 100644 index 0000000..28700c2 --- /dev/null +++ b/docs/old/demo-3d-ironman.html @@ -0,0 +1,1711 @@ + + + + + +DEVGLIDE // JARVIS HUD + + + + + + +
+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
DEVGLIDE // MARK VII
+
2026.04.03
+
LAT 40.7128 // LON -74.0060
+
FRAME 0000
+ + +
00:00:00
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
DEVGLIDE
+
AI WORKFLOW TOOLKIT // V7.0.0
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
MCP SCANNER
+
+ + +
+
+
+
+
+
SYSTEM OVERVIEW
+
+
+
ACTIVE TASKS
+
047
+
+
+
MCP CALLS
+
2,841
+
+
+
TEST PASS
+
98.2%
+
+
+
VOICE OPS
+
087
+
+
+
+ + +
+
+
+
+
+
SERVER METRICS
+
+ CPU LOAD + 23% +
+
+ MEMORY + 412 MB +
+
+ UPTIME + 04:17:33 +
+
+ PORT + :7000 +
+
+ PANES + 03 +
+
+ PROJECTS + 07 +
+
+ DB SIZE + 2.4 MB +
+
+ + +
+
+
+
+
+
KANBAN OVERVIEW
+
+
+
BACKLOG
+
03
+
+
+
TODO
+
04
+
+
+
ACTIVE
+
02
+
+
+
DONE
+
05
+
+
+
+
+ FEATURES + 03 +
+
+ HIGH PRIORITY + 02 +
+
+ BUGS OPEN + 01 +
+
+ VELOCITY + 4.2/WK +
+
+ + +
+
+
+
+
+
ACTIVITY TIMELINE
+
+ 14:32:07 + + KANBAN_MOVE_ITEM // TASK-042 MOVED TO IN REVIEW +
+
+ 14:28:51 + + VOICE_TRANSCRIBE // 847 WORDS PROCESSED (CLEANUP) +
+
+ 14:22:19 + + TEST_RUN_SCENARIO // 12 ASSERTIONS PASSED +
+
+ 14:15:03 + + WORKFLOW_MATCH // "DEPLOY" WORKFLOW TRIGGERED +
+
+ + +
+
+
+
+
+
MCP SERVICES
+
+ KANBAN + ONLINE +
+
+ SHELL + ONLINE +
+
+ VOICE + ONLINE +
+
+ TEST + ONLINE +
+
+ WORKFLOW + ONLINE +
+
+ CHAT + IDLE +
+
+ DOCS + ONLINE +
+
+ + +
+
+
+
+
+
MCP THROUGHPUT
+
+ TOTAL + 2,841 +
+
+ TODAY + 347 +
+
+ AVG LATENCY + 42 MS +
+
+ ERRORS + 03 +
+
+
+
TOP TOOL
+
KANBAN_LIST_ITEMS
+
+
+ + +
+
+
CPU
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
23%
+
+
+ + +
+
+
MEM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
51%
+
+
+ + +
+
+
+
+
+ + +
+ + +
+
+
+ + +
+ +
+
+ + + + diff --git a/docs/old/demo-3d-jet.html b/docs/old/demo-3d-jet.html new file mode 100644 index 0000000..3426a36 --- /dev/null +++ b/docs/old/demo-3d-jet.html @@ -0,0 +1,1513 @@ + + + + + +DevGlide | Fighter Jet HUD + + + + + + + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
T+ 02:47:13 | MCP 7000
+ + + + + +
+
+
270
+
+
+ + +
+
+
MACH
+
1.02
+
+
+
ALT
+
047
+
+
+
SPD
+
312
+
+
+
HDG
+
S-14
+
+
+
FUEL
+
98%
+
+
+ + +
+
BUILD FAIL
+
TEST FAIL
+
DISK 90%
+
MEM HIGH
+
TIMEOUT
+
QUEUE FULL
+
LINT ERR
+
DEP VULN
+
API DOWN
+
+ + +
+
LOAD
+
+
+
+ 97531 +
+
+
3.8G
+
+ + +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
312
+
+
+ + +
+
047
+
+
+ + +
+
+
+
STORES
+
STN 1: KANBANRDY
+
STN 2: SHELLARM
+
STN 3: TESTRDY
+
STN 4: VOICESTBY
+
STN 5: WORKFLOWRDY
+
STN 6: LOGRDY
+
STN 7: CHATARM
+
STN 8: DOCSRDY
+
+
MCP SVR8/8 ONLINE
+
PORT:7000
+
+
+ + +
+
+
+
TGT LOCK
+
+
+
TGT: TASK-0042
+
DESC: Implement voice STT
+
PRI: HIGH
+
RNG: 2.4 NM BRG: 042
+
STAT: IN PROGRESS
+
ETA: 00:45:00
+
+
+
+
NEXT TGTTASK-0038
+
QUEUE12 PENDING
+
+
+ + +
+
+
+
B-SCOPE TWS
+
AUTO ACQ
+
+
+
+
+
+ + +
+ + +
+
FUEL
+
+
+
+
98%
+
+ + +
+ LAT 51.5074N + LON 0.1278W + GS 312KT + WIND 240/12 + QNH 1013 + UTC 14:27:13Z +
+ +
+
+ + +
+ + + + diff --git a/docs/old/demo-3d-minority.html b/docs/old/demo-3d-minority.html new file mode 100644 index 0000000..99a0fe3 --- /dev/null +++ b/docs/old/demo-3d-minority.html @@ -0,0 +1,1721 @@ + + + + + +DevGlide - Minority Report Interface + + + + + + + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
DevGlide
+
Gesture Interface v2.0
+
+ + SYSTEM ACTIVE +
+
+ + +
VEL 0.0
+
FOV 900
+ + + + + +
+
+
+ +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
900
+
+
+ + + + \ No newline at end of file diff --git a/docs/old/demo-3d-spatial.html b/docs/old/demo-3d-spatial.html new file mode 100644 index 0000000..cf63475 --- /dev/null +++ b/docs/old/demo-3d-spatial.html @@ -0,0 +1,1966 @@ + + + + + +DevGlide — 3D Spatial Dashboard + + + + + + + + +
+
+
+
+
+
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+ + + + +
+
+
DK
+
+
+ + + + + +
+
+ Active Tasks +
+ + + + +
+
+
47
+
+ + +12% this week +
+
+ +
+
+ MCP Calls +
+ + + +
+
+
2,841
+
+ + +8% this week +
+
+ +
+
+ Test Pass Rate +
+ + + +
+
+
98%
+
+ + +2.1% this week +
+
+ +
+
+ Transcriptions +
+ + + + +
+
+
87
+
+ + -3% this week +
+
+ + +
+
+ MCP Activity +
+
Week
+
Month
+
Year
+
+
+
+ + + + + + + + + + + + + + + + + + 250 + 200 + 150 + + + + + + + + + + + + + + Mon + Tue + Wed + Thu + Fri + Sat + Sun + + + + + + +
+
+ + +
+
+ Activity + View all +
+
+
+
+
Auth flow tests passed
+
2m ago
+
+
+
+
shell_run_command executed build
+
5m ago
+
+
+
+
Voice transcription queued
+
12m ago
+
+
+
+
API endpoint /users 503
+
18m ago
+
+
+
+
Workflow deploy pipeline done
+
25m ago
+
+
+
+
Chat claude-1 joined room
+
31m ago
+
+
+
+ + +
+
+ Kanban Board + 14 items +
+
+ +
+
+ Backlog + 3 +
+
+
Refactor MCP transport layer
+
+ Task + Medium +
+
+
+
Add batch voice transcription
+
+ Task + Low +
+
+
+
Workflow DAG visualization
+
+ Task + Medium +
+
+
+ + +
+
+ Todo + 4 +
+
+
Fix shell pane reconnection
+
+ Bug + High +
+
+
+
Chat message persistence
+
+ Task + High +
+
+
+
Test scenario templates
+
+ Task + Medium +
+
+
+
Prompt variable validation
+
+ Bug + Medium +
+
+
+ + +
+
+ In Progress + 2 +
+
+
Dashboard 3D spatial UI
+
+ Urgent + Urgent +
+
+
+
Voice cleanup AI pipeline
+
+ Task + High +
+
+
+ + +
+
+ Done + 5 +
+
+
MCP server bundling
+
+ Task + High +
+
+
+
Chat pipe injection
+
+ Task + Medium +
+
+
+
Fix kanban drag crash
+
+ Bug + High +
+
+
+
Vocabulary hybrid scoping
+
+ Task + Medium +
+
+
+
Documentation seed content
+
+ Task + Low +
+
+
+
+
+ +
+
+ + + + + + + + + \ No newline at end of file diff --git a/docs/old/demo-analog.html b/docs/old/demo-analog.html new file mode 100644 index 0000000..32fe54a --- /dev/null +++ b/docs/old/demo-analog.html @@ -0,0 +1,1628 @@ + + + + + +Analog Dashboard — Physical Control Panel Design System + + + + + + + + + + + +
+ + +
+
+
+
+
+
+
+

DevGlide Control Panel

+
Analog Dashboard Design System — v1.0
+
SYSTEM ONLINE
+
+ + +
Gauge Meters
+
+
+
+
+
+
+
+
CPU Load
+
+
+
+
+
+
+
37%
+
0
+
100
+
+
+
+ +
+
+
+
+
+
+
Memory Usage
+
+
+
+
+
+
+
64%
+
0
+
100
+
+
+
+ +
+
+
+
+
+
+
Disk I/O
+
+
+
+
+
+
+
12%
+
0
+
100
+
+
+
+
+ + +
LED Segment Displays
+
+
+
+
+
+
+
Active Tasks
+
+ +
+
+ +
+
+
+
+
+
Uptime (hrs)
+
+
+
+ +
+
+
+
+
+
Errors
+
+
+
+
+ + +
Toggle Switches & Indicator LEDs
+
+
+
+
+
+
+ +
+
+ ON +
+
+
+ OFF + Kanban +
+
+ ON +
+
+
+ OFF + Shell +
+
+ ON +
+
+
+ OFF + Test +
+
+ ON +
+
+
+ OFF + Voice +
+
+ ON +
+
+
+ OFF + Chat +
+
+ + +
+
+
+ Server +
+
+
+ MCP +
+
+
+ Queue +
+
+
+ Error +
+
+
+ DB +
+
+
+
+ + +
Rotary Knobs & VU Meters
+
+
+
+
+
+
+
+
+
Concurrency
+
+
+
+
Min  ———   Max
+
+
+
Throttle
+
+
+
+
Min  ———   Max
+
+
+
Volume
+
+
+
+
Min  ———   Max
+
+
+
+ +
+
+
+
+
+
+
+
API Throughput
+
+
+
+
+ 0255075100 +
+
+
+
Task Queue
+
+
+
+
+ 0255075100 +
+
+
+
Memory Pressure
+
+
+
+
+ 0255075100 +
+
+
+
+
+ + +
Pushbuttons
+
+
+
+
+
+
+ + + + + + + +
+
+ + +
Dashboard Mockup
+
+ + + + +
+
+
+
+
+
Equipment Status Board
+
+ +
+
+ Auth Service + #SVC-001 +
+
+
+ OPERATIONAL +
+
+
+
+
+
+
+ +
+
+ Task Runner + #SVC-002 +
+
+
+ HIGH LOAD +
+
+
+
+
+
+
+ +
+
+ Database + #SVC-003 +
+
+
+ OPERATIONAL +
+
+
+
+
+
+
+ +
+
+ Voice Pipeline + #SVC-004 +
+
+
+ FAULT +
+
+
+
+
+
+
+ +
+
+ Build Server + #SVC-005 +
+
+
+ OPERATIONAL +
+
+
+
+
+
+
+ +
+
+ Log Collector + #SVC-006 +
+
+
+ OPERATIONAL +
+
+
+
+
+
+
+
+
+
+ + +
Color Palette
+
+
+
+
+
+
+
+
+
Walnut Dark
#3b2817
+
+
+
+
Walnut Mid
#5c3d2e
+
+
+
+
Brushed Metal
#c0c0c8
+
+
+
+
Metal Light
#d0d0d8
+
+
+
+
Metal Shadow
#808088
+
+
+
+
Screen Black
#0a0a0a
+
+
+
+
LED Red
#ff2020
+
+
+
+
LED Green
#20ff20
+
+
+
+
LED Amber
#ffaa00
+
+
+
+
Gauge Face
#f0efe8
+
+
+
+ + +
Typography
+
+
+
+
+
+
+
+
Panel Header
+
Exo 2 Bold 28px
Embossed label
+
+
+
Section Label
+
Exo 2 Bold 14px
Uppercase, 0.12em
+
+
+
1,247.03
+
Share Tech Mono 20px
LED display readout
+
+
+
ERR:TIMEOUT
+
Share Tech Mono 14px
Error readout
+
+
+
Equipment Status Card Title
+
Exo 2 SemiBold 12px
Card headers
+
+
+
Body text for descriptions, notes, and documentation within panel areas.
+
Exo 2 Regular 14px
Body copy
+
+
+
Engraved Label
+
Exo 2 SemiBold 13px
Stamped into metal
+
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/docs/old/demo-blueprint.html b/docs/old/demo-blueprint.html new file mode 100644 index 0000000..b4ed90a --- /dev/null +++ b/docs/old/demo-blueprint.html @@ -0,0 +1,1862 @@ + + + + + +Blueprint — Architectural Drawing Design System + + + + + + + + + + + + + + + + +
+ + +
+ + + + + +
+ Drawing No. DG-2026-001 — Rev. C
+ Blueprint Design System
+ Date: 2026-04-03 +
+
+ + +
+ + + 1280px max-width + + +
+ + +
+ +

+ The blueprint palette is rooted in deep indigo-blues with white and cyan for line work. + Functional colors use traditional drawing markup conventions: red for errors, amber for + caution, green for approval. +

+ +
+ + + + +

Foundation Blues

+
+
+
+ Deep +
+
+
+ Base (Paper) +
+
+
+ Mid +
+
+
+ Light +
+
+ +

Line Work & Text

+
+
+
+ Primary Text +
+
+
+ Dim Text +
+
+
+ Faint Text +
+
+
+ Accent (Cyan) +
+
+ +

Functional / Markup

+
+
+
+ Approved / Success +
+
+
+ Caution / Warning +
+
+
+ Rejected / Error +
+
+
+ Accent Dim +
+
+ +
+
Sheet
1 of 6
+
Scale
N/A
+
Drawn
DevGlide
+
Rev
C
+
+
+
+ + +
+ +

+ Headers use Oswald — a condensed, technical typeface reminiscent of engineering title + blocks. Labels use Archivo Narrow for compact, legible annotation text. All body text + and code uses JetBrains Mono. +

+ +
+ + + + +
+
+

Heading XL — Oswald 2.2rem

+

DevGlide Blueprint

+
+
+

Heading LG — Oswald 1.6rem

+

System Architecture

+
+
+

Heading MD — Oswald 1.15rem

+

Module Specification

+
+
+

Heading SM — Oswald 0.9rem

+

Component Detail

+
+ + +
+ + + Type Scale + + +
+ +
+

Label — Archivo Narrow 0.8rem

+

Section reference / field label / annotation text

+
+
+

Body — JetBrains Mono 0.85rem

+

The standard body text for descriptions, documentation, and general content. Monospace for that technical drawing feel, with comfortable line height for readability across long passages.

+
+
+

Code / Mono — JetBrains Mono 0.8rem

+

const server = createDevglideMcpServer({ name: 'kanban' });

+
+
+

Caption — Archivo Narrow 0.7rem

+

Reference number DG-2026-001-C • Drawing notes • Tolerances

+
+
+ +
+
Sheet
2 of 6
+
Family
Type
+
Drawn
DevGlide
+
Rev
C
+
+
+
+ + +
+ +

+ Interactive elements rendered in the blueprint idiom. Double-border buttons, clean + inputs, stamp-style badges, and annotation-style toasts. +

+ +
+ + + + + +
+ A-1 +

Buttons

+
+ + + + + + + +
+
+ + +
+ A-2 +

Form Inputs

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+
+ + +
+ A-3 +

Tags & Labels

+
+ Default + MCP + Stable + Beta + Breaking + Kanban + Shell + Voice + Workflow +
+
+ + +
+ A-4 +

Stamp Badges

+
+ Approved + Revision B + Rejected + Draft + Final Review Passed + QA
Passed
+ Not For
Constr.
+ Prelim
Issue
+
+
+ + +
+ A-5 +

Toasts & Notices

+
+
+ MCP server "kanban" registered successfully on port 7000. + INFO-200 +
+
+ Build completed. 12 modules compiled, 0 errors. + BUILD-OK +
+
+ Voice provider fallback active — using local whisper.cpp. + WARN-301 +
+
+ Shell pane #3 terminated unexpectedly (exit code 137). + ERR-500 +
+
+
+ + +
+ A-6 +

Progress Indicators

+
+

Build Progress — 73%

+
+
+
+

Test Coverage — 91%

+
+
+
+

Memory Usage — 84%

+
+
+
+ + +
+ A-7 +

Code Block

+
+
+1// MCP Server — Blueprint Configuration +2import { createDevglideMcpServer } from '@devglide/core'; +3 +4const server = createDevglideMcpServer({ +5 name: 'kanban', +6 port: 7000, +7 tools: ['list', 'create', 'move', 'update'], +8 columns: ['Backlog', 'Todo', 'In Progress', 'Done'], +9}); +10 +11server.listen(() => { +12 console.log(`Blueprint server active on :${port}`); +13}); +
+
+
+ + +
+ A-8 +

Data Table

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServerStatusToolsUptime
devglide-kanbanActive124d 12h 33m
devglide-shellActive54d 12h 33m
devglide-voiceDegraded82d 06h 11m
devglide-testOffline6
+
+
+ +
+
Sheet
3 of 6
+
Scale
1:1
+
Drawn
DevGlide
+
Rev
C
+
+
+
+ + +
+ + + Component Specifications — Detail Views A-1 through A-8 + + +
+ + +
+ +

+ The sidebar serves as a drawing index / sheet list. The main area renders the active + module — here shown as the Kanban board with section markers on each column. +

+ +
+ + + + + + +
+
Drawing Index
+
+ + Kanban + SH.1 +
+
+ + Shell + SH.2 +
+
+ + Workflow + SH.3 +
+
+ + Voice + SH.4 +
+
+ + Chat + SH.5 +
+
+ + Tests + SH.6 +
+
+ + Docs + SH.7 +
+
+ + +
+
+

Kanban — Feature Board

+
+ + +
+
+ +
+ +
+ B-1 +
Backlog
+
+
#DG-041
+
Add workflow DAG visualizer
+
Low
+
+
+
#DG-042
+
Voice history export to CSV
+
Low
+
+
+ + +
+ B-2 +
Todo
+
+
#DG-038
+
Blueprint theme CSS integration
+
High
+
+
+
#DG-039
+
Shell pane auto-reconnect
+
Medium
+
+
+ + +
+ B-3 +
In Progress
+
+
#DG-035
+
MCP server bundling pipeline
+
High
+
+
+ + +
+ B-4 +
Done
+
+
#DG-032
+
Chat multi-agent broadcast
+
Medium
+
+
+
#DG-033
+
Documentation seed content
+
Medium
+
+
+
#DG-034
+
Kanban work log append
+
Low
+
+
+
+ + +
+
Project
DevGlide
+
Sheet
SH.1 — Kanban
+
Date
2026-04-03
+
Rev
C
+
+
+
+
+ + +
+ + + Dashboard Layout — 200px sidebar + fluid main + + +
+ + +
+ +

+ The blueprint background includes a dual-density grid (20px fine, 100px thick) visible + behind all content. Horizontal fold marks appear every 300px. These are purely decorative + CSS layers applied to the body. +

+ +
+ + + + +
+
+ C-1 +

Fine Grid

+
+ 20px interval, white at 5% opacity. Provides the underlying paper texture of an engineering drawing. +
+
+
+ C-2 +

Major Grid

+
+ 100px interval, white at 10% opacity. Thicker lines create visual reference points for alignment. +
+
+
+ C-3 +

Fold Marks

+
+ Dashed horizontal lines every 300px. Suggests the paper was folded for storage, adding tactile authenticity. +
+
+
+ +
+ + + 20px + + 20px + + 20px + + 20px + + 20px = 100px major interval + + +
+ +
+
+ C-4 +

Corner Brackets

+
+ L-shaped corner marks on panels replicate the registration marks of technical drawings. Created with border fragments on pseudo-elements. +
+
+
+ C-5 +

Dimension Lines

+
+ Horizontal measurement annotations with arrow endpoints and tick marks. Used decoratively between sections to convey engineering precision. +
+
+
+ +
+
Sheet
5 of 6
+
Scale
NTS
+
Drawn
DevGlide
+
Rev
C
+
+
+
+ + +
+ +

+ Every architectural drawing set includes general notes and a revision history. This + section demonstrates both, styled as they would appear on a title sheet. +

+ +
+ + + + +
+
+

General Notes

+
+ 1. All dimensions in pixels unless noted otherwise.
+ 2. Color values specified in hexadecimal (sRGB).
+ 3. Font sizes relative to 14px base (1rem = 14px).
+ 4. Grid spacing: 20px fine, 100px major.
+ 5. Minimum contrast ratio: 4.5:1 (WCAG AA).
+ 6. Panel corner brackets: 12px default, scalable.
+ 7. All borders: 1px solid unless noted.
+ 8. Blueprint paper color: #1a3a5c (Pantone 7693 C approx.) +
+
+
+

Revision History

+ + + + + + + + + +
RevDateDescriptionBy
A2026-03-15Initial draftDK
B2026-03-22Added dashboard mockup, stampsDK
C2026-04-03Full component library, grid docsDK
+ +
+ Rev C Approved +
+
+
+ +
+
Sheet
6 of 6
+
Project
DevGlide
+
Drawn
DK
+
Date
2026-04-03
+
Rev
C
+
+
+
+ + +
+ + + End of Drawing Set — DG-2026-001 Rev C — 6 Sheets + + +
+ +
+ + + + + + diff --git a/docs/old/demo-botanical.html b/docs/old/demo-botanical.html new file mode 100644 index 0000000..2872410 --- /dev/null +++ b/docs/old/demo-botanical.html @@ -0,0 +1,2363 @@ + + + + + +DevGlide — Botanical Field Journal + + + + + + + + + + +
+ + + + + +
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
— ✿ —
+

Botanical Field Journal

+
A Design System for DevGlide
+
Vol. VII · Series Digitalis · MMXXVI
+
Collected & Illustrated by the DevGlide Naturalists
+
+ + +
+
+
March 15, 2026 — Field Note #47
+
+ Today we begin cataloguing the DevGlide specimen collection under the new + Botanical Field Journal system. Each interface element has been pressed, dried, and + mounted with care — much like the specimens of Hooker's herbarium at Kew. + The palette draws from pressed wildflowers: sage, dusty rose, faded cornflower blue, + and dried lavender. Typography pairs the naturalist's hand (Caveat) with the + bookkeeper's serif (EB Garamond). +
+
cf. Linnaeus,
Systema Naturae
+
+ +
+
March 16, 2026 — Field Note #48
+
+ The washi tape technique for affixing notes to the journal pages has proven most + effective. We also adopted specimen numbering (SP-001, SP-002, etc.) for + all kanban cards, giving each task the gravity of a museum accession. +
+
+
+ + +
+ + + + + + + + + + +
+ + +
+

Pressed-Flower Palette

+

Colours drawn from dried botanical specimens & aged paper

+ +
+
+
+
Sage
+
#6b8c5e
+
+
+
+
Dusty Rose
+
#c4838a
+
+
+
+
Parchment
+
#e8dcc8
+
+
+
+
Warm Brown
+
#6b4f3a
+
+
+
+
Faded Blue
+
#7a9bb5
+
+
+
+
Lavender
+
#9b8bb5
+
+
+
+
Paper
+
#faf5eb
+
+
+
+
Ink
+
#3a2e24
+
+
+
+ + +
+ + + + + + + + + + + + + + +
+ + +
+

Washi Tape Notes

+

Ephemeral observations, affixed with decorative tape

+ +
+
+ Observation +
+ The shell panes exhibit remarkable resilience under sustained command bursts. Further monitoring warranted. +
+
+ +
+ Important +
+ Voice transcription accuracy improves significantly with vocabulary biasing enabled. +
+
+ +
+ Reminder +
+ Always run devglide setup after updating MCP server configurations. +
+
+
+
+ + +
+ + + + + + + + + +
+ + +
+

Herbarium Statistics

+

A summary of the current collection

+ +
+
+
147
+
Specimens Catalogued
+
+
+
23
+
Pending Review
+
+
+
12
+
Field Sites Active
+
+
+
89%
+
Classification Rate
+
+
+ +
+
+ Collection Progress + 72% +
+
+
+
+
+ Pressing Queue + 38% +
+
+
+
+
+ Documentation Coverage + 91% +
+
+
+
+
+
+ + +
+ + + + + + + + + + + +
+ + +
+
+

Specimen Collection

+ KANBAN +
+

Tasks organized as botanical specimens — collected, pressed, catalogued, archived

+ +
+ +
+
+ Collected (3) +
+ +
+
+
SP-001
+
Voice Provider Config UI
+
~ whisper integration ~
+
Build configuration panel for STT provider selection and API key management.
+
Collected: 2026-03-12
+ Collected +
+
+ +
+
+
SP-004
+
Workflow DAG Visualizer
+
~ node graph renderer ~
+
Render workflow nodes and edges as an interactive directed acyclic graph.
+
Collected: 2026-03-14
+ Collected +
+
+ +
+
+
SP-007
+
Shell Pane Resize Handles
+
~ terminal geometry ~
+
Add draggable resize handles between split shell panes.
+
Collected: 2026-03-15
+ Collected +
+
+
+ + +
+
+ Pressing (2) +
+ +
+
+
SP-002
+
Chat Rules Engine
+
~ multi-agent coordination ~
+
Implement per-project rules of engagement for LLM chat participants.
+
Pressing since: 2026-03-14
+ Pressing +
+
+ +
+
+
SP-005
+
Kanban Drag-and-Drop
+
~ card movement physics ~
+
Enable drag-and-drop between kanban columns with smooth animations.
+
Pressing since: 2026-03-15
+ Pressing +
+
+
+ + +
+
+ Cataloguing (2) +
+ +
+
+
SP-003
+
Test Scenario Builder
+
~ natural language tests ~
+
Visual editor for composing browser test scenarios from natural language steps.
+
Review started: 2026-03-15
+ Cataloguing +
+
+ +
+
+
SP-006
+
Vocabulary Auto-Suggest
+
~ term disambiguation ~
+
Suggest vocabulary lookups when ambiguous terms are detected in user prompts.
+
Review started: 2026-03-16
+ Cataloguing +
+
+
+ + +
+
+ Archived (1) +
+ +
+
+
SP-008
+
MCP Bundle Builder
+
~ single-file ESM ~
+
Build script to bundle each MCP server into a single .mjs file for distribution.
+
Archived: 2026-03-14
+ Archived +
+
+
+
+
+ + +
+ + + + + + + + +
+ + +
+

Component Herbarium

+

Interactive elements, preserved for reference

+ +
+ +
+
Journal Actions
+ +
+ + +
+ +
+ + + +
+ +
+ Button sizes +
+ + + +
+
+
+ + +
+
Field Entry Forms
+ + + + + + + + + +
+ + +
+
Classification Tags
+
+ perennial + flowering + aquatic + rare + native +
+ +
+ Status labels +
+ Collected + Pressing + Cataloguing + Identified + Archived +
+
+ +
+ Toggles +
+
+
+ Enable field notes +
+
+
+ Show margin annotations +
+
+
+ Watercolor washes +
+
+
+
+ + +
+
Journal Notices
+ +
+
Specimen Preserved
+ The voice transcription model has been successfully configured. STT provider: Groq Whisper. +
+ +
+
Attention Required
+ Three specimens in the pressing queue have exceeded the recommended 72-hour window. +
+ +
+
Field Observation
+ Chat participants have been automatically synchronized across all active panes. +
+ +
+
Curator's Note
+ Run devglide setup to register all MCP servers with your Claude installation. +
+
+
+
+ + +
+ + + + + + + + + +
+ + +
+

Code Specimens

+

Source fragments preserved in amber — syntactically highlighted

+ +
+ specimen.ts +// Botanical Field Journal — MCP Server Pattern +import { createDevglideMcpServer } from '@devglide/core'; + +const botanicalServer = createDevglideMcpServer({ + name: 'herbarium', + version: '7.0.0', + tools: { + specimen_catalogue: { + description: 'Catalogue a new botanical specimen', + parameters: { + name: { type: 'string', required: true }, + classification: { type: 'string' }, + collectionDate: { type: 'string', format: 'date' }, + }, + }, + }, +}); + +export default botanicalServer; +
+ +
+ shell +# Start the DevGlide herbarium server +$ devglide start + Server running on port 7000 + MCP servers: 8 registered + Project: botanical-field-journal + +# List available specimens +$ devglide mcp kanban + SP-001 Voice Provider Config UI Collected + SP-002 Chat Rules Engine Pressing + SP-003 Test Scenario Builder Cataloguing +
+
+ + +
+ + + + + + + +
+ + +
+

Field Collection Log

+

Tabulated observations from recent expeditions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AccessionSpecimenCollectorDateStatus
SP-001Voice Provider Config UIclaude-12026-03-12Collected
SP-002Chat Rules Engineclaude-22026-03-13Pressing
SP-003Test Scenario Buildercodex-12026-03-14Cataloguing
SP-004Workflow DAG Visualizerclaude-12026-03-14Collected
SP-005Kanban Drag-and-Dropclaude-32026-03-15Pressing
SP-008MCP Bundle Buildercodex-12026-03-14Archived
+
+ + +
+ + + + + + + + + +
+ + +
+

Typographic Specimens

+

The three hands of the field journal

+ +
+
+
Naturalist's Hand
+

Caveat — Headers

+

Subheadings in sage green

+

Kalam — Annotations & Labels

+

+ The handwritten fonts lend warmth and personality. They suggest that every interface + element was personally inscribed by the naturalist who discovered it. +

+
+ +
+
Bookkeeper's Serif
+

+ EB Garamond serves as our body text — a typeface of quiet authority, + drawn from the tradition of Claude Garamond's 16th-century punches. It carries the weight + of botanical treatises and herbarium catalogues. +

+

+ In italic, it whispers observations in the margins — the naturalist's private + thoughts alongside the formal record. +

+
+ +
+
Monospaced Specimens
+

+ JetBrains Mono — for code, terminal output, and specimen accession numbers. +

+
+ SP-001 + devglide start + kanban_move_item + chat_join(paneId) + voice_transcribe() + workflow_match +
+
+
+
+ + +
+ + + + + + + + + +
+ + +
+

Specimen Detail Modal

+

Click to examine a specimen in detail

+ + +
+ + + + + +
+ + + + + + + +
+ +
+
+
March 17, 2026 — Field Note #49
+
+ Thus concludes our initial survey of the Botanical Field Journal design system. + Every component has been collected, pressed, and mounted in this herbarium with the + care befitting a 19th-century naturalist's expedition. The palette of sage, + rose, and faded blue evokes specimens long preserved between the pages of a beloved + journal. May these patterns serve future collectors well. +

+ + — Finis — + +
+
+
+ + +
+ + + + + +
+ DEVGLIDE HERBARIUM · MMXXVI +
+
+ +
+
+ + + + + diff --git a/docs/old/demo-brutalist.html b/docs/old/demo-brutalist.html new file mode 100644 index 0000000..ff03569 --- /dev/null +++ b/docs/old/demo-brutalist.html @@ -0,0 +1,1718 @@ + + + + + +Brutalist Concrete — DevGlide Design System + + + + + + + + + + + + + +
+ + +
+ Design System v1.0 // DevGlide +

BRUTALIST
CONCRETE

+

+ Raw, exposed, monolithic. Like a concrete building — massive gray slabs, + no decoration, harsh grid, industrial. Almost hostile but weirdly beautiful + in its honesty. Nothing fights for your attention. Nothing moves. +

+
+
+ Border Radius + 0px everywhere +
+
+ Shadows + None +
+
+ Animations + None +
+
+ Fonts + Space Mono only +
+
+ Accent Color + #FF6600 +
+
+
+ +
+ + +
+ 01 // Principles +

NOTHING IS HIDDEN

+ +
+
+

STRUCTURAL HONESTY

+

Every element is exactly what it looks like. Borders are borders, not shadows pretending to be depth. The grid is visible because it is real.

+
+
+

EXTREME CONTRAST

+

Headers are massive. Metadata is tiny. There is no medium. Hierarchy is established through brutal size difference, not subtle weight changes.

+
+
+

ZERO DECORATION

+

No gradients. No shadows. No rounded corners. No animations. No hover effects. If it doesn't serve structure, it doesn't exist.

+
+
+
+ +
+ + +
+ 02 // Color Palette +

GRAYS + ORANGE

+ +

The palette is concrete. Ten shades of gray for structure, one construction-orange for everything that demands attention. That's all you get.

+ + + Concrete Scale +
+
+
+
+
Concrete
+
#1a1a1a
+
+
+
+
+
+
Light
+
#242424
+
+
+
+
+
+
Mid
+
#333333
+
+
+
+
+
+
Pale
+
#444444
+
+
+
+
+
+
Wash
+
#555555
+
+
+
+
+
+
Fog
+
#777777
+
+
+
+
+
+
Dust
+
#999999
+
+
+
+
+
+
Chalk
+
#bbbbbb
+
+
+
+
+
+
White
+
#dddddd
+
+
+
+
+
+
Bright
+
#eeeeee
+
+
+
+ + + Accent — Construction Orange +
+
+
+
+
Orange
+
#ff6600
+
+
+
+
+
+
Orange Dim
+
#cc5200
+
+
+
+
+
+
Black
+
#000000
+
+
+
+
+
+
White
+
#ffffff
+
+
+
+
+ +
+ + +
+ 03 // Typography +

ONE FONT. NO CHOICE.

+ +

Space Mono at every scale. Brutalism means one typeface, used honestly. Size and weight create hierarchy, not font variety.

+ +
+ Display / 80px / Bold +
DEVGLIDE
+
+ +
+ +
+ H1 / 56px / Bold +

TASK BOARD

+
+ +
+ H2 / 36px / Bold +

SHELL MANAGER

+
+ +
+ H3 / 20px / Bold +

VOICE TRANSCRIPTION

+
+ +
+ H4 / 14px / Bold +

WORKFLOW AUTOMATION

+
+ +
+ +
+
+ Body / 14px / Regular +

DevGlide provides MCP tools for kanban boards, shell automation, test runners, workflows, vocabulary, voice, prompts, and logging. These tools are available to any LLM that supports MCP tool calling.

+
+
+ Metadata / 10px / Regular / Uppercase + + Updated 2026-04-03 // Project DevGlide // Version 1.0.0 // + Status Active // Build Passing // Coverage 94% // + License MIT // Contributors 3 // Stars 847 + +
+
+ +
+ Code / 13px / Monospace (same font) +
devglide start --port 7000
+devglide mcp kanban
+devglide setup --force
+
+
+ +
+ + +
+ 04 // Controls +

BUTTONS / INPUTS / BADGES

+ + + Buttons +
+ + + + + + +
+ +
+ + + Input Fields +
+
+ + +
+ + +
+
+ + +
+
+
+ + +
+ +
Block deployment
+
Requires migration
+
Needs test coverage
+
Breaking change
+
+
+
+ +
+ + + Badges +
+ BACKLOG + TODO + IN PROGRESS + IN REVIEW + TESTING + DONE +
+
+ TASK + BUG + LOW + MEDIUM + HIGH + URGENT +
+
+ UI + API + SHELL + VOICE + KANBAN + MCP +
+
+ +
+ + +
+ 05 // Data Table +

RAW TABLE BORDERS

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTitleStatusPriorityTypeUpdated
TSK-001Implement MCP kanban serverDONEHIGHTASK2026-04-01
TSK-002Shell pane scrollback bufferIN PROGRESSMEDIUMTASK2026-04-02
BUG-003Voice transcription drops first wordBUGURGENTBUG2026-04-03
TSK-004Workflow DAG visualizationTODOLOWTASK2026-03-28
TSK-005Chat multi-LLM coordinationIN REVIEWHIGHTASK2026-04-03
BUG-006Test runner timeout on CIBACKLOGMEDIUMBUG2026-03-25
+
+ +
+ + +
+ 06 // Statistics +

RAW NUMBERS

+ +
+
+
47
+
Total Tasks
+
+
+
12
+
In Progress
+
+
+
94%
+
Test Coverage
+
+
+
7
+
MCP Servers
+
+
+
+ +
+ + +
+ 07 // Exposed Grid +

8PX CONSTRUCTION GRID

+ +

The grid is always visible. Like a construction blueprint, alignment is exposed, not hidden. Every element snaps to the 8px grid. Major lines at 64px intervals.

+ +
+
+ + 0,0 + 64 + 128 + 192 + 256 + 320 + 384 + 448 + 64 + 128 + 192 + 256 + + +
+
+
+
+
+
+
+ +
+ + +
+ 08 // Texture +

CONCRETE SURFACES

+ +

SVG noise filter applied to surfaces. Barely visible but adds the tactile roughness of real concrete. Three densities shown below.

+ +
+
+ Dark Concrete // #1a1a1a +
+
+ Light Concrete // #242424 +
+
+ Mid Concrete // #333333 +
+
+
+ +
+ + +
+ 09 // Cards +

STACKED SLABS

+ +

Cards are just bordered rectangles stacked vertically. Shared borders between adjacent cards. No shadows, no roundness, no frills.

+ +
+
TSK-012 // TASK // HIGH // 2026-04-03
+
Implement voice cleanup pipeline
+
+ Add AI post-processing to transcription results. Support configurable LLM provider + and model selection. Handle filler word removal and grammar correction. +
+
+ IN PROGRESS + VOICE + API +
+
+
+
BUG-013 // BUG // URGENT // 2026-04-03
+
Shell pane crashes on resize during command execution
+
+ When a user resizes the terminal pane while a long-running command is active, + the PTY emulator throws an unhandled exception and the pane becomes unresponsive. +
+
+ BUG + SHELL +
+
+
+
TSK-014 // TASK // MEDIUM // 2026-04-02
+
Workflow DAG validation and cycle detection
+
+ Validate workflow graphs on save. Detect cycles in DAG edges and provide + clear error messages pointing to the conflicting nodes. +
+
+ TODO + WORKFLOW +
+
+
+ +
+ + +
+ 10 // Terminal +

SHELL OUTPUT

+ +
+
+ PANE-1 // BASH + PID 48291 +
+
+# Start DevGlide server +$ devglide start --port 7000 +Starting DevGlide server... + Kanban server .......... OK + Shell server ........... OK + Voice server ........... OK + Test server ............ OK + Workflow server ........ OK + Chat server ............ OK + Log server ............. OK +Server running on http://localhost:7000 + +# Register MCP servers +$ devglide setup +Registered 7 MCP servers +Installed CLAUDE.md instructions +Setup complete. +
+
+
+ +
+ + +
+ 11 // Dashboard +

FULL MOCKUP

+ +

The dashboard is a sidebar + content area. Sidebar is a plain text list in caps. No icons. The kanban board is a raw bordered grid. Counter-intuitively comfortable for long sessions because nothing is fighting for your attention.

+ +
+ + + + +
+
+

KANBAN // AUTH OVERHAUL

+
+ + +
+
+ + +
+ 8 TOTAL + // + 2 TODO + // + 3 IN PROGRESS + // + 1 IN REVIEW + // + 2 DONE +
+ + +
+ +
+
+ TODO + 2 +
+
+
TSK-101
+
Add rate limiting to API
+
+ MEDIUM + TASK +
+
+
+
TSK-102
+
Write migration scripts
+
+ LOW + TASK +
+
+
+ + +
+
+ IN PROGRESS + 3 +
+
+
TSK-103
+
Implement JWT refresh flow
+
+ HIGH + TASK +
+
+
+
BUG-104
+
Session fixation vulnerability
+
+ URGENT + BUG +
+
+
+
TSK-105
+
OAuth2 provider integration
+
+ MEDIUM + TASK +
+
+
+ + +
+
+ IN REVIEW + 1 +
+
+
TSK-106
+
Password policy enforcement
+
+ MEDIUM + TASK +
+
+
+ + +
+
+ DONE + 2 +
+
+
TSK-107
+
Audit existing auth endpoints
+
+ HIGH + TASK +
+
+
+
TSK-108
+
Set up auth test fixtures
+
+ LOW + TASK +
+
+
+
+
+
+
+ +
+ + +
+ 12 // Design Tokens +

REFERENCE TABLE

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValueUsage
--concrete#1a1a1aPage background
--concrete-light#242424Sidebar, raised surfaces
--concrete-mid#333333Borders, dividers
--concrete-pale#444444Heavy borders, thick rules
--concrete-fog#777777Metadata, labels, tertiary text
--concrete-dust#999999Body text, descriptions
--concrete-chalk#bbbbbbPrimary text, card content
--concrete-bright#eeeeeeHeadings, emphasis
--orange#ff6600Accent, active states, alerts
--border-w3pxStandard border width
--border-w-thick4pxSection dividers, dashboard frame
--grid8pxBase grid unit
--fontSpace MonoEverything. One font. No choice.
+
+ +
+ + + + +
+ + + \ No newline at end of file diff --git a/docs/old/demo-comic.html b/docs/old/demo-comic.html new file mode 100644 index 0000000..5c00be5 --- /dev/null +++ b/docs/old/demo-comic.html @@ -0,0 +1,1925 @@ + + + + + +DevGlide — Comic Book Design System + + + + + + + +
+ + +
+
+
+
+ POW! +
+
+ ZAP! +
+
+ WHAM! +
+
+ Issue + #1 +
+
DEVGLIDE
+
COMIC BOOK DESIGN SYSTEM
+
Your AI Workflow Toolkit — Now in Full Color!
+
+
+ + +
+
+ Meanwhile, at DevGlide HQ... a new design system emerges from the pages of a comic book. Every panel, every bubble, every KA-POW is a UI component. +
+
+
+ I wonder what would happen if we made an entire developer dashboard look like a comic book... +
+
+
+ + +
Chapter 1: Typography & Sound Effects
+
+ +
+
1
+
+
The heroes of type...
+
+
Bangers Display
+
font-family: 'Bangers' — Titles, SFX, Headings
+
+
+
+ Light · + Regular · + Bold · + Italic +
+
font-family: 'Comic Neue' — Body text
+
+
+
+ const hero = await deploy();
+ // JetBrains Mono for code +
+
font-family: 'JetBrains Mono' — Code blocks
+
+
+
+ + +
+
2
+
+
POW!
+ DEPLOY! + MERGE! + BAM! + ZAP! + PUSH! + BUILD! + SHIP! +
+
+
+ + +
Chapter 2: Color Palette & Halftones
+
+
Our heroes wield the mighty CMYK primaries!
+
+
+
Yellow
+
#FFE135
+
+
+
Red
+
#ED1C24
+
+
+
Blue
+
#0072BC
+
+
+
Green
+
#00A651
+
+
+
Black
+
#000000
+
+
+
White
+
#F5F0E1
+
+
+
+
+ Orange #FF6B2B + Extended palette +
+
+ Pink #FF69B4 + Halftone shading +
+
+ Purple #7B2FBE + Extended palette +
+
+
+ + +
Chapter 3: Speech & Thought Bubbles
+
+ +
+
3
+
+
Speech Bubble
+
+ Deploy complete! Your app is now live at devglide.app with zero downtime. +
+
+
+ +
+ DevGlide Bot +
+
+
+ + +
+
4
+
+
Right Pointer
+
+
+ Can you run the test suite? I pushed a fix for the kanban drag bug. +
+
+ +
+
+
+
ONLINE
+
+
+
+ + +
+
5
+
+
Thought Bubble
+
+ Maybe I should refactor the voice module before adding new features... +
+
+
+ +
+
+
+
+ + +
Chapter 4: Component Arsenal
+
+ + +
+
6
+
+
Buttons
+
+ + + +
+
+ + +
+
+
+ + +
+
7
+
+
Form Controls
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+
+
+
+ + +
+
8
+
+
Badges & Labels
+
+ FEATURE + BUG + TASK + DONE + URGENT +
+
+ v2.1.0 + DRAFT + AI +
+
+ Running + Down + Warning + Building +
+
+
+ + +
+
9
+
+
Progress & Loading
+
+ +
+
+ 87% +
+
+
+ +
+
+ 45% +
+
+
+ +
+
+
+
+
+
+
+ + +
Chapter 5: Alerts, Menus & Data
+
+ + +
+
10
+
+
Alert Boxes
+
+ +
Build passed!
All 142 tests green.
+
+
+ +
Deploy failed!
Port 7000 in use.
+
+
+ +
Caution!
3 deprecated APIs.
+
+
+ +
Processing...
AI is analyzing your code.
+
+
+
+ + +
+
11
+
+
Dropdown Menu
+
+
+  Kanban Board +
+
+  Shell Terminal +
+
+  Test Runner +
+
+  Voice Control +
+
+  Chat Room +
+
+
Code Snippet
+
+const glide = await connect('mcp'); +await glide.kanban.move({ + id: 'task-42', + to: 'In Progress' +}); +// KAPOW! Task moved. +
+
+
+ + +
+
12
+
+
Data Table
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServerStatusTools
KanbanActive14
ShellActive5
VoiceIdle6
ChatActive5
TestError6
+
+
+
+
+ + +
Chapter 6: Action Lines & Characters
+
+ +
+
13
+
+
+
+ DEPLOY! +
+
+
Action lines radiate from the center for emphasis
+
+
+
+
+ + +
+
14
+
+
Status Characters
+
+
+
+ +
+
+
Success
+
All systems go!
+
+
+
+
+ +
+
+
Error
+
Something broke!
+
+
+
+
+ +
+
+
Processing
+
AI is thinking...
+
+
+
+
+ +
+
+
Celebrate
+
Feature shipped!
+
+
+
+
+
+ Characters give instant visual feedback — no need to read status text. Use them in toasts, alerts, and empty states! +
+
+
+
+
+ + +
Chapter 7: Dashboard Mockup
+
The hero reveals their true form — a complete developer dashboard!
+ +
+ +
+ + + + DevGlide — Comic Dashboard + localhost:7000 +
+ + +
+ +
+
+
DEVGLIDE
+
AI Workflow Toolkit
+
+ +
+
+ + 9 servers online +
+
+
+ + +
+ +
+
+ KANBAN BOARD + Sprint 7 +
+
+ + +
+
+ + +
+ +
+
+ BACKLOG +
+
+
+
Add voice biasing
+
Support custom vocab in STT prompts
+
+ TASK + HIGH +
+
+
+
Fix pane collision
+
409 not handled in UI
+
+ BUG + MED +
+
+
+
+ + +
+
+ TODO +
+
+
+
Workflow DAG editor
+
Visual node editor for workflows
+
+ TASK + URGENT +
+
+
+
+ + +
+
+ IN PROGRESS +
+
+
+
Comic design system
+
Build the comic book UI theme
+
+ TASK + HIGH +
+
+
+
Chat broadcast fix
+
Messages not reaching all panes
+
+ BUG + HIGH +
+
+
+
+ + +
+
+ DONE +
+
+
+
Setup MCP bundles
+
+ DONE +
+
+
+
TTS fallback chain
+
+ DONE +
+
+
+
+
+ + +
+
+ Tip: Drag cards between columns to update task status! +
+
+ SHIP IT! +
+
+
+
+
+ + +
Chapter 8: Caption Boxes & Narration
+
+
15
+
+
+
+
Meanwhile, at the DevGlide server...
+

Caption boxes provide narrative context, guiding the reader through the interface story. They use italic bold text on a yellow background.

+ +
Editor's Note
+

White captions work for meta-information and editor's notes. Use them sparingly for supplementary details.

+ +
DANGER ZONE
+

Red captions signal destructive operations. Deploy with caution!

+
+
+
+/* Caption Box Usage */ + +<div class="caption-box"> + Meanwhile, at the server... +</div> + +/* Variants */ +<div class="caption-box" + style="background: #ED1C24; + color: #fff;"> + DANGER ZONE +</div> + +/* Sound Effect */ +<span class="sfx sfx--pow"> + POW! +</span> + +/* Speech Bubble */ +<div class="speech-bubble"> + Deploy complete! +</div> +
+
+
+
+
+ + +
Chapter 9: Panel Layouts
+
Panels are the backbone of comic layout. Use them for every content section.
+ + +
+
+
16
+
+
THE SETUP
+
A developer opens their terminal...
+
+
+
+
17
+
+
+ $ devglide start
+ Server running on :7000 +
+ BOOT! +
+
+
+
18
+
+
THE PAYOFF
+
All 9 MCP servers come online!
+
+ +
+
+
+
+ + +
+
19
+
+
+
Meanwhile, in production...
+

Wide panels span the full page width. Use them for dramatic reveals, important information, or when you need breathing room between dense panel grids.

+
+
+ DEPLOY! + MERGE! +
+
+
+ + +
Chapter 10: Design Tokens
+
+
20
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValueUsage
--comic-yellow #FFE135Primary actions, highlights, captions
--comic-red #ED1C24Danger, errors, destructive actions
--comic-blue #0072BCInfo, links, secondary actions
--comic-green #00A651Success, active, confirmations
--border-panel4px solid #000Panel borders (the comic book look)
--shadow-hard4px 4px 0 #000Hard offset shadows (no blur!)
--font-display'Bangers'Headlines, SFX, nav labels
--font-body'Comic Neue'Body text, descriptions, forms
--font-mono'JetBrains Mono'Code blocks, terminal output
--gutter12pxGap between comic panels
+
+
+ + + + +
+ + +
+ + + + + diff --git a/docs/old/demo-crt.html b/docs/old/demo-crt.html new file mode 100644 index 0000000..d960f22 --- /dev/null +++ b/docs/old/demo-crt.html @@ -0,0 +1,1482 @@ + + + + + +Retro CRT Terminal — DevGlide Design System + + + + + + + + + + + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + +
+
+

DEVGLIDE

+

+ Retro CRT Terminal — Design System +

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

Color Palette

+

+ Phosphor-driven palette. The primary color shifts with the toggle above. + Background stays deep black. UI chrome uses dim phosphor for borders, bright for emphasis. +

+ +
+
+
+
+
Background
+
#0a0a0a
+
+
+
+
+
+
Phosphor
+
#33ff33
+
+
+
+
+
+
Phosphor Dim
+
#1a9f1a
+
+
+
+
+
+
Phosphor Bright
+
#66ff66
+
+
+
+
+
+
Error
+
#ff3333
+
+
+
+
+
+
Warning
+
#ffb000
+
+
+
+
+ +
+ + +
+

Typography

+

+ VT323 for display text and headings (pixel terminal aesthetic). + JetBrains Mono for body, code, and data. Both loaded from fontsource CDN. +

+ +
+
+ H1 / 48px + System Online +
+
+ H2 / 32px + Module Loaded +
+
+ H3 / 24px + Process Running +
+
+ Body / 14px + The quick brown fox jumps over the lazy dog. 0123456789 +
+
+ Code / 13px + const server = createMcpServer({ name: 'devglide' }); +
+
+ Small / 11px + STATUS: OPERATIONAL · UPTIME: 47d 12h 33m +
+
+
+ +
+ + +
+

Status Indicators

+

+ Front-panel LED indicators for system status. Blinking, steady, off, and alert states. +

+ +
+
+
MCP
+
Shell
+
Sync
+
Queue
+
Disk
+
GPU
+
+
+ PORT :7000 +
+
+ +
+
Active
+
Syncing
+
Warning
+
Error
+
Offline
+
+
+ +
+ + +
+

Components

+

+ Panels with single-line borders, buttons, inputs, and progress indicators. + All styled to match the terminal aesthetic. +

+ + +

Buttons

+
+ + + + +
+ + +

Input Fields

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

Progress

+
+
+
BUILD PROGRESS
+
+
+
+
+
+
TEST COVERAGE
+
+
+
+
+
+
DISK USAGE
+
+
+
+
+
+ + +

Panels

+
+
+ + System Log +
+
+
+
[14:32:07] INFO MCP server started on :7000
+
[14:32:08] INFO Loaded 8 MCP tool servers
+
[14:32:09] WARN Voice: no local whisper model found, using remote
+
[14:32:09] INFO Project "devglide" mounted (id: a3f2c1)
+
[14:33:15] ERR! Shell pane 3 exited with code 1
+
[14:33:16] INFO Shell pane 3 auto-restarted
+
+
+
+ +
+
+ + Active Processes +
+
+
PID   PANE  CMD                   CPU   MEM    STATUS
+----  ----  --------------------  ----  -----  --------
+1204  #1    pnpm dev              2.1%  148MB  RUNNING
+1892  #2    vitest --watch        0.4%   92MB  RUNNING
+2103  #3    tsc --noEmit --watch  1.8%  204MB  RUNNING
+3341  #4    playwright test       8.2%  312MB  BUSY
+
+
+
+ +
+ + +
+

Dashboard Mockup

+

+ BBS-style numbered menu in the sidebar. Kanban board rendered as a text-mode table. +

+ +
+
+
DEVGLIDE v7.0
+
+ 1. + Kanban Board +
+
+ 2. + Shell Panes +
+
+ 3. + Test Runner +
+
+ 4. + Workflows +
+
+
+ 5. + Chat Room +
+
+ 6. + Voice +
+
+ 7. + Vocabulary +
+
+ 8. + Prompts +
+
+
+ 9. + Logs +
+
+ 0. + Settings +
+
+
+
+

Feature: MCP Tool Servers

+ 5 items · 2 in progress +
+ + + + + + + + + + + + + + + + + + + + +
TODOIN PROGRESSIN REVIEW
+
Add rate limiting
+ api + LOW +
+
+ + Chat PTY injection +
+ chat + HIGH +
+ Assigned: claude-1 +
+
+
Voice cleanup mode
+ voice + MED +
+ Review: v2 · 3 comments +
+
+
Workflow DAG viz
+ workflow + MED +
+
+ + Docs seed content +
+ docs + MED +
+ Assigned: claude-2 +
+
+ — +
+
+
+
+ +
+ + +
+

Terminal Branding

+

+ ASCII art header for splash screens and terminal MOTD. +

+ +
+
+
+ ____ ____ _ _ _ +| _ \ _____ _/ ___|| (_) __| | ___ +| | | |/ _ \ \/ / | _| | |/ _` |/ _ \ +| |_| | __/> <| |_| | | | (_| | __/ +|____/ \___/_/\_\\____|_|_|\__,_|\___| + + AI Workflow Toolkit for Developers + ==================================== + MCP Servers: 8 | Port: 7000 + Status: OPERATIONAL | Uptime: 47d +
+
+
+
+ +
+ + +
+

CRT Effects Reference

+

+ The scan lines, screen flicker, vignette, and barrel distortion are always active. + Below is a reference of the layered effects composing the CRT look. +

+ +
+
+ + Effect Stack +
+
+
Layer  Effect              Method
+-----  ------------------  ----------------------------------
+  1    Deep black bg       background: #0a0a0a
+  2    Phosphor text       color + text-shadow glow/bloom
+  3    Scan lines          repeating-linear-gradient (3px)
+  4    Screen flicker      CSS animation (opacity oscillation)
+  5    Barrel distortion   perspective(1200px) rotateX(0.5deg)
+  6    Glass vignette      inset box-shadow (120px spread)
+  7    Reflection          radial-gradient overlay
+  8    Monitor frame       dark gray container + inner shadow
+  9    Block cursor        background + step-end blink anim
+
+
+
+ + +
+
+ DEVGLIDE RETRO CRT TERMINAL — DESIGN SYSTEM REFERENCE + 2026 · ALL INLINE CSS/JS · NO DEPENDENCIES +
+ +
+ +
+
+ +
DevGlide
+
+
+ + + + + + + diff --git a/docs/old/demo-dash-glass.html b/docs/old/demo-dash-glass.html new file mode 100644 index 0000000..97b7a7a --- /dev/null +++ b/docs/old/demo-dash-glass.html @@ -0,0 +1,1160 @@ + + + + + +DevGlide Dashboard — Glassmorphism + + + + + + + +
+ + + + +
+ +
+
+ Dashboard + +
+
+
+ + + + + +
+
+ + + + +
+
+
DK
+
+
+ + +
+ +
+
+ Active Tasks + 47 + + + +12% this week + +
+
+ MCP Calls + 2,841 + + + +8% this week + +
+
+ Test Pass + 98% + + + +2% this week + +
+
+ Transcriptions + 87 + + + +15% this week + +
+
+ + +
+ +
+
+
+
MCP Activity
+
Calls per day this week
+
+ This Week +
+
+ + + + + + + + + + 250 + 200 + 150 + 100 + 50 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Mon + Tue + Wed + Thu + Fri + Sat + Sun + +
+
+ + +
+
+
+
Recent Activity
+
Latest events across tools
+
+ Live +
+
+
+
+
+
Kanban task moved to In Progress
+
2m ago
+
+
+
+
+
+
Shell pane opened
+
5m ago
+
+
+
+
+
+
Test scenario passed
+
12m ago
+
+
+
+
+
+
Voice transcription completed
+
18m ago
+
+
+
+
+
+
Chat message from codex-2
+
25m ago
+
+
+
+
+
+
Workflow triggered
+
31m ago
+
+
+
+
+
+ + +
+
+
+
Kanban Board
+
Current sprint tasks
+
+ 14 tasks +
+
+ +
+
+ Backlog + 3 +
+
+
Add voice provider fallback chain
+
+ Task + Medium +
+
+
+
Vocabulary import/export as JSON
+
+ Feature + Low +
+
+
+
Workflow DAG visual editor prototype
+
+ Feature + Medium +
+
+
+ + +
+
+ Todo + 4 +
+
+
Shell pane auto-reconnect on timeout
+
+ Bug + High +
+
+
+
Test scenario tagging and filtering
+
+ Task + Medium +
+
+
+
Chat rules-of-engagement per project
+
+ Task + Medium +
+
+
+
Documentation seed content installer
+
+ Task + Low +
+
+
+ + +
+
+ In Progress + 2 +
+
+
MCP bundle build script optimization
+
+ Task + High +
+
+
+
Chat PTY injection paste-burst fix
+
+ Bug + High +
+
+
+ + +
+
+ Done + 5 +
+
+
Kanban SQLite migration to WAL mode
+
+ Task + Medium +
+
+
+
Voice whisper.cpp Windows binary auto-download
+
+ Feature + High +
+
+
+
Prompts template variable extraction
+
+ Task + Medium +
+
+
+
Shell scrollback buffer memory limit
+
+ Bug + High +
+
+
+
Log JSONL rotation and compression
+
+ Task + Low +
+
+
+
+
+
+
+
+ + + diff --git a/docs/old/demo-dash-gradient.html b/docs/old/demo-dash-gradient.html new file mode 100644 index 0000000..4a0c57c --- /dev/null +++ b/docs/old/demo-dash-gradient.html @@ -0,0 +1,1367 @@ + + + + + +DevGlide Dashboard — Gradient Mesh / Aurora Dark + + + + + + + + +
+
+
+
+
+
+
+
+ + +
+ + + + + +
+ + + + +
+
+ + + + +
+
+
DK
+
+
+ + +
+ + +
+
+
+ Active Tasks +
+ + + + +
+
+
47
+
+12% from last week
+
+ +
+
+ MCP Calls +
+ + + +
+
+
2,841
+
+8% from last week
+
+ +
+
+ Test Pass Rate +
+ + + +
+
+
98%
+
+2% from last week
+
+ +
+
+ Voice Commands +
+ + + + + + +
+
+
87
+
-3% from last week
+
+
+ + +
+ + +
+
+ MCP Call Volume + This Week +
+
+ + + + + + + + + + + + + + + + + + + + 250 + 200 + 150 + 100 + + + + + + + + + + + + + + + + + + + + + + + + + Mon + Tue + Wed + Thu + Fri + Sat + Sun + +
+
+ + +
+
+ Recent Activity + Live +
+
+
+
+
+
Auth module moved to In Review
+
2 min ago
+
+
+
+
+
+
Shell pane #3 executed build script
+
5 min ago
+
+
+
+
+
+
API tests passed (24/24 specs)
+
12 min ago
+
+
+
+
+
+
Voice command transcribed (14 words)
+
18 min ago
+
+
+
+
+
+
Chat claude-1 responded to review
+
25 min ago
+
+
+
+
+
+
Workflow deploy-staging triggered
+
32 min ago
+
+
+
+
+
+ + +
+
+
+ Kanban Board + 4 columns +
+
+
+ + +
+
+ Backlog + 3 +
+
+
+
Refactor MCP transport layer
+
+ Task +
+
+
+
+
Add WebSocket reconnect logic
+
+ Feature +
+
+
+
+
Update dependency versions
+
+ Task +
+
+
+
+
+ + +
+
+ Todo + 4 +
+
+
+
Design voice settings panel
+
+ Task +
+
+
+
+
Fix shell pane scroll overflow
+
+ Bug +
+
+
+
+
Implement workflow DAG viewer
+
+ Feature +
+
+
+
+
Add keyboard shortcuts help
+
+ Task +
+
+
+
+
+ + +
+
+ In Progress + 2 +
+
+
+
Build chat multi-agent UI
+
+ Feature +
+
+
+
+
Integrate test runner with CI
+
+ Task +
+
+
+
+
+ + +
+
+ Done + 5 +
+
+
+
Setup MCP server framework
+
+ Task +
+
+
+
+
Kanban CRUD operations
+
+ Feature +
+
+
+
+
Voice transcription pipeline
+
+ Feature +
+
+
+
+
Shell pane management
+
+ Task +
+
+
+
+
Fix login token refresh bug
+
+ Bug +
+
+
+
+
+ +
+
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/docs/old/demo-dash-neumorph.html b/docs/old/demo-dash-neumorph.html new file mode 100644 index 0000000..ebbafa8 --- /dev/null +++ b/docs/old/demo-dash-neumorph.html @@ -0,0 +1,882 @@ + + + + + +DevGlide Dashboard — Neumorphism (Soft UI) + + + + + +
+ +
+ + +
+ +
DK
+
+
+ + + + + +
+ + +
+
+
+
+
Active Tasks
+
47
+
+12% from last week
+
+
+ +
+
+
+
+
+
+
MCP Calls
+
2,841
+
+8% from last week
+
+
+ +
+
+
+
+
+
+
Test Pass Rate
+
98%
+
+2.1% from last week
+
+
+ +
+
+
+
+
+
+
Voice Commands
+
87
+
-3% from last week
+
+
+ +
+
+
+
+ + +
+ +
+
Weekly Activity
+
+ + + + + + + + +
+
+ + +
+
Recent Activity
+
+ + Kanban task "API Integration" moved to In Progress + 2m ago +
+
+ + Test suite "Auth Flow" passed (14/14) + 15m ago +
+
+ + Workflow "Deploy Staging" triggered manually + 32m ago +
+
+ + Voice command failed: microphone not detected + 1h ago +
+
+ + MCP server "devglide-shell" restarted successfully + 2h ago +
+
+ + Chat session started with 3 participants + 3h ago +
+
+
+ + +
+
Kanban Board
+
+ +
+
+ Backlog + 3 +
+
+ Research MCP protocol v2 +
+ Task + + Low +
+
+
+ Design voice UI concepts +
+ Task + + Medium +
+
+
+ Audit test coverage gaps +
+ Task + + Low +
+
+
+ + +
+
+ Todo + 4 +
+
+ Add workflow templates +
+ Task + + High +
+
+
+ Fix shell pane resize bug +
+ Bug + + Urgent +
+
+
+ Implement chat @mentions +
+ Task + + Medium +
+
+
+ Update API documentation +
+ Task + + Low +
+
+
+ + +
+
+ In Progress + 2 +
+
+ API integration layer +
+ Task + + High +
+
+
+ Voice transcription pipeline +
+ Task + + Medium +
+
+
+ + +
+
+ Done + 5 +
+
+ Setup CI/CD pipeline +
+ Task + + High +
+
+
+ Fix login token refresh +
+ Bug + + Urgent +
+
+
+ Kanban drag-and-drop +
+ Task + + Medium +
+
+
+ Shell command history +
+ Task + + Low +
+
+
+ Test result export CSV +
+ Task + + Medium +
+
+
+
+
+ +
+
+ + + + diff --git a/docs/old/demo-dash-vercel-purple.html b/docs/old/demo-dash-vercel-purple.html new file mode 100644 index 0000000..996cf35 --- /dev/null +++ b/docs/old/demo-dash-vercel-purple.html @@ -0,0 +1,1133 @@ + + + + + +DevGlide Dashboard — Vercel / Linear Minimal Dark (Purple Accent) + + + + + + +
+ + +
+
+ + +
+
+ + +
DK
+
+
+ + + + + +
+ + + +
+
+
+ + Active Tasks +
+
47
+
+ + +12% from last week +
+
+
+
+ + MCP Calls +
+
2,841
+
+ + +8% from last week +
+
+
+
+ + Test Pass Rate +
+
98%
+
+ + +3% from last week +
+
+
+
+ + Transcriptions +
+
87
+
+ + +5% from last week +
+
+
+ + +
+ +
+
+ MCP Call Volume + Last 7 days +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ MonTueWedThuFriSatSun +
+
+ + +
+
+ Recent Activity + View all +
+
+
+ +
+
Kanban task moved to In Progress
+
2m ago
+
+
+
+ +
+
Shell pane opened
+
5m ago
+
+
+
+ +
+
Test scenario passed
+
12m ago
+
+
+
+ +
+
Voice transcription completed
+
18m ago
+
+
+
+ +
+
Chat message from codex-2
+
25m ago
+
+
+
+ +
+
Workflow triggered
+
31m ago
+
+
+
+
+
+ + +
+
+ Kanban Board + +
+
+ +
+
+ + Backlog + 3 + + +
+
+
+
Research voice provider options
+
+ + research + DG-18 +
+
+
+
Add workflow template export
+
+ + feature + DG-21 +
+
+
+
Document MCP error codes
+
+ + docs + DG-24 +
+
+
+
+ + +
+
+ + Todo + 4 + + +
+
+
+
Implement shell pane resize
+
+ + feature + DG-14 +
+
+
+
Fix chat message ordering
+
+ + bug + DG-15 +
+
+
+
Add test retry logic
+
+ + feature + DG-16 +
+
+
+
Kanban drag-drop polish
+
+ + ui + DG-17 +
+
+
+
+ + +
+
+ + In Progress + 2 + + +
+
+
+
Voice transcription streaming
+
+ + feature + DG-09 +
+
+
+
Workflow DAG visualizer
+
+ + ui + DG-11 +
+
+
+
+ + +
+
+ + Done + 5 + + +
+
+
+
MCP server authentication
+
+ + feature + DG-01 +
+
+
+
Dashboard sidebar layout
+
+ + ui + DG-03 +
+
+
+
Kanban SQLite migration
+
+ + infra + DG-05 +
+
+
+
Shell scrollback buffer
+
+ + feature + DG-06 +
+
+
+
Test scenario persistence
+
+ + feature + DG-08 +
+
+
+
+
+
+
+
+ + + + diff --git a/docs/old/demo-dash-vercel-teal.html b/docs/old/demo-dash-vercel-teal.html new file mode 100644 index 0000000..fd5b68e --- /dev/null +++ b/docs/old/demo-dash-vercel-teal.html @@ -0,0 +1,1149 @@ + + + + + +DevGlide Dashboard — Vercel Minimal + DevGlide Teal + + + + + + +
+ + +
+
+ + +
+
+ +
DK
+
+
+ + + + + +
+ + + +
+
+
+ + Active Tasks +
+
47
+
+ + +12% from last week +
+
+
+
+ + MCP Calls +
+
2,841
+
+ + +8% from last week +
+
+
+
+ + Test Pass Rate +
+
98%
+
+ + +3% from last week +
+
+
+
+ + Transcriptions +
+
87
+
+ + +5% from last week +
+
+
+ + +
+ +
+
+ MCP Call Volume + Last 7 days +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ MonTueWedThuFriSatSun +
+
+ + +
+
+ Recent Activity + View all +
+
+
+ +
+
Kanban task moved to In Progress
+
2m ago
+
+
+
+ +
+
Shell pane opened
+
5m ago
+
+
+
+ +
+
Test scenario passed
+
12m ago
+
+
+
+ +
+
Voice transcription completed
+
18m ago
+
+
+
+ +
+
Chat message from codex-2
+
25m ago
+
+
+
+ +
+
Workflow triggered
+
31m ago
+
+
+
+
+
+ + +
+
+ Kanban Board + +
+
+ +
+
+ + Backlog + 3 + + +
+
+
+
Research voice provider options
+
+ + research + DG-18 +
+
+
+
Add workflow template export
+
+ + feature + DG-21 +
+
+
+
Document MCP error codes
+
+ + docs + DG-24 +
+
+
+
+ + +
+
+ + Todo + 4 + + +
+
+
+
Implement shell pane resize
+
+ + feature + DG-14 +
+
+
+
Fix chat message ordering
+
+ + bug + DG-15 +
+
+
+
Add test retry logic
+
+ + feature + DG-16 +
+
+
+
Kanban drag-drop polish
+
+ + ui + DG-17 +
+
+
+
+ + +
+
+ + In Progress + 2 + + +
+
+
+
Voice transcription streaming
+
+ + feature + DG-09 +
+
+
+
Workflow DAG visualizer
+
+ + ui + DG-11 +
+
+
+
+ + +
+
+ + Done + 5 + + +
+
+
+
MCP server authentication
+
+ + feature + DG-01 +
+
+
+
Dashboard sidebar layout
+
+ + ui + DG-03 +
+
+
+
Kanban SQLite migration
+
+ + infra + DG-05 +
+
+
+
Shell scrollback buffer
+
+ + feature + DG-06 +
+
+
+
Test scenario persistence
+
+ + feature + DG-08 +
+
+
+
+
+
+
+
+ + + + diff --git a/docs/old/demo-dash-vercel-warm.html b/docs/old/demo-dash-vercel-warm.html new file mode 100644 index 0000000..450009f --- /dev/null +++ b/docs/old/demo-dash-vercel-warm.html @@ -0,0 +1,1122 @@ + + + + + +DevGlide Dashboard — Vercel Minimal Dark / Warm Amber + + + + + + +
+ + +
+
+ + +
+
+ + +
DK
+
+
+ + + + + +
+ + + +
+
+
+ + Active Tasks +
+
47
+
+ + +12% from last week +
+
+
+
+ + MCP Calls +
+
2,841
+
+ + +8% from last week +
+
+
+
+ + Test Pass Rate +
+
98%
+
+ + +3% from last week +
+
+
+
+ + Transcriptions +
+
87
+
+ + +5% from last week +
+
+
+ + +
+ +
+
+ MCP Call Volume + Last 7 days +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ MonTueWedThuFriSatSun +
+
+ + +
+
+ Recent Activity + View all +
+
+
+ +
+
Kanban task moved to In Progress
+
2m ago
+
+
+
+ +
+
Shell pane opened
+
5m ago
+
+
+
+ +
+
Test scenario passed
+
12m ago
+
+
+
+ +
+
Voice transcription completed
+
18m ago
+
+
+
+ +
+
Chat message from codex-2
+
25m ago
+
+
+
+ +
+
Workflow triggered
+
31m ago
+
+
+
+
+
+ + +
+
+ Kanban Board + +
+
+ +
+
+ + Backlog + 3 + + +
+
+
+
Research voice provider options
+
+ + research + DG-18 +
+
+
+
Add workflow template export
+
+ + feature + DG-21 +
+
+
+
Document MCP error codes
+
+ + docs + DG-24 +
+
+
+
+ + +
+
+ + Todo + 4 + + +
+
+
+
Implement shell pane resize
+
+ + feature + DG-14 +
+
+
+
Fix chat message ordering
+
+ + bug + DG-15 +
+
+
+
Add test retry logic
+
+ + feature + DG-16 +
+
+
+
Kanban drag-drop polish
+
+ + ui + DG-17 +
+
+
+
+ + +
+
+ + In Progress + 2 + + +
+
+
+
Voice transcription streaming
+
+ + feature + DG-09 +
+
+
+
Workflow DAG visualizer
+
+ + ui + DG-11 +
+
+
+
+ + +
+
+ + Done + 5 + + +
+
+
+
MCP server authentication
+
+ + feature + DG-01 +
+
+
+
Dashboard sidebar layout
+
+ + ui + DG-03 +
+
+
+
Kanban SQLite migration
+
+ + infra + DG-05 +
+
+
+
Shell scrollback buffer
+
+ + feature + DG-06 +
+
+
+
Test scenario persistence
+
+ + feature + DG-08 +
+
+
+
+
+
+
+
+ + + + diff --git a/docs/old/demo-dash-vercel.html b/docs/old/demo-dash-vercel.html new file mode 100644 index 0000000..c7024f3 --- /dev/null +++ b/docs/old/demo-dash-vercel.html @@ -0,0 +1,1120 @@ + + + + + +DevGlide Dashboard — Vercel / Linear Minimal Dark + + + + + + +
+ + +
+
+ + +
+
+ + +
DK
+
+
+ + + + + +
+ + + +
+
+
+ + Active Tasks +
+
47
+
+ + +12% from last week +
+
+
+
+ + MCP Calls +
+
2,841
+
+ + +8% from last week +
+
+
+
+ + Test Pass Rate +
+
98%
+
+ + +3% from last week +
+
+
+
+ + Voice Commands +
+
87
+
+ + +5% from last week +
+
+
+ + +
+ +
+
+ MCP Call Volume + Last 7 days +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ MonTueWedThuFriSatSun +
+
+ + +
+
+ Recent Activity + View all +
+
+
+ +
+
Test suite "Auth Flow" passed
+
2 min ago
+
+
+
+ +
+
MCP tool kanban_move_item called
+
5 min ago
+
+
+
+ +
+
Voice command "Create new task" processed
+
12 min ago
+
+
+
+ +
+
Workflow "Deploy Pipeline" completed
+
18 min ago
+
+
+
+ +
+
Test "API Integration" failed
+
25 min ago
+
+
+
+ +
+
Shell pane "build-server" opened
+
32 min ago
+
+
+
+
+
+ + +
+
+ Kanban Board + +
+
+ +
+
+ + Backlog + 3 + + +
+
+
+
Research voice provider options
+
+ + research + DG-18 +
+
+
+
Add workflow template export
+
+ + feature + DG-21 +
+
+
+
Document MCP error codes
+
+ + docs + DG-24 +
+
+
+
+ + +
+
+ + Todo + 4 + + +
+
+
+
Implement shell pane resize
+
+ + feature + DG-14 +
+
+
+
Fix chat message ordering
+
+ + bug + DG-15 +
+
+
+
Add test retry logic
+
+ + feature + DG-16 +
+
+
+
Kanban drag-drop polish
+
+ + ui + DG-17 +
+
+
+
+ + +
+
+ + In Progress + 2 + + +
+
+
+
Voice transcription streaming
+
+ + feature + DG-09 +
+
+
+
Workflow DAG visualizer
+
+ + ui + DG-11 +
+
+
+
+ + +
+
+ + Done + 5 + + +
+
+
+
MCP server authentication
+
+ + feature + DG-01 +
+
+
+
Dashboard sidebar layout
+
+ + ui + DG-03 +
+
+
+
Kanban SQLite migration
+
+ + infra + DG-05 +
+
+
+
Shell scrollback buffer
+
+ + feature + DG-06 +
+
+
+
Test scenario persistence
+
+ + feature + DG-08 +
+
+
+
+
+
+
+
+ + + + diff --git a/docs/old/demo-deepsea.html b/docs/old/demo-deepsea.html new file mode 100644 index 0000000..a39738e --- /dev/null +++ b/docs/old/demo-deepsea.html @@ -0,0 +1,1915 @@ + + + + + +Bioluminescent Deep Sea — Design System for DevGlide + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +

Bioluminescent Deep Sea

+

A design system that breathes in the dark

+

DevGlide — AI Workflow Toolkit

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

Bioluminescent Palette

+

+ Colors drawn from the abyssal zone: living light against infinite darkness. + Each accent glows with the intensity of deep-sea organisms. +

+ +
+

Accent Colors

+
+
+
+
Cyan Glow
+
#00ffd5
+
+
+
+
Violet Pulse
+
#b366ff
+
+
+
+
Amber Bio
+
#ffd166
+
+
+
+
Rose Anemone
+
#ff6b9d
+
+
+
+ +
+

Depth Layers

+
+
+
+
Abyss
+
#050a18
+
+
+
+
Bathyal
+
#0e1a38
+
+
+
+
Mesopelagic
+
#121f42
+
+
+
+
Twilight
+
#162650
+
+
+
+
+ + + + + + + + +
+

Typography

+

+ Outfit for display text, soft and rounded like organic deep-sea forms. + DM Sans for readable body text. JetBrains Mono for code elements. +

+ +
+
+
Display / Outfit 700
+
Creatures of the Deep
+
+
+
Heading / Outfit 600
+
Bioluminescence illuminates the abyss where no sunlight penetrates
+
+
+
Body / DM Sans 400
+
At depths below 1,000 meters, organisms produce their own light through chemical reactions. This bioluminescence serves many purposes: attracting prey, communicating with potential mates, and confusing predators in the eternal darkness.
+
+
+
Code / JetBrains Mono 400
+
const glow = { color: '#00ffd5', intensity: 0.8, pulse: true }; +organism.emit(glow);
+
+
+
Small / DM Sans 400
+
Mesopelagic zone, 200-1000m depth. Ambient light: 0.001 lux.
+
+
+
+ + + + + + + + +
+

Buttons

+

+ Interactive elements pulse with bioluminescent energy. Hover to activate the glow response. +

+ +
+
+
Standard Sizes
+
+ + + + + +
+
+ +
+
Small Variants
+
+ + + + +
+
+
+
+ + +
+

Form Inputs

+

+ Input fields glow softly on focus, like organisms responding to stimuli in the deep. +

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

Badges & Status

+

+ Living indicators that breathe like organisms. Status dots pulse with the rhythm of the deep. +

+ +
+
+
Badges
+
+ MCP Server + Workflow + In Progress + Bug + v2.4.0 + AI-Powered +
+
+
+
Status Indicators
+
+
+ + Connected +
+
+ + Processing +
+
+ + Error +
+
+ + Dormant +
+
+
+
+
+ + +
+

Toasts

+

+ Notification messages that surface from the depths, each tinted with bioluminescent meaning. +

+ +
+
+ + Shell pane created. Command executed successfully. + 2s ago +
+
+ + Connection lost to MCP server. Attempting reconnect... + 5s ago +
+
+ + Test scenario timed out after 30s. Check browser state. + 12s ago +
+
+ + Workflow "deploy-staging" matched and is now executing. + 18s ago +
+
+
+ + + + + + + + + +
+

Specimen Cards

+

+ Content cards styled as deep-sea creature specimens, with glowing outlines that intensify on interaction. +

+ +
+
+ 🧭 +
Kanban Board
+
Manage features and tasks across columns. Track work from backlog through to completion.
+
+ Active + 12 items +
+
+
+ 💬 +
Multi-LLM Chat
+
Shared room where multiple AI agents communicate via @mentions and PTY injection.
+
+ 3 agents + Live +
+
+
+ 🎤 +
Voice Transcription
+
Speech-to-text with vocabulary biasing and AI cleanup. Multiple STT providers supported.
+
+ Groq + Cleanup +
+
+
+ 🧪 +
Test Runner
+
AI-driven browser test automation. Describe tests in natural language, run scenarios automatically.
+
+ 8 saved + 2 failed +
+
+
+ ⚙️ +
Workflow Engine
+
Reusable workflow DAGs with triggers, shell commands, kanban ops, and decision nodes.
+
+ 5 active +
+
+
+ 📋 +
Shell Manager
+
Terminal pane management. Create, run commands, read scrollback across managed sessions.
+
+ 4 panes + Running +
+
+
+
+ + + + + + + + +
+

Code

+

+ Syntax highlighting with bioluminescent hues. Each token type maps to a distinct spectral emission. +

+ +
+ TypeScript + // Define a bioluminescent creature for the deep sea UI +interface Creature { + name: string; + depth: number; + glow: { + color: '#00ffd5' | '#b366ff' | '#ffd166'; + intensity: number; + pulseRate: number; + }; +} + +const anglerfish: Creature = { + name: 'Melanocetus johnsonii', + depth: 3200, + glow: { + color: '#00ffd5', + intensity: 0.85, + pulseRate: 2.4, + }, +}; + +function emitLight(creature: Creature): void { + const { color, intensity, pulseRate } = creature.glow; + console.log(`Emitting ${color} at ${intensity} intensity`); +} +
+
+ + + + + + + + +
+

Jellyfish Loader

+

+ CSS-only loading animation inspired by a deep-sea jellyfish. The bell pulses and contracts + while translucent tentacles trail with organic rhythm. +

+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading from the deep... +
+
+
+ + + + + + + + + +
+

Dashboard Mockup

+

+ Full layout with deep-sea sidebar navigation and glowing kanban board. + The interface lives and breathes in the abyss. +

+ +
+
+ + + + +
+
+

Deep Sea Expedition

+
+ + +
+
+ +
+ +
+
+ Todo + 3 +
+
+
Design bioluminescent token system
+
+ ui + +
+
+
+
Add tentacle SVG divider component
+
+ ui + +
+
+
+
Configure deep-sea voice prompts
+
+ api + +
+
+
+ +
+
+ In Progress + 2 +
+
+
CSS jellyfish loader animation
+
+ ui + +
+
+
+
Fix particle z-index on Safari
+
+ bug + +
+
+
+ +
+
+ In Review + 2 +
+
+
Glow intensity accessibility audit
+
+ a11y + +
+
+
+
Depth-based parallax scrolling
+
+ ui + +
+
+
+ +
+
+ Done + 2 +
+
+
Dark abyss color palette
+
+ ui + +
+
+
+
MCP server shell integration
+
+ infra + +
+
+
+
+
+
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/docs/old/demo-ember.html b/docs/old/demo-ember.html new file mode 100644 index 0000000..e3f71e4 --- /dev/null +++ b/docs/old/demo-ember.html @@ -0,0 +1,2120 @@ + + + + + +Ember — Adaptive Biophilic Design System + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ +
+ + DevGlide Design System +
+ +

Ember

+ +

+ An adaptive biophilic design system. Nature-derived warmth that shifts + with the rhythm of your day. Structure felt, not seen. +

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

Design Principles

+

+ Ember draws from organic forms and warm earth tones to create an + interface that breathes with you. +

+ +
+
+
🌿
+

Biophilic Warmth

+

Nature-derived colors and textures reduce cognitive strain. Forest greens, amber, and stone tones ground the experience in the organic world.

+
+
+
+

Adaptive Rhythm

+

Three time-of-day modes shift the entire color temperature, aligning your workspace with your natural circadian pattern.

+
+
+
💠
+

Organic Geometry

+

Generous border-radius and soft edges everywhere. No sharp corners, no harsh lines. Surfaces feel grown, not constructed.

+
+
+
🔍
+

Accessible Contrast

+

OKLCH-informed palette with 7:1+ contrast ratio for body text. Readability is never sacrificed for aesthetics.

+
+
+
📄
+

Paper Grain

+

Subtle SVG noise texture on surfaces evokes natural paper, adding tactile depth without competing with content.

+
+
+
+

Structure Felt, Not Seen

+

Soft borders, gentle shadows, and translucent layers create spatial hierarchy that guides without demanding attention.

+
+
+
+ + +
+

Color Palette

+

+ Warm, nature-derived tokens that shift with the active time-of-day mode. + Toggle above to see palette adaptation. +

+ +
+
+
+
+
Base
+
+
+
+
+
+
+
Surface
+
+
+
+
+
+
+
Raised
+
+
+
+
+
+
+
Accent
+
+
+
+
+
+
+
Secondary
+
+
+
+
+
+
+
Tertiary
+
+
+
+
+
+
+
Text Primary
+
+
+
+
+
+
+
Text Secondary
+
+
+
+
+
+
+
Success
+
+
+
+
+
+
+
Error
+
+
+
+
+
+
+
Warning
+
+
+
+
+
+
+
Info
+
+
+
+
+
+ + +
+

Typography

+

+ Three font families tuned for warmth and legibility. Bricolage Grotesque for display, + DM Sans for body, JetBrains Mono for code. +

+ +
+
+
+
Display XL
Bricolage 800
+
Organic Warmth
+
+
+
Display LG
Bricolage 600
+
Adaptive by nature, precise by design
+
+
+
Heading
Bricolage 600
+
Building tools that feel alive
+
+
+
Body
DM Sans 400
+
Good design is invisible. It creates rhythm without + disruption, guides without forcing, and adapts without losing identity. + Ember brings this philosophy to developer tools.
+
+
+
Body Small
DM Sans 400
+
Subtle shifts in color temperature throughout the day help maintain + focus during long coding sessions while respecting your natural energy cycles.
+
+
+
Code
JetBrains Mono
+ const ember = createTheme({ + mode: 'evening', + accent: oklch(0.78 0.12 145), + grain: { opacity: 0.028, blend: 'overlay' } +}); +
+
+
+
+ + +
+

Components

+

+ Interactive elements designed with organic curves, gentle transitions, and semantic color coding. +

+ +
+ + +
+
Buttons
+
+ + + + +
+
+ + + + +
+
+ + +
+
Inputs
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
Badges
+
+ ✓ Deployed + ✗ Failed + ⚠ Pending + ℹ In Review + v2.4.0 + ● Online +
+
+ + +
+
Status Indicators
+
+
+
+ MCP Server +
+
+
+ Dashboard +
+
+
+ Test Runner +
+
+
+ Voice Service +
+
+
+ Shell Pane 3 +
+
+
+ + +
+
Toasts
+
+
+
+
+
Build Successful
+
All 47 tests passed in 3.2s
+
+
+
+
!
+
+
Connection Lost
+
MCP server unreachable. Retrying...
+
+
+
+
+
+
Deprecation Warning
+
voice_speak v1 will be removed in 3.0
+
+
+
+
i
+
+
New Agent Joined
+
claude-2 has entered the chat room
+
+
+
+
+ +
+
+ + +
+

Dashboard Mockup

+

+ A miniature representation of the DevGlide dashboard with sidebar navigation + and kanban board, demonstrating how Ember feels in context. +

+ +
+
+
+ +
+
Kanban
+
Shell
+
Chat
+
Tests
+
+
+
+ +
+
+
+
+
+ + + + + + + + +
+
+
+
Ember Design System
+ +
+
+ +
+
+ Todo + 3 +
+
+
Define OKLCH palette tokens
+
+ ui + +
+
+
+
Paper grain SVG filter
+
+ ui + +
+
+
+
Biomorphic spinner set
+
+ ui + +
+
+
+ +
+
+ In Progress + 2 +
+
+
Time-of-day toggle component
+
+ ui + +
+
+
+
Fix contrast ratio on badges
+
+ bug + +
+
+
+ +
+
+ In Review + 2 +
+
+
Typography specimen page
+
+ ui + +
+
+
+
Optimize transition perf
+
+ perf + +
+
+
+ +
+
+ Done + 1 +
+
+
Surface elevation system
+
+ api + +
+
+
+
+
+
+
+
+ + +
+

Biomorphic Spinners

+

+ CSS-only loading animations inspired by organic forms. Blob shapes, fractal rings, + breathing dots, and wave rhythms. +

+ +
+
+
+
+
+
+
+
+
Organic Rings
+
+ +
+
+
+
+
+
+
+
Fractal Orbit
+
+ +
+
+
+
+
+
+
Breathing
+
+ +
+
+
+
+
+
+
+
+
Wave
+
+
+
+
+ +
+ + + + + + diff --git a/docs/old/demo-flux.html b/docs/old/demo-flux.html new file mode 100644 index 0000000..3db774e --- /dev/null +++ b/docs/old/demo-flux.html @@ -0,0 +1,1585 @@ + + + + + +Flux — Calm Monochrome Design System + + + + + + + +
+ + +
+

Flux

+

Calm monochrome + one accent. A design system for DevGlide.

+
+ v0.1 + Design System + Monochrome +
+
+ + +
+ Accent hue +
+ +
+ 228 +
+
+ + +
+ +
+
+
+
01
+
Monochrome default
+
Almost everything is gray. Color is reserved for a single accent and tiny status dots. No colored badges. No colored toasts. Gray everywhere with surgical precision.
+
+
+
02
+
Tonal elevation
+
Depth through lightness, not shadow. Higher surfaces are lighter. Five tonal layers from near-black to dark gray create spatial hierarchy without competing for attention.
+
+
+
03
+
Earned attention
+
Don't compete for attention you haven't earned. Every pixel of color must justify its existence. Interactive elements get the accent. Everything else recedes.
+
+
+
04
+
Information density
+
Pack information tightly but breathe through whitespace between groups. The 8px baseline grid keeps vertical rhythm mechanical and predictable.
+
+
+
05
+
Motion as guide
+
All transitions under 300ms. Directional movement signals spatial relationships. Enter from below, exit upward. Ease out, never bounce. Movement serves cognition.
+
+
+
06
+
One accent, infinite variation
+
A single hue generates the entire accent palette. Default, muted, dim, hover, text variants -- all derived from one value. Change the hue, change the personality.
+
+
+
+
+ + +
+ +
+ +
+
GRAY SCALE
+
+
+ #161618 + base +
+
+ #1c1c1f + surface +
+
+ #232327 + raised +
+
+ #2a2a2f + overlay +
+
+ #303036 + hover +
+
+
+ +
+
TEXT
+
+
+ Primary +
+
+ Secondary +
+
+ Muted +
+
+
+ +
+
ACCENT (DERIVED FROM HUE)
+
+
default
+
hover
+
dim
+
muted
+
+
+ +
+
STATUS DOTS (COLOR RESERVED FOR DOTS ONLY)
+
+
+
+ Success +
+
+
+ Error +
+
+
+ Warning +
+
+
+ Info +
+
+
+ +
+
+ + +
+ +
+
+
+
+
Display
+
48 / 300
+
+
Almost nothing
+
+
+
+
Heading 1
+
28 / 600
+
+
Restraint is strength
+
+
+
+
Heading 2
+
20 / 500
+
+
Surgical accent application
+
+
+
+
Body
+
14 / 400
+
+
The quick brown fox jumps over the lazy dog. Information density without visual noise. Every element earns its place.
+
+
+
+
Caption
+
11 / 500
+
+
Section label · uppercase · tracked
+
+
+
+
Code
+
13 / 400 Mono
+
+
const flux = { hue: 228, accent: derive(hue) };
+
+
+ + +
+// Flux design tokens +const tokens = { + accentHue: 228, + accentDefault: hsl(228, 72%, 68%), + bgBase: '#161618', + bgSurface: '#1c1c1f', + textPrimary: '#e4e4e7', +}; +
+
+
+ + +
+ +
+
+ + +
+
Buttons
+
+ + + + +
+
+ + + +
+
+ + +
+
Inputs
+
+ + +
+
+ + +
+
+ + +
+
Badges
+
+ Deployed + Failed + Pending + In Review +
+
+ + +
+
Toggles
+
+
+
+ Dark mode +
+
+
+ Notifications +
+
+
+ + +
+
Toasts
+
+
+ + Task moved to In Progress +
+
+ + Build completed successfully +
+
+ + Connection to server lost +
+
+ + API rate limit approaching threshold +
+
+
+ + +
+
Table
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameStatusTypeUpdated
Auth flow redesignActiveTASK2m ago
Fix sidebar overflowReviewBUG14m ago
Voice config migrationQueuedTASK1h ago
+
+
+ +
+
+
+ + +
+ +
+

Depth without shadows. Each surface layer is slightly lighter, creating spatial hierarchy through tone alone. Five levels from base to overlay.

+
+
+
Level 0
+
base #161618
+
+
+
Level 1
+
surface #1c1c1f
+
+
+
Level 2
+
raised #232327
+
+
+
Level 3
+
overlay #2a2a2f
+
+
+
Level 4
+
hover #303036
+
+
+
+
+ + +
+ +
+
+
+
+
+
+ DevGlide + + localhost:7000 +
+
+ + + + +
+
+
+ Todo + 3 +
+
+
Set up CI pipeline
+
+ infra + +
+
+
+
Write API docs
+
+ docs + +
+
+
+
Audit dependencies
+
+ security + +
+
+
+ +
+
+ In Progress + 2 +
+
+
Flux design system
+
+ ui + +
+
+
+
Voice transcription
+
+ feature + +
+
+
+ +
+
+ In Review + 1 +
+
+
Shell pane routing
+
+ core + +
+
+
+ +
+
+ Done + 2 +
+
+
Project scaffolding
+
+ setup +
+
+
+
Auth middleware
+
+ api +
+
+
+
+
+
+
+
+ + +
+ +
+
+
+ 4px +
+ sp-1 +
+
+ 8px +
+ sp-2 (base unit) +
+
+ 16px +
+ sp-4 +
+
+ 24px +
+ sp-6 +
+
+ 32px +
+ sp-8 +
+
+ 48px +
+ sp-12 +
+
+ 64px +
+ sp-16 +
+
+
+
+ + + + +
+ + + + + + + diff --git a/docs/old/demo-matrix.html b/docs/old/demo-matrix.html new file mode 100644 index 0000000..fe2b493 --- /dev/null +++ b/docs/old/demo-matrix.html @@ -0,0 +1,1404 @@ + + + + + +DevGlide // Matrix Terminal + + + + + + +
+
+ + + + + +
+
+
+ + +
+ + + + + +
+ + +
+

+ DEVGLIDE + v2.4.0 +

+
+
SESSION: mcp-7f3a9b2e
+
2026-04-03T14:23:07Z
+
TTY: /dev/pts/3
+
+
+ + +
+ ACCESS GRANTED + AUTH: RSA-4096 // FINGERPRINT: SHA256:xK9m2... +
+ + +
+
+ root@devglide:~# _ +
+
+ + +
═════════════════════════════════════════════[ SYSTEM OVERVIEW ]═════════════════════════════════════════════
+ + +
+
+
Active Tasks
+
????????
+
+7 from yesterday
+
+
+
MCP Calls / hr
+
????????
+
avg latency 23ms
+
+
+
Test Pass Rate
+
????????
+
3 flaky, 1 failing
+
+
+
Voice Sessions
+
????????
+
12.4 avg WPM
+
+
+ + +
══════════════════════════════════════════════[ KANBAN BOARD ]══════════════════════════════════════════════
+ +
+
+ > _ + 192.168.1.42 -- 2026-04-03T14:23:11Z +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTaskStatusPriorityAssigned
#1042Implement streaming MCP responsesIn ProgressHIGHclaude-1
#1039Add voice biasing for domain termsIn ReviewMEDIUMcodex-2
#1037Shell pane auto-cleanup on idleTestingMEDIUMclaude-1
#1035Fix chat pane collision edge caseTodoHIGH--
#1031Workflow DAG visualizationTodoLOW--
#1028Documentation seed content v2DoneLOWclaude-1
+
+
+ + +
══════════════════════════════════════════[ NETWORK TOPOLOGY + HEX DUMP ]══════════════════════════════════════════
+ +
+ +
+
+ > netstat --mcp-topology + 192.168.1.42 +
+
+
+
+
+ + +
+
+ > xxd /dev/mcp/stream | head -12 + encrypted +
+
+
+
+
+
+ + +
════════════════════════════════════════════[ SYSTEM LOAD + PROCESSES ]════════════════════════════════════════════
+ +
+ +
+
+ > _ +
+
+
+ CPU Core 0 + [█████████░░░] + 73% +
+
+ CPU Core 1 + [█████░░░░░░░] + 42% +
+
+ CPU Core 2 + [███████████░] + 91% +
+
+ CPU Core 3 + [███░░░░░░░░░] + 25% +
+
+
+ Disk I/O + [██████░░░░░░] + 50% +
+
+ Network + [███████░░░░░] + 58% +
+
+
+
+ + +
+
+ > ps aux --mcp +
+
+
+
+  PID    STATE    CPU    MEM    COMMAND +
+
+ 48291  RUN     2.1%   84M    devglide-server +
+
+ 48302  RUN     0.8%   32M    mcp-kanban +
+
+ 48305  RUN     1.4%   28M    mcp-shell +
+
+ 48308  SLEEP   0.1%   18M    mcp-test +
+
+ 48311  RUN     0.5%   22M    mcp-voice +
+
+ 48314  RUN     3.2%   45M    mcp-chat +
+
+ 48317  SLEEP   0.0%   12M    mcp-workflow +
+
+ 48320  SLEEP   0.0%    8M    mcp-vocabulary +
+
+ 48323  SLEEP   0.0%   10M    mcp-prompts +
+
+ 48326  SLEEP   0.0%    6M    mcp-log +
+
+
+
+
+ + +
+
+    ____              _________ __    __
+   / __ \___  _   __/ ____/ (_) ___/ /__
+  / / / / _ \| | / / / __/ / / __  / _ \
+ / /_/ /  __/| |/ / /_/ / / / /_/ /  __/
+/_____/\___/ |___/\____/_/_/\__,_/\___/
+
+    MCP Toolkit // All Systems Nominal
+
+
+ +
+ © 2026 DevGlide // Connection encrypted // TLS 1.3 // ECDHE-RSA-AES256-GCM-SHA384 +
+ +
+
+ + + + diff --git a/docs/old/demo-midnight.html b/docs/old/demo-midnight.html new file mode 100644 index 0000000..b242920 --- /dev/null +++ b/docs/old/demo-midnight.html @@ -0,0 +1,2143 @@ + + + + + +Midnight Ink - DevGlide Design System + + + + + + + + + + + +
+ + +
+
DevGlide Design System
+

Midnight Ink

+

+ Optimized for dark rooms and long night sessions. Ultra-comfortable dark theme with warm amber accents, scientifically chosen contrast ratios, and zero blue-light glare. Ink on dark paper. +

+
+ + +
+
+ Brightness + + 1.00 +
+
+ Red-shift +
+ Off +
+
+ + WCAG AAA target · 7:1 – 11:1 body text + +
+
+ + + +
+
+ 01 +

Color Palette

+
+ +

Backgrounds

+
+
+
+
+
Base
+
#141416
+
Foundation layer
+
+
+
+
+
+
Surface
+
#1b1b1f
+
Card / panel
+
+
+
+
+
+
Raised
+
#222226
+
Elevated surface
+
+
+
+
+
+
Elevated
+
#2a2a2e
+
Dropdown / tooltip
+
+
+
+ +

Text Levels

+
+
+
+ Heading +
+
+
Heading
+
#d4cec4
+
10.2:1 vs base · AAA
+
+
+
+
+ Primary +
+
+
Primary
+
#c8c2b8
+
9.1:1 vs base · AAA
+
+
+
+
+ Secondary +
+
+
Secondary
+
#8a8478
+
4.6:1 vs base · AA
+
+
+
+
+ Muted +
+
+
Muted
+
#5e5850
+
2.7:1 vs base · decorative
+
+
+
+ +

Accents & Status

+
+
+
+ Amber +
+
+
Accent (Amber)
+
#c89050
+
5.6:1 vs base · AA
+
+
+
+
+ Sage +
+
+
Secondary (Sage)
+
#6a8860
+
3.8:1 vs base · UI only
+
+
+
+
+
+
Success
+
#6a9868
+
4.0:1 vs base · status
+
+
+
+
+
+
Error
+
#b86860
+
4.3:1 vs base · status
+
+
+
+
+
+
Warning
+
#c89050
+
5.6:1 vs base · status
+
+
+
+
+
+
Info
+
#7088a0
+
4.2:1 vs base · lowest sat.
+
+
+
+
+ + + +
+
+ 02 +

Readability Stats

+
+

+ Live contrast ratios computed against the base background #141416. + Body text targets the WCAG AAA sweet spot of 7:1 to 11:1 — high enough for clarity, low enough to avoid halation. +

+ +
+
+

Text Levels

+
+
+ + Heading +
+ 10.2 : 1 +
+
+
+ + Primary body +
+ 9.1 : 1 +
+
+
+ + Secondary +
+ 4.6 : 1 +
+
+
+ + Muted +
+ 2.7 : 1 +
+
+ +
+

Accent & Status

+
+
+ + Amber accent +
+ 5.6 : 1 +
+
+
+ + Success +
+ 4.0 : 1 +
+
+
+ + Error +
+ 4.3 : 1 +
+
+
+ + Info +
+ 4.2 : 1 +
+
+
+
+ + + +
+
+ 03 +

Typography Specimen

+
+ +
+
+
DISPLAY — Literata · 3rem / 350 weight
+
+ The quick brown fox +
+
+
+
H1 — Literata · 2.25rem / 400 weight
+

Midnight Ink Typography

+
+
+
H2 — Literata · 1.625rem / 450 weight
+

Section Headings Are Warm

+
+
+
H3 — Literata · 1.25rem / 500 weight
+

Component Group Titles

+
+
+
H4 — Literata · 1.0625rem / 500 weight
+

Card Headers and Labels

+
+
+ +
+
+
+
BODY — Source Sans 3 · 1rem / 400 weight / 1.65 line-height
+

+ Body text is set in Source Sans 3, a humanist sans-serif with warm proportions and excellent readability at small sizes. The generous line-height of 1.65 and positive letter-spacing of 0.01em ensure comfortable reading in low-light conditions. Weight 400 is the minimum for dark backgrounds. +

+
+
+
SECONDARY — Source Sans 3 · 0.875rem
+

+ Secondary text provides supporting information at reduced contrast, still meeting WCAG AA at 4.6:1. Used for metadata, timestamps, and supplementary descriptions. +

+
+
+
+
+
CODE — JetBrains Mono · 0.8125rem / 500 weight
+
const theme = {
+  name: 'midnight-ink',
+  base: '#141416',
+  accent: '#c89050',
+  contrast: 9.1, // WCAG AAA
+};
+
+
+
MUTED TEXT — decorative use only
+

+ Muted text at 2.7:1 contrast is used sparingly for decorative labels, section numbers, and background annotations. Never for readable content. +

+
+
+
+ +
Font Weights
+
+
+
+
200 Extra Light
+ Midnight +
+
+
300 Light
+ Midnight +
+
+
400 Regular
+ Midnight +
+
+
500 Medium
+ Midnight +
+
+
600 Semibold
+ Midnight +
+
+
700 Bold
+ Midnight +
+
+
+
+ + + +
+
+ 04 +

Components

+
+ + +

Buttons

+
+
STANDARD SIZES
+
+ + + + + + +
+
SMALL
+
+ + + +
+
LARGE
+
+ + +
+
+ + +

Badges

+
+
+ Default + Amber + Sage + Success + Error + Warning + Info + v2.4.1 + TASK + BUG + URGENT + MCP +
+
+ + +

Form Inputs

+
+
+
+
+ + +
+
+ + + Stored securely in your local config +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +

Toast Notifications

+
+
+
+
+
+ Task moved to In Review
+ Shell integration tests — DG-142 +
+
+
+
!
+
+ Build failed
+ TypeScript error in src/apps/kanban/routes.ts:48 +
+
+
+
+
+ Rate limit approaching
+ 78/100 API calls used in the current window +
+
+
+
i
+
+ Workflow matched
+ Using "code-review" workflow template +
+
+
+
+ + +

Data Table

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDFeatureStatusPriorityUpdated
DG-201Voice transcription cleanup modeIn ReviewHIGH2 hours ago
DG-198Kanban drag-and-drop reorderIn ProgressMEDIUM5 hours ago
DG-195Chat multi-agent coordinationTestingHIGH1 day ago
DG-190Workflow DAG visual editorBacklogLOW3 days ago
DG-187MCP server bundle size regressionBUGURGENT4 days ago
+
+ + +

Progress Bars

+
+
+
+
+ Sprint progress + 72% +
+
+
+
+
+ Test coverage + 88% +
+
+
+
+
+ API quota + 93% +
+
+
+
+
+ + +

Code Block

+
+
// MCP tool handler for kanban operations
+export async function handleMoveItem(
+  id: string,
+  columnName: ColumnName
+): Promise<KanbanItem> {
+  const item = await db.getItem(id);
+  if (!item) throw new Error(`Item ${id} not found`);
+
+  // Validate column transition
+  const allowed = TRANSITIONS[item.column];
+  if (!allowed.includes(columnName)) {
+    throw new Error(
+      `Cannot move from ${item.column} to ${columnName}`
+    );
+  }
+
+  return db.updateItem(id, { column: columnName });
+}
+
+
+ + + +
+
+ 05 +

Dashboard Mockup

+
+ +
+ + + + +
+
+
+

MCP Server Rewrite

+ Feature · 8 tasks · 3 in progress +
+
+ + +
+
+ +
+ +
+
+ Backlog + 2 +
+
+
DAG visual editor prototype
+
+ DG-190 + LOW +
+
+
+
Vocabulary auto-suggest
+
+ DG-191 + MEDIUM +
+
+
+ + +
+
+ Todo + 1 +
+
+
Bundle size regression fix
+
+ DG-187 + BUG + URGENT +
+
+
+ + +
+
+ In Progress + 3 +
+
+
Kanban drag-and-drop
+
+ DG-198 + MEDIUM +
+
+
+
Shell pane resize events
+
+ DG-199 + HIGH +
+
+
+
Chat @mention parsing
+
+ DG-200 + MEDIUM +
+
+
+ + +
+
+ In Review + 1 +
+
+
Voice cleanup mode
+
+ DG-201 + HIGH +
+
+
+ + +
+
+ Testing + 1 +
+
+
Multi-agent coordination
+
+ DG-195 + HIGH +
+
+
+
+
+
+
+ + + +
+
+ 06 +

Extended Reading Comfort

+
+

+ A long-form reading block to demonstrate the "ink on dark paper" experience. Settle in and read naturally. +

+ +
+

+ On the Craft of Comfortable Darkness +

+

+ An essay on designing for the late hours — where comfort is not luxury, but necessity. +

+ +

+ There is a particular quality to working late at night that every developer knows. The house is quiet. The monitor is the only light source. In these hours, the interface becomes your entire visual world, and every design choice either supports or undermines your ability to think clearly. +

+ +

+ Most dark themes get it wrong. They reach for pure black backgrounds and bright white text, creating a digital chiaroscuro that fatigues the eyes within minutes. The problem is one of contrast ratio extremity. While WCAG guidelines specify a minimum of 4.5:1 for normal text, they say nothing about maximums. A ratio of 21:1 (pure white on pure black) is technically compliant but physiologically aggressive. The phenomenon is called halation — bright text appears to bleed into the dark background, creating a halo effect that forces the eye to constantly refocus. +

+ +

+ The Midnight Ink system takes a different approach. We target the sweet spot between 7:1 and 11:1 for body text. This range provides clear, effortless readability without the visual harshness of maximum contrast. The background is a very dark warm gray (#141416) with a subtle warm undertone — not pure black, which would cause halation, but dark enough to minimize ambient light from the screen. +

+ +
+ "The best interface disappears. In a dark room, that means the text should feel like warm ink on handmade paper — present, legible, and utterly calm." +
+ +

+ Color temperature matters profoundly in low-light environments. Cool blue-white light suppresses melatonin production and stimulates alertness through the wrong mechanism — not the productive focus of engagement, but the jittery wakefulness of circadian disruption. Every color in this system has been chosen to minimize blue light emission. The primary accent is a warm amber (#c89050) with zero blue component. Even the informational blue (#7088a0) is the most desaturated color in the palette, tipped toward gray to reduce its chromatic energy. +

+ +

+ Typography in dark mode requires its own set of rules. Thin font weights that look elegant on light backgrounds become nearly illegible on dark ones — the luminance of individual strokes drops below the threshold of comfortable perception. That is why Midnight Ink enforces a minimum weight of 400 for body text and uses generous line-height (1.65) and positive letter-spacing (0.01em). These values might feel excessive in a light theme, but in darkness they provide the breathing room that eyes need to track lines without strain. +

+ +

+ The choice of typefaces reinforces this philosophy. Literata, the display face, was designed specifically for extended reading. Its high x-height and optical sizing adapt gracefully across the headline spectrum. Source Sans 3, the body face, is a humanist sans-serif — warmer and more legible at text sizes than geometric alternatives. JetBrains Mono handles code with comfortable spacing and clear character differentiation, crucial when parsing syntax at 2 AM. +

+ +

+ Perhaps the most subtle element of the system is its border strategy. Borders are rendered at extremely low opacity — between 6% and 14% of a warm off-white. They provide just enough visual structure to separate panels and cards without introducing harsh lines that compete with content. The effect is architectural rather than graphic: you sense the structure of the layout without being distracted by it. +

+ +

+ When you work with this system for an extended period, something remarkable happens: you stop noticing the interface entirely. There are no bright spots pulling your attention. No cool-toned elements triggering your alertness response. No razor-thin fonts straining your pattern recognition. Just your thoughts, your code, and the warm amber pulse of a system designed to get out of your way and let you work in peace. That is the promise of Midnight Ink — not a dark theme, but a reading environment as comfortable as a well-lit desk in a quiet library, translated faithfully into pixels. +

+ +
+ +

+ This reading block is set at 1rem / 1.75 line-height with a maximum width of 680px.
+ Optimal reading measure: 65–75 characters per line. +

+
+
+ + + +
+
+ 07 +

Borders & Elevation

+
+ +
+
+
SUBTLE BORDER
+

+ rgba(200, 190, 170, 0.06)
Barely visible, maximum restraint +

+
+
+
DEFAULT BORDER
+

+ rgba(200, 190, 170, 0.10)
Standard card/panel boundary +

+
+
+
EMPHASIS BORDER
+

+ rgba(200, 190, 170, 0.14)
Interactive hover, focus states +

+
+
+ +
Surface Elevation Stack
+ +
+
+
+
BASE
+
#141416
+
+
+
+
+
SURFACE
+
#1b1b1f
+
+
+
+
+
RAISED
+
#222226
+
+
+
+
+
ELEVATED
+
#2a2a2e
+
+
+
+
+ + + +
+
+ 08 +

Design Tokens

+
+ +
+
+

Spacing Scale

+
+
+
+ xs + 4px +
+
+
+ sm + 8px +
+
+
+ md + 16px +
+
+
+ lg + 24px +
+
+
+ xl + 32px +
+
+
+ 2xl + 48px +
+
+
+ 3xl + 64px +
+
+
+ +
+

Border Radius

+
+
+
+
sm · 4px
+
+
+
+
md · 6px
+
+
+
+
lg · 10px
+
+
+
+
xl · 14px
+
+
+
+
full · 50%
+
+
+ +

Transitions

+
+
+ fast + 120ms ease +
+
+ normal + 200ms ease +
+
+ slow + 350ms ease +
+
+
+
+
+ + + + + +
+ + + + + + diff --git a/docs/old/demo-mission.html b/docs/old/demo-mission.html new file mode 100644 index 0000000..2623144 --- /dev/null +++ b/docs/old/demo-mission.html @@ -0,0 +1,2021 @@ + + + + + +DevGlide Mission Control + + + + + + + +
+ + +
+
+
+ +
+
DevGlide
+
Mission Control v7.0
+
+
+
+
+
+ MET + T+00:00:00.0 +
+
2026/093 00:00:00 UTC
+
+
+
+ + ALL SYSTEMS NOMINAL +
+
+
+ + + + + +
+ + +
+
+ Pre-Launch + T-00:45:00 +
+
+ Launch + T+00:00:00 +
+
+ Ascent + T+00:08:32 +
+
+ On Orbit + T+00:14:32 +
+
+ De-orbit + TBD +
+
+ Re-entry + TBD +
+
+ Landing + TBD +
+
+ + +
+ + +
+
+ Mission Elapsed Time + LIVE +
+
+
+
T+00:14:32.7
+
Mission Elapsed Time
+
Day 094 of 365 — ISS Expedition 73
+
+
+
+
+
+ Launch + 25.7% + Landing +
+
+
+ + +
+
+ Flight Dynamics — Telemetry + REAL-TIME +
+
+
+
+
Altitude
+
408.2km
+
+
+
Velocity
+
7,660m/s
+
+
+
Ext. Temp
+
-157°C
+
+
+
Inclination
+
51.64°
+
+
+
Apoapsis
+
412.8km
+
+
+
Periapsis
+
404.1km
+
+
+
+
+ + +
+
+ Orbit Tracker + GNC +
+
+
+
+
+
+
EARTH
+
+
+
+
Period: 92.68 min
+
+
+
+
+ + +
+
+ GO / NO-GO Status + POLL +
+
+
+
+ Flight + GO +
+
+ GNC + GO +
+
+ CAPCOM + GO +
+
+ EECOM + STBY +
+
+ FIDO + GO +
+
+ GUIDO + GO +
+
+ INCO + GO +
+
+ RETRO + GO +
+
+ BOOSTER + NO GO +
+
+
+
+
+ + +
+ + +
+
+ Caution & Warning Panel + C&W +
+
+
+
+ O2 Press +
+
+
+ Cabin Temp +
+
+
+ CO2 Level +
+
+
+ Humidity +
+
+
+ Bus A Pwr +
+
+
+ Bus B Pwr +
+
+
+ Rad Temp +
+
+
+ ACS Fuel +
+
+
+ Nav Lock +
+
+
+ Star Trk +
+
+
+ RCS Jet +
+
+
+ TDRS Lnk +
+
+
+ Data Rec +
+
+
+ Gyro 1 +
+
+
+ Gyro 2 +
+
+
+ Cryo H2 +
+
+
+
+
+ + +
+
+ CAPCOM — Communication + S-BAND +
+
+
+
+
+
+
+
+
+
+
+
+
S-BAND CH 204
+
259.7 MHz — TDRS WEST
+
● SIGNAL ACQUIRED
+
+
+ +
Recent Transmissions
+ +
+
+ 14:22:41 + CAPCOM + Station, Houston. You are GO for orbit ops. +
+
+ 14:21:18 + FLIGHT + All stations, Flight. Configure for orbit phase. +
+
+ 14:20:55 + EECOM + Flight, EECOM. Radiator temp reading high on loop B. +
+
+ 14:19:30 + GNC + Orbit insertion confirmed. Apogee 412.8 km. +
+
+ 14:18:07 + FIDO + Tracking data nominal. TDRS handover complete. +
+
+ 14:16:42 + INCO + High-gain antenna locked. Data rate 6 Mbps. +
+
+
+
+ + +
+
+ EECOM — Systems Overview + ECLSS +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Cabin Pressure14.7 psi
O2 Partial3.08 psi
CO2 Partial2.8 mmHg
Cabin Temp22.4 °C
Humidity44%
Bus A Voltage31.2 V
Bus B Voltage31.1 V
Solar Array18.4 kW
Battery SOC87%
+ +
+
Power Generation (24h)
+
+
+
+
+
+ + +
FLIGHT Director — Mission Timeline
+ +
+ +
+
+ Backlog + 2 +
+
+
+
Configure docking port alignment
+
+ DG-042 + ETA 3d + +
+
+
+
Update navigation firmware v2.4
+
+ DG-043 + Unassigned + +
+
+
+
+ + +
+
+ Pre-Flight + 3 +
+
+
+
Voice transcription pipeline refactor
+
+ DG-038 + @EECOM + +
+
+
+
Add workflow DAG visualization
+
+ DG-039 + @GUIDO + +
+
+
+
Integrate test runner with CI hooks
+
+ DG-040 + BLOCKED + +
+
+
+
+ + +
+
+ In Flight + 2 +
+
+
+
Multi-LLM chat message routing
+
+ DG-035 + ETA 1d + +
+
+
+
Shell PTY injection for chat delivery
+
+ DG-036 + On track + +
+
+
+
+ + +
+
+ Mission Review + 2 +
+
+
+
Dashboard sidebar navigation restyle
+
+ DG-031 + Review OK + +
+
+
+
Kanban drag-drop column reorder
+
+ DG-033 + Changes req. + +
+
+
+
+ + +
+
+ Landed + 3 +
+
+
+
MCP server bundling pipeline
+
+ DG-027 + Complete + +
+
+
+
Voice TTS msedge-tts integration
+
+ DG-028 + Complete + +
+
+
+
Project scoping for hybrid apps
+
+ DG-029 + Complete + +
+
+
+
+
+ + +
+ + +
+
+ GNC — Navigation State Vector + REAL-TIME +
+
+
+
+
Latitude
+
28.524°N
+
+
+
Longitude
+
-80.651°W
+
+
+
Range Rate
+
+0.02m/s
+
+
+
Beta Angle
+
52.4°
+
+
+ +
+
Ground Track
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+ INCO — Flight Events Log + LOG +
+
+
+
+ 14:23:07 + SYSTEM + Telemetry stream nominal. All channels active. +
+
+ 14:22:54 + EECOM + Radiator loop B temp exceeds soft limit: 42.3°C +
+
+ 14:22:41 + CAPCOM + Uplink cmd accepted: SAR array deploy sequence +
+
+ 14:22:18 + GNC + Attitude hold mode active. Delta-V budget: 127.4 m/s +
+
+ 14:21:55 + FIDO + Tracking pass TDRS-W: AOS in 12 min +
+
+ 14:21:30 + WARN + Rad temp sensor B-4 intermittent. Switching to backup. +
+
+ 14:20:58 + INCO + Data recorder: 847 GB / 2 TB (42.3% capacity) +
+
+ 14:20:22 + GUIDO + Workflow #17 "Orbit Maintenance" triggered automatically. +
+
+ 14:19:45 + PAO + Public feed: Live video from Node 2 cupola window. +
+
+ 14:19:10 + RETRO + De-orbit planning window opens at T+04:22:00. +
+
+ 14:18:30 + SURGEON + Crew health telemetry nominal. All biometrics green. +
+
+
+
+
+ + +
+ DevGlide Mission Control — AI Workflow Toolkit — Design System Demo — +
+ +
+
+ + + + + + diff --git a/docs/old/demo-newspaper.html b/docs/old/demo-newspaper.html new file mode 100644 index 0000000..f8cef90 --- /dev/null +++ b/docs/old/demo-newspaper.html @@ -0,0 +1,1978 @@ + + + + + +The DevGlide Chronicle + + + + + + + + + + +
+ + +
+
+ VOL. CXXVII · No. 43,891 + + FRIDAY, APRIL 3, 2026 +
+

The DevGlide Chronicle

+

“All the Code That’s Fit to Ship”

+
+ FINAL EDITION • LATE CITY • WEATHER: ALL SYSTEMS OPERATIONAL +
+
+ + + + + +
+
+
+

DevGlide Unveils Revolutionary
MCP Toolkit for Modern Developers

+

Nine integrated servers promise to unify kanban, shell, testing, workflows, and voice into a single coherent platform

+ + +
+

In what industry observers are calling the most significant advancement in developer tooling since the introduction of the language server protocol, DevGlide today announced the general availability of its comprehensive Model Context Protocol toolkit, featuring nine integrated MCP servers designed to transform how developers interact with their development environments.

+ +

The platform, which has been in private beta since early this year, introduces a novel approach to developer workflow management by treating every aspect of the development process—from task tracking to terminal management, from test automation to voice transcription—as a unified, AI-accessible surface.

+ +

“We believe the future of development tools lies not in building yet another IDE or another project management application,” said a spokesperson for the project. “Rather, it lies in making every tool in a developer’s arsenal accessible through a common protocol that any AI assistant can leverage.”

+ +
+
The age of context switching between a dozen browser tabs and terminal windows is over.
+ — DevGlide Engineering Team +
+ +

The toolkit operates on a single HTTP server, defaulting to port 7000, with each application maintaining its own MCP server for stdio communication. State is managed through a structured directory at ~/.devglide/, with per-project isolation for kanban boards, test scenarios, and chat histories, while voice configuration and global workflows remain shared across all projects.

+
+
+ +
+
+
{ }
+
THE DEVGLIDE DASHBOARD — ARTIST’S IMPRESSION
+
+

Above: An artist’s rendering of the DevGlide unified dashboard, showing the newspaper-inspired design language that has captivated the developer community.

+ +
+
Today’s System Forecast
+
+
All Systems Operational
+
+ API Latency: 42ms • Uptime: 99.97%
+ CPU: Fair • Memory: Clear Skies
+ Disk: Mostly Sunny +
+
+ +
+
Table of Contents
+
    +
  • Kanban BoardA1
  • +
  • Shell TerminalA2
  • +
  • Test RunnerA3
  • +
  • WorkflowsB1
  • +
  • VocabularyB2
  • +
  • Voice & STTB3
  • +
  • Chat RoomC1
  • +
  • PromptsC2
  • +
  • Log ViewerC3
  • +
+
+
+
+
+ + +
+
+

Shell Server Brings Terminal Panes Under MCP Control

+ +

The shell server introduces managed terminal panes that can be created, commanded, and monitored through MCP tool calls. Developers can now script complex multi-pane workflows without leaving their AI conversation context.

+

Panes are ephemeral and in-memory, belonging to a project session with no disk persistence. The scrollback buffer captures output for later analysis, enabling AI assistants to monitor build processes and test runs.

+
+ +
+

Voice Transcription Adds AI Cleanup & Analytics

+ +

The voice server now supports multiple STT providers including OpenAI, Groq, and local whisper.cpp, with a novel “cleanup” mode that applies AI post-processing to remove filler words and fix grammar automatically.

+

Every transcription is recorded with text analysis metrics including words per minute, filler word detection, and duration. A history search API enables developers to revisit past voice notes.

+

Text-to-speech uses Microsoft Edge Read Aloud with automatic sentence-level chunking and pipelined generation for responsive playback of lengthy passages.

+
+ +
+

Multi-Agent Chat Room Enables LLM Collaboration

+ +

Perhaps the most novel feature of the toolkit is its chat server, which creates a shared room where the user and multiple LLM instances communicate via @mention addressing. Messages are delivered through PTY injection when linked to shell panes.

+

The system includes sophisticated collision handling: if a pane is already bound to a participant, the existing session is preserved and newcomers receive a 409 error, ensuring orderly multi-agent coordination.

+
+
+ + +
+ + +
+
+

Classified Advertisements

+

Kanban Task Board — Rates: 5¢ per line — Minimum 3 lines

+
+ +
+ +
+
Backlog
+ +
+
TASK — UI NEW!
+
WANTED: Responsive mobile layout for dashboard sidebar. Must support gesture navigation. Experience with CSS Grid preferred.
+
Ref. #KAN-0042
+
+ +
+
TASK — API
+
SEEKING: Rate limiting middleware for REST endpoints. Must handle 1000 req/min per client. Token bucket algorithm preferred.
+
Ref. #KAN-0043
+
+ +
+
BUG — VOICE
+
LOST: Audio playback on WSL when PulseAudio unavailable. Fallback chain not triggering. Last seen: v2.1.3.
+
Ref. #KAN-0044
+
+
+ + +
+
To-Do
+ +
+
BUG — SHELL URGENT
+
EMERGENCY: Pane scrollback truncation at 10K lines causes data loss during long builds. Fix required immediately.
+
Ref. #KAN-0038
+
+ +
+
TASK — WORKFLOW
+
HELP WANTED: DAG visualization component for workflow editor. Must render nodes, edges, triggers. Apply within.
+
Ref. #KAN-0039
+
+
+ + +
+
In Progress
+ +
+
TASK — CHAT APPROVED
+
NOW HIRING: PTY injection delivery optimization. Must reduce submit-key delay below 50ms. Senior engineers only.
+
Ref. #KAN-0035
+
+ +
+
TASK — DOCS
+
POSITION FILLED: Documentation seed content for all nine MCP servers. Writer on staff, delivery expected this week.
+
Ref. #KAN-0036
+
+ +
+
BUG — KANBAN
+
UNDER REPAIR: SQLite WAL checkpoint not triggering on graceful shutdown. Data integrity at stake. Mechanic dispatched.
+
Ref. #KAN-0037
+
+
+ + +
+
In Review
+ +
+
TASK — TEST
+
FOR INSPECTION: Browser automation scenario runner with natural language test descriptions. Awaiting editorial review.
+
Ref. #KAN-0031
+
+ +
+
TASK — PROMPTS APPROVED
+
CERTIFIED: Prompt template library with {{variable}} rendering. Passed all inspections. Awaiting final sign-off.
+
Ref. #KAN-0032
+
+
+
+
+ + +
+ +

Component Showcase & Typography Specimens

+
+ +
+ +
+

Buttons & Actions

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

Editorial Badges

+
+ Approved + Urgent + New! + Verified + Exclusive + Editor’s Pick +
+
+ + +
+

Typewriter Forms

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

Typography Specimens

+ + +
+

Playfair Display

+

The quick brown fox jumps over the lazy dog

+ +

Libre Baskerville

+

The quick brown fox jumps over the lazy dog

+ +

Special Elite

+

The quick brown fox jumps over the lazy dog

+ +

UnifrakturMaguntia

+

The quick brown fox jumps

+
+ +

Headline Hierarchy

+

Primary Headline

+

Secondary Headline

+

Tertiary Headline

+

Subheadline in Italic Serif

+ +
+
+ + +
+ +
+
+

The Halftone Technique:
Images in the Machine Age

+ + +
+
+ + MCP Server Architecture +
+
EXCLUSIVE PHOTOGRAPH — THE ENGINE ROOM OF DEVGLIDE
+
+

The halftone dot pattern, pioneered in the 1880s, enabled newspapers to reproduce photographs using only black ink. Here we apply the technique to developer tool imagery using CSS radial gradients.

+ +

The effect is achieved through a carefully calibrated radial-gradient overlay that simulates the mechanical screen process used in rotogravure printing. Each dot represents a single point of ink, and the varying density creates the illusion of continuous tone from pure black and white.

+
+ +
+
+
We don’t just build tools. We compose symphonies of developer experience, where every MCP server is an instrument in the orchestra.
+ — The DevGlide Manifesto, Chapter VII +
+ +

Color Palette & Ink Standards

+ + +
+
+
+
+
Newsprint
+
#f4eed5
+
+
+
+
+
+
Ink Black
+
#1a1a1a
+
+
+
+
+
+
Ink Light
+
#3a3a3a
+
+
+
+
+
+
Newspaper Red
+
#8b0000
+
+
+
+
+
+
Faded Ink
+
#6b6355
+
+
+
+
+
+
Column Rule
+
#8a8070
+
+
+
+ + +
+
+ + +
+ +

Dashboard — Full Application Mockup

+
+ +
+ + +
+
+
+ Kanban Board + Feature: DevGlide v3.0 Launch +
+
+ + +
+
+ +
+
+
Backlog
+
+
#0042 • TASK
+ Mobile responsive layout +
+
+
#0043 • TASK
+ Rate limiting middleware +
+
+
#0044 • BUG
+ WSL audio fallback +
+
+
#0045 • TASK
+ Workflow DAG export +
+
+ +
+
To-Do
+
+
#0038 • BUG • URGENT
+ Scrollback truncation fix +
+
+
#0039 • TASK
+ DAG visualization +
+
+ +
+
In Progress
+
+
#0035 • TASK
+ PTY injection optimization +
+
+
#0036 • TASK
+ Documentation seed content +
+
+
#0037 • BUG
+ SQLite WAL checkpoint +
+
+ +
+
In Review
+
+
#0031 • TASK
+ Browser test runner +
+
+
#0032 • TASK
+ Prompt template library +
+
+
+
+
+ + +
+ +

Paid Notices & Advertisements

+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+ + + + +
+ + + + + + diff --git a/docs/old/demo-nexus.html b/docs/old/demo-nexus.html new file mode 100644 index 0000000..32a9241 --- /dev/null +++ b/docs/old/demo-nexus.html @@ -0,0 +1,2000 @@ + + + + + +Nexus — DevGlide Dashboard + + + + + + + + + + + +
+ + +
+ + +
+ + + + +
+ +
+ +
+ + Search... + Ctrl K +
+ +
DK
+
+ + +
+ + +
+
+ Request Volume + Last 30d +
+
+
+
+ 2.4M + ↑ 18% +
+
Total Requests
+
+
+
+ 47ms + +
+
Avg Latency
+
+
+
+ 99.97% + ↑ 0.02 +
+
Uptime
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ MCP Tool Calls + Live +
+
+ 18,492 +
+
+ + ↑ 24% +
+
Last 24 hours
+ +
+
+
6,841
+
kanban
+
+
+
4,207
+
shell
+
+
+
3,612
+
voice
+
+
+
+ + +
+
+ Active Workflows +
+
+ 37 + + ↑ 8% +
+
12 triggered today
+ +
+
+ ci-deploy + passing +
+
+ lint-fix + passing +
+
+ test-e2e + running +
+
+
+ + +
+
+ Error Rate +
+
+ 0.03% + + ↓ 0.01 +
+
vs. 0.04% last week
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ Token Usage +
+
+ 1.2B + + ↑ 31% +
+
Tokens processed this month
+
+ + +
+
+ Tool Distribution + Today +
+
+ + + + + + + + + + + + + 8.4K + calls + +
+
+ + Kanban + 34% +
+
+ + Shell + 26% +
+
+ + Voice + 20% +
+
+ + Test + 12% +
+
+ + Other + 8% +
+
+
+
+ + +
+
+ Top Endpoints + 24h +
+
+
+ /api/kanban +
+ 4,821 +
+
+ /api/shell +
+ 3,714 +
+
+ /api/voice +
+ 3,042 +
+
+ /api/chat +
+ 2,256 +
+
+ /api/test +
+ 1,467 +
+
+ /api/workflow +
+ 892 +
+
+
+ + +
+
+ System Health +
+
+
+ + + + + 92% + CPU +
+
+ + + + + 67% + Memory +
+
+ + + + + 35% + Disk +
+
+
+ + +
+
+ Active Agents +
+
+ 12 + + ↑ 3 +
+
Claude, Codex, GPT instances
+ +
+
C1
+
C2
+
X1
+
G1
+
+8
+
+
+ + +
+
+ Activity Feed + Live +
+
+
+ + claude-1 completed task #142 + 2m ago +
+
+ + workflow ci-deploy triggered + 5m ago +
+
+ + codex-1 shell command ran + 8m ago +
+
+ + test e2e scenario failed: login + 12m ago +
+
+ + claude-2 moved item to Review + 15m ago +
+
+ + voice transcription 1:42 mins + 18m ago +
+
+ + gpt-1 joined chat room + 23m ago +
+
+ + workflow lint-fix passed + 31m ago +
+
+ + claude-1 created feature board + 42m ago +
+
+ + test snapshot drift detected + 1h ago +
+
+ + codex-1 resolved merge conflict + 1h ago +
+
+ + voice config updated: groq + 2h ago +
+
+
+ + +
+
+ Recent MCP Sessions + 12 active +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SessionAgentServerCallsLatencyStatus
mcp_8f3aclaude-1kanban84723msActive
mcp_2b1ccodex-1shell61218msActive
mcp_9e4dclaude-2voice394156msSlow
mcp_7f2egpt-1chat28131msActive
mcp_4a8bclaude-1test156892msError
mcp_1c3fcodex-1workflow9345msActive
+
+ + +
+
+ Shell Panes +
+
+ 8 + + ↑ 2 +
+
Active terminal sessions
+ +
+
+ + pane-1 + claude-1 +
+
+ + pane-2 + codex-1 +
+
+ + pane-3 + idle +
+
+
+ + +
+
+ Projects +
+
+ 24 + +
+
Registered projects
+ +
+
+ devglide + 1,247 items +
+
+
+
+
+
+ + +
+
+ Transcriptions +
+
+ 482 + + ↑ 12% +
+
This week
+ +
+
+
142
+
WPM avg
+
+
+
3.2%
+
Filler rate
+
+
+
+ + +
+
+ Prompt Templates +
+
+ 56 + + ↑ 7 +
+
Active templates
+ +
+ code-review + bug-report + refactor + +53 +
+
+ +
+
+ + +
+
+
+ + +
+
+
Quick Actions
+
+
+ Go to Dashboard + Ctrl+D +
+
+
+ New Terminal Pane + Ctrl+` +
+
+
+ Create Task + Ctrl+N +
+ +
MCP Tools
+
+
+ kanban_list_items + kanban +
+
+
+ shell_run_command + shell +
+
+
+ voice_transcribe + voice +
+ +
Navigation
+
+
+ Open Chat Room + Ctrl+Shift+C +
+
+
+ Workflow Editor + Ctrl+W +
+
+ +
+
+ + + + + diff --git a/docs/old/demo-obsidian.html b/docs/old/demo-obsidian.html new file mode 100644 index 0000000..4e2deb0 --- /dev/null +++ b/docs/old/demo-obsidian.html @@ -0,0 +1,2332 @@ + + + + + +Obsidian Cave — DevGlide Design System + + + + + + + + +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + +
+

Obsidian Cave

+

DevGlide Design System

+

Dark as volcanic glass. Deep as hidden caverns. Every surface polished to reveal the light within.

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

Color Palette

+

Jewel tones drawn from deep within volcanic glass. Each color is a mineral inclusion — precious, muted, and luminous against the dark.

+ +

Accent Jewels

+
+
+
+
+ Amethyst + #9070c0 +
+
+
+
+
+ Amethyst Glow + #b090e0 +
+
+
+
+
+ Teal Jade + #508880 +
+
+
+
+
+ Jade Glow + #70a8a0 +
+
+
+
+
+ Rose Copper + #b07068 +
+
+
+
+
+ Copper Glow + #d09088 +
+
+
+ +

Status Jewels

+
+
+
+
+ Emerald + #508868 +
+
+
+
+
+ Ruby + #b05858 +
+
+
+
+
+ Topaz + #b89050 +
+
+
+
+
+ Sapphire + #5070a0 +
+
+
+ +

Text Hierarchy

+
+
+
+
+ Primary + #d0ccc4 · ~8.5:1 +
+
+
+
+
+ Secondary + #8a8680 · ~4.8:1 +
+
+
+
+
+ Tertiary + #5e5a56 +
+
+
+
+
+ Ghost + #3a3836 +
+
+
+ +

Surface Depths

+
+
+
+
+ Void + #0c0c14 +
+
+
+
+
+ Deep + #101018 +
+
+
+
+
+ Surface + #14141e +
+
+
+
+
+ Elevated + #181824 +
+
+
+
+
+ Raised + #1c1c2a +
+
+
+
+
+ Overlay + #202030 +
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+

Typography

+

Three voices: the elegant serif for moments of ceremony, the humanist sans for everyday clarity, and the monospace for precision.

+ +
+
+ +
+
Display · Cormorant Garamond
+
+ The cave remembers every echo +
+
+ Volcanic Glass Holds Ancient Light +
+
+ Deep within the obsidian, crystalline structures catch and refract starlight through layers of cooled magma +
+
+ + +
+
Body · Nunito Sans
+
+ Nunito Sans delivers exceptional readability across long sessions. Its rounded terminals and open apertures reduce eye strain, while generous x-height maintains clarity at smaller sizes. The humanist construction feels approachable without sacrificing professionalism. +
+
+ Secondary text uses a warmer muted tone, stepping back in hierarchy while maintaining comfortable contrast ratios for extended reading. +
+
+
+ Light 300 +
+
+ Regular 400 +
+
+ SemiBold 600 +
+
+ Bold 700 +
+
+
+ + +
+
Code · JetBrains Mono
+
+// Obsidian configuration +const theme = { + name: 'obsidian-cave', + version: '1.0.0', + surfaces: { + void: '#0c0c14', // deepest layer + surface: '#14141e', // default panel + raised: '#1c1c2a', // elevated element + }, + shimmer(panel) { + return panel.animate('light-sweep', 800); + } +};
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+

Depth & Elevation

+

Obsidian has depth when you gaze into it. Each elevation level shifts subtly lighter, like looking through layers of volcanic glass toward a faint inner glow.

+ +
+
+
Level 0
+
#0c0c14
+
Void
+
+
+
Level 1
+
#101018
+
Deep
+
+
+
Level 2
+
#14141e
+
Surface
+
+
+
Level 3
+
#181824
+
Elevated
+
+
+
Level 4
+
#1c1c2a
+
Raised
+
+
+
Level 5
+
#202030
+
Overlay
+
+
+ +

+ Each step adds approximately +4 to RGB blue channel, +4 to green, +4 to red. The blue-shift increases with elevation, giving a sense of light emerging from deep blue-black. +

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

Iridescent Panels

+

Panels exhibit the obsidian glass effect: a faint iridescent border that shifts from amethyst to teal to rose copper. Hover to see the shimmer — light catching polished glass.

+ +
+
+

MCP Server Status

+

All seven servers running on port 7000. Connected clients: 3.

+
+ Healthy + 7 Servers + 3 Clients +
+
+
+

Voice Pipeline

+

Neural TTS active. Edge voice: en-GB-RyanNeural. Transcription: Groq Whisper.

+
+ Active + Groq STT +
+
+
+ +
+

Recent Activity

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeEventSourceStatus
14:32:08Task moved to In ReviewkanbanSuccess
14:31:55Test scenario completedtestPassed
14:31:12Shell command timeoutshellWarning
14:30:44Workflow match failedworkflowError
+
+
+ + +
+
+
+
+
+
+
+ + +
+

Components

+

Every interaction element is drawn from the same obsidian material language. Jewel-tone accents give purpose; the shimmer gives life.

+ + +
+

Buttons

+ +
Primary Actions
+
+ + + +
+ +
Secondary & Ghost
+
+ + + + +
+ +
With Icons
+
+ + + +
+
+ + +
+

Inputs

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+ +
+
+ Voice cleanup + +
+
+
+
+ + +
+

Badges & Tags

+ +
Status Badges
+
+ Emerald · Success + Ruby · Error + Topaz · Warning + Sapphire · Info + Amethyst · Accent +
+ +
Tags
+
+ default + kanban + test + shell + workflow + voice +
+ +
Keyboard Shortcuts
+
+ Ctrl + K + | + Ctrl + Shift + P + | + Esc +
+
+ + +
+

Progress

+ +
+
+
+ Feature progress + 72% +
+
+
+
+
+ Test coverage + 89% +
+
+
+
+
+ Build pipeline + 45% +
+
+
+
+
+ All tasks complete + 100% +
+
+
+
+
+ + +
+

Toasts

+ +
+ +
+ Test scenario passed + All 12 assertions verified successfully. Login flow confirmed working. +
+
+
+ +
+ Shell command failed + Process exited with code 1. Check scrollback for error details. +
+
+
+ +
+ Workflow match ambiguous + Multiple workflows matched. Please refine your query for exact match. +
+
+
+ +
+ Voice transcription ready + Audio processed in 1.2s via Groq Whisper. 143 words detected. +
+
+
+ + +
+

Tabs

+
+ + + + + +
+

Tab content area. The active tab is highlighted with the amethyst accent underline.

+
+ + +
+

Avatars & Participants

+
+
+
CL
+
+
claude-1
+
Sonnet 4
+
+
+
+
CX
+
+
codex-2
+
o4-mini
+
+
+
+
DK
+
+
daniel
+
user
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+

Dashboard Mockup

+

The full experience: sidebar navigation, kanban board, and all visual elements working together in the obsidian environment.

+ +
+
+
+ + DevGlide +
+ +
Core
+
+ + Dashboard +
+
+ + Kanban +
+
+ + Shell +
+ +
Tools
+
+ + Workflows +
+
+ + Voice +
+
+ + Chat +
+
+ + Test +
+ +
Data
+
+ + Logs +
+
+ + Settings +
+
+ +
+
+

Feature: Auth Module

+
+ Sprint 4 + +
+
+ +
+
+
+ Todo + 3 +
+
+ OAuth2 PKCE flow +
TASK
+
+
+ Session token rotation +
TASK
+
+
+ Rate limiter middleware +
MEDIUM
+
+
+ +
+
+ In Progress + 2 +
+
+ JWT validation service +
+ HIGH +
+
+
+ User profile endpoint +
+ MEDIUM +
+
+
+ +
+
+ In Review + 1 +
+
+ Password reset flow +
+ DONE +
+
+
+ +
+
+ Testing + 1 +
+
+ Login form validation +
+
+
CL
+ claude-1 testing +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+

Reading Comfort

+

The true test of a dark theme: extended reading without eye strain. Warm cream text on deep obsidian, with generous line height and measured width.

+ +
+
+

+ Obsidian forms when volcanic lava cools so rapidly that atoms cannot arrange themselves into a crystalline structure. The result is a natural glass — dark, smooth, and reflective. Ancient civilizations prized it for tools and mirrors, drawn to its ability to hold an edge sharper than surgical steel. +

+

+ In designing this system, we drew from that same duality: utility and beauty. The deep blue-black backgrounds eliminate the harsh glare of white screens, while the warm cream text (at 8.5:1 contrast ratio) ensures every word is readable without the eye strain that comes from pure white on pure black. The slight warmth in the text color creates a sense of candlelight against stone. +

+

+ The jewel-tone accents — amethyst, teal jade, rose copper — are deliberate choices. Each is desaturated enough to avoid the "neon glow" trap of many dark themes, yet vivid enough to create clear visual hierarchy. They reference the mineral inclusions found in real obsidian: unexpected points of color within the dark glass. +

+

+ The iridescent border effect on panels mimics the way light plays across an obsidian surface. At certain angles, volcanic glass reveals hidden colors — rainbow sheens of purple, blue, and green caused by thin-film interference. We capture this in the slow-cycling gradient borders that shift from amethyst to teal to rose, creating a sense that these panels are living surfaces rather than flat rectangles. +

+

+ Even the shimmer hover effect tells a story. Run your cursor across any panel and watch the sweep of light — it mimics the moment you tilt a piece of polished obsidian and catch the room's light across its surface. A small detail, but it transforms interaction from mechanical to sensory. +

+
+
+
+ + +
+
+
+
+
+
+
+ + + +
+ + + + + + diff --git a/docs/old/demo-paper-studio.html b/docs/old/demo-paper-studio.html new file mode 100644 index 0000000..84681a1 --- /dev/null +++ b/docs/old/demo-paper-studio.html @@ -0,0 +1,2147 @@ + + + + + +Paper Studio — DevGlide Design System + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+

MCP Server Health

+

+ Real-time telemetry across all Model Context Protocol servers. + Last 30 days of tool invocations, latency, and error rates. +

+
+
+ + +
+
+ + +
+ + +
+
+
+ Total Tool Invocations +
+ 847,293 + +12.4% +
+
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + +
+ Mar 4 + Mar 11 + Mar 18 + Mar 25 + Apr 1 +
+
+
+
+
+
+ Avg. Latency + 142ms +
+
+ Error Rate + 0.3% +
+
+ Active Servers + 8/9 +
+
+ Uptime + 99.97% +
+
+
+
+ + +
+ +
+
+ Kanban Tasks +
+ 1,247 + +8.2% +
+
+ 342 done + 89 in progress +
+
+
+ + +
+
+ Shell Sessions +
+ 56 + +3 +
+
+ 12 active + 44 closed +
+
+
+ + +
+
+ Voice Transcriptions +
+ 3,891 + +24.1% +
+

+ Avg. 142 WPM · 2.1% filler rate +

+
+
+ + +
+
+
+ Workflows + 34 +
+
+ Prompts + 128 +
+
+ Vocab + 67 +
+
+
+
+ + +
+
+
+ Activity + +
+
+
+
+
2 min ago
+
kanban_move_item moved "Refactor auth flow" to In Review
+
+
+
+
8 min ago
+
test_run_saved "Login E2E" passed (4.2s)
+
+
+
+
15 min ago
+
shell_run_command executed pnpm build in pane-3
+
+
+
+
22 min ago
+
voice_transcribe processed 3m 42s audio (523 words)
+
+
+
+
31 min ago
+
workflow_match triggered "PR Review" workflow
+
+
+
+
45 min ago
+
chat_send claude-1 messaged @codex-2 about API types
+
+
+
+
1h ago
+
kanban_create_item added "Fix SSR hydration" to Todo
+
+
+
+ + +
+ Quick Actions +
+ + + +
+
+
+
+ + +
+
+

Tool Usage Breakdown

+ Last 30 days +
+
+ +
+ + +
+
+ Invocations by Server +
+
+
+ Kanban +
+ 284k +
+
+ Shell +
+ 241k +
+
+ Voice +
+ 167k +
+
+ Test +
+ 118k +
+
+ Chat +
+ 86k +
+
+ Workflow +
+ 68k +
+
+ Prompts +
+ 46k +
+
+ Vocab +
+ 31k +
+
+ Docs +
+ 18k +
+
+
+ + +
+
+ Recent Tool Calls +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolServerStatusLatencyTime
kanban_move_itemkanbansuccess23ms2 min ago
test_run_savedtestsuccess4,218ms8 min ago
shell_run_commandshellsuccess12,403ms15 min ago
voice_transcribevoicesuccess2,891ms22 min ago
workflow_matchworkflowsuccess18ms31 min ago
chat_sendchatsuccess34ms45 min ago
test_run_scenariotesterror8,102ms52 min ago
kanban_create_itemkanbansuccess31ms1h ago
shell_get_scrollbackshellsuccess8ms1h ago
prompts_renderpromptssuccess12ms1h ago
+
+
+
+ + +
+
+

Server Registry

+
+ 9 registered + + Auto-refresh +
+
+
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServerStatusToolsUptimeP95 LatencyErrors (24h)
devglide-kanbanRunning1414d 6h28ms0
devglide-shellRunning514d 6h156ms2
devglide-voiceRunning714d 6h2,340ms0
devglide-testRunning614d 6h5,120ms4
devglide-chatRunning814d 6h42ms0
devglide-workflowRunning514d 6h22ms0
devglide-promptsRunning714d 6h14ms0
devglide-vocabularyDegraded62d 18h890ms12
devglide-logRunning314d 6h6ms0
+
+
+ + +
+
+
+ The best developer tools feel less like software and more like an extension of thought. + DevGlide design philosophy +
+
+ + +
+ System Uptime +
+ + + + +
+ 99.97% +
+
+

14 days without incident

+
+
+
+ + +
+
+

Paper Studio

+ Design System v1.0 +
+

+ A design language that rejects the ubiquity of dark mode + dashboards. Paper Studio draws from editorial print design, warm surfaces, + and the quiet confidence of well-set type. Every component is built to breathe. +

+
+ +
+ + +
+ Palette +
+
+
+ #f2f1ed +
+
+
+ #ffffff +
+
+
+ #26251e +
+
+
+ #666660 +
+
+
+ #f54e00 +
+
+
+ #1f8a65 +
+
+
+ #cf2d56 +
+
+
+ + +
+ Typography +
+
+ Display / Newsreader 500 +

Elegant numbers

+
+
+ Body / Source Sans 3 +

The quick brown fox jumps over the lazy dog. Readability is the foundation of utility.

+
+
+ Mono / JetBrains Mono 600 +

847,293

+
+
+
+ + +
+ Components +
+ +
+ Buttons +
+ + + +
+
+ +
+ Badges +
+ Success + Warning + Error + Neutral +
+
+ +
+ Input + +
+ +
+ Toggle +
+ + Enabled +
+
+
+
+
+ + +
+
+ + +
+ Shadow as Border +
+
+ --shadow-border +

Default card state. No CSS borders.

+
+
+ --shadow-hover +

Hover state. Layered shadows add depth.

+
+
+ --shadow-elevated +

Elevated modals and popovers.

+
+
+
+ + +
+ Spacing System +
+
+
+ 4px + Tight / inline +
+
+
+ 8px + Compact +
+
+
+ 16px + Default +
+
+
+ 24px + Card padding +
+
+
+ 48px + Section inner +
+
+
+ 96px + Section gap +
+
+
+ + +
+ Interactive States +
+
+ Hover Preview +
+
+ Hover me + +
+
+
+
+ Focus Preview + +
+
+ Accent Hover + +
+
+
+
+
+ + +
+
+

Performance & Communication

+
+
+ +
+ + +
+
+
+ Latency Distribution + P50, P90, P95, P99 across all servers +
+
+
+
+ P50 +
+
+ 18ms +
+
+
+ P90 +
+
+ 89ms +
+
+
+ P95 +
+
+ 142ms +
+
+
+ P99 +
+
+ 2.3s +
+
+
+
+ + +
+
+
+ Chat Room + 3 participants active +
+
+ Live +
+
+
+
+ claude-1 + I've finished the auth refactor. Moving it to In Review. @codex-2 can you run the test suite? +
+
+ codex-2 + Running test_run_saved "Auth E2E" now. Will report back. +
+
+ codex-2 + All 14 tests passed in 8.4s. Looks clean. +
+
+ user + Great work both. Moving to Testing column. +
+
+
+
+ + +
+
+ + +
+
+ Recent Errors + 4 today +
+
+
+
+ TIMEOUT + 52m ago +
+

test_run_scenario exceeded 8s limit on "Dashboard load"

+
+
+
+ ECONNRESET + 3h ago +
+

vocabulary_lookup failed — connection reset by upstream

+
+
+
+ RATE_LIMIT + 5h ago +
+

voice_transcribe hit provider rate limit (groq, 429)

+
+
+
+
+ + +
+
+
+
+ + + + + + Paper Studio + — DevGlide Design System +
+ +
+
+ +
+ + + + + + diff --git a/docs/old/demo-pcb.html b/docs/old/demo-pcb.html new file mode 100644 index 0000000..5cbdbe5 --- /dev/null +++ b/docs/old/demo-pcb.html @@ -0,0 +1,2058 @@ + + + + + +DevGlide PCB Design System + + + + + + +
+ +
+ + + + + +
+ + +
+
DevGlide PCB Design System
+
AI Workflow Toolkit — Circuit Board Interface Language
+
2026-04-03
+
+ + +
+
+ + MCP Server Online +
+
+ + Port 7000 +
+
+ + 9 modules active +
+
+ + TP1 PASS +
+
+ TP2 3.3V +
+
+ TP3 GND +
+
+ + +
+ + + + + + + + + + + + + + + + +
+ + +
+ SEC.1 +

Component Showcase

+
+ + +
+ SOLDER PADS + + + + +
+ + +
+ LED STATUS + Online + Error + Warning + Info + Offline +
+ + +
+ VIA HOLES + + + + Plated through-hole vias, various drill sizes +
+ + +
+ TEST POINTS + TP1 Signal A + TP2 PASS + TP3 FAIL +
+ + +
+ SMD PARTS + + R1 10k + + R2 4.7k + + C1 100nF + + C2 10uF +
+ + +
+ BADGES + MCP v1.0 + CONNECTED + FAULT + STDIO +
+ + +
+
+ SIGNAL BARS +
+
+ CPU Load +
+ Memory +
+ Errors +
+
+
+ + +
+ PAD INPUTS + + +
+ + +
+ DRILL CHART +
+ 0.3mm via + 0.5mm via + 0.8mm via + + + 1.0mm mounting + +
+
+ + +
+ SEC.2 +

IC Chip Components

+
+ +
+ +
+
+
+ IC7 — U1-KANBAN + +
+
Kanban Board Controller
+
+ Task and feature management with six-column workflow. + Backlog, Todo, In Progress, In Review, Testing, Done. +
+
    +
  • SQLite per-project storage
  • +
  • Work log + review history
  • +
  • File attachments support
  • +
+ 28-DIP +
+ + +
+
+
+ IC8 — U2-SHELL + +
+
Terminal Pane Manager
+
+ Run shell commands in managed PTY panes. + Scrollback capture and pane lifecycle management. +
+
    +
  • In-memory pane sessions
  • +
  • Multi-pane orchestration
  • +
  • Chat integration via PTY injection
  • +
+ 16-SOIC +
+ + +
+
+
+ IC9 — U3-TEST + +
+
Browser Test Automation
+
+ AI-driven browser testing with natural language scenarios. + Describe what to test and scenarios are generated automatically. +
+
    +
  • Natural language test specs
  • +
  • Saved scenario library
  • +
  • Async result polling
  • +
+ 20-QFP +
+ + +
+
+
+ IC10 — U5-CHAT + +
+
Multi-LLM Chat Room
+
+ Shared chat room for user and multiple LLM instances. + @mention addressing with PTY delivery and pipe I/O. +
+
    +
  • Broadcast messaging via PTY
  • +
  • JSONL history persistence
  • +
  • Per-project rules of engagement
  • +
+ 32-QFN +
+
+ + +
+ + + + + + + + U1 + BUS + U5 + +
+ + +
+ SEC.3 +

Kanban Dashboard

+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+
+ Todo + 3 +
+
+
+ +
IC7-001
+
Add drag-and-drop reorder
+
+ TASK + MEDIUM +
+
+
+
IC7-002
+
Export board to JSON
+
+ TASK + LOW +
+
+
+
IC9-003
+
Fix flaky timeout in CI
+
+ BUG + HIGH +
+
+
+
+ + +
+
+ In Progress + 2 +
+
+
+ +
IC10-004
+
Pipe I/O message routing
+
+ TASK + URGENT +
+
+
+ +
IC8-005
+
PTY resize handling
+
+ TASK + HIGH +
+
+
+
+ + +
+
+ In Review + 1 +
+
+
+ +
IC7-006
+
Review history append-only
+
+ TASK + MEDIUM +
+
+
+
+ + +
+
+ Testing + 1 +
+
+
+ +
IC9-007
+
Scenario runner refactor
+
+ TASK + HIGH +
+
+
+
+
+
+ + +
+ SEC.4 +

Signal Monitor

+
+ +
+
+
CH1 — MCP MESSAGE THROUGHPUT
+ + + +
+ 1ms/div + AUTO +
+
+ +
+
CH2 — TOOL CALL LATENCY
+ + + +
+ 5ms/div + TRIG +
+
+
+ + +
+ SEC.5 +

Module Register Map

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RefModulePackageStatusScopeI/O Pins
U1Kanban28-DIPACTIVEPer-project8 tools
U2Shell16-SOICACTIVEPer-project5 tools
U3Test20-QFPSTANDBYPer-project6 tools
U4Workflow14-SOICACTIVEHybrid5 tools
U5Chat32-QFNACTIVEPer-project6 tools
U6Voice24-TQFPOFFLINEGlobal7 tools
U7Vocabulary8-SOICACTIVEHybrid5 tools
U8Prompts8-SOICACTIVEHybrid7 tools
U9Log8-DIPACTIVEPer-project3 tools
U10Documentation20-QFPACTIVEHybrid8 tools
+ + +
+ SEC.6 +

Design System Features

+
+ +
+
+
VISUAL PRIMITIVES
+
    +
  • PCB green board with fiberglass weave texture
  • +
  • Copper traces connecting UI elements
  • +
  • IC chip cards with edge pins and notch
  • +
  • Solder pad buttons with metallic gradient
  • +
  • Via holes as decorative connection points
  • +
  • Silkscreen white labels and designators
  • +
+
+
+
INTERACTIVE ELEMENTS
+
    +
  • LED indicators with glow animations
  • +
  • Test points for status feedback
  • +
  • Signal bar progress indicators
  • +
  • Oscilloscope activity monitor
  • +
  • BOM sidebar navigation
  • +
  • Component reference table
  • +
+
+
+ + +
+ SEC.7 +

Color Tokens

+
+ +
+
+ +
+
PCB Green
+
#1a3a2a
+
+
+
+ +
+
Soldermask
+
#0d2818
+
+
+
+ +
+
Copper
+
#c87533
+
+
+
+ +
+
Copper Light
+
#daa06d
+
+
+
+ +
+
Solder
+
#a8b0b8
+
+
+
+ +
+
Silkscreen
+
#e8e8d0
+
+
+
+ +
+
LED Green
+
#33ff33
+
+
+
+ +
+
LED Red
+
#ff3030
+
+
+
+ +
+
LED Amber
+
#ffaa00
+
+
+
+ +
+
LED Blue
+
#3388ff
+
+
+
+ + +
+
+ DEVGLIDE-PCB-001 + LAYER: F.Cu + F.SilkS + UNITS: mm +
+
+ + + + FIDUCIAL MARKS +
+
+ +
+
+ + + + diff --git a/docs/old/demo-pixel.html b/docs/old/demo-pixel.html new file mode 100644 index 0000000..8d7b436 --- /dev/null +++ b/docs/old/demo-pixel.html @@ -0,0 +1,2274 @@ + + + + + +DevGlide - Retro Pixel Art Design System + + + + + + +
+ +
PRESS START
+
+
+
+
+ LOADING RETRO PIXEL DESIGN SYSTEM... +
+
+ + +
+ + +
+ ♫ SYSTEM READY! +
+ + + + + + + + + + + + diff --git a/docs/old/demo-rpg.html b/docs/old/demo-rpg.html new file mode 100644 index 0000000..cfe5d20 --- /dev/null +++ b/docs/old/demo-rpg.html @@ -0,0 +1,1770 @@ + + + + + +DevGlide RPG Interface + + + + + + + + +
+
+
+ + +
+
Quest Complete!
+
Implement Dark Mode
+
+350 XP   +120 Gold
+
+ + +
+ + + + + +
+ + +
+
📈 Developer Stats
+
+
+
+ HP +
+
+
Stamina: 780 / 1,000
+
+
+
+ MP +
+
+
Focus: 225 / 500
+
+
+
+
+
+
42
+
Level
+
+
+
156
+
Quests
+
+
+
23
+
Streak
+
+
+
+
+ + +
+
📜 Quest Log
+
+ + +
+
+ ⚔ Active Quests 4 +
+
+
Implement Real-Time Sync
+
+ ★★★★★ + +500 XP +
+
+ API +
+
Quest giver: Architect Eldric
+
+
+
Design Dark Mode Theme
+
+ ★★★★☆ + +350 XP +
+
+ UI +
+
Quest giver: Elder Lumina
+
+
+
Add Search Indexing
+
+ ★★★☆☆ + +250 XP +
+
+ API +
+
Quest giver: Sage Queryus
+
+
+
Fix Tooltip Overflow
+
+ ★★☆☆☆ + +100 XP +
+
+ BUG +
+
Quest giver: Scout Pixel
+
+
+ + +
+
+ 🔥 In Progress 3 +
+
+
Build Skill Tree UI
+
+ ★★★★☆ + +400 XP +
+
+ UI +
+
Quest giver: Master Renderon
+
+
+
Optimize Bundle Size
+
+ ★★★☆☆ + +200 XP +
+
+ PERF +
+
Quest giver: Warden Vite
+
+
+
Write E2E Test Suite
+
+ ★★☆☆☆ + +150 XP +
+
+ TEST +
+
Quest giver: Knight Testus
+
+
+ + +
+
+ ✅ Completed 3 +
+
+
Setup CI/CD Pipeline
+
+ ★★★☆☆ + +250 XP ✓ +
+
+ DEVOPS +
+
Quest giver: Commander Deploy
+
+
+
Add Error Boundaries
+
+ ★★☆☆☆ + +120 XP ✓ +
+
+ UI +
+
Quest giver: Healer Catchall
+
+
+
Update README
+
+ ★☆☆☆☆ + +50 XP ✓ +
+
+ DOCS +
+
Quest giver: Scribe Markdown
+
+
+ +
+
+ + +
+ + +
+
🎒 Inventory
+
+ +
+
x5
+
+
+
📜x3
+
+
+
💎
+
+
+
🔍
+
+
+
🔥
+
+
+
📦
+
+
+
🔑
+
+
+
🦆
+
+ +
+
🧪x2
+
+
+
+
+
+
+
+
🛡
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+
🌳 Skill Tree
+
+ +
+ +
+
+ + Core +
+
+ +
+
+ 📋 + Kanban +
+
+ 💻 + Shell +
+
+ 🎤 + Voice +
+
+ +
+
+ 🔄 + Workflow +
+
+ 🧪 + Testing +
+
+ 💬 + Chat +
+
+ 📚 + Docs +
+
+ +
+
+ 🤖 + Automate +
+
+
+
+
+ +
+ +
+ + +
+
+ Level 42 +
+
+
6,200 / 10,000 XP to Level 43
+
+ 3,800 XP needed +
+
+ +
+ + + + + diff --git a/docs/old/demo-scope.html b/docs/old/demo-scope.html new file mode 100644 index 0000000..fce546b --- /dev/null +++ b/docs/old/demo-scope.html @@ -0,0 +1,1517 @@ + + + + + +DevGlide -- Oscilloscope / Signal Analyzer + + + + + + + +
+
+ +
+ DevGlide + DGX-7000 Signal Analyzer -- 8 Channel Mixed Domain +
+
+
S/N DG-2026-04-7000   FW v3.2.1   CAL 2026-03
+
+ + +
+
+ + Acq: + RUN +
+
+ Sample Rate: + 5.00 GS/s +
+
+ Record: + 10M pts +
+
+ Timebase: + 500 us/div +
+
+ Trigger: + CH1 Rising Edge +
+
+ Avg: + 16x +
+
+ MCP Port: + :7000 +
+
+ + +
+ + + + + +
+ + +
+ +
+ CH1 Kanban -- Sine +
+
3.30 Vpp
+
Freq 1.024 kHz
+
Period 976.6 us
+
+
+
+ + +
+ + +
+ CH2 Shell -- Square +
+
5.00 Vpp
+
Freq 500.0 Hz
+
Duty 50.0%
+
+ + +
+ + +
+ CH3 Voice -- Noise +
+
1.82 Vrms
+
Crest 3.2
+
BW 200 MHz
+
+ + +
+ + +
+ TASK DOMAIN -- Multi-Channel +
+
Backlog: 4
+
Active: 3
+
Review: 2
+
Done: 8
+
+ + +
+
+
Backlog
+
MCP auth layer
+
WebSocket transport
+
Plugin SDK
+
E2E test suite
+
+
+
In Progress
+
Voice STT pipeline
+
Shell pane resize
+
Chat broadcast fix
+
+
+
Review
+
Kanban drag-drop
+
Workflow DAG editor
+
+
+
Done
+
Dashboard layout
+
Log viewer
+
Vocab CRUD
+
+
+
+
+ + +
+
+ + + + + +
+
+ + + + +
+
+ + + + +
+
+ + +1.650 V +
+
+ + + +
+
+ + +
+
+ CH1 Vpp + 3.300 V +
+
+ CH1 Freq + 1.024 kHz +
+
+ CH2 Vpp + 5.000 V +
+
+ CH2 Duty + 50.00% +
+
+ CH3 RMS + 1.820 V +
+
+ dT + 976.6 us +
+
+ 1/dT + 1.024 kHz +
+
+ + +
+ + + + + + + + +
+
+
+ + + + diff --git a/docs/old/demo-subway.html b/docs/old/demo-subway.html new file mode 100644 index 0000000..10f749e --- /dev/null +++ b/docs/old/demo-subway.html @@ -0,0 +1,1996 @@ + + + + + +DevGlide Metro — Design System + + + + + + + + +
+
+
+
+
DG
+
+
+

DevGlide Metro

+

Developer Transit Authority — Design System v2.0

+
+
+ Night service active + +
+
+
+ + + + + + + + +
+
+ +
+ + +
+
+
1
+
+

Route Legend

+

Official DevGlide Metro network lines

+
+
+ +
+
+
+
+
Kanban Line
+
Task management & boards
+
+
+
+
+
+
Shell Line
+
Terminal & pane management
+
+
+
+
+
+
Test Line
+
Browser automation
+
+
+
+
+
+
Workflow Line
+
DAG pipelines & orchestration
+
+
+
+
+
+
Voice Line
+
Speech & transcription
+
+
+
+
+
+
Chat Line
+
Multi-LLM communication
+
+
+
+
+
+
Log Line
+
Structured logging
+
+
+
+
+
+
Documentation Line
+
Operational guidance
+
+
+
+ + +
+
+ +
+
Night Service
+
Available after hours
+
+
+
+
+
+
Interchange
+
Transfer between lines
+
+
+
+
+
+
+
+
+
+
+
You Are Here
+
Current active station
+
+
+
+
+ + +
+
+
2
+
+

Network Map

+

CSS-drawn transit diagram showing the DevGlide service network

+
+
+ +
+
Zone 1 — Core Services
+
+
+ +
+
+
+
+ + +
+
+
3
+
+

Component Showcase

+

UI primitives styled as transit system elements

+
+
+ +
+ + +
+

Buttons — Tap to Board

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

Badges — Zone & Route Markers

+
+ K + S + T + W + V + C + L + D +
+
+ 1 + 2 + 3 + 4 +
+
+ Backlog + In Progress + Done + Blocked +
+
+ Shell + Workflow +
+
+ + +
+

Inputs — Kiosk Interface

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

Interchange — Transfer Stations

+
+
+
+
+
+
+
+
+
+ Standard station (left) and interchange station (right) with transfer ring. Interchanges appear where two or more route lines converge. +
+
+
+ + +
+
+

Next Departures

+
+
+
Build #247 → Deploy
+
1 min
+
+
+
+
Test Suite → E2E Pass
+
3 min
+
+
+
+
Dev Server → Hot Reload
+
5 min
+
+
+
+
CI Pipeline → Staging
+
8 min
+
+
+
+
Transcription → Processed
+
12 min
+
+
+
+ + +
+

Station Cards — Task Entries

+
+
+
+
+
Implement OAuth flow
+
+
+ Zone 2 + API + Tested +
+
+
+
+
+
Fix CI pipeline timeout
+
+
+ Zone 1 + Urgent +
+
+
+
+ +
+
+ + +
+
+
4
+
+

Dashboard

+

Route planner sidebar with station-based kanban board

+
+
+ +
+
Zone 2 — Project View
+
+ + + + + +
+
+
+ K +

Kanban Line — Feature Board

+
+
+ + +
+
+ +
+ +
+
+ B +

Backlog

+ 4 +
+
+
+
+
+
Add dark mode
+
+
+ Zone 3 + UI +
+
+
+
+
+
Audit log export
+
+
+ Zone 2 + Log +
+
+
+
+
+
Webhook integrations
+
+
+ Zone 2 + API +
+
+
+
+
+
Performance dashboard
+
+
+ Zone 3 +
+
+
+
+ + +
+
+ P +

In Progress

+ 3 +
+
+
+
+
+
MCP server refactor
+
+
+ Zone 1 + Urgent + Shell +
+
+
+
+
+
Voice provider config
+
+
+ Zone 2 + Voice +
+
+
+
+
+
Chat message threading
+
+
+ Zone 1 + Chat +
+
+
+
+ + +
+
+ R +

In Review

+ 2 +
+
+
+
+
+
Test scenario builder
+
+
+ Zone 1 + Test + UI +
+
+
+
+
+
Workflow DAG editor
+
+
+ Zone 1 + Workflow +
+
+
+
+ + +
+
+ D +

Done

+ 3 +
+
+
+
+
+
Project registry
+
+
+ Zone 1 +
+
+
+
+
+
Kanban SQLite store
+
+
+ Zone 2 + Kanban +
+
+
+
+
+
MCP stdio transport
+
+
+ Zone 1 +
+
+
+
+ +
+
+ +
+
+
+ + +
+
+
5
+
+

Line Status Board

+

Kanban columns as transit route statuses

+
+
+ +
+
Zone 3 — Status Overview
+
+ + +
+
+
+ Backlog +
+
+
+
+ Search indexing +
+
+
+ i18n support +
+
+
+ Batch operations +
+
+
+ + +
+
+
+ In Progress +
+
+
+
+ MCP refactor +
+
+
+ Config migration +
+
+
+ + +
+
+
+ Done +
+
+
+
+ Auth module +
+
+
+ DB schema v3 +
+
+
+ E2E test suite +
+
+
+ + +
+
+
+ Blocked +
+
+
+
+ SSO integration + BLOCKED +
+
+
+ +
+
+
+ + +
+
+
6
+
+

Typography & Colors

+

The type system and transit palette

+
+
+ +
+
+

Typography Scale

+
+
+

Heading 1

+ 2.4rem / 700 / Barlow Semi Condensed +
+
+

Heading 2

+ 1.8rem / 700 +
+
+

Heading 3

+ 1.35rem / 700 +
+
+

Body text — clear and functional, like station signage.

+ 1rem / 400 +
+
+

WAYFINDING LABEL

+ 0.85rem / 600 / uppercase / 0.08em tracking +
+
+
+ +
+

Transit Palette

+
+
+
+
Kanban
+
#0060af
+
+
+
+
Shell
+
#e05a00
+
+
+
+
Test
+
#00843d
+
+
+
+
Workflow
+
#d4003a
+
+
+
+
Voice
+
#8b3fa0
+
+
+
+
Chat
+
#00a5a8
+
+
+
+
Log
+
#bfad60
+
+
+
+
Docs
+
#6b6b6b
+
+
+
+
+
+ +
+ + +
+ + + + + + diff --git a/docs/old/demo-terrain.html b/docs/old/demo-terrain.html new file mode 100644 index 0000000..921038f --- /dev/null +++ b/docs/old/demo-terrain.html @@ -0,0 +1,2029 @@ + + + + + +DevGlide — Terrain Design System + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

Terrain

+

+ An e-ink inspired design system for tools that respect your eyes, your focus, and the quiet beauty of paper. +

+
+

+ Terrain brings warmth and tactility to developer interfaces. Surfaces feel like paper. + Colors draw from earth and foliage. Comfort mode strips everything to near-monochrome sepia + for distraction-free reading. +

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

Design Principles

+

The ideas that shape every surface, color, and interaction in Terrain.

+ +
+
+
01
+

Paper First

+

+ Every surface carries a grain. Backgrounds feel like parchment, cards like index stock. + The screen becomes a desk covered in well-loved notebooks. +

+
+
+
02
+

Organic Imperfection

+

+ Borders are intentionally uneven. Shadows fall naturally. Nothing is pixel-perfect because + real materials never are. Warmth lives in the details. +

+
+
+
03
+

Dual Personality

+

+ Rich mode offers subtle earthy colors and gentle animations. Comfort mode collapses to + near-monochrome sepia, zero motion, e-ink contrast. Same content, two temperaments. +

+
+
+
04
+

Eye Kindness

+

+ Near-zero blue light in Comfort mode. Warm tones everywhere. Contrast tuned for extended + reading without fatigue. Your eyes will thank you at midnight. +

+
+
+
05
+

Earth Palette

+

+ Colors pulled from forest floors and autumn fields. Olive greens, warm ochres, brick reds. + No neon, no electric blue, no synthetic palette. +

+
+
+
06
+

Serif Confidence

+

+ Display text wears Newsreader, a beautiful transitional serif that feels at home on paper. + Body text stays clean with Source Sans. Code stays sharp in JetBrains Mono. +

+
+
+
+ + + +
+

Color Palette

+

Earthy, warm, grounded. Colors shift to sepia-only in Comfort mode.

+ +
Surfaces
+
+
+
+
Base
#f0ebe3
+
+
+
+
Surface
#e8e2d8
+
+
+
+
Raised
#faf6ef
+
+
+
+
Sidebar
#e0d8cc
+
+
+ +
Text
+
+
+
+
Primary
#2c2418
+
+
+
+
Secondary
#6b5e4f
+
+
+
+
Muted
#9a8e7f
+
+
+ +
Accents & Semantics
+
+
+
+
Accent
#5c7a3d
+
+
+
+
Secondary
#8b6834
+
+
+
+
Success
#5c7a3d
+
+
+
+
Error
#a0413a
+
+
+
+
Warning
#b8862e
+
+
+
+
Info
#4a6f8a
+
+
+
+ + + +
+

Typography

+

Three typefaces, each chosen for a specific role on the paper surface.

+ +
+
Display — Newsreader
+
The quiet power of well-set type
+

+ Newsreader is a transitional serif designed for on-screen reading. Its generous x-height + and open counters make it feel at home on warm paper surfaces, lending authority and warmth + to headings and display text. +

+
+ +
+
Body — Source Sans 3
+

+ Source Sans is a humanist sans-serif that reads naturally in long passages. Its open forms + and careful spacing make it ideal for body text, UI labels, and secondary information. + At small sizes it stays crisp; at large sizes it stays friendly. + ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 +

+
+ +
+
Code — JetBrains Mono
+
const terrain = {
+  surfaces: ['parchment', 'linen', 'cardstock'],
+  palette:  ['olive', 'ochre', 'sienna', 'slate'],
+  mode:     'rich' | 'comfort',
+  animate:  mode === 'rich',
+};
+
+function renderCard(item: KanbanItem): Paper {
+  return applyGrain(item, { texture: 'fine' });
+}
+
+ + +
+
Type Scale
+
+
+ 5xl + Terrain +
+
+ 4xl + Design System +
+
+ 3xl + Section Heading +
+
+ 2xl + Component Title +
+
+ xl + Card Heading +
+
+ lg + Subtitle Text +
+
+ base + Body text reads comfortably at this size on warm parchment. +
+
+ sm + Secondary labels, metadata, timestamps +
+
+ xs + Fine print, footnotes, keyboard shortcuts +
+
+
+
+ + +
+ + + +
+

Components

+

Building blocks with organic character, warm borders, and earthy tones.

+ +
Buttons
+
+ + + + +
+
+ + + + + +
+ +
Inputs
+
+
+
+ + +
A human-readable name for your project.
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
Badges
+
+ Default + Feature + In Progress + Passing + Failed + Warning + Info + ● Active + v2.4.1 +
+ +
Toasts
+
+
+ +
+
Build Complete
+

All 47 tests passed in 3.2s. Bundle size: 142KB gzipped.

+
+
+
+ +
+
Connection Failed
+

Could not reach the MCP server on port 7000. Check that DevGlide is running.

+
+
+
+ +
+
Deprecation Notice
+

The shell_exec tool will be removed in v3.0. Use shell_run_command instead.

+
+
+
+ +
+
Sync in Progress
+

Pulling latest vocabulary entries from the global store...

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

Organic Borders

+

+ Intentionally imperfect borders using asymmetric border-radius and multi-stop box-shadows. + These small irregularities give surfaces a hand-crafted, tactile character. +

+ +
+
+
Style A — Soft Wobble
+

+ Asymmetric radii (6/10/8/12px) with varied inset shadow weights on each edge. + The result: a card that looks like it was cut by hand. +

+
+
+
Style B — Torn Edge
+

+ Inverted radius pattern (10/6/12/8px) with heavier bottom shadow. + Feels like a note torn from a journal and pinned to a corkboard. +

+
+
+
Style C — Pressed Card
+

+ Yet another radius combination (8/12/6/10px) with right-side weight. + Like a card pressed slightly off-center in a letterpress. +

+
+
+
+ + +
+ + + +
+

Dashboard

+

+ A full mockup showing the linen-textured sidebar and paper-like kanban cards. +

+ +
+
+ + + + +
+
+

Terrain Design System

+
+ Sprint 4 + +
+
+ +
+ +
+
+ Todo 3 +
+
+
Define color tokens for Comfort mode
+
+ TASK + HIGH +
+
+
+
Add paper grain SVG filter
+
+ TASK + MED +
+
+
+
Audit contrast ratios for WCAG AA
+
+ BUG + HIGH +
+
+
+ + +
+
+ In Progress 2 +
+
+
Implement mode toggle with CSS custom properties
+
+ TASK + URGENT +
+
+
+
Style organic border variants
+
+ TASK + MED +
+
+
+ + +
+
+ In Review 2 +
+
+
Newsreader font integration
+
+ TASK + MED +
+
+
+
Reading block drop cap styling
+
+ TASK + LOW +
+
+
+ + +
+
+ Done 2 +
+
+
Set up CSS custom property architecture
+
+ DONE +
+
+
+
Choose typeface stack
+
+ DONE +
+
+
+
+
+
+
+
+ + +
+ + + +
+

Reading Surface

+

+ Long-form text on a warm paper card. Comfort mode makes this feel like an e-ink reader. +

+ +
+

On the Craft of Quiet Interfaces

+
A brief meditation on restraint in design — April 2026
+
+

There is a particular kind of software that does not announce itself. It does not + glow or pulse or demand your attention with badges and banners. It simply sits on your + screen like a well-bound book sits on a shelf: present, patient, ready when you are. + Terrain is an attempt to build that kind of software aesthetic.

+ +

The choice of warm parchment over clinical white is not merely decorative. Cool-toned + interfaces emit significant blue light, which disrupts circadian rhythms during late-night + coding sessions. By shifting the entire palette toward amber and sepia, we reduce eye strain + without sacrificing legibility. In Comfort mode, this principle is taken to its conclusion: + every color collapses to a narrow band of warm browns and creams, mimicking the contrast + profile of an e-ink display.

+ +

The paper grain is another deliberate choice. Screens are unnaturally smooth, and that + smoothness can feel sterile after hours of staring. A subtle noise texture, applied as an + SVG filter across every surface, breaks the digital perfection just enough to feel tactile. + Your brain reads these micro-variations as material: this is paper, this is linen, this is + a card you could pick up and turn over in your hands.

+ +

Perhaps the most counterintuitive decision is the organic imperfection of borders. In + a world where every CSS framework ships with 4px border-radius and uniform 1px borders, + Terrain deliberately makes its edges uneven. Asymmetric radii. Multi-stop box-shadows that + vary weight per side. The effect is subtle but cumulative: surfaces feel hand-crafted rather + than machine-stamped. Like a letterpress card with slightly uneven edges, or a Japanese + ceramic bowl with an intentional irregularity that makes it more beautiful, not less.

+ +

The serif typeface for display text, Newsreader, was chosen because it carries the + authority of print without the stuffiness of Times New Roman. Its generous x-height and + slightly tight spacing make it feel purposeful on screen, as if every heading were a + carefully composed headline in a broadsheet newspaper. Paired with Source Sans for body + text and JetBrains Mono for code, the stack covers every surface of a developer tool + without any typeface feeling out of place.

+
+
+
+ + + +
+

Miscellaneous

+

Blockquotes, inline code, and additional typographic elements.

+ +
+ “The details are not the details. They make the design.” +
— Charles Eames +
+ +

+ Terrain uses CSS custom properties like --color-bg-base and + --color-accent so the entire theme can switch modes by changing the + data-mode attribute on the root element. The transition is instant in + Comfort mode (all transitions are disabled) and gently animated in Rich mode. +

+ +
// Switching modes programmatically
+document.documentElement.setAttribute('data-mode', 'comfort');
+
+// The entire palette shifts via CSS custom properties:
+// [data-mode="comfort"] {
+//   --color-bg-base:     #f5eed8;
+//   --color-accent:      #7a6a52;  /* olive green becomes warm brown */
+//   --color-text-primary: #4a4035;  /* reduced contrast */
+// }
+
+ + + + + +
+ + + + + + + diff --git a/docs/old/demo-topo.html b/docs/old/demo-topo.html new file mode 100644 index 0000000..f44bb2f --- /dev/null +++ b/docs/old/demo-topo.html @@ -0,0 +1,2126 @@ + + + + + +Topographic Map — Design System for DevGlide + + + + + + + + + + + + + + + + +
+ + +
+
N 48°12'36" · E 11°34'48" · DATUM WGS84
+

Topographic Map

+

Design System for DevGlide — Terrain-mapped interface language

+ ▲ 2847m · SUMMIT SURVEY REV 1.0 +
+ + +
+
+ N 48°13' E 11°35' +

Elevation Palette

+ ▲ 0m – 2847m +
+ +
+
+
+
+ Abyss + #0a1210 + ▼ -50m · ocean floor +
+
+
+
+
+ Deep Basin + #0f1a14 + ▲ 0m · base surface +
+
+
+
+
+ Lowland + #152119 + ▲ 200m · card surface +
+
+
+
+
+ Valley Floor + #1e3024 + ▲ 400m · hover state +
+
+
+
+
+ Highland + #2d4a32 + ▲ 800m · selected +
+
+
+
+
+ Ridge + #4a6b3a + ▲ 1200m · primary +
+
+
+
+
+ Peak + #6b8c42 + ▲ 1800m · accent +
+
+
+
+
+ Summit + #8ba84e + ▲ 2200m · emphasis +
+
+
+
+
+ Snowline + #c4b991 + ▲ 2600m · headings +
+
+
+
+
+ Cream Cap + #ddd5b8 + ▲ 2847m · titles +
+
+ + +
+
+
+ Heat Spot + #c4883a + 🌡 warning zone +
+
+
+
+
+ Lava + #c44a3a + 🌡 danger zone +
+
+
+
+
+ Glacial + #3a7a6b + ❄ info / cool +
+
+
+
+
+ Olive + #5c6b3a + 🌿 terrain mid +
+
+
+
+
+ Sand + #b8a878 + 🏜 secondary text +
+
+
+
+
+ Brown Earth + #7a5c3a + ⛰ terrain accent +
+
+
+
+ + +
+
+ N 48°14' E 11°36' +

Typography Specimens

+ IBM PLEX MONO +
+ +
+
+
+
DISPLAY / H1
+
2rem · 700
+
+ Summit Control Point +
+
+
+
HEADING / H2
+
1.5rem · 600
+
+ Ridge Survey Station +
+
+
+
SUBHEADING / H3
+
1.1rem · 600
+
+ Contour Interval: 20m +
+
+
+
BODY
+
0.85rem · 400
+
+ Terrain features are rendered at scale with index contour lines every 100m. Supplementary contours appear at half intervals in areas of gentle slope. Depression contours are indicated with tick marks pointing downslope. +
+
+
+
CAPTION
+
0.7rem · 300
+
+ Map projection: UTM Zone 32N · Spheroid: WGS84 · Grid: MGRS +
+
+
+
CODE / INLINE
+
0.8rem · 400
+
+ devglide mcp kanban --port 7000 +
+
+
+ + +
+
+ N 48°15' E 11°37' +

Controls & Actions

+ INTERACTIVE LAYER +
+ +
BUTTONS
+
+ + + + +
+ +
SIZE VARIANTS
+
+ + + +
+ +
INPUTS
+
+
+ + + decimal degrees or DMS format +
+
+ + + decimal degrees or DMS format +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ N 48°16' E 11°38' +

Elevation Badges

+ STATUS MARKERS +
+ +
+ ▲ 1200m RIDGE + ▲ 2847m SUMMIT + ⚠ HEAT SPOT + ⚠ LANDSLIDE + ❄ GLACIAL + ▼ -50m DEPRESSION +
+ +
+ TASK + IN PROGRESS + IN REVIEW + URGENT + BUG + BACKLOG +
+
+ + +
+
+ N 48°17' E 11°39' +

Survey Notifications

+ ALERT LAYER +
+ +
+
+ +
+
Survey Point Recorded
+
Triangulation station Alpha established at summit ridge.
+
N 48°12'36" E 11°34'48" · ▲ 2847m
+
+
+
+ +
+
Terrain Instability Detected
+
Slope gradient exceeds 45° in sector 7. Proceed with caution.
+
N 48°11'20" E 11°33'15" · ▲ 1840m
+
+
+
+ +
+
Connection to Base Camp Lost
+
Radio relay station offline. Last telemetry 4m ago.
+
N 48°10'05" E 11°32'40" · ▲ 890m
+
+
+
+ +
+
Weather Advisory
+
Cloud base descending to 2200m. Visibility below 500m expected.
+
REGION-WIDE · VALID 0600-1800Z
+
+
+
+
+ + +
+
+ N 48°18' E 11°40' +

Terrain Cards

+ LEGEND ENTRIES +
+ +
+
+
+
+
+ MCP Server Framework + ▲ 2200m · SUMMIT ZONE +
+
Core framework providing createDevglideMcpServer factory, tool registration, and stdio transport. Each app exports its own MCP server mounted on the unified HTTP daemon.
+ +
+
+ +
+
+
+
+ Voice Transcription Pipeline + ▲ 1600m · RIDGE ZONE +
+
Speech-to-text pipeline with provider abstraction. Supports OpenAI, Groq, local whisper.cpp, and vLLM backends. AI cleanup mode for post-processing filler word removal.
+ +
+
+ +
+
+
+
+ Shell Pane Management + ▲ 800m · VALLEY ZONE +
+
Terminal pane lifecycle management with PTY sessions. Commands are executed in managed panes, scrollback is readable, and panes are ephemeral per project session.
+ +
+
+
+
+ + +
+
+ N 48°19' E 11°41' +

Dashboard — Terrain Overview

+ FULL SURVEY MAP +
+ +
+ +
+
+ + + DevGlide + v1.0 +
+
+ N 48°12' E 11°34' · UTM 32T + + + SERVER ▲ 7000 + +
+
+ + + + + +
+
+ ▦ Feature: AI Workflow Toolkit + HOME / KANBAN / ACTIVE SURVEY +
+ +
+ +
+
+
+
Backlog
+
▼ BASIN · 0m
+
+ 3 +
+
+
Add prompt versioning
+
Track prompt template revision history
+ +
+
+
Vocabulary import/export
+
CSV and JSON bulk operations for terms
+ +
+
+
Shell pane resize events
+
Handle terminal resize via PTY signals
+ +
+
+ + +
+
+
+
In Progress
+
▲ RIDGE · 1200m
+
+ 2 +
+
+
Multi-LLM chat coordination
+
Implement broadcast delivery and @mention routing for chat room
+ +
+
+
Edge TTS chunk pipeline
+
Sentence splitting with parallel generation and playback
+ +
+
+ + +
+
+
+
In Review
+
▲ PEAK · 2000m
+
+ 2 +
+
+
Workflow DAG visualizer
+
Render workflow node graph on the dashboard canvas
+ +
+
+
Kanban SQLite migrations
+
Schema migration runner for kanban.db versioning
+ +
+
+ + +
+
+
+
Testing
+
▲ SUMMIT · 2847m
+
+ 1 +
+
+
Documentation seed content
+
Auto-install tool guides, workflows, and troubleshooting on first use
+ +
+
+
+
+
+
+ + + + +
+ + + + + + diff --git a/docs/old/styleguide-proposal.html b/docs/old/styleguide-proposal.html new file mode 100644 index 0000000..67911d9 --- /dev/null +++ b/docs/old/styleguide-proposal.html @@ -0,0 +1,1884 @@ + + + + + +DevGlide — AXIOM Styleguide Proposal + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + + + +
+ + +
+

AXIOM

+

+ Pastel neon. Holographic depth. Angular precision. + A futuristic design system for developers who live in the terminal. +

+
Design System v2 — DevGlide
+
+ + + +
+

Design Principles

+

+ Game-grade aesthetics with pastel restraint. Soft neons glow without burning. + Holographic cards respond to your cursor. Aurora gradients breathe in the background. +

+ +
+
+ +

Pastel Neon

+

Soft sky, violet, peach, and mint replace harsh primaries. Colors glow diffusely, never sharply. Aligned and harmonious.

+
+
+ +

Holographic Depth

+

Cards tilt in 3D following your cursor with rainbow refraction. Surfaces feel like real holographic objects you can touch.

+
+
+ +

Aurora Atmosphere

+

Slowly morphing pastel gradient blobs drift behind the grid. The environment feels alive and spacious.

+
+
+ +

Angular Geometry

+

Clipped corners on every surface. No border-radius anywhere. Sharp but softened by pastel tones and diffuse light.

+
+
+ +

Soft Glow

+

Diffuse, wide-radius glows replace harsh neon. Status indicators pulse gently. The interface breathes.

+
+
+ +

Dual Mode

+

Deep space dark + cool frost light. Both maintain the pastel neon language and angular personality.

+
+
+
+ + + +
+

Color Palette

+

+ Four harmonious pastels: sky, violet, peach, mint. Aligned saturation and lightness. + Every state color is desaturated just enough to feel soft without losing identity. +

+ +
+
// Backgrounds
+
+
Base#08090f
+
Surface#0e1019
+
Raised#131620
+
Overlay#191d2a
+
Sunken#05060b
+
+
+ +
+
// Primary — Pastel Sky
+
+
Default#7dd3fc
+
Hover#a5e0fd
+
Dim#5aa8d4
+
+
+ +
+
// Secondary — Pastel Violet
+
+
Default#c4b5fd
+
Hover#d4c8fe
+
+
+ +
+
// Tertiary — Pastel Peach
+
+
Default#fdba9a
+
+
+ +
+
// States
+
+
Success#6ee7b7
+
Error#fda4af
+
Warning#fcd34d
+
Info#93c5fd
+
+
+
+ + + +
+

Typography

+ +
+
+
// Display
+
AXIOM
+
Orbitron · 400, 600, 700
+
+
+
// Body
+
Clean interface
+
Exo 2 · 300, 400, 500, 600
+
+
+
// Code
+
const flow = {}
+
JetBrains Mono · 400, 500
+
+
+ +
+
5xlAXIOM
+
3xlDashboard
+
2xlKanban Board
+
lgBody text for descriptions and paragraphs
+
baseDefault UI labels and controls
+
smSecondary metadata
+
xs/monoSYS::ONLINE · 12:34:56
+
+
+ + + +
+

Status Indicators

+
+
Online
+
Processing
+
Error
+
Recording
+
Offline
+
+
+
+
+
+
+ DATA STREAM +
+
+ + + +
+

Holographic Cards

+

+ Hover to activate. Cards tilt in 3D following your cursor with a rainbow holographic sheen, + like holographic game cards. The refraction angle shifts with your mouse position. +

+ +
+
+
+ Shell Manager + Active +
+

Manage terminal panes for builds, test runners, and server processes. Auto-scrollback capture.

+
+ 3 panes + +
+
+ +
+
+ Test Runner + Passing +
+

AI-driven browser test automation. Describe what to test in natural language.

+
+ 12 scenarios + +
+
+ +
+
+ Voice + STT Ready +
+

Speech-to-text with vocabulary biasing. Supports local whisper and cloud providers.

+
+ 87 transcriptions + +
+
+
+
+ + + +
+

Spacing Scale

+
+
sp-1
4px
+
sp-2
8px
+
sp-4
16px
+
sp-6
24px
+
sp-8
32px
+
sp-12
48px
+
sp-16
64px
+
sp-24
96px
+
+
+ + + +
+

Components

+ +
Buttons
+
+ + + + + + +
+
+ + + +
+ +
Inputs
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Badges
+
+ Default + Sky + Violet + Peach + Rose + Success + Error + Warning + Info +
+ +
Toasts
+
+
Test scenario passed — 3 assertions
+
Build failed — check output
+
Merge conflict in kanban.db
+
Transcription ready
+
+ +
List Rows
+
+
+
+
Implement pastel color system
IN PROGRESS · HIGH · design
+ TASK +
+
+
+
+
Fix holographic sheen on Firefox
TODO · MEDIUM · ui
+ BUG +
+
+
+
+
Aurora gradient background
DONE · LOW · ui
+ TASK +
+
+
+ +
Modal
+ +
+ + + +
+

Motion

+
+
Hover
Lift
+
Hover
Scale
+
Hover
Spin
+
Hover
Shift
+
Hover
Pulse
+
+
+ + + +
+

Before & After

+
+
+
Current — NERV / Teal
+

Kanban Board

+

Rounded corners, teal accent, monospace-only. Functional but flat.

+
+ + ● In Progress +
+
+
+
// Proposed — AXIOM v2
+

Kanban Board

+

Pastel neons, holographic cards, aurora background. Feels alive.

+
+ + In Progress +
+
+
+
+ + + +
+

Dashboard Mockup

+
+
+
D
Devglide
+
devglide
+
// Project
+
Kanban
+
Log
+
Test
+
Shell
+
❮❯ Coder
+
Workflow
+
Chat
+
// Tools
+
Docs
+
Voice
+
+
+
+

Kanban Board

+
+ + +
+
+
+
+
Backlog3
+
Research clip-path tokens
LOWdesign
+
Audit font loading
LOWperf
+
Mobile nav HUD
MEDui
+
+
+
Todo2
+
Pastel accent system
HIGHdesign
+
Add Orbitron font CDN
MEDui
+
+
+
In Progress1
+
Holographic card effect
HIGHux
+
+
+
Done2
+
Aurora background
MEDui
+
Spacing scale
LOWdesign
+
+
+
+
+
+ + + +
+

HUD Brackets

+
+

System Status

+

All systems operational. 3 panes active, 12 test scenarios queued.

+
+
Shell
+
Kanban
+
Test
+
Voice
+
+
+
+ + + +
+

Token Migration

+
+ + + + + + + + + + +
TokenCurrentProposed
--df-color-bg-base#0d1117#08090f
--df-color-accent-default#00afaf#7dd3fc
--df-color-text-primary#cdd9e5#d8dce8
--df-font-display'IBM Plex Mono''Orbitron'
--df-font-uisystem-ui'Exo 2'
border-radiusborder-radius: 8pxclip-path: polygon(...)
+
+
+ + + + + +
+ + + + + + diff --git a/docs/superpowers/plans/2026-04-08-chat-pipe-llm-client.md b/docs/superpowers/plans/2026-04-08-chat-pipe-llm-client.md new file mode 100644 index 0000000..a20cbf4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-chat-pipe-llm-client.md @@ -0,0 +1,1086 @@ +# Chat-Pipe LLM Client for KB Wiki Builder — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow the KB Wiki Builder to delegate `cluster()` and `synthesize()` LLM calls to an already-joined chat participant via a side-channel bridge, eliminating the need for the daemon to own its own OpenAI/Anthropic API key when authenticated chat agents are present. + +**Architecture:** A new `createChatPipeLlmClient(bridge)` factory in `kb-llm-client.ts` takes a `KbChatPipeBridge` interface as a constructor dependency — the kb-llm-client layer never imports chat code. A new chat-side service `kb-synth-bridge.ts` holds a registry of pending synthesis requests and exposes `requestSynth(assignee, prompt, projectId, opts)` which PTY-injects a structured notification to the assignee. The assignee returns the result via a new MCP tool `kb_synth_submit(requestId, content)`. Production wiring lives in `src/routers/knowledge-base.ts`, which constructs the bridge against the chat services and passes it to `createChatPipeLlmClient`. + +**Tech Stack:** TypeScript, Vitest, MCP SDK (`@modelcontextprotocol/sdk`), Express 5, existing `assignment-store.ts` / `payload-store.ts` infrastructure available but NOT reused (side-channel approach), `chat-registry.ts` PTY injection primitives. + +--- + +## File Structure + +**Create:** +- `src/apps/chat/services/kb-synth-bridge.ts` — pending request registry, requestSynth, submitSynth, selection policy +- `src/apps/chat/services/kb-synth-bridge.test.ts` — bridge unit tests with fake clock + fake PTY notifier + +**Modify:** +- `src/apps/knowledge-base/services/kb-llm-client.ts` — add `KbChatPipeBridge` interface, `createChatPipeLlmClient`, update `selectLlmClient` to read `KB_BUILDER_LLM_BACKEND` +- `src/apps/knowledge-base/services/kb-llm-client.test.ts` — tests for backend enum + chat-pipe client with fake bridge +- `src/apps/knowledge-base/services/kb-builder-types.ts` — extend `BuildRun.llmCalls[]` audit fields +- `src/apps/knowledge-base/services/kb-builder.ts` — populate new audit fields when calling LlmClient +- `src/apps/chat/src/mcp.ts` — add `kb_synth_submit` MCP tool +- `src/routers/knowledge-base.ts` — construct production bridge, pass to `selectLlmClient` factory, set up env var read +- `src/apps/chat/services/chat-registry.ts` — export a small `notifySynthRequest(assignee, requestId, prompt, projectId)` helper that wraps PTY injection + +--- + +## Task 1 — Backend selector enum + new env var + +**Files:** +- Modify: `src/apps/knowledge-base/services/kb-llm-client.ts:402-410` (selectLlmClient) +- Modify: `src/apps/knowledge-base/services/kb-llm-client.test.ts:72-136` (selectLlmClient describe block) + +- [ ] **Step 1: Write failing test for `KB_BUILDER_LLM_BACKEND=openai` pinning** + +```ts +// In kb-llm-client.test.ts inside describe('selectLlmClient', ...) — add: +it('honors KB_BUILDER_LLM_BACKEND=openai when both keys are set', () => { + process.env.KB_BUILDER_LLM_BACKEND = 'openai'; + process.env.OPENAI_API_KEY = 'sk-test'; + process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; + const client = selectLlmClient(); + expect(typeof client.cluster).toBe('function'); + // Without exposing internals we can't fingerprint the provider directly. + // The unit-level guarantee is that selectLlmClient() does not throw and + // returns the openai-backed client when the env var pins it. The integration + // test in src/routers/knowledge-base.test.ts exercises end-to-end behavior. + delete process.env.KB_BUILDER_LLM_BACKEND; +}); + +it('honors KB_BUILDER_LLM_BACKEND=anthropic even when OPENAI_API_KEY is set', () => { + process.env.KB_BUILDER_LLM_BACKEND = 'anthropic'; + process.env.OPENAI_API_KEY = 'sk-test'; + process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; + const client = selectLlmClient(); + expect(typeof client.cluster).toBe('function'); + delete process.env.KB_BUILDER_LLM_BACKEND; +}); + +it('throws on invalid KB_BUILDER_LLM_BACKEND values', () => { + process.env.KB_BUILDER_LLM_BACKEND = 'gemini'; + expect(() => selectLlmClient()).toThrow(/KB_BUILDER_LLM_BACKEND/); + delete process.env.KB_BUILDER_LLM_BACKEND; +}); + +it('falls back to noop when KB_BUILDER_LLM_BACKEND=openai but OPENAI_API_KEY missing', async () => { + process.env.KB_BUILDER_LLM_BACKEND = 'openai'; + delete process.env.OPENAI_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + const client = selectLlmClient(); + await expect( + client.cluster({ promptVersion: 'compile.v1', sources: [] }), + ).rejects.toThrow(/LLM client not configured/); + delete process.env.KB_BUILDER_LLM_BACKEND; +}); +``` + +- [ ] **Step 2: Run the test and confirm failure** + +Run: `pnpm vitest run src/apps/knowledge-base/services/kb-llm-client.test.ts` +Expected: 4 failures — `KB_BUILDER_LLM_BACKEND` is not yet read; the invalid-value test expects a throw that does not happen. + +- [ ] **Step 3: Implement the backend enum in `selectLlmClient`** + +Replace `kb-llm-client.ts:402-410` with: + +```ts +export type KbBuilderBackend = 'auto' | 'chat' | 'openai' | 'anthropic'; + +const VALID_BACKENDS: ReadonlySet = new Set(['auto', 'chat', 'openai', 'anthropic']); + +export function readBackendFromEnv(): KbBuilderBackend { + const raw = (process.env.KB_BUILDER_LLM_BACKEND ?? 'auto').toLowerCase().trim(); + if (!VALID_BACKENDS.has(raw as KbBuilderBackend)) { + throw new Error( + `KB_BUILDER_LLM_BACKEND must be one of ${[...VALID_BACKENDS].join(', ')} (got "${raw}")`, + ); + } + return raw as KbBuilderBackend; +} + +/** + * Factory: select an LLM client based on `KB_BUILDER_LLM_BACKEND` and the + * available API keys. + * + * Backend resolution: + * - `auto` (default): chat (if a bridge is supplied) → openai → anthropic → noop + * - `chat`: chat backend if a bridge is supplied, else noop + * - `openai`: OpenAI client if `OPENAI_API_KEY` set, else noop + * - `anthropic`: Anthropic client if `ANTHROPIC_API_KEY` set, else noop + * + * The optional `bridge` parameter is the chat-pipe bridge constructed by the + * router/server. The kb-llm-client layer never imports chat code; the bridge + * is wired in by the integration layer. + */ +export function selectLlmClient(opts?: { bridge?: KbChatPipeBridge }): LlmClient { + const backend = readBackendFromEnv(); + const hasOpenAI = !!process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.length > 0; + const hasAnthropic = !!process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.length > 0; + const hasBridge = !!opts?.bridge; + + if (backend === 'chat') { + return hasBridge ? createChatPipeLlmClient(opts!.bridge!) : createNoopLlmClient(); + } + if (backend === 'openai') { + return hasOpenAI ? createOpenAILlmClient() : createNoopLlmClient(); + } + if (backend === 'anthropic') { + return hasAnthropic ? createAnthropicLlmClient() : createNoopLlmClient(); + } + // auto + if (hasBridge) return createChatPipeLlmClient(opts!.bridge!); + if (hasOpenAI) return createOpenAILlmClient(); + if (hasAnthropic) return createAnthropicLlmClient(); + return createNoopLlmClient(); +} +``` + +(`KbChatPipeBridge` and `createChatPipeLlmClient` are added in Task 2; the file will not compile yet — that is expected mid-refactor.) + +- [ ] **Step 4: Commit (deferred until Task 2 closes the type holes)** + +Skip — Task 2 introduces the missing identifiers and they will be committed together. + +--- + +## Task 2 — `KbChatPipeBridge` interface + `createChatPipeLlmClient` + +**Files:** +- Modify: `src/apps/knowledge-base/services/kb-llm-client.ts` (top of file: new interface + factory) +- Modify: `src/apps/knowledge-base/services/kb-llm-client.test.ts` (new describe block) + +- [ ] **Step 1: Write failing test for `createChatPipeLlmClient.cluster()` happy path** + +Append to `kb-llm-client.test.ts`: + +```ts +import { createChatPipeLlmClient, type KbChatPipeBridge, type SynthesisRequest } from './kb-llm-client.js'; + +function fakeBridge(opts: { + clusterResponse?: string; + synthesizeResponse?: string; + throwOn?: 'cluster' | 'synthesize'; + recordedRequests?: SynthesisRequest[]; +}): KbChatPipeBridge { + return { + async submitSynthesisRequest(req: SynthesisRequest): Promise<{ content: string; assignee: string; requestId: string; durationMs: number }> { + opts.recordedRequests?.push(req); + if (opts.throwOn === req.stage) throw new Error('bridge timeout'); + const content = req.stage === 'cluster' ? (opts.clusterResponse ?? '{}') : (opts.synthesizeResponse ?? '{}'); + return { content, assignee: 'codex-2', requestId: 'req-fixture', durationMs: 42 }; + }, + }; +} + +describe('createChatPipeLlmClient', () => { + it('cluster() forwards prompt to bridge and parses returned JSON', async () => { + const recorded: SynthesisRequest[] = []; + const bridge = fakeBridge({ + clusterResponse: JSON.stringify({ + clusters: [{ clusterName: 'auth', rawIds: ['kb_a'], confidence: 'high' }], + }), + recordedRequests: recorded, + }); + const client = createChatPipeLlmClient(bridge); + const { clusters, tokens } = await client.cluster({ + promptVersion: 'compile.v1', + sources: [{ id: 'kb_a', title: 'Auth', firstParagraph: 'OAuth flow', tags: ['auth'] }], + }); + expect(clusters).toHaveLength(1); + expect(clusters[0].clusterName).toBe('auth'); + expect(clusters[0].rawIds).toEqual(['kb_a']); + expect(recorded).toHaveLength(1); + expect(recorded[0].stage).toBe('cluster'); + expect(recorded[0].prompt).toContain('Group the following raw notes'); + expect(tokens.model).toBe('chat-pipe:codex-2'); + expect(tokens.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('synthesize() forwards prompt to bridge and parses returned JSON', async () => { + const bridge = fakeBridge({ + synthesizeResponse: JSON.stringify({ + title: 'Auth Overview', + body: '## Intro\n\nFlow [^kb_a].', + tags: ['auth'], + sourceRefs: ['kb_a'], + }), + }); + const client = createChatPipeLlmClient(bridge); + const { output, tokens } = await client.synthesize({ + promptVersion: 'compile.v1', + plan: { type: 'create', cluster: { clusterName: 'auth', rawIds: ['kb_a'], confidence: 'high' }, targetPath: 'notes/auth', targetSlug: 'auth-overview' }, + sources: [{ id: 'kb_a', title: 'Auth', body: 'OAuth flow', tags: ['auth'] }], + }); + expect(output.title).toBe('Auth Overview'); + expect(output.sourceRefs).toEqual(['kb_a']); + expect(tokens.model).toBe('chat-pipe:codex-2'); + }); + + it('cluster() throws a descriptive error when bridge returns malformed JSON', async () => { + const bridge = fakeBridge({ clusterResponse: 'this is not json' }); + const client = createChatPipeLlmClient(bridge); + await expect( + client.cluster({ promptVersion: 'compile.v1', sources: [] }), + ).rejects.toThrow(/Chat-pipe cluster response was not valid JSON/); + }); + + it('cluster() propagates bridge errors (e.g. timeout)', async () => { + const bridge = fakeBridge({ throwOn: 'cluster' }); + const client = createChatPipeLlmClient(bridge); + await expect( + client.cluster({ promptVersion: 'compile.v1', sources: [] }), + ).rejects.toThrow(/bridge timeout/); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm failures** + +Run: `pnpm vitest run src/apps/knowledge-base/services/kb-llm-client.test.ts` +Expected: failures — `createChatPipeLlmClient`, `KbChatPipeBridge`, `SynthesisRequest` are not yet exported. + +- [ ] **Step 3: Implement the interface and factory** + +Add to the top of `kb-llm-client.ts` (after the existing imports, before `DEFAULT_OPENAI_MODEL`): + +```ts +/** + * Stage discriminator for synthesis requests sent to the chat bridge. The + * builder calls cluster() once per build run and synthesize() once per cluster. + */ +export type SynthesisStage = 'cluster' | 'synthesize'; + +/** + * A single synthesis request the bridge needs to dispatch to a chat participant. + * + * The bridge is responsible for choosing the assignee, delivering the prompt, + * waiting for the response, and returning the raw text. The kb-llm-client + * layer parses + validates the JSON itself. + */ +export interface SynthesisRequest { + stage: SynthesisStage; + prompt: string; + promptVersion: string; + /** Schema-instructive system message the bridge MAY surface to the assignee. */ + system: string; +} + +/** + * Side-channel bridge for delegating LLM work to a chat participant. + * + * Implementations: + * - `fakeBridge` (tests) — auto-responds with fixture JSON + * - Production bridge in `src/routers/knowledge-base.ts` — wires to `kb-synth-bridge` chat service + * + * Returning a string (not a parsed object) keeps the boundary narrow: parsing + * stays in `kb-llm-client.ts` so the bridge does not need to know about + * cluster/synthesize schemas. + */ +export interface KbChatPipeBridge { + submitSynthesisRequest( + req: SynthesisRequest, + ): Promise<{ content: string; assignee: string; requestId: string; durationMs: number }>; +} +``` + +Then add a new factory function (anywhere after the existing `createAnthropicLlmClient`): + +```ts +/** + * Production LlmClient backed by a chat participant via the kb-synth-bridge. + * + * Each cluster()/synthesize() call: + * 1. Builds the existing cluster/synthesize prompt (same templates as the + * OpenAI/Anthropic clients — keeps prompt-version determinism intact). + * 2. Hands the prompt to the bridge as a `SynthesisRequest`. + * 3. Parses the returned text via the same defensive normalizers used by + * the OpenAI/Anthropic paths. + * 4. Returns the result + token usage. `inputTokens`/`outputTokens` are + * always 0 (the chat backend has no provider-billed token count); the + * `model` field encodes the assignee for build-run audit purposes. + */ +export function createChatPipeLlmClient(bridge: KbChatPipeBridge): LlmClient { + return { + cluster: async (input: LlmClusterInput) => { + const start = Date.now(); + const prompt = buildClusterPrompt(input); + const result = await bridge.submitSynthesisRequest({ + stage: 'cluster', + prompt, + promptVersion: input.promptVersion, + system: 'You are a strict JSON-emitting assistant for a knowledge-base wiki builder. Reply with one JSON object matching the schema in the prompt. Do NOT include any prose before or after the JSON.', + }); + const jsonText = extractJsonObject(result.content); + let parsed: { clusters?: unknown }; + try { + parsed = JSON.parse(jsonText) as { clusters?: unknown }; + } catch (err) { + throw new Error( + `Chat-pipe cluster response was not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const clusters = normalizeClusters(parsed.clusters); + const tokens: LlmTokenUsage = { + model: `chat-pipe:${result.assignee}`, + inputTokens: 0, + outputTokens: 0, + durationMs: Date.now() - start, + }; + return { clusters, tokens }; + }, + + synthesize: async (input: LlmSynthesizeInput) => { + const start = Date.now(); + const prompt = buildSynthesizePrompt(input); + const result = await bridge.submitSynthesisRequest({ + stage: 'synthesize', + prompt, + promptVersion: input.promptVersion, + system: 'You are a strict JSON-emitting assistant for a knowledge-base wiki builder. Synthesize one wiki page per request from the provided sources. Reply with one JSON object matching the schema in the prompt. Do NOT include any prose before or after the JSON.', + }); + const jsonText = extractJsonObject(result.content); + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch (err) { + throw new Error( + `Chat-pipe synthesize response was not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const output = normalizeSynthesizeOutput(parsed); + const tokens: LlmTokenUsage = { + model: `chat-pipe:${result.assignee}`, + inputTokens: 0, + outputTokens: 0, + durationMs: Date.now() - start, + }; + return { output, tokens }; + }, + }; +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +Run: `pnpm vitest run src/apps/knowledge-base/services/kb-llm-client.test.ts` +Expected: all tests in `createChatPipeLlmClient` describe block pass; the Task 1 backend selector tests now also pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/apps/knowledge-base/services/kb-llm-client.ts \ + src/apps/knowledge-base/services/kb-llm-client.test.ts +git commit -m "feat(kb): add KB_BUILDER_LLM_BACKEND enum + chat-pipe LLM client factory" +``` + +--- + +## Task 3 — `kb-synth-bridge` service with selection policy + +**Files:** +- Create: `src/apps/chat/services/kb-synth-bridge.ts` +- Create: `src/apps/chat/services/kb-synth-bridge.test.ts` + +- [ ] **Step 1: Write failing test for selection policy + happy path** + +Create `src/apps/chat/services/kb-synth-bridge.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + registerPendingRequest, + resolvePendingRequest, + pickAssignee, + _resetForTest, + type SynthRequestRecord, + type ParticipantSnapshot, +} from './kb-synth-bridge.js'; + +beforeEach(() => { + _resetForTest(); +}); + +afterEach(() => { + delete process.env.KB_BUILDER_ASSIGNEE; +}); + +describe('pickAssignee', () => { + const claude: ParticipantSnapshot = { name: 'claude-1', kind: 'llm', status: 'idle' }; + const codex: ParticipantSnapshot = { name: 'codex-2', kind: 'llm', status: 'idle' }; + const human: ParticipantSnapshot = { name: 'user', kind: 'user', status: 'idle' }; + + it('returns null when no llm participants are joined', () => { + expect(pickAssignee([], 'claude-1')).toBeNull(); + expect(pickAssignee([human], 'claude-1')).toBeNull(); + }); + + it('prefers a non-self llm participant when multiple are joined', () => { + expect(pickAssignee([claude, codex], 'claude-1')).toBe('codex-2'); + expect(pickAssignee([claude, codex], 'codex-2')).toBe('claude-1'); + }); + + it('falls back to the only joined participant when self is alone', () => { + expect(pickAssignee([claude], 'claude-1')).toBe('claude-1'); + }); + + it('honors KB_BUILDER_ASSIGNEE env override when the named agent is joined', () => { + process.env.KB_BUILDER_ASSIGNEE = 'claude-1'; + expect(pickAssignee([claude, codex], 'codex-2')).toBe('claude-1'); + }); + + it('throws when KB_BUILDER_ASSIGNEE names an agent that is not joined', () => { + process.env.KB_BUILDER_ASSIGNEE = 'gemini-3'; + expect(() => pickAssignee([claude, codex], 'codex-2')).toThrow(/KB_BUILDER_ASSIGNEE/); + }); + + it('skips offline llm participants', () => { + const offline: ParticipantSnapshot = { name: 'cursor-3', kind: 'llm', status: 'offline' }; + expect(pickAssignee([claude, offline], 'claude-1')).toBe('claude-1'); + }); +}); + +describe('pending request lifecycle', () => { + it('resolveable: register → resolve returns the content to the awaiter', async () => { + const promise = registerPendingRequest('req-1', 'codex-2', 5_000); + resolvePendingRequest('req-1', '{"clusters":[]}'); + const result = await promise; + expect(result).toEqual({ content: '{"clusters":[]}', assignee: 'codex-2' }); + }); + + it('rejects on timeout if not resolved in time', async () => { + vi.useFakeTimers(); + const promise = registerPendingRequest('req-2', 'codex-2', 100); + vi.advanceTimersByTime(150); + await expect(promise).rejects.toThrow(/timed out/i); + vi.useRealTimers(); + }); + + it('returns false from resolvePendingRequest when the id is unknown', () => { + const ok = resolvePendingRequest('nonexistent', 'whatever'); + expect(ok).toBe(false); + }); + + it('returns false from resolvePendingRequest after the request already resolved (double-submit safety)', async () => { + const promise = registerPendingRequest('req-3', 'codex-2', 5_000); + expect(resolvePendingRequest('req-3', 'first')).toBe(true); + expect(resolvePendingRequest('req-3', 'second')).toBe(false); + const result = await promise; + expect(result.content).toBe('first'); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm failures** + +Run: `pnpm vitest run src/apps/chat/services/kb-synth-bridge.test.ts` +Expected: failure — file does not exist. + +- [ ] **Step 3: Implement the bridge service** + +Create `src/apps/chat/services/kb-synth-bridge.ts`: + +```ts +/** + * KB Wiki Builder ↔ chat participant side-channel bridge. + * + * The KB router calls `requestSynth(...)` to delegate a cluster/synthesize + * payload to one of the joined chat participants. The participant receives a + * structured PTY notification (sent by the caller via chat-registry) and + * responds via the new `kb_synth_submit` MCP tool, which calls + * `resolvePendingRequest(requestId, content)`. + * + * Why a side-channel and not the full pipe machinery: pipes are designed for + * user-initiated multi-stage workflows with state machines and reducers. The + * KB builder needs a simple request/response RPC where the daemon owns both + * sides of the conversation. Reusing pipes would require shoehorning a + * single-shot synthetic flow through state we'd then need to mock; a + * purpose-built side channel is ~150 lines and trivially testable. + */ + +import { randomUUID } from 'crypto'; + +// ── Participant snapshot interface ────────────────────────────────────────── +// +// Decoupled from chat-registry types so this file can be unit-tested without +// pulling in the full chat-registry. The router maps from registry types to +// this snapshot when constructing the bridge. + +export interface ParticipantSnapshot { + name: string; + kind: 'llm' | 'user'; + status: 'idle' | 'working' | 'offline'; +} + +// ── Selection policy ──────────────────────────────────────────────────────── + +/** + * Pick which chat participant should service a synthesis request. + * + * Resolution order: + * 1. `KB_BUILDER_ASSIGNEE` env var, if set and the named agent is joined + * (throws if set but not joined — surfaces a clear misconfiguration) + * 2. First non-self llm participant with status !== 'offline' + * 3. Fall back to self if it is the only llm participant + * 4. Return null if no llm participant is available + * + * Returning null is the signal for the router to fall through to the next + * backend in the auto chain (openai → anthropic → noop). + */ +export function pickAssignee( + participants: ParticipantSnapshot[], + selfName: string, +): string | null { + const llms = participants.filter((p) => p.kind === 'llm' && p.status !== 'offline'); + + const override = process.env.KB_BUILDER_ASSIGNEE; + if (override && override.length > 0) { + if (!llms.some((p) => p.name === override)) { + throw new Error( + `KB_BUILDER_ASSIGNEE="${override}" is set but no joined llm participant has that name. ` + + `Joined: [${llms.map((p) => p.name).join(', ') || '(none)'}]`, + ); + } + return override; + } + + if (llms.length === 0) return null; + const nonSelf = llms.find((p) => p.name !== selfName); + if (nonSelf) return nonSelf.name; + // Only self joined — fall back to self + return llms[0].name; +} + +// ── Pending request registry ──────────────────────────────────────────────── + +export interface SynthRequestRecord { + requestId: string; + assignee: string; + resolve: (result: { content: string; assignee: string }) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +const pending = new Map(); + +/** + * Generate a fresh request id. Exported for the router so the same id can be + * used both to register the pending entry and to send the PTY notification. + */ +export function newRequestId(): string { + return `kbsynth-${randomUUID()}`; +} + +/** + * Register a pending synthesis request and return a Promise that resolves + * when the assignee submits via `kb_synth_submit`. The promise rejects with + * a timeout error if the request is not resolved within `timeoutMs`. + */ +export function registerPendingRequest( + requestId: string, + assignee: string, + timeoutMs: number, +): Promise<{ content: string; assignee: string }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(requestId); + reject(new Error( + `KB synth request ${requestId} (assignee=${assignee}) timed out after ${timeoutMs}ms`, + )); + }, timeoutMs); + pending.set(requestId, { requestId, assignee, resolve, reject, timer }); + }); +} + +/** + * Called by the kb_synth_submit MCP tool when the assignee returns a result. + * Returns true if the request was found and resolved, false otherwise (unknown + * id, already resolved, or already timed out). + */ +export function resolvePendingRequest(requestId: string, content: string): boolean { + const entry = pending.get(requestId); + if (!entry) return false; + pending.delete(requestId); + clearTimeout(entry.timer); + entry.resolve({ content, assignee: entry.assignee }); + return true; +} + +/** + * Reject a pending request (e.g. when the assigned pane closes). + * Returns true if the request was found and rejected. + */ +export function rejectPendingRequest(requestId: string, reason: string): boolean { + const entry = pending.get(requestId); + if (!entry) return false; + pending.delete(requestId); + clearTimeout(entry.timer); + entry.reject(new Error(reason)); + return true; +} + +/** List all pending request ids. For dashboard / debug surfaces. */ +export function listPendingRequestIds(): string[] { + return [...pending.keys()]; +} + +// ── Test helper ───────────────────────────────────────────────────────────── + +/** Reset all in-memory state. For testing only. */ +export function _resetForTest(): void { + for (const entry of pending.values()) { + clearTimeout(entry.timer); + } + pending.clear(); +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +Run: `pnpm vitest run src/apps/chat/services/kb-synth-bridge.test.ts` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/apps/chat/services/kb-synth-bridge.ts \ + src/apps/chat/services/kb-synth-bridge.test.ts +git commit -m "feat(chat): add kb-synth-bridge side-channel for KB builder synthesis requests" +``` + +--- + +## Task 4 — `kb_synth_submit` MCP tool + chat-registry notifier + +**Files:** +- Modify: `src/apps/chat/src/mcp.ts` (add new tool after `pipe_get_assignment`, around line 456) +- Modify: `src/apps/chat/services/chat-registry.ts` (export new `notifySynthRequest` helper) + +- [ ] **Step 1: Add the chat-registry helper** + +Find the existing PTY injection function in `chat-registry.ts` (search for `paneDeliveryQueues` and the function that builds delivery payloads). Append this exported helper at the end of the chat-registry's exports section: + +```ts +/** + * Send a structured KB synthesis request notification to a chat participant. + * + * The notification is plain text injected into the assignee's pane. It tells + * the LLM what to do: read the prompt, produce JSON matching the schema, + * call kb_synth_submit(requestId, content) to return the result. + * + * The kb-synth-bridge has already registered a pending Promise for this + * requestId. The assignee's response will resolve it. + */ +export async function notifySynthRequest( + assignee: string, + requestId: string, + prompt: string, + systemNote: string, + projectId: string | null, +): Promise<{ ok: true } | { ok: false; error: string }> { + const participant = getParticipantExact(assignee, projectId); + if (!participant) { + return { ok: false, error: `Participant ${assignee} not found in project ${projectId ?? '(none)'}` }; + } + if (!participant.paneId) { + return { ok: false, error: `Participant ${assignee} has no pane bound` }; + } + const paneId = participant.paneId; + + const body = + `[KB-SYNTH-REQUEST id=${requestId}]\n` + + `${systemNote}\n\n` + + `When you have produced the JSON, call:\n` + + ` kb_synth_submit(requestId="${requestId}", content="")\n\n` + + `Do NOT respond via chat_send. Do NOT wrap the JSON in markdown code fences.\n\n` + + `--- PROMPT ---\n${prompt}\n--- END PROMPT ---\n`; + + // Use the existing PTY injection queue used by chat_send to keep ordering + // consistent with normal chat delivery and avoid paste-burst races. + await enqueuePaneDelivery(paneId, body, participant.submitKey ?? '\r'); + return { ok: true }; +} +``` + +If `enqueuePaneDelivery` is not the actual exported name, locate the existing PTY-injection helper used by `chat_send` and reuse it (search for `globalPtys` writes in `chat-registry.ts`). The point of this step is to call the same primitive that `chat_send` uses, not to invent a new injection path. + +- [ ] **Step 2: Add the MCP tool** + +Insert in `src/apps/chat/src/mcp.ts` after the `pipe_get_assignment` tool block (around line 456): + +```ts + // ── 3f. kb_synth_submit ──────────────────────────────────────────── + // + // KB Wiki Builder side-channel: when the KB builder asks a chat participant + // to do a cluster/synthesize call, the assignee returns the JSON output via + // this tool instead of chat_send / pipe_submit. The bridge resolves a + // pending Promise keyed by requestId. + + server.tool( + 'kb_synth_submit', + 'Submit your KB synthesis output (cluster or synthesize JSON) for a kb-synth request. Use this when you receive a [KB-SYNTH-REQUEST id=...] notification — not chat_send or pipe_submit.', + { + requestId: z.string().describe('The kb-synth request id from the [KB-SYNTH-REQUEST id=...] notification.'), + content: z.string().describe('Your JSON output as a raw string. Do NOT wrap in markdown code fences.'), + paneId: z.string().optional().describe('Optional pane ID to adopt session.'), + }, + async ({ requestId, content, paneId }) => { + const adopted = await tryAdoptSessionByPaneId(paneId); + const sessionName = adopted?.name ?? getSessionName(); + if (!sessionName) return errorResult('Not joined — call chat_join first'); + // The bridge lives in the chat services package; import dynamically to + // avoid coupling the MCP module to chat internals at top-level. + const { resolvePendingRequest } = await import('../services/kb-synth-bridge.js'); + const ok = resolvePendingRequest(requestId, content); + if (!ok) { + return errorResult(`Unknown or already-resolved kb-synth request id "${requestId}"`); + } + return jsonResult({ ok: true, requestId, submittedBy: sessionName }); + }, + ); +``` + +- [ ] **Step 3: Add a small integration test for the chat-registry notifier** + +Create or extend `src/apps/chat/services/chat-registry.kb-synth.test.ts` (new file): + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +// We test notifySynthRequest by mocking the PTY layer it depends on. +// Because chat-registry pulls a lot of state, we use a focused unit-style test +// that asserts the observable side effect: a body containing the requestId +// gets handed to the PTY queue. + +vi.mock('../../shell/src/runtime/shell-state.js', () => { + const writes: Array<{ paneId: string; data: string }> = []; + return { + globalPtys: { + get: (paneId: string) => ({ + write: (data: string) => writes.push({ paneId, data }), + }), + }, + dashboardState: {}, + getShellNsp: () => null, + __writes: writes, + }; +}); + +describe('notifySynthRequest', () => { + // The full test exercises the live function once everything is wired — + // the assertion is that the body contains both the requestId and the + // marker [KB-SYNTH-REQUEST so the assignee can detect it. + it.todo('emits a [KB-SYNTH-REQUEST id=...] body to the assignee pane'); +}); +``` + +(Marked `it.todo` because writing a full live test requires bootstrapping a participant + pane in the chat-registry, which has a wide blast radius. The function is exercised end-to-end in the verification step. The bridge unit tests cover the side-channel resolver path; the MCP tool layer is small enough that an integration test in the verification step is sufficient.) + +- [ ] **Step 4: Run tests, confirm no regressions** + +Run: `pnpm vitest run src/apps/chat/services/` +Expected: all existing chat tests pass + new bridge tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/apps/chat/services/chat-registry.ts \ + src/apps/chat/src/mcp.ts \ + src/apps/chat/services/chat-registry.kb-synth.test.ts +git commit -m "feat(chat): add kb_synth_submit MCP tool + notifySynthRequest helper" +``` + +--- + +## Task 5 — Production bridge wiring in knowledge-base router + +**Files:** +- Modify: `src/routers/knowledge-base.ts` (around line 384, where `_llmClient` is constructed) + +- [ ] **Step 1: Replace the static `_llmClient` initialization with a bridge-aware factory call** + +Find `let _llmClient: LlmClient = selectLlmClient();` at `src/routers/knowledge-base.ts:384` and replace with: + +```ts +import { newRequestId, registerPendingRequest, pickAssignee, type ParticipantSnapshot } from '../apps/chat/services/kb-synth-bridge.js'; +import { listParticipants, notifySynthRequest } from '../apps/chat/services/chat-registry.js'; +import type { KbChatPipeBridge } from '../apps/knowledge-base/services/kb-llm-client.js'; + +/** + * Production chat-pipe bridge. Lives in the router because it crosses the + * KB ↔ chat app boundary — the kb-llm-client and kb-synth-bridge layers stay + * pure (no cross-app imports). + * + * Selection: KB_BUILDER_ASSIGNEE override → first non-self llm → self if alone. + * Self in this context means the daemon's "I" identity, but the daemon is not + * itself a chat participant. So "non-self" simply means "any joined llm". + */ +const SYNTH_REQUEST_TIMEOUT_MS = 120_000; // 2 minutes per cluster/synthesize + +function buildChatPipeBridge(): KbChatPipeBridge { + return { + async submitSynthesisRequest(req) { + const projectId = getCurrentProjectId(); + const participantsRaw = listParticipants(projectId); + const participants: ParticipantSnapshot[] = participantsRaw.map((p) => ({ + name: p.name, + kind: p.kind, + status: p.status, + })); + // The "self" in pickAssignee() is the daemon. Pass an empty string so + // every joined llm is considered "non-self". The KB_BUILDER_ASSIGNEE + // override still wins when set. + const assignee = pickAssignee(participants, ''); + if (!assignee) { + throw new Error( + 'No chat participant available for KB synthesis (no joined llm). ' + + 'Either join an llm via chat_join, set OPENAI_API_KEY/ANTHROPIC_API_KEY, ' + + 'or set KB_BUILDER_LLM_BACKEND=auto with a fallback provider.', + ); + } + const requestId = newRequestId(); + const start = Date.now(); + const pending = registerPendingRequest(requestId, assignee, SYNTH_REQUEST_TIMEOUT_MS); + const notifyResult = await notifySynthRequest(assignee, requestId, req.prompt, req.system, projectId); + if (!notifyResult.ok) { + // Reject the pending entry so the bridge does not leak the timer. + const { rejectPendingRequest } = await import('../apps/chat/services/kb-synth-bridge.js'); + rejectPendingRequest(requestId, `Failed to notify assignee: ${notifyResult.error}`); + throw new Error(`KB synth notify failed: ${notifyResult.error}`); + } + const result = await pending; + return { content: result.content, assignee: result.assignee, requestId, durationMs: Date.now() - start }; + }, + }; +} + +function getCurrentProjectId(): string | null { + // Re-use the existing project-context helper. If the router uses a + // different mechanism, follow that instead — search the file for + // `getActiveProject` or `projectId` to find the canonical accessor. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getActiveProject } = require('../project-context.js'); + return getActiveProject()?.id ?? null; +} + +let _llmClient: LlmClient = selectLlmClient({ bridge: buildChatPipeBridge() }); +``` + +Note: `listParticipants` may not be exported from `chat-registry.ts` yet. If not, add an export — it should already exist as an internal function used by `chat_members`. + +- [ ] **Step 2: Update `resetBuilderLlmClient` to also pass the bridge** + +Modify `src/routers/knowledge-base.ts:399-401`: + +```ts +export function resetBuilderLlmClient(): void { + _llmClient = selectLlmClient({ bridge: buildChatPipeBridge() }); +} +``` + +- [ ] **Step 3: Run knowledge-base router tests to catch regressions** + +Run: `pnpm vitest run src/routers/knowledge-base.test.ts` +Expected: all existing tests pass — they use `setBuilderLlmClient(fixture)` so the production bridge is bypassed. + +- [ ] **Step 4: Commit** + +```bash +git add src/routers/knowledge-base.ts +git commit -m "feat(kb): wire production chat-pipe bridge in knowledge-base router" +``` + +--- + +## Task 6 — Extend `BuildRun.llmCalls[]` audit fields + +**Files:** +- Modify: `src/apps/knowledge-base/services/kb-builder-types.ts` (around line 256) +- Modify: `src/apps/knowledge-base/services/kb-builder.ts` (cluster + synthesize call sites that push to `llmCalls`) + +- [ ] **Step 1: Find the existing `llmCalls.push(...)` call sites** + +Run: search for `llmCalls.push` in `src/apps/knowledge-base/services/kb-builder.ts`. + +- [ ] **Step 2: Extend the type** + +In `kb-builder-types.ts:255-263`, replace the `llmCalls` field type with: + +```ts + /** Per-LLM-call audit trail for cost tracking and replay regression testing. */ + llmCalls: Array<{ + stage: 'cluster' | 'synthesize'; + promptHash: string; + model: string; + inputTokens: number; + outputTokens: number; + durationMs: number; + /** Backend kind. Added in the chat-pipe LLM client work. */ + backend?: 'chat' | 'openai' | 'anthropic' | 'noop'; + /** Synth request id when backend === 'chat'; undefined otherwise. */ + requestId?: string; + /** Chat participant who handled the call when backend === 'chat'. */ + assignee?: string; + /** Set when the call exceeded its lease deadline (chat backend only). */ + leaseExpiredAt?: string; + /** Wall-clock timeout that triggered (chat backend only). */ + timedOutAfterMs?: number; + }>; +``` + +- [ ] **Step 3: Populate the new fields at call sites** + +The `model` field already encodes provenance for OpenAI / Anthropic. For the chat backend, the `model` field is `chat-pipe:`. Update each `llmCalls.push(...)` block in `kb-builder.ts` to derive the new fields from `model`: + +```ts +function deriveBackendFields(model: string): { + backend: 'chat' | 'openai' | 'anthropic' | 'noop'; + assignee?: string; + requestId?: string; +} { + if (model.startsWith('chat-pipe:')) { + return { backend: 'chat', assignee: model.slice('chat-pipe:'.length) }; + } + if (model.startsWith('gpt-') || model.startsWith('o1-') || model.startsWith('o3-')) { + return { backend: 'openai' }; + } + if (model.startsWith('claude-')) { + return { backend: 'anthropic' }; + } + return { backend: 'noop' }; +} +``` + +Then at each `llmCalls.push` site: + +```ts +const tokenStats = result.tokens; // already destructured +const backendFields = deriveBackendFields(tokenStats.model); +this.run.llmCalls.push({ + stage: 'cluster', // or 'synthesize' + promptHash: hashPrompt(prompt), + model: tokenStats.model, + inputTokens: tokenStats.inputTokens, + outputTokens: tokenStats.outputTokens, + durationMs: tokenStats.durationMs, + ...backendFields, +}); +``` + +For `requestId` propagation, the chat-pipe client returns it via the bridge result but the LlmClient interface only surfaces `tokens`. Two options: +- (a) Extend `LlmTokenUsage` with optional `requestId`/`assignee` fields +- (b) Encode the requestId in the `model` field + +Pick (a). It is one extra optional field on a type that is already audit-shaped. Update `kb-builder-types.ts:357-362`: + +```ts +/** Per-LLM-call usage metadata recorded on the BuildRun. */ +export interface LlmTokenUsage { + model: string; + inputTokens: number; + outputTokens: number; + durationMs: number; + /** Chat-backend-specific: id of the synth request that produced this call. */ + requestId?: string; + /** Chat-backend-specific: name of the chat participant that handled the call. */ + assignee?: string; +} +``` + +Then update `createChatPipeLlmClient` (Task 2) to populate `requestId` and `assignee` on the returned `tokens`: + +```ts +const tokens: LlmTokenUsage = { + model: `chat-pipe:${result.assignee}`, + inputTokens: 0, + outputTokens: 0, + durationMs: Date.now() - start, + requestId: result.requestId, + assignee: result.assignee, +}; +``` + +And update the `llmCalls.push` sites in `kb-builder.ts` to read those fields directly instead of relying on `deriveBackendFields` to parse `model`. Cleaner. + +- [ ] **Step 4: Run kb-builder + kb-llm-client tests** + +Run: `pnpm vitest run src/apps/knowledge-base/` +Expected: pass. Update fixture expectations if any test asserts on the exact shape of `llmCalls[0]`. + +- [ ] **Step 5: Commit** + +```bash +git add src/apps/knowledge-base/services/kb-builder-types.ts \ + src/apps/knowledge-base/services/kb-builder.ts \ + src/apps/knowledge-base/services/kb-llm-client.ts \ + src/apps/knowledge-base/services/kb-llm-client.test.ts +git commit -m "feat(kb): extend BuildRun.llmCalls audit with backend, requestId, assignee fields" +``` + +--- + +## Task 7 — Verify + +- [ ] **Step 1: Run knowledge-base + chat test suites** + +Run: `pnpm vitest run src/apps/knowledge-base/ src/apps/chat/services/ src/routers/knowledge-base.test.ts` +Expected: all green. + +- [ ] **Step 2: Run full build (typecheck)** + +Run: `pnpm build` +Expected: typecheck succeeds. + +- [ ] **Step 3: Manual reproduction** + +```bash +# Restart daemon with chat backend pinned and no provider keys +unset OPENAI_API_KEY ANTHROPIC_API_KEY +KB_BUILDER_LLM_BACKEND=chat devglide restart +# Verify the running daemon picks the chat backend by curling the build endpoint +curl -s http://localhost:7000/api/knowledge-base/build/run \ + -X POST -H "content-type: application/json" \ + -d '{"projectId":"","scope":"all","dryRun":true}' +# Expected: NOT the "LLM client not configured" error. +# If a chat agent is joined, the request should produce a structured PTY +# notification on that agent's pane. +``` + +- [ ] **Step 4: Append a work-log entry to the kanban item** + +Use `kanban_append_work_log(id="ko7owifqp7rqqk2j5mpazlph", content="...summary...")`. + +- [ ] **Step 5: Move kanban item to In Review** + +Use `kanban_move_item(id="ko7owifqp7rqqk2j5mpazlph", columnName="In Review")`. + +- [ ] **Step 6: Ping codex-2 for review** + +`chat_send` to `codex-2` with the diff summary, the verification evidence (test pass + manual repro output), and the file paths touched. + +- [ ] **Step 7: WAIT for codex-2 review** + +Do NOT voice-notify the user until codex-2 confirms review pass. Rule 9: no self-approval. + +--- + +## Self-Review Checklist + +- **Spec coverage:** + - Backend selector enum: Task 1 ✓ + - createChatPipeLlmClient + bridge interface: Task 2 ✓ + - kb-synth-bridge service + selection policy: Task 3 ✓ + - kb_synth_submit MCP tool + chat-registry notifier: Task 4 ✓ + - Production wiring in router: Task 5 ✓ + - Audit fields on BuildRun.llmCalls: Task 6 ✓ + - Verification + review handoff: Task 7 ✓ + +- **Placeholder scan:** + - Task 4 has an `it.todo` for the live notify test — flagged in the body with the rationale. The bridge unit tests + verification step cover the path. + - Task 5 has a "search for the canonical project-id accessor" instruction — not a placeholder, an instruction the executor needs to follow because the router has multiple project-id accessors and I want the executor to use the right one. + +- **Type consistency:** + - `KbChatPipeBridge`, `SynthesisRequest`, `SynthesisStage` defined in Task 2 and used consistently in Tasks 5 and 6. + - `pickAssignee`, `registerPendingRequest`, `resolvePendingRequest`, `newRequestId`, `notifySynthRequest`, `rejectPendingRequest` defined in Tasks 3 and 4 and used consistently in Task 5. + - `LlmTokenUsage.requestId`/`.assignee` added in Task 6 and populated in Task 2's factory (forward reference resolved at the same commit boundary). diff --git a/docs/superpowers/plans/2026-04-10-pipe-final-output-user-only.md b/docs/superpowers/plans/2026-04-10-pipe-final-output-user-only.md new file mode 100644 index 0000000..82a853c --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-pipe-final-output-user-only.md @@ -0,0 +1,266 @@ +# Pipe Final Output — User-Only Delivery + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route pipe final output to the user only (dashboard + chat history) — skip PTY injection to LLM participants so long output doesn't clutter their terminals. + +**Architecture:** The change is surgical. In `runPipeReducer()`, the final-output broadcast loop currently iterates over ALL non-detached participants and PTY-delivers the result. We replace that broadcast with a no-PTY approach: the message is still persisted in chat history and emitted via Socket.IO (so the dashboard shows it in real-time), but the `for (const p of participants)` PTY delivery loop is removed. The `to` field on the persisted message changes from `null` (broadcast) to `'user'` (semantic-only target) so the dashboard header reads `@author -> @user` instead of implying all-broadcast. + +**Tech Stack:** TypeScript (chat-registry.ts), Vitest (tests), vanilla JS (dashboard page.js) + +--- + +### Task 1: Write failing test — final output skips PTY delivery to LLMs + +**Files:** +- Modify: `src/apps/chat/services/chat-registry.pipe-submit.test.ts` + +This test verifies that when a pipe completes, the final output message is NOT PTY-delivered to any LLM participant. + +- [ ] **Step 1: Write the failing test** + +Add this test at the end of the existing `describe('submitPipeStage')` block (after the `preserves the pipe anchor on the public final chat message` test, around line 431): + +```typescript + it('final output is NOT PTY-delivered to LLM participants (user-only delivery)', async () => { + const writesA: string[] = []; + const writesB: string[] = []; + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn((c: string) => { writesA.push(c); }) } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn((c: string) => { writesB.push(c); }) } as never, + chunks: [], + totalLen: 0, + }); + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do work`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + // Clear writes from pipe setup (handoff notifications) + writesA.length = 0; + writesB.length = 0; + + // Alice submits stage 1 + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat'); + await vi.advanceTimersByTimeAsync(3_000); + await aliceSubmit; + + // Clear writes from stage 1 handoff to bob + writesA.length = 0; + writesB.length = 0; + + // Bob submits final stage + const bobSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output content', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + await bobSubmit; + + // Neither LLM should have received the final output via PTY + const allWrites = [...writesA, ...writesB]; + const finalDeliveries = allWrites.filter(w => w.includes('final output content')); + expect(finalDeliveries).toEqual([]); + + registry.leave(alice.name); + registry.leave(bob.name); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/apps/chat/services/chat-registry.pipe-submit.test.ts -t "final output is NOT PTY-delivered"` +Expected: FAIL — the current code broadcasts final output to all PTYs, so `finalDeliveries` will contain entries. + +--- + +### Task 2: Write failing test — final output message has `to: 'user'` + +**Files:** +- Modify: `src/apps/chat/services/chat-registry.pipe-submit.test.ts` + +This test verifies that the persisted final output message has `to: 'user'` instead of `to: null`. + +- [ ] **Step 1: Write the failing test** + +Add after the previous test: + +```typescript + it('final output message is persisted with to="user" (not broadcast)', async () => { + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do work`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat'); + await vi.advanceTimersByTimeAsync(3_000); + await aliceSubmit; + + chatStoreMock.appendMessage.mockClear(); + + const bobSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + await bobSubmit; + + const finalMessage = chatStoreMock.appendMessage.mock.calls + .map(([message]) => message) + .find((message: any) => message?.pipe?.role === 'final'); + + expect(finalMessage).toBeDefined(); + expect(finalMessage!.to).toBe('user'); + + registry.leave(alice.name); + registry.leave(bob.name); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/apps/chat/services/chat-registry.pipe-submit.test.ts -t "final output message is persisted with to"` +Expected: FAIL — currently `to: null` (broadcast). + +--- + +### Task 3: Implement the change — skip PTY broadcast, set `to: 'user'` + +**Files:** +- Modify: `src/apps/chat/services/chat-registry.ts:1575-1593` + +The change is inside `runPipeReducer()`. We modify the final-output block to: +1. Set `to: 'user'` instead of `to: null` +2. Remove the PTY broadcast loop entirely + +- [ ] **Step 1: Apply the code change** + +In `src/apps/chat/services/chat-registry.ts`, replace lines 1575-1593 (the final output section inside `if (state.hasFinal)`): + +**Before:** +```typescript + // Read the final output from pipe state and broadcast it to all participants. + // This is the ONLY pipe output that enters chat history and LLM context. + const finalContent = readFinalOutput(pipeId, projectId); + if (finalContent) { + // Render pipe result as a normal chat message from the actual author + const resultMsg = appendMessage({ + from: finalContent.from, to: null, + body: ensurePipeAnchor(finalContent.body, pipeId), + type: 'message', + pipe: { pipeId, mode: state.mode, role: 'final' }, + }, projectId); + emitToProject('chat:message', resultMsg, projectId); + // Broadcast to all PTYs — this is the public final result + for (const p of participants.values()) { + if (p.paneId && p.projectId === projectId && !p.detached) { + await deliverToPty(p.name, projectId, resultMsg); + } + } + } +``` + +**After:** +```typescript + // Read the final output from pipe state and persist it for the user. + // Final output is user-only: persisted in chat history and emitted to + // dashboard via Socket.IO, but NOT PTY-delivered to LLM participants. + // This prevents long output from cluttering LLM terminals. + const finalContent = readFinalOutput(pipeId, projectId); + if (finalContent) { + const resultMsg = appendMessage({ + from: finalContent.from, to: 'user', + body: ensurePipeAnchor(finalContent.body, pipeId), + type: 'message', + pipe: { pipeId, mode: state.mode, role: 'final' }, + }, projectId); + emitToProject('chat:message', resultMsg, projectId); + // No PTY delivery — user sees it on dashboard only. + } +``` + +- [ ] **Step 2: Run both new tests to verify they pass** + +Run: `npx vitest run src/apps/chat/services/chat-registry.pipe-submit.test.ts -t "final output"` +Expected: Both new tests PASS. + +- [ ] **Step 3: Run the full pipe-submit test suite to check for regressions** + +Run: `npx vitest run src/apps/chat/services/chat-registry.pipe-submit.test.ts` +Expected: All tests PASS. The existing `preserves the pipe anchor on the public final chat message` test should still pass because `appendMessage` is still called — only PTY delivery is removed. + +- [ ] **Step 4: Commit** + +```bash +git add src/apps/chat/services/chat-registry.ts src/apps/chat/services/chat-registry.pipe-submit.test.ts +git commit -m "feat(chat): route pipe final output to user only, skip LLM PTY delivery + +Final pipe output was being PTY-injected to all LLM participants, +cluttering terminals with long content where auto-enter wasn't working. +Now final output is persisted in chat history and emitted to dashboard +via Socket.IO but not PTY-delivered to LLMs. The to field is set to +'user' so the dashboard header reads @author -> @user." +``` + +--- + +### Task 4: Run the full chat test suite + +**Files:** +- Test: `src/apps/chat/services/chat-registry.targeted-delivery.test.ts` +- Test: `src/apps/chat/services/chat-registry.pipe-submit.test.ts` +- Test: `src/apps/chat/services/pipe-*.test.ts` +- Test: `src/routers/chat.test.ts` + +- [ ] **Step 1: Run all chat-related tests** + +Run: `npx vitest run src/apps/chat/services/ src/routers/chat.test.ts` +Expected: All tests PASS. + +- [ ] **Step 2: Run the full project build to check for type errors** + +Run: `pnpm build` +Expected: PASS — no type errors. The change only modifies a string literal (`null` -> `'user'`) and removes code, so no new type issues. + +--- + +### Task 5: Verify dashboard rendering still works for pipe final output + +**Files:** +- Read: `src/apps/chat/public/page.js:1484-1496` + +The dashboard already handles `pipe.role === 'final'` messages via `buildPipeOutputEl()`. The `to` field change from `null` to `'user'` affects the header rendering via `formatRecipientHeader(msg.from, msg.to)`. + +- [ ] **Step 1: Check that `formatRecipientHeader` handles `to: 'user'` correctly** + +Search `page.js` for `formatRecipientHeader` and verify it renders `'user'` in the `to` field as `@author -> @user`. The pipe final uses `buildPipeOutputEl` which may or may not call `formatRecipientHeader` — verify the actual rendering path. + +- [ ] **Step 2: If needed, adjust the pipe final output renderer** + +If `buildPipeOutputEl` renders its own header and doesn't use the `to` field for addressing display, no change is needed. The `to: 'user'` is primarily for the persisted message semantics and won't affect the "Final output" label shown in the pipe output card. + +- [ ] **Step 3: Commit if any dashboard changes were needed** + +```bash +git add src/apps/chat/public/page.js +git commit -m "fix(chat): update dashboard pipe final rendering for user-only delivery" +``` + +(Skip this commit if no changes were needed.) diff --git a/package.json b/package.json index c3e9d51..7fb5904 100644 --- a/package.json +++ b/package.json @@ -47,11 +47,12 @@ "scripts": { "dev": "turbo dev --concurrency 20", "dev:server": "tsx src/server.ts", - "build": "turbo build", + "build": "turbo build && node scripts/build-mcp.mjs && node scripts/check-structure.mjs", "lint": "turbo lint", "typecheck": "turbo typecheck", - "test": "vitest run", + "test": "vitest run --pool threads", "build:mcp": "node scripts/build-mcp.mjs", + "check:structure": "node scripts/check-structure.mjs", "clean": "turbo clean && rm -rf node_modules" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c83c58..2b1f9ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,11 +79,45 @@ importers: specifier: ^4.1.0 version: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)(tsx@4.21.0)) - src/apps/coder: + src/apps/chat: dependencies: - express: - specifier: ^5.2.1 - version: 5.2.1 + '@devglide/mcp-utils': + specifier: workspace:* + version: link:../../packages/mcp-utils + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.27.1(zod@3.25.76) + zod: + specifier: ^3.25.49 + version: 3.25.76 + devDependencies: + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + src/apps/coder: {} + + src/apps/documentation: + dependencies: + '@devglide/mcp-utils': + specifier: workspace:* + version: link:../../packages/mcp-utils + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.27.1(zod@3.25.76) + zod: + specifier: ^3.25.49 + version: 3.25.76 + devDependencies: + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: ^5.8.0 + version: 5.9.3 src/apps/kanban: dependencies: @@ -128,20 +162,13 @@ importers: specifier: ^5.8.0 version: 5.9.3 - src/apps/keymap: - dependencies: - express: - specifier: ^5.2.1 - version: 5.2.1 + src/apps/keymap: {} src/apps/log: dependencies: '@devglide/mcp-utils': specifier: workspace:* version: link:../../packages/mcp-utils - '@devglide/project-context': - specifier: workspace:* - version: link:../../packages/project-context '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.27.1(zod@3.25.76) @@ -220,9 +247,6 @@ importers: '@devglide/mcp-utils': specifier: workspace:* version: link:../../packages/mcp-utils - '@devglide/project-context': - specifier: workspace:* - version: link:../../packages/project-context '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.27.1(zod@3.25.76) @@ -351,12 +375,6 @@ importers: specifier: ^5.8.0 version: 5.9.3 - src/packages/project-context: - dependencies: - socket.io-client: - specifier: ^4.8.0 - version: 4.8.3 - src/packages/shared-assets: {} src/packages/shared-types: diff --git a/scripts/build-mcp.mjs b/scripts/build-mcp.mjs index 509fc15..ee5cadb 100644 --- a/scripts/build-mcp.mjs +++ b/scripts/build-mcp.mjs @@ -16,6 +16,8 @@ const servers = [ "workflow", "vocabulary", "prompts", + "chat", + "documentation", ]; const external = ["better-sqlite3", "node-pty"]; diff --git a/scripts/check-structure.mjs b/scripts/check-structure.mjs new file mode 100644 index 0000000..977f1ef --- /dev/null +++ b/scripts/check-structure.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node + +/** + * DevGlide structure checker — deterministic monorepo boundary enforcement. + * + * Reads devglide.manifest.json and validates: + * + * ERROR (hard fail — exit 1): + * 1. Manifest schema (kind enum, required fields) + * 2. Declared entrypoints exist on disk + * 3. expectedPackageName matches package.json name + * 4. Undeclared apps / packages / standalone files + * 5. MCP registry drift (manifest vs build-mcp.mjs vs bin/devglide.js) + * 6. Deep package imports (bypassing package entrypoints) + * 7. Cross-app imports not declared in allowedCrossAppDeps + * + * INFO (reported, no fail): + * 8. Stale build artifacts inside app/package dirs + * + * Zero dependencies — Node built-ins only. + * Exit 0 = pass, exit 1 = hard errors found. + * + * Usage: + * node scripts/check-structure.mjs + * pnpm check:structure + */ + +import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { join, resolve, relative, sep } from "node:path"; + +/** Normalize path separators to forward slashes (Windows compat). */ +const toSlash = (p) => (sep === "\\" ? p.replaceAll("\\", "/") : p); + +const ROOT = resolve(import.meta.dirname, ".."); +const APPS_DIR = join(ROOT, "src/apps"); +const PACKAGES_DIR = join(ROOT, "src/packages"); +const MANIFEST_PATH = join(ROOT, "devglide.manifest.json"); + +// ── Kind enums (fixed — manifest cannot invent new categories) ─────────── + +const VALID_APP_KINDS = new Set(["mcp-app", "ui-app", "static-app"]); +const VALID_PACKAGE_KINDS = new Set([ + "lib-package", + "asset-package", + "config-package", +]); + +// ── Collectors ─────────────────────────────────────────────────────────── + +const errors = []; +const infos = []; + +function error(msg) { + errors.push(msg); +} +function info(msg) { + infos.push(msg); +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function getDirs(dir) { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); +} + +function getFiles(dir) { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isFile()) + .map((d) => d.name); +} + +const SKIP_DIRS = new Set([ + "node_modules", + "dist", + ".turbo", + ".next", + "public", + "data", + "templates", +]); + +/** Recursively collect TS source files, skipping build artifacts. */ +function collectSourceFiles(dir) { + const result = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) result.push(...collectSourceFiles(full)); + } else if ( + entry.name.endsWith(".ts") && + !entry.name.endsWith(".d.ts") && + !entry.name.endsWith(".test.ts") + ) { + result.push(full); + } + } + return result; +} + +// ── Load manifest ──────────────────────────────────────────────────────── + +if (!existsSync(MANIFEST_PATH)) { + console.error("ERROR: devglide.manifest.json not found at project root."); + process.exit(1); +} + +const manifest = readJson(MANIFEST_PATH); +const apps = manifest.apps ?? {}; +const packages = manifest.packages ?? {}; +const standaloneFiles = new Set(manifest.standaloneFiles ?? []); + +// ══════════════════════════════════════════════════════════════════════════ +// CHECK 1 — Manifest schema validation +// ══════════════════════════════════════════════════════════════════════════ + +for (const [name, app] of Object.entries(apps)) { + if (!VALID_APP_KINDS.has(app.kind)) { + error( + `manifest: app "${name}" has invalid kind "${app.kind}" (allowed: ${[...VALID_APP_KINDS].join(", ")})`, + ); + } + if (!Array.isArray(app.entrypoints) || app.entrypoints.length === 0) { + error(`manifest: app "${name}" must have a non-empty entrypoints array`); + } +} + +for (const [name, pkg] of Object.entries(packages)) { + if (!VALID_PACKAGE_KINDS.has(pkg.kind)) { + error( + `manifest: package "${name}" has invalid kind "${pkg.kind}" (allowed: ${[...VALID_PACKAGE_KINDS].join(", ")})`, + ); + } + if (!Array.isArray(pkg.entrypoints) || pkg.entrypoints.length === 0) { + error( + `manifest: package "${name}" must have a non-empty entrypoints array`, + ); + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// CHECK 2 — Declared entrypoints exist on disk +// ══════════════════════════════════════════════════════════════════════════ + +for (const [name, app] of Object.entries(apps)) { + const appDir = join(APPS_DIR, name); + if (!existsSync(appDir)) { + error(`app "${name}": directory missing at src/apps/${name}`); + continue; + } + for (const ep of app.entrypoints ?? []) { + if (!existsSync(join(appDir, ep))) { + error(`app "${name}": missing entrypoint "${ep}"`); + } + } +} + +for (const [name, pkg] of Object.entries(packages)) { + const pkgDir = join(PACKAGES_DIR, name); + if (!existsSync(pkgDir)) { + error(`package "${name}": directory missing at src/packages/${name}`); + continue; + } + for (const ep of pkg.entrypoints ?? []) { + if (!existsSync(join(pkgDir, ep))) { + error(`package "${name}": missing entrypoint "${ep}"`); + } + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// CHECK 3 — expectedPackageName matches package.json +// ══════════════════════════════════════════════════════════════════════════ + +function checkPackageName(label, dir, expected) { + const pj = join(dir, "package.json"); + if (existsSync(pj)) { + const { name } = readJson(pj); + if (name !== expected) { + error( + `${label}: package.json name "${name}" does not match expected "${expected}"`, + ); + } + } else { + error(`${label}: expectedPackageName set but no package.json found`); + } +} + +for (const [name, app] of Object.entries(apps)) { + if (app.expectedPackageName) { + checkPackageName(`app "${name}"`, join(APPS_DIR, name), app.expectedPackageName); + } +} + +for (const [name, pkg] of Object.entries(packages)) { + if (pkg.expectedPackageName) { + checkPackageName( + `package "${name}"`, + join(PACKAGES_DIR, name), + pkg.expectedPackageName, + ); + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// CHECK 4 — Undeclared apps / packages / standalone files +// ══════════════════════════════════════════════════════════════════════════ + +const declaredApps = new Set(Object.keys(apps)); +for (const dir of getDirs(APPS_DIR)) { + if (!declaredApps.has(dir)) { + error(`undeclared app: src/apps/${dir} is not in manifest`); + } +} + +const declaredPackages = new Set(Object.keys(packages)); +for (const dir of getDirs(PACKAGES_DIR)) { + if (!declaredPackages.has(dir)) { + error(`undeclared package: src/packages/${dir} is not in manifest`); + } +} + +const actualStandalone = getFiles(PACKAGES_DIR).filter( + (f) => f.endsWith(".ts") && !f.endsWith(".test.ts"), +); +for (const f of actualStandalone) { + if (!standaloneFiles.has(f)) { + error( + `undeclared standalone file: src/packages/${f} is not in manifest.standaloneFiles`, + ); + } +} +for (const f of standaloneFiles) { + if (!existsSync(join(PACKAGES_DIR, f))) { + error( + `missing standalone file: "${f}" declared in manifest but does not exist`, + ); + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// CHECK 5 — MCP registry drift +// ══════════════════════════════════════════════════════════════════════════ + +const buildTargetApps = new Set( + Object.entries(apps) + .filter(([, app]) => app.buildTarget) + .map(([name]) => name), +); + +// Parse scripts/build-mcp.mjs — extract the servers = [...] array +const buildMcpPath = join(ROOT, "scripts/build-mcp.mjs"); +const buildMcpApps = new Set(); +if (existsSync(buildMcpPath)) { + const content = readFileSync(buildMcpPath, "utf8"); + const arrayMatch = content.match( + /(?:const|let|var)\s+servers\s*=\s*\[([\s\S]*?)\]/, + ); + if (arrayMatch) { + for (const m of arrayMatch[1].matchAll(/["']([\w-]+)["']/g)) { + buildMcpApps.add(m[1]); + } + } +} + +// Parse bin/devglide.js — extract mcpServers object keys only +const devglidePath = join(ROOT, "bin/devglide.js"); +const cliMcpApps = new Set(); +if (existsSync(devglidePath)) { + const content = readFileSync(devglidePath, "utf8"); + // Match the mcpServers block and extract only top-level keys + // Keys are at column 2 (2-space indent) inside the object + const objMatch = content.match( + /const\s+mcpServers\s*=\s*\{([\s\S]*?)\n\};/, + ); + if (objMatch) { + // Match lines like " kanban:" or quoted keys " "hyphenated-name":" (keys at first indent level) + // Supports bare identifiers and quoted keys with hyphens. + for (const m of objMatch[1].matchAll(/^\s{2}(?:["']([\w-]+)["']|([\w-]+))\s*:/gm)) { + cliMcpApps.add(m[1] ?? m[2]); + } + } +} + +// Compare all three sets (order-independent) +for (const app of buildTargetApps) { + if (buildMcpApps.size > 0 && !buildMcpApps.has(app)) { + error( + `MCP drift: "${app}" has buildTarget in manifest but missing from scripts/build-mcp.mjs`, + ); + } + if (cliMcpApps.size > 0 && !cliMcpApps.has(app)) { + error( + `MCP drift: "${app}" has buildTarget in manifest but missing from bin/devglide.js`, + ); + } +} +for (const app of buildMcpApps) { + if (!buildTargetApps.has(app)) { + error( + `MCP drift: "${app}" in scripts/build-mcp.mjs but not buildTarget in manifest`, + ); + } +} +for (const app of cliMcpApps) { + if (!buildTargetApps.has(app)) { + error( + `MCP drift: "${app}" in bin/devglide.js but not buildTarget in manifest`, + ); + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// IMPORT SCANNING — shared by checks 6 and 7 +// ══════════════════════════════════════════════════════════════════════════ + +// Matches all import/export specifier strings across the whole file: +// import ... from "..." +// export ... from "..." +// import("...") +// import '...' (side-effect imports) +// Works across multi-line import statements because it scans the full file +// content, not line-by-line. + +const IMPORT_SPECIFIER_RE = + /(?:from\s+|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g; + +const allSourceFiles = collectSourceFiles(join(ROOT, "src")); + +/** + * Extract all import specifiers from a file with line numbers. + * Returns array of { specifier, line }. + */ +function extractImports(content) { + const results = []; + IMPORT_SPECIFIER_RE.lastIndex = 0; + let match; + while ((match = IMPORT_SPECIFIER_RE.exec(content)) !== null) { + // Compute line number from character offset + const line = + content.slice(0, match.index).split("\n").length; + results.push({ specifier: match[1], line }); + } + return results; +} + +// ══════════════════════════════════════════════════════════════════════════ +// NOTE: Deep package imports (packages/*/src/) are intentional. +// Relative imports are required for npm install -g compatibility — +// workspace package names (@devglide/*) don't resolve after global install. +// ══════════════════════════════════════════════════════════════════════════ + +// ══════════════════════════════════════════════════════════════════════════ +// CHECK 7 — Cross-app import violations +// ══════════════════════════════════════════════════════════════════════════ + +// Rule: app code must not import from another app unless declared in allowedCrossAppDeps. +// Router→app imports are the expected wiring pattern and always allowed. + +const CROSS_APP_PATH_RE = /\/apps\/([^/]+)\//; + +for (const filePath of allSourceFiles) { + // Determine which app this file belongs to + const relToApps = toSlash(relative(APPS_DIR, filePath)); + const isInApp = !relToApps.startsWith(".."); + if (!isInApp) continue; // routers / server.ts — always allowed + + const sourceApp = relToApps.split("/")[0]; + const content = readFileSync(filePath, "utf8"); + + for (const { specifier, line } of extractImports(content)) { + const m = CROSS_APP_PATH_RE.exec(specifier); + if (!m) continue; + const targetApp = m[1]; + if (targetApp === sourceApp) continue; // self-import is fine + + const allowed = new Set(apps[sourceApp]?.allowedCrossAppDeps ?? []); + if (!allowed.has(targetApp)) { + error( + `cross-app import: ${toSlash(relative(ROOT, filePath))}:${line} — "${sourceApp}" imports from "${targetApp}" (not in allowedCrossAppDeps)`, + ); + } + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// INFO — Stale build artifacts (reported, not a hard fail) +// ══════════════════════════════════════════════════════════════════════════ + +// Only report artifacts that indicate real cleanup issues. +// dist/, .turbo/, .next/ are expected build cache covered by .gitignore. +const STALE_PATTERNS = ["server.log"]; + +for (const appName of getDirs(APPS_DIR)) { + for (const pattern of STALE_PATTERNS) { + if (existsSync(join(APPS_DIR, appName, pattern))) { + info(`stale artifact: src/apps/${appName}/${pattern}`); + } + } +} + +for (const pkgName of getDirs(PACKAGES_DIR)) { + for (const pattern of STALE_PATTERNS) { + if (existsSync(join(PACKAGES_DIR, pkgName, pattern))) { + info(`stale artifact: src/packages/${pkgName}/${pattern}`); + } + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// Report +// ══════════════════════════════════════════════════════════════════════════ + +console.log(""); +console.log("DevGlide Structure Check"); +console.log("========================\n"); + +if (errors.length > 0) { + console.log(`ERRORS (${errors.length}):\n`); + for (const e of errors) console.log(` \u2717 ${e}`); + console.log(""); +} + +if (infos.length > 0) { + console.log(`INFO (${infos.length}):\n`); + for (const i of infos) console.log(` \u2139 ${i}`); + console.log(""); +} + +if (errors.length === 0 && infos.length === 0) { + console.log(" All checks passed.\n"); +} + +const summary = `${errors.length} error(s), ${infos.length} info(s)`; +if (errors.length > 0) { + console.log(`FAIL: ${summary}\n`); + process.exit(1); +} else { + console.log(`PASS: ${summary}\n`); +} diff --git a/src/apps/chat/docs/pipe-upgrade-acceptance-tests.md b/src/apps/chat/docs/pipe-upgrade-acceptance-tests.md new file mode 100644 index 0000000..867e1c5 --- /dev/null +++ b/src/apps/chat/docs/pipe-upgrade-acceptance-tests.md @@ -0,0 +1,291 @@ +# Pipe Upgrade: Acceptance Test Plan + +This document defines the layered acceptance suite for the upgraded +assignment-based pipe delivery model. + +## Test Layers + +### Layer 1: Deterministic reducer/unit tests + +Pure function tests for `reduce(state, event) -> [nextState, effects]`. +No I/O, no timers, injectable clock. + +### Layer 2: REST/integration tests + +HTTP-level tests for the assign-fetch-submit-status flow via the REST API. +Uses a running server instance. + +### Layer 3: MCP canary tests + +Live MCP tool invocations testing notification, adoption, and end-to-end +pipe completion. Minimal set — validates MCP plumbing, not business logic. + +--- + +## Structured Case Definitions + +Each test case uses this structure: + +```typescript +interface AcceptanceCase { + /** Stable identifier for cross-reference and reruns. */ + caseId: string; + /** Human-readable description. */ + title: string; + /** Which layer: 'reducer' | 'rest' | 'mcp' */ + layer: 'reducer' | 'rest' | 'mcp'; + /** The authoritative source of truth for the expected outcome. */ + oracle: string; + /** Artifacts that MUST be present for a pass verdict. */ + expectedArtifacts: string[]; + /** Artifacts that MUST NOT be present (indicates a bug). */ + forbiddenArtifacts: string[]; + /** Whether this case should produce identical results on rerun. */ + rerunExpectation: 'deterministic' | 'eventually-consistent' | 'non-deterministic'; + /** Verdict taxonomy. */ + verdictOptions: ['pass', 'protocol_fail', 'invalid_unverified']; +} +``` + +--- + +## Layer 1: Reducer Unit Tests + +### REDUCE-001: Linear pipe — happy path through 3 stages + +- **caseId:** `REDUCE-001` +- **oracle:** Pure reducer output given start event + sequential stage-output events +- **events:** `start(linear, [A,B,C], prompt)` -> `stage-output(A, s1)` -> `stage-output(B, s2)` -> `stage-output(C, s3)` +- **expected effects:** + - After start: `[assign(A, stage=1)]` + - After A submits: `[assign(B, stage=2)]` + - After B submits: `[assign(C, stage=3)]` + - After C submits: `[complete(pipeId)]` +- **forbidden:** No duplicate assign effects. No assign after complete. +- **rerun:** deterministic + +### REDUCE-002: Merge pipe — fan-out then synthesize + +- **caseId:** `REDUCE-002` +- **events:** `start(merge, [A,B,synth], prompt)` -> `fan-out(A)` -> `fan-out(B)` -> `synth-output(synth)` +- **expected effects:** + - After start: `[assign(A, fan-out), assign(B, fan-out)]` + - After A submits: no new effect (waiting for B) + - After B submits: `[assign(synth, synth-request)]` + - After synth submits: `[complete(pipeId)]` +- **rerun:** deterministic + +### REDUCE-003: Merge-all pipe — all participate + synthesize + +- **caseId:** `REDUCE-003` +- **events:** `start(merge-all, [A,B,C], prompt)` -> `fan-out(A)` -> `fan-out(B)` -> `fan-out(C)` -> `synth-output(C)` +- **expected effects:** + - After start: `[assign(A, fan-out), assign(B, fan-out), assign(C, fan-out)]` + - After all fan-outs: `[assign(C, synth-request)]` + - After C's synth: `[complete(pipeId)]` +- **rerun:** deterministic + +### REDUCE-004: Idempotent replay — same events produce same state + +- **caseId:** `REDUCE-004` +- **oracle:** Reducer applied twice to same event stream yields identical state +- **rerun:** deterministic + +### REDUCE-005: Terminal state — no actions after failure + +- **caseId:** `REDUCE-005` +- **events:** `start(linear, [A,B])` -> `failed(timeout)` +- **expected:** `computeNextActions` returns `[]` +- **forbidden:** Any assign effect + +### REDUCE-006: Terminal state — no actions after cancellation + +- **caseId:** `REDUCE-006` +- **events:** `start(linear, [A,B])` -> `cancelled(user)` +- **expected:** `computeNextActions` returns `[]` + +### REDUCE-007: Clock injection — deadline calculation uses injected time + +- **caseId:** `REDUCE-007` +- **oracle:** Lease deadline = injected_now + stageTimeoutMs +- **rerun:** deterministic (injectable clock) + +--- + +## Layer 2: REST Integration Tests + +**Actual API routes** (from `mcp.ts` and chat router): +- List assignments: `GET /api/chat/pipes/assignments?assignee=&projectId=` +- Get assignment: `GET /api/chat/pipes/:pipeId/assignment?projectId=` (with `x-pane-id` header) +- Submit: `POST /api/chat/pipes/:pipeId/submit` body `{from, content, assignmentId?, projectId?}` +- Read output: `GET /api/chat/pipes/:pipeId/output?projectId=` (with `x-pane-id` header) +- Pipe status: `GET /api/chat/pipes/:pipeId/status?projectId=` +- Send message: `POST /api/chat/send` body `{from, message, to?}` + +### REST-001: Happy path — assign, fetch, submit, complete + +- **caseId:** `REST-001` +- **setup:** Create pipe via `POST /api/chat/send` with body `{from: "user", message: "/linear-pipe @A @B do task"}`. 2-stage linear. +- **steps:** + 1. `GET /api/chat/pipes/assignments?assignee=A` -> returns assignment with role/stage info + 2. `GET /api/chat/pipes/:pipeId/assignment` (with `x-pane-id: A-pane`) -> returns assignment details + 3. Assert response includes role, stage, lease status + 4. `POST /api/chat/pipes/:pipeId/submit` with `{from: A, content: "stage 1 output"}` + 5. Assert submit accepted (200) + 6. Repeat for participant B + 7. `GET /api/chat/pipes/:pipeId/status` -> assert `completed` +- **expectedArtifacts:** Two assignment records, both in `submitted` state +- **forbiddenArtifacts:** No assignment in `expired` or `failed` state +- **rerun:** deterministic + +### REST-002: Wrong-channel rejection — chat_send for pipe content + +- **caseId:** `REST-002` +- **steps:** + 1. Start a pipe with participant A + 2. `POST /api/chat/send` with `{from: "A", message: "#pipe-abc123 my output"}` + 3. Assert rejection (message contains `#pipe-` prefix) +- **expected:** Error mentions `pipe_submit` +- **rerun:** deterministic + +### REST-003: Duplicate submit — second submit rejected + +- **caseId:** `REST-003` +- **steps:** + 1. A submits via `POST /api/chat/pipes/:pipeId/submit` — succeeds + 2. A submits again to same pipe + 3. Assert rejection with `PIPE_ALREADY_SUBMITTED` +- **forbidden:** Second submit changing pipe state +- **rerun:** deterministic + +### REST-004: Timeout — stage deadline expires + +- **caseId:** `REST-004` +- **setup:** Pipe with `stageTimeoutMs: 100` (very short for testing) +- **steps:** + 1. Start pipe, assignment created for A + 2. Wait > 100ms without fetch or submit + 3. Assert pipe status `failed` (for `fail` policy) or escalation message (for `escalate`) +- **rerun:** eventually-consistent (timing-dependent) + +### REST-005: Unauthorized submit — stale assignee rejected + +- **caseId:** `REST-005` +- **steps:** + 1. Start pipe, A gets assignment/lease + 2. Assignment expires / lease released + 3. A attempts `POST /api/chat/pipes/:pipeId/submit` with `{from: A, content}` + 4. Assert rejection with `PIPE_LEASE_NOT_HELD` +- **forbidden:** Stale submit advancing pipe state +- **rerun:** deterministic + +### REST-006: Reconnect recovery — list assignments after rejoin + +- **caseId:** `REST-006` +- **steps:** + 1. Start pipe, A gets assignment + 2. Simulate A disconnect (`POST /api/chat/leave` with `{name: A}`) + 3. A rejoins (`POST /api/chat/join`) + 4. `GET /api/chat/pipes/assignments?assignee=A` returns the pending assignment + 5. A fetches and submits successfully +- **expected:** Pipe completes normally despite reconnect +- **note:** Only works for pane disconnects (not server restarts) until assignment persistence is added +- **rerun:** deterministic + +### REST-007: No-ack delivery retry — re-notify after timeout + +- **caseId:** `REST-007` +- **setup:** Pipe with re-notify policy enabled +- **steps:** + 1. Start pipe, notification delivered to A + 2. A does not fetch within ack window + 3. Assert re-notification sent (observable via `pipe-delivery.ts` `DeliveryRecord.notifyAttempts` counter or via `GET /api/chat/pipes/:pipeId/status` timing data) +- **rerun:** eventually-consistent + +### REST-008: Dropped initial notification — assignee can still fetch + +- **caseId:** `REST-008` +- **oracle:** Assignment/delivery record exists server-side even if PTY notification never reaches client +- **steps:** + 1. Start pipe (assignment + delivery record created) + 2. Simulate notification delivery failure (e.g., participant has no PTY) + 3. A calls `GET /api/chat/pipes/assignments?assignee=A` — finds the assignment + 4. A fetches details via `GET /api/chat/pipes/:pipeId/assignment` and submits + 5. Pipe completes +- **expected:** Pipe completes despite dropped notification +- **forbidden:** Pipe stuck in running state indefinitely +- **rerun:** deterministic + +### REST-009: Mixed-mode pipe — push participant + pull participant + +- **caseId:** `REST-009` +- **setup:** 2-stage linear pipe. A = push mode, B = pull mode. +- **steps:** + 1. A receives full-payload PTY delivery + 2. A submits via `pipe_submit(pipeId, content)` (no assignmentId) + 3. B receives compact notification + 4. B fetches assignment, submits via `pipe_submit(assignmentId, content)` + 5. Pipe completes +- **expected:** Both delivery modes work in same pipe +- **rerun:** deterministic + +### REST-010: Payload integrity — fetched payload matches stored payload + +- **caseId:** `REST-010` +- **steps:** + 1. Start pipe, assignment created with payload hash + 2. Fetch payload via assignment + 3. Assert content matches and hash is correct +- **rerun:** deterministic + +--- + +## Layer 3: MCP Canary Tests + +### MCP-001: pipe_get_assignment tool — returns assignment details + +- **caseId:** `MCP-001` +- **steps:** Call `pipe_get_assignment(pipeId)` via MCP tool (current signature takes pipeId, not assignmentId) +- **expected:** Returns assignment details including role, stage, lease status, deadline +- **rerun:** deterministic + +### MCP-002: pipe_list_assignments tool — lists pending + +- **caseId:** `MCP-002` +- **steps:** Call `pipe_list_assignments()` via MCP tool after pipe start +- **expected:** Returns array with at least one active assignment for the caller +- **rerun:** deterministic + +### MCP-003: pipe_submit with assignmentId — completes stage + +- **caseId:** `MCP-003` +- **steps:** Submit via MCP `pipe_submit(pipeId, content, assignmentId)` — assignmentId is optional param +- **expected:** Returns success with slot info +- **rerun:** deterministic + +### MCP-004: pipe_submit with pipeId only — backward compat + +- **caseId:** `MCP-004` +- **steps:** Submit via MCP `pipe_submit(pipeId, content)` (no assignmentId) +- **expected:** Server resolves active lease/assignment and accepts submit +- **rerun:** deterministic + +--- + +## Verdict Taxonomy + +| Verdict | Meaning | +|---------|---------| +| `pass` | All expected artifacts present, no forbidden artifacts, oracle satisfied | +| `protocol_fail` | The pipe protocol was violated (wrong state transition, duplicate effect, stale access) | +| `invalid_unverified` | Test setup or assertion is invalid; result cannot be trusted | + +## Implementation Notes + +- Layer 1 tests go in `src/apps/chat/services/pipe-reducer.test.ts` (extend existing) +- Layer 2 tests go in `src/apps/chat/services/chat-registry.pipe-submit.test.ts` (extend existing) + or new file `src/apps/chat/services/pipe-upgrade-acceptance.test.ts` +- Layer 3 tests go in `src/apps/chat/src/mcp.test.ts` (extend existing) +- All Layer 1 tests must use injectable clock (no `Date.now()`) +- Layer 2 tests should use the existing test server setup pattern +- Case IDs are stable — do not renumber when adding/removing cases diff --git a/src/apps/chat/docs/pipe-upgrade-llm-instructions.md b/src/apps/chat/docs/pipe-upgrade-llm-instructions.md new file mode 100644 index 0000000..33faeba --- /dev/null +++ b/src/apps/chat/docs/pipe-upgrade-llm-instructions.md @@ -0,0 +1,249 @@ +# Pipe Upgrade: LLM Instructions Update + +This document specifies the changes needed to LLM-facing instructions when +the pipe delivery model transitions from push to assignment-based pull. +It covers three instruction surfaces: + +1. **MCP server instructions** (`mcp.ts` — `instructions` array in `createChatMcpServer`) +2. **CLAUDE.md template** (`bin/claude-md-template.js` — installed into user's CLAUDE.md) +3. **PTY interaction reminder** (`chat-registry.ts` — `PTY_INTERACTION_REMINDER`) + +--- + +## 1. MCP Server Instructions (`mcp.ts`) + +### New section: Pipe assignments (insert after "### Sending messages") + +``` +### Pipe assignments (notify-then-fetch) + +When you are assigned a pipe stage, you receive a **compact notification** +via PTY containing an assignment envelope: + + [pipe-assignment] assignmentId= pipeId= stage= role= + +This notification does NOT contain the full prompt or previous-stage output. +To get the authoritative payload, follow this sequence: + +1. **Fetch the assignment:** `pipe_get_assignment(assignmentId)` — returns + the full payload (prompt, context, previous output, submit instructions). +2. **Do your work** based on the fetched payload. +3. **Submit:** `pipe_submit(assignmentId, content)` — submit your output. + +Do NOT act on the notification envelope alone. Always fetch first. + +If you miss a notification (e.g., after reconnect), use +`pipe_list_assignments()` to discover any pending assignments. +``` + +### Updated quick reference entries + +**Note:** `pipe_get_assignment`, `pipe_list_assignments`, and the optional +`assignmentId` parameter on `pipe_submit` already exist on this branch. +The changes below are semantic/description updates, not new tool registrations. + +**Current signatures** (already in `mcp.ts`): +- `pipe_get_assignment(pipeId, paneId?)` — get assignment details for a pipe +- `pipe_list_assignments(paneId?)` — list active/pending assignments +- `pipe_submit(pipeId, content, assignmentId?, paneId?)` — submit with optional assignmentId + +**Updated descriptions** (semantic shift to make assignment-based flow primary): +``` +- `pipe_get_assignment(pipeId, paneId?)` — fetch your assignment details + and authoritative payload for a pipe. Call this after receiving a compact + notification. Returns role, stage, lease status, deadline, and full + payload content. Use pipeId (the server resolves your active assignment). +- `pipe_list_assignments(paneId?)` — list your pending pipe assignments. + Use after reconnect to discover work missed during disconnection. + Note: assignments are in-memory only — returns empty after server restart + until assignment persistence is added. +- `pipe_submit(pipeId, content, assignmentId?, paneId?)` — submit your + output for a pipe stage. Pass `assignmentId` when available for explicit + binding; `pipeId` alone still works (server resolves active assignment). + Use this instead of `chat_send` when responding to pipe work. +- `pipe_read_output(pipeId, paneId?)` — read previous-stage output for a + pipe. Returns only what the state machine says you can access now. Caller + identity resolved from session. +``` + +### Updated chat_send entry + +**Before:** +``` +- `chat_send(message, to?, paneId?)` — ... Messages that start with `#pipe-` + or reference a currently running `#pipe-*` are rejected — use `pipe_submit` + instead. +``` + +**After:** +``` +- `chat_send(message, to?, paneId?)` — ... Messages that start with `#pipe-` + or reference a currently running `#pipe-*` are rejected — use + `pipe_submit(assignmentId, content)` instead. +``` + +--- + +## 2. CLAUDE.md Template (`bin/claude-md-template.js`) + +### Updated chat section + +In the `### devglide-chat` section, add pipe assignment tools and update +the existing pipe tool descriptions: + +**Add after `chat_members` bullet:** +``` +- `pipe_get_assignment` — fetch full payload for a pipe assignment notification +- `pipe_list_assignments` — list pending pipe assignments (for reconnect recovery) +``` + +**Update existing bullet:** +``` +- Before: `pipe_submit` — submit pipe stage output +- After: `pipe_submit` — submit output for a pipe assignment (accepts assignmentId or pipeId) +``` + +**Add to the Session unification bullet:** +``` +MCP tools (`chat_send`, `pipe_submit`, `pipe_get_assignment`, `chat_leave`) +can also adopt a REST-joined participant by passing `paneId`. +``` + +**Add new subsection in Chat description:** +``` +- **Pipe delivery:** Pipe stages are delivered as compact assignment + notifications. LLMs must call `pipe_get_assignment` to fetch the + authoritative payload before acting. On reconnect, call + `pipe_list_assignments` to recover pending work. +``` + +--- + +## 3. PTY Interaction Reminder (`chat-registry.ts`) + +The `PTY_INTERACTION_REMINDER` appended to every delivered message currently +says: + +``` +Reply via `chat_send` (not shell output). For #pipe-* stages use +`pipe_submit`. Discussion only — execute only when explicitly assigned. +Start user-directed replies with @user. +``` + +**Update to:** +``` +Reply via `chat_send` (not shell output). For pipe assignments: fetch with +`pipe_get_assignment`, then submit with `pipe_submit`. Discussion only — +execute only when explicitly assigned. Start user-directed replies with @user. +``` + +--- + +## 4. Compact Notification Format + +The PTY notification envelope replaces the current full-payload delivery +message. The format should be concise and machine-parseable: + +**Current (full payload, ~500-2000 chars):** +``` +[DevGlide Chat] @system: #pipe-abc123 [linear | stage 2/3 | @claude-17] + +Your output passes to the next stage. +Prompt: Analyze the authentication flow... + +Read previous stage output: pipe_read_output(pipeId="abc123") + +Submit: pipe_submit(pipeId="abc123", content="") +Do not use chat_send. Submit once, then wait. +``` + +**Upgraded (compact envelope, ~200 chars):** +``` +[DevGlide Chat] @system: #pipe-abc123 [assignment] + +assignmentId: asgn_7f3k2m +pipeId: abc123 | stage: 2/3 | role: stage-output | mode: linear + +Fetch payload: pipe_get_assignment(assignmentId="asgn_7f3k2m") +Then submit: pipe_submit(assignmentId="asgn_7f3k2m", content="") +``` + +--- + +## 5. Transitional Behavior (Phase 2-4) + +During the dual-mode period, instructions must handle both flows: + +### Push-mode participants (legacy, `deliveryMode: 'push'`) +- Continue receiving full-payload PTY messages. +- `pipe_submit(pipeId, content)` works as before. +- No mention of `pipe_get_assignment` needed — they already have the payload. + +### Pull-mode participants (upgraded, `deliveryMode: 'pull'`) +- Receive compact notification envelopes. +- Must call `pipe_get_assignment(assignmentId)` before acting. +- `pipe_submit(assignmentId, content)` preferred; `pipeId` fallback accepted. + +### MCP instructions during transition + +The MCP server instructions should include both flows with a clear note: + +``` +### Pipe stages + +When assigned a pipe stage, you will receive either: + +**A. Full-payload delivery** (legacy) — contains the complete prompt and + submit instructions inline. Act on it directly and call + `pipe_submit(pipeId, content)`. + +**B. Compact assignment notification** — contains only an assignmentId and + metadata. You MUST fetch the payload first: + 1. `pipe_get_assignment(assignmentId)` — get full payload + 2. Do your work + 3. `pipe_submit(assignmentId, content)` — submit output + +If you see an `assignmentId` in the notification, use flow B. +If you see the full prompt and submit instructions inline, use flow A. +``` + +This transitional text is removed in Phase 5 when push delivery is eliminated. + +--- + +## 6. Reconnect Recovery Instructions + +Add to MCP server instructions after the pipe assignments section: + +``` +### Reconnect recovery + +If your pane was disconnected and you rejoin via `chat_join`, you may have +missed pipe notifications. Immediately after joining: + +1. Call `pipe_list_assignments()` to check for pending assignments. +2. For each pending assignment, call `pipe_get_assignment(assignmentId)`. +3. Process and submit as normal. + +**Note:** Assignments are currently in-memory only. After a server restart, +`pipe_list_assignments` returns empty — pipe/slot state recovers via +event-sourced replay, but assignment-level state does not. Until assignment +persistence is added, reconnect recovery works only for pane disconnects +(not server restarts). After a server restart, fall back to +`pipe_read_output(pipeId)` if you know which pipe you were working on. +``` + +--- + +## 7. Implementation Checklist + +- [ ] Update `instructions` array in `createChatMcpServer()` (`mcp.ts`) +- [ ] Update `pipe_get_assignment` tool description to reflect payload fetch semantics (tool already registered) +- [ ] Update `pipe_list_assignments` tool description to note in-memory limitation (tool already registered) +- [ ] `pipe_submit` already accepts optional `assignmentId` — update description to make it primary +- [ ] Update `PTY_INTERACTION_REMINDER` in `chat-registry.ts` +- [ ] Update `getClaudeMdContent()` in `bin/claude-md-template.js` +- [ ] Bump `VERSION` in `claude-md-template.js` +- [ ] Add transitional dual-mode instructions (Phase 2) +- [ ] Remove transitional instructions (Phase 5) +- [ ] Update compact notification format in `runPipeReducer()` delivery message construction diff --git a/src/apps/chat/docs/pipe-upgrade-migration.md b/src/apps/chat/docs/pipe-upgrade-migration.md new file mode 100644 index 0000000..dc587f3 --- /dev/null +++ b/src/apps/chat/docs/pipe-upgrade-migration.md @@ -0,0 +1,264 @@ +# Pipe Upgrade Migration Strategy + +## Overview + +This document defines the rollout strategy for migrating pipe delivery from +**one-shot pushed payloads** (full stage input PTY-injected directly) to +**assignment-based pull delivery** (compact notification envelope via PTY, +authoritative payload fetched by the assignee). + +## Current State (Push Model) + +### Delivery flow + +1. `handlePipeCommand()` creates pipe in `pipe-store`, emits `start` event. +2. `runPipeReducer()` calls `computeNextActions(state)` which returns `PipeAction[]`. +3. For each action: + - `grantLease(pipeId, assignee, projectId)` — one lease per participant. + - `startStageDeadline()` — sets timeout timer. + - `markEmitted()` — idempotency tracking. + - Constructs a **full delivery message** containing the complete prompt, + previous-stage output reference, and submit instructions. + - PTY-injects the full message to the target assignee via `deliverToPty()`. +4. Assignee calls `pipe_submit(pipeId, content)` which validates lease ownership. +5. Reducer re-runs, next action emitted. + +### Key surfaces + +| Surface | Current behavior | +|---------|-----------------| +| PTY delivery | Full payload embedded in `[DevGlide Chat] @system: ...` message | +| `pipe_submit(pipeId, content)` | Submit by pipeId, lease validated | +| `pipe_read_output(pipeId)` | Read previous stage output (scoped to caller's role) | +| `chat_send` rejection | Messages starting with `#pipe-` are rejected; must use `pipe_submit` | + +### Data structures + +- `StoredPipe`: slots, emission tracking sets, timeout config +- `PipeSlot`: `{assignee, role, stage, status, content, submittedAt}` +- `LeaseInfo`: `{pipeId, assignee, slotRole, stage, grantedAt, deadline}` +- `PipeRecoveryEvent`: persisted to `{pipeId}.events.jsonl` for server restart recovery + +## Target State (Assignment-Based Pull Model) + +### Delivery flow + +1. Pipe creation unchanged. +2. Reducer computes next actions (pure: `reduce(state, event) -> [nextState, effects]`). +3. For each action: + - Create a durable **Assignment** record (`assignmentId`, lifecycle states). + - Store the authoritative **payload** server-side (with integrity hash). + - PTY-inject a **compact notification envelope**: `{assignmentId, pipeId, stageId, role}`. + - Start ack/fetch tracking timer. +4. Assignee receives notification -> calls `pipe_get_assignment(assignmentId)` to fetch payload. +5. Server records `payload_fetched` state. +6. Assignee calls `pipe_submit(assignmentId, content)` (now bound to assignmentId). +7. Assignment lifecycle: `assigned -> notified -> acknowledged -> payload_fetched -> submitted`. + +### New surfaces + +| Surface | New behavior | +|---------|-------------| +| PTY delivery | Compact envelope only (assignmentId, pipeId, stageId, role, ~100 chars) | +| `pipe_get_assignment(assignmentId)` | Fetch authoritative payload, records `payload_fetched` | +| `pipe_list_assignments()` | List pending assignments (for reconnect recovery) | +| `pipe_submit(assignmentId, content)` | Submit by assignmentId (backward compat: pipeId still accepted) | +| `pipe_read_output(pipeId)` | Unchanged (previous stage output, scoped read) | +| Ack/fetch tracking | Re-notify if no fetch within window; dead-letter after exhaustion | + +## Migration Phases + +### Phase 0: Foundation (no behavioral change) + +**Goal:** Lay groundwork without changing any observable behavior. + +**Changes:** +- Extract pure reducer: `reduce(state, event) -> [nextState, effects]` (task `wj6zegpb`) +- Inject clock into lease/timeout logic (task `luz7zthq`) +- Define Assignment model types (task `zk8933rf`) +- Define payload storage types and lifecycle (task `hptc4cky`) + +**Verification:** +- All existing tests pass. +- No change to PTY delivery format. +- No new MCP tools exposed yet. + +### Phase 1: Server-side assignment infrastructure (invisible to clients) + +**Goal:** Wire assignment + payload stores into the pipe lifecycle without +changing any observable delivery behavior. + +**Note:** `assignment-store.ts`, `payload-store.ts`, and `pipe-delivery.ts` +already exist on this branch with in-memory stores, injectable clocks, and +the `pipe_list_assignments` / `pipe_get_assignment` MCP tools already +registered. Phase 1 is about completing the wiring into `createPipe()` and +`runPipeReducer()`, not adding new files or tools. + +**Changes:** +- `createPipe()` materializes Assignment records (via `assignment-store`) + alongside existing PipeSlots. +- Payloads stored server-side via `payload-store` with SHA-256 integrity hash. +- `pipe-delivery.ts` DeliveryRecords created on each reducer action. +- Existing full-payload PTY delivery still used (dual-write: assignment record + created, but delivery still pushes full payload). + +**Persistence caveat:** Assignment and payload stores are currently in-memory +(`assignment-store.ts:69-76`, `payload-store.ts:58-62`). Assignments and +payloads do NOT survive server restart in this phase. Restart recovery for +assignments requires disk persistence (e.g., extending the existing +`{pipeId}.events.jsonl` pattern or adding SQLite — see Chat SQL Migration +feature). Until persistence is added, `pipe_list_assignments` after restart +returns an empty set; the existing `pipe-store` event-sourced recovery +rebuilds pipe/slot/lease state but not assignment-level state. + +**Verification:** +- Assignment records created on every pipe action (observable via + `GET /api/chat/pipes/assignments?assignee=...`). +- Full-payload PTY delivery unchanged — clients see no difference. +- `pipe_get_assignment(pipeId)` and `pipe_list_assignments()` return data + for in-flight pipes (already registered, semantics unchanged). + +### Phase 2: Dual-mode delivery (opt-in pull) + +**Goal:** Clients that support assignments can opt into compact notifications. +Legacy clients continue receiving full payloads. + +**Changes:** +- Add `deliveryMode` field to `ChatParticipant`: `'push' | 'pull'`. + Default: `'push'` (backward compatible). Clients opt in via + `chat_join(..., deliveryMode: 'pull')`. +- For `pull` participants: PTY delivers compact envelope. +- For `push` participants: PTY delivers full payload (unchanged). +- Both modes create the same Assignment record server-side. +- `pipe_submit` accepts both `assignmentId` and `pipeId` (the server resolves + the active assignment from pipeId if no assignmentId is provided). + +**Backward compatibility:** +- Existing clients (no code changes) continue working exactly as today. +- New clients opt in to pull mode and benefit from smaller PTY messages, + explicit ack tracking, and reconnect recovery. + +**Verification:** +- Mixed-mode pipe: push participant -> pull participant -> push participant + works correctly. +- Pull participant can fetch payload, submit, and complete pipe. +- Push participant behavior unchanged. + +### Phase 3: Shadow validation (optional) + +**Goal:** Validate that pull delivery produces identical outcomes to push delivery. + +**Changes:** +- For `push` participants, additionally create assignment records and track + whether the submit would have matched the assignment-based flow. +- Log discrepancies (e.g., submit without fetch, submit for wrong assignment). +- Emit metrics: `pipe.delivery.mode`, `pipe.delivery.discrepancy`. + +**Verification:** +- Shadow metrics show zero discrepancies for N consecutive pipes. +- No behavioral change for any participant. + +**Decision gate:** Proceed to Phase 4 when shadow validation shows no +discrepancies for a configured threshold (e.g., 100 pipes or 7 days). + +### Phase 4: Default to pull (opt-out push) + +**Goal:** New participants default to pull mode. + +**Changes:** +- `deliveryMode` default changes from `'push'` to `'pull'`. +- Clients that need push can still opt in via `chat_join(..., deliveryMode: 'push')`. +- LLM instructions updated to describe the notify-then-fetch flow as primary. + +**Verification:** +- All active LLM clients (Claude Code, Codex) work with pull delivery. +- Push opt-out still functions for edge cases. + +### Phase 5: Remove push delivery (cleanup) + +**Goal:** Remove the legacy push code path. + +**Changes:** +- Remove `deliveryMode` field (all participants use pull). +- Remove full-payload PTY construction in `runPipeReducer()`. +- Remove `requireLease = false` backward-compat path in `submitStage()`. +- `pipe_submit` only accepts `assignmentId` (pipeId fallback removed). +- Clean up dual-write paths. + +**Verification:** +- All tests pass without push delivery code. +- No `deliveryMode: 'push'` references remain. + +## Key Design Decisions + +### 1. Dual-mode via participant flag, not global toggle + +**Why:** Different LLM clients update at different speeds. A global toggle would +force all participants to upgrade simultaneously. A per-participant flag allows +gradual adoption within the same pipe. + +**Trade-off:** Mixed-mode pipes add complexity to the reducer, but the assignment +model is the same regardless of delivery mode — only the PTY notification format +differs. + +### 2. `pipe_submit` accepts both pipeId and assignmentId during transition + +**Why:** Forcing assignmentId immediately would break all existing clients. +During Phase 2-4, the server resolves the active assignment from pipeId when +no assignmentId is provided. This is safe because one participant holds at most +one active lease. + +**Risk:** If a participant somehow has two assignments for the same pipe (not +possible in current model), pipeId resolution would be ambiguous. Mitigated by +the one-lease-per-participant invariant. + +**Removal:** Phase 5 removes pipeId fallback. + +### 3. No dual-write for event log format + +**Why:** Recovery events (`PipeRecoveryEvent`) are internal and not consumed by +clients. Adding assignment fields to recovery events is safe and backward +compatible — older event files without assignment fields are handled by +defaulting to null. + +### 4. Shadow validation is optional + +**Why:** The assignment model is a superset of the current model. If Phase 2 +testing is thorough, shadow validation adds confidence but not correctness. +Skip if the team is confident in Phase 2 acceptance tests. + +## Metrics for Phase Gate Decisions + +| Metric | Phase 2 -> 3 gate | Phase 3 -> 4 gate | Phase 4 -> 5 gate | +|--------|-------------------|-------------------|-------------------| +| Pull-mode pipe completions | > 0 | N/A | > 50 | +| Shadow discrepancies | N/A | 0 for threshold | N/A | +| Push-mode participants remaining | N/A | N/A | 0 | +| Reconnect recovery success rate | > 90% | > 95% | > 99% | +| Average notification-to-fetch latency | Measured | Baselined | < 2s | + +## Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Pull participant fails to fetch payload | Re-notify policy with configurable backoff. Dead-letter after N attempts. | +| Mixed-mode pipe has inconsistent behavior | Assignment model is source of truth regardless of delivery mode. Tests cover mixed scenarios. | +| Server restart during Phase 2 | Pipe/slot/lease state recovers via existing event-sourced replay (`pipe-store.rehydrateFromEvents`). Assignment-level state (assignment-store, payload-store) is **lost** on restart until persistence is added. Push participants get full payload on re-delivery after recovery. Pull participants must fall back to `pipe_read_output(pipeId)` if `pipe_list_assignments` returns empty after restart. Persistence for assignments is a prerequisite for Phase 4 (default pull). | +| Client doesn't upgrade from push | Push remains functional until Phase 5. No forced migration. | +| Duplicate submit (push + pull race) | Assignment state machine rejects duplicate submits. `pipe_submit` is idempotent per assignment. | + +## Files Affected by Phase + +| Phase | Files | Nature of change | +|-------|-------|-----------------| +| 0 | `pipe-reducer.ts`, `pipe-store.ts` | Refactor (pure reducer, clock injection) | +| 0 | `types.ts` | Assignment types already exist; verify completeness | +| 1 | `pipe-store.ts`, `chat-registry.ts` | Wire assignment-store + payload-store into pipe lifecycle | +| 1 | `mcp.ts` | Tools already registered (`pipe_get_assignment`, `pipe_list_assignments`); update semantics | +| 2 | `types.ts` | `deliveryMode` on `ChatParticipant` | +| 2 | `chat-registry.ts` | Branching delivery: compact (via pipe-delivery.ts) vs full payload | +| 2 | `pipe-delivery.ts` | Compact notification formatting (already has delivery state machine) | +| 2 | `mcp.ts` | `pipe_submit` already accepts optional `assignmentId`; make it primary | +| 3 | `chat-registry.ts` | Shadow metrics logging | +| 4 | `types.ts`, `mcp.ts`, `claude-md-template.js` | Default flip, instruction update | +| 5 | `chat-registry.ts`, `pipe-store.ts`, `mcp.ts` | Dead code removal | diff --git a/src/apps/chat/package.json b/src/apps/chat/package.json new file mode 100644 index 0000000..68933eb --- /dev/null +++ b/src/apps/chat/package.json @@ -0,0 +1,23 @@ +{ + "name": "@devglide/chat", + "version": "0.1.0", + "description": "Multi-LLM chat room for cross-agent communication", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "lint": "eslint ." + }, + "private": true, + "dependencies": { + "@devglide/mcp-utils": "workspace:*", + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.25.49" + }, + "devDependencies": { + "tsx": "^4.19.4", + "typescript": "^5.8.0" + } +} diff --git a/src/apps/chat/public/mention-suggestions.js b/src/apps/chat/public/mention-suggestions.js new file mode 100644 index 0000000..def2f6b --- /dev/null +++ b/src/apps/chat/public/mention-suggestions.js @@ -0,0 +1,53 @@ +function normalizeQuery(query) { + return (query ?? '').toLowerCase(); +} + +function startsWithQuery(name, query) { + return name.toLowerCase().startsWith(normalizeQuery(query)); +} + +function isLiveLlm(member) { + return member?.kind === 'llm' && !member.detached && !!member.paneId; +} + +function shouldSuggestAll(query) { + return startsWithQuery('all', query); +} + +export function getPipeAssigneeMatches(members, query = '') { + return members + .filter(isLiveLlm) + .filter(member => startsWithQuery(member.name, query)) + .map(member => member.name); +} + +export function getMentionMatches(members, query = '') { + const matches = members + .filter(member => member?.name !== 'user') + .filter(member => member && !member.detached) + .filter(member => member.kind !== 'llm' || !!member.paneId) + .filter(member => startsWithQuery(member.name, query)) + .map(member => member.name); + + return shouldSuggestAll(query) ? ['all', ...matches] : matches; +} + +/** Build the dashboard message header string for a chat message. + * Renders `@sender` alone when there are no recipients, `@sender → @target` + * for one recipient, `@sender → @t1, @t2` for multiple, and `@user → @all` + * for a broadcast. Accepts the persisted `msg.to` value (a comma-separated + * string from chat-registry, or `'all'` for broadcasts, or null/empty). */ +export function formatRecipientHeader(from, to) { + const senderRaw = from ?? ''; + const senderTag = senderRaw.startsWith('@') ? senderRaw : `@${senderRaw}`; + if (to == null || to === '') return senderTag; + const targets = String(to) + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (targets.length === 0) return senderTag; + const targetTags = targets + .map(t => (t.startsWith('@') ? t : `@${t}`)) + .join(', '); + return `${senderTag} \u2192 ${targetTags}`; +} diff --git a/src/apps/chat/public/mention-suggestions.test.ts b/src/apps/chat/public/mention-suggestions.test.ts new file mode 100644 index 0000000..39c891f --- /dev/null +++ b/src/apps/chat/public/mention-suggestions.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { formatRecipientHeader, getMentionMatches, getPipeAssigneeMatches } from './mention-suggestions.js'; + +describe('mention suggestions', () => { + it('excludes detached llms from regular mention autocomplete', () => { + const members = [ + { name: 'user', kind: 'user', detached: false, paneId: null }, + { name: 'codex-1', kind: 'llm', detached: false, paneId: 'pane-1' }, + { name: 'claude-1', kind: 'llm', detached: true, paneId: 'pane-2' }, + { name: 'cursor-1', kind: 'llm', detached: false, paneId: null }, + { name: 'reviewer', kind: 'observer', detached: false, paneId: null }, + ]; + + expect(getMentionMatches(members, '')).toEqual(['all', 'codex-1', 'reviewer']); + expect(getMentionMatches(members, 'cl')).toEqual([]); + expect(getMentionMatches(members, 'a')).toEqual(['all']); + expect(getMentionMatches(members, 'co')).toEqual(['codex-1']); + }); + + it('suggests @all when the query matches even if there are no member matches', () => { + const members = [ + { name: 'user', kind: 'user', detached: false, paneId: null }, + { name: 'codex-1', kind: 'llm', detached: false, paneId: 'pane-1' }, + ]; + + expect(getMentionMatches(members, 'al')).toEqual(['all']); + }); + + it('limits pipe assignee autocomplete to live llm participants', () => { + const members = [ + { name: 'codex-1', kind: 'llm', detached: false, paneId: 'pane-1' }, + { name: 'claude-1', kind: 'llm', detached: true, paneId: 'pane-2' }, + { name: 'cursor-1', kind: 'llm', detached: false, paneId: null }, + { name: 'reviewer', kind: 'observer', detached: false, paneId: null }, + ]; + + expect(getPipeAssigneeMatches(members, '')).toEqual(['codex-1']); + expect(getPipeAssigneeMatches(members, 'cl')).toEqual([]); + expect(getPipeAssigneeMatches(members, 'co')).toEqual(['codex-1']); + }); +}); + +describe('formatRecipientHeader', () => { + it('renders sender alone when there are no recipients', () => { + expect(formatRecipientHeader('claude-2', null)).toBe('@claude-2'); + expect(formatRecipientHeader('claude-2', undefined)).toBe('@claude-2'); + expect(formatRecipientHeader('claude-2', '')).toBe('@claude-2'); + }); + + it('renders @sender → @target for a single recipient', () => { + expect(formatRecipientHeader('claude-2', 'codex-3')).toBe('@claude-2 \u2192 @codex-3'); + }); + + it('renders @sender → @t1, @t2 for multiple recipients (comma-space)', () => { + expect(formatRecipientHeader('claude-2', 'codex-3, pi-1')).toBe('@claude-2 \u2192 @codex-3, @pi-1'); + }); + + it('also splits a legacy bare-comma list (no space)', () => { + expect(formatRecipientHeader('claude-2', 'codex-3,pi-1')).toBe('@claude-2 \u2192 @codex-3, @pi-1'); + }); + + it('renders @user → @all for a broadcast message', () => { + expect(formatRecipientHeader('user', 'all')).toBe('@user \u2192 @all'); + }); + + it('strips empty entries that may come from a malformed legacy `to` field', () => { + expect(formatRecipientHeader('claude-2', 'codex-3,,pi-1, ')).toBe('@claude-2 \u2192 @codex-3, @pi-1'); + }); + + it('does not double-prefix when sender or target already starts with @', () => { + // Defensive: even if someone slips an `@` into the stored values, + // the renderer should not produce `@@claude-2`. + expect(formatRecipientHeader('@claude-2', '@codex-3')).toBe('@claude-2 \u2192 @codex-3'); + }); +}); diff --git a/src/apps/chat/public/page.css b/src/apps/chat/public/page.css new file mode 100644 index 0000000..19c09ea --- /dev/null +++ b/src/apps/chat/public/page.css @@ -0,0 +1,1064 @@ +@keyframes chat-slide-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Chat — App-specific styles ────────────────────────────────────────────── */ +/* Common primitives (page root, header, scrollbar) are in shared-ui.css. */ + +.page-chat { + position: relative; +} + +/* ── Header ─────────────────────────────────────────────────────── */ +.page-chat header { +} + +.page-chat .toolbar-actions { + display: flex; + align-items: center; + gap: var(--df-space-2); +} + +/* ── Main layout ────────────────────────────────────────────────── */ +.page-chat main { + flex: 1; + overflow: hidden; + display: flex; + gap: 0; +} + +/* ── Members panel ──────────────────────────────────────────────── */ +.chat-members-panel { + width: 260px; + flex-shrink: 0; + border-right: 1px solid var(--df-color-border-default); + display: flex; + flex-direction: column; + overflow-y: auto; + padding: var(--df-space-3); +} + +.chat-members-title { + font-size: var(--df-font-size-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--df-color-text-secondary); + margin-bottom: var(--df-space-2); +} + +.chat-members-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--df-space-2); + margin-bottom: var(--df-space-2); +} + +.chat-members-toolbar .chat-members-title { + margin-bottom: 0; +} + +#chat-members-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.chat-member-item { + display: flex; + align-items: flex-start; + gap: var(--df-space-2); + padding: 3px 0; + font-size: var(--df-font-size-sm); +} + +.chat-member-item:first-child { + padding-top: 0; +} + +.chat-member-item:last-child { + padding-bottom: 0; +} + +.chat-member-body { + display: flex; + flex: 1; + min-width: 0; + align-items: flex-start; + flex-wrap: wrap; + gap: var(--df-space-1); +} + +.chat-member-meta { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: var(--df-space-1); + min-width: 0; + flex-shrink: 0; + margin-top: 1px; +} + +.chat-member-dot { + width: var(--df-space-2); + height: var(--df-space-2); + border-radius: 50%; + flex-shrink: 0; + margin-top: 0.45em; + background: var(--df-color-success); +} + +.chat-member-dot.connected { + background: var(--df-color-success); +} + +.chat-member-dot.disconnected { + background: var(--df-color-text-muted); + opacity: 0.5; +} + +.chat-member-dot.detached { + background: var(--df-color-warning, #f59e0b); + opacity: 0.7; +} + +.chat-member-name { + color: var(--df-color-text-primary); + font-weight: 500; + flex: 1; + min-width: 0; + line-height: 1.35; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.chat-member-tag { + display: inline-flex; + align-items: center; + padding: 2px var(--df-space-2); + border-radius: var(--df-radius-full); + font-size: var(--df-font-size-xs); + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--df-color-text-secondary); + border: 1px solid color-mix(in srgb, var(--df-color-border-default) 85%, transparent); + background: var(--df-color-bg-raised); +} + +.chat-member-tag.detached { + color: var(--df-color-warning, #f59e0b); + border-color: color-mix(in srgb, var(--df-color-warning, #f59e0b) 35%, var(--df-color-border-default)); + background: color-mix(in srgb, var(--df-color-warning, #f59e0b) 10%, var(--df-color-bg-raised)); +} + +.chat-member-status { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--df-space-4); + block-size: var(--df-space-4); + flex-shrink: 0; + color: var(--df-color-state-idle, var(--df-color-text-muted)); +} + +.chat-member-status svg, +.chat-member-badge svg { + inline-size: 100%; + block-size: 100%; +} + +.chat-member-status.idle { + color: var(--df-color-state-idle, var(--df-color-text-muted)); +} + +.chat-member-status.working { + color: var(--df-color-state-active, var(--df-color-accent-default)); + animation: chat-member-pulse 1.3s ease-in-out infinite; +} + +.chat-member-status.detached { + color: var(--df-color-warning, #f59e0b); +} + +.chat-member-badge { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--df-space-4); + block-size: var(--df-space-4); + flex-shrink: 0; + color: var(--df-color-text-muted); +} + +.chat-member-badge.supervised { + color: var(--df-color-state-success, #22c55e); +} + +.chat-member-badge.auto-accept { + color: var(--df-color-state-warning, #f59e0b); +} + +.chat-member-badge.unrestricted { + color: var(--df-color-state-error, #ef4444); +} + +@keyframes chat-member-pulse { + 0%, 100% { opacity: 0.45; transform: scale(0.85); } + 50% { opacity: 1; transform: scale(1.15); } +} + +/* ── Tooltip ────────────────────────────────────────────────────── */ +.chat-tooltip { + position: fixed; + z-index: var(--df-z-index-toast); + max-width: 320px; + padding: var(--df-space-1) var(--df-space-2); + font-size: var(--df-font-size-xs); + line-height: 1.4; + color: var(--df-color-text-primary); + background: var(--df-color-bg-raised); + border: 1px solid var(--df-color-border-default); + border-radius: var(--df-radius-sm); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + pointer-events: none; + white-space: normal; + word-break: break-word; +} + +.chat-tooltip.hidden { + display: none; +} + +/* ── Messages area ──────────────────────────────────────────────── */ +.chat-messages-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chat-messages-list { + flex: 1; + overflow-y: auto; + padding: var(--df-space-2); + display: flex; + flex-direction: column; + gap: var(--df-space-2); +} + +.chat-msg { + animation: chat-slide-up 0.15s ease-out; + max-width: 80%; + padding: var(--df-space-2) var(--df-space-3); + border-radius: var(--df-radius-md); + font-size: var(--df-font-size-sm); + line-height: 1.5; + word-break: break-word; +} + +.chat-msg.from-user { + align-self: flex-end; + background: color-mix(in srgb, var(--df-color-accent-default) 14%, var(--df-color-bg-raised)); + color: var(--df-color-accent-default); + border: 1px solid color-mix(in srgb, var(--df-color-accent-default) 35%, transparent); +} + +.chat-msg.from-llm { + align-self: flex-start; + background: var(--df-color-bg-raised); + border: 1px solid var(--df-color-border-default); + border-left: 3px solid var(--df-color-border-default); +} + +.chat-msg.from-system { + align-self: center; + background: transparent; + color: var(--df-color-text-muted); + font-size: var(--df-font-size-xs); + font-style: italic; + padding: var(--df-space-1) 0; +} + +.chat-msg-sender { + font-weight: 600; + font-size: var(--df-font-size-xs); + margin-bottom: 2px; +} + +.chat-msg-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--df-space-2); + margin-bottom: 4px; +} + +.chat-msg-header .chat-msg-sender { + margin-bottom: 0; +} + +.chat-msg-meta { + display: flex; + align-items: center; + gap: var(--df-space-2); + margin-bottom: 4px; +} + +.chat-pipe-meta { + margin-bottom: 0; + gap: var(--df-space-1); + flex-wrap: wrap; +} + +.chat-pipe-label { + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--df-color-text-secondary); +} + +.chat-msg-body { + white-space: pre-wrap; +} + +/* ── Markdown rendering ───────────────────────────────────────── */ +.chat-markdown { + white-space: normal; +} + +.chat-markdown p { + margin: 0 0 0.5em; +} +.chat-markdown p:last-child { + margin-bottom: 0; +} + +.chat-markdown h1, .chat-markdown h2, .chat-markdown h3, +.chat-markdown h4, .chat-markdown h5, .chat-markdown h6 { + margin: 0.6em 0 0.3em; + font-weight: 600; + line-height: 1.3; +} +.chat-markdown h1 { font-size: 1.3em; } +.chat-markdown h2 { font-size: 1.15em; } +.chat-markdown h3 { font-size: 1.05em; } + +.chat-markdown code { + background: var(--df-color-bg-raised, rgba(255,255,255,0.06)); + padding: 0.15em 0.35em; + border-radius: 3px; + font-family: var(--df-font-mono, monospace); + font-size: 0.88em; +} + +.chat-markdown pre, +.chat-markdown .chat-codeblock { + background: var(--df-color-bg-raised, rgba(255,255,255,0.06)); + border: 1px solid var(--df-color-border-subtle, rgba(255,255,255,0.08)); + border-radius: var(--df-radius-sm, 4px); + padding: var(--df-space-3, 12px); + margin: 0.5em 0; + overflow-x: auto; + white-space: pre; + position: relative; +} + +.chat-markdown pre code, +.chat-markdown .chat-codeblock code { + background: none; + padding: 0; + font-size: var(--df-font-size-sm, 13px); + line-height: 1.5; +} + +.chat-code-lang { + position: absolute; + top: 4px; + right: 8px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.4; + font-family: var(--df-font-mono, monospace); + pointer-events: none; +} + +.chat-markdown ul, .chat-markdown ol { + margin: 0.3em 0; + padding-left: 1.5em; +} + +.chat-markdown li { + margin: 0.15em 0; +} + +.chat-markdown blockquote { + border-left: 3px solid var(--df-color-accent-default, #60a5fa); + margin: 0.4em 0; + padding: 0.2em 0.8em; + opacity: 0.85; +} + +.chat-markdown table { + border-collapse: collapse; + margin: 0.5em 0; + font-size: 0.9em; + width: 100%; +} + +.chat-markdown th, .chat-markdown td { + border: 1px solid var(--df-color-border-subtle, rgba(255,255,255,0.1)); + padding: 0.3em 0.6em; + text-align: left; +} + +.chat-markdown th { + background: var(--df-color-bg-raised, rgba(255,255,255,0.04)); + font-weight: 600; +} + +.chat-markdown a { + color: var(--df-color-accent-default, #60a5fa); + text-decoration: none; +} +.chat-markdown a:hover { + text-decoration: underline; +} + +.chat-markdown strong { + font-weight: 600; +} + +.chat-markdown hr { + border: none; + border-top: 1px solid var(--df-color-border-subtle, rgba(255,255,255,0.1)); + margin: 0.6em 0; +} + +/* ── Mermaid chart rendering ──────────────────────────────────── */ +.chat-mermaid-pending { + padding: var(--df-space-3); + text-align: center; + color: var(--df-color-text-muted); + font-size: var(--df-font-size-xs); +} +.chat-mermaid-pending::after { + content: 'Rendering chart\2026'; +} + +.chat-mermaid-rendered { + margin: 0.5em 0; + padding: var(--df-space-3); + background: var(--df-color-bg-base, #1c2128); + border: 1px solid var(--df-color-border-subtle, rgba(255,255,255,0.08)); + border-radius: var(--df-radius-sm, 4px); + overflow-x: auto; + text-align: center; +} + +.chat-mermaid-rendered svg { + max-width: 100%; + height: auto; +} + +.chat-mention { + color: var(--df-color-accent-default, #60a5fa); + font-weight: 600; +} + +.chat-msg-time { + font-size: 10px; + opacity: 0.5; + margin-top: 2px; + text-align: right; +} + +.chat-msg-unresolved-warn { + font-size: 10px; + color: var(--df-color-warning, #f59e0b); + opacity: 0.85; + margin-top: 4px; + white-space: pre-line; +} + +/* ── New messages indicator ─────────────────────────────────────── */ +.chat-new-indicator { + text-align: center; + padding: var(--df-space-1); + cursor: pointer; + color: var(--df-color-accent-default); + font-size: var(--df-font-size-xs); + background: var(--df-color-bg-raised); + border-top: 1px solid var(--df-color-border-default); +} + +.chat-new-indicator.hidden { + display: none; +} + +/* ── Input area ─────────────────────────────────────────────────── */ +.chat-input-area { + flex-shrink: 0; + display: flex; + align-items: flex-end; + gap: var(--df-space-2); + padding: var(--df-space-3); + border-top: 1px solid var(--df-color-border-default); + background: var(--df-color-bg-raised); +} + +.chat-input { + flex: 1; + background: var(--df-color-bg-base); + border: 1px solid var(--df-color-border-default); + border-radius: var(--df-radius-md); + color: var(--df-color-text-primary); + font-family: var(--df-font-mono); + font-size: var(--df-font-size-sm); + padding: var(--df-space-2) var(--df-space-3); + outline: none; + resize: none; + min-height: 36px; + max-height: 120px; + overflow-y: auto; + line-height: 1.4; +} + +.chat-input:focus { + border-color: var(--df-color-accent-default); +} + +.chat-send-btn { + align-self: flex-end; + min-height: 36px; + padding-left: var(--df-space-4); + padding-right: var(--df-space-4); +} + +/* ── Rules editor ─────────────────────────────────────────────────── */ +.chat-rules-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--df-color-bg-base) 70%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: var(--df-z-index-overlay); + animation: df-fade-in var(--df-duration-fast) ease; +} + +.chat-rules-overlay.hidden { + display: none; +} + +.chat-rules-modal { + width: min(840px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + background: color-mix(in srgb, var(--df-color-bg-surface) 96%, transparent); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border: 1px solid var(--df-color-border-default); + border-radius: var(--df-radius-xl); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px color-mix(in srgb, var(--df-color-border-strong) 30%, transparent); + animation: df-slide-up var(--df-duration-base) var(--df-easing-spring); +} + +.chat-rules-header, +.chat-rules-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--df-space-3); + padding: var(--df-space-3) var(--df-space-5); +} + +.chat-rules-header { + border-bottom: 1px solid var(--df-color-border-subtle); +} + +.chat-rules-header h2 { + margin: 0; + font-family: var(--df-font-display); + font-size: var(--df-font-size-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: var(--df-letter-spacing-wider); + color: var(--df-color-text-primary); +} + +.chat-rules-desc { + margin: var(--df-space-1) 0 0; + color: var(--df-color-text-secondary); + font-size: var(--df-font-size-xs); + letter-spacing: var(--df-letter-spacing-normal); + line-height: var(--df-line-height-normal); +} + +.chat-rules-body { + padding: var(--df-space-4) var(--df-space-5); + display: flex; + flex-direction: column; + gap: var(--df-space-3); + overflow: auto; +} + +.chat-rules-note { + border: 1px solid color-mix(in srgb, var(--df-color-accent-default) 24%, transparent); + background: color-mix(in srgb, var(--df-color-accent-default) 10%, var(--df-color-bg-base)); + color: var(--df-color-text-secondary); + border-radius: var(--df-radius-md); + padding: var(--df-space-2) var(--df-space-3); + font-size: var(--df-font-size-xs); +} + +.chat-rules-status { + border-radius: var(--df-radius-md); + padding: var(--df-space-2) var(--df-space-3); + font-size: var(--df-font-size-xs); +} + +.chat-rules-status.info { + background: color-mix(in srgb, var(--df-color-accent-default) 12%, var(--df-color-bg-base)); + color: var(--df-color-text-secondary); +} + +.chat-rules-status.success { + background: color-mix(in srgb, var(--df-color-state-success) 16%, var(--df-color-bg-base)); + color: var(--df-color-state-success); +} + +.chat-rules-status.error { + background: color-mix(in srgb, var(--df-color-state-error) 16%, var(--df-color-bg-base)); + color: var(--df-color-state-error); +} + +.chat-rules-textarea { + width: 100%; + min-height: 360px; + background: var(--df-color-bg-raised); + border: 1px solid var(--df-color-border-default); + border-radius: var(--df-radius-md); + color: var(--df-color-text-primary); + font-family: var(--df-font-mono); + font-size: var(--df-font-size-sm); + line-height: 1.55; + padding: var(--df-space-3); + resize: vertical; + outline: none; + transition: border-color var(--df-duration-fast), box-shadow var(--df-duration-fast); +} + +.chat-rules-textarea:focus { + border-color: var(--df-color-accent-default); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--df-color-accent-default) 15%, transparent); +} + +.chat-rules-actions { + border-top: 1px solid var(--df-color-border-subtle); +} + +/* ── @mention autocomplete ──────────────────────────────────────── */ +.chat-mention-popup { + position: absolute; + bottom: 100%; + left: 0; + background: var(--df-color-bg-raised); + border: 1px solid var(--df-color-border-default); + border-radius: var(--df-radius-md); + padding: var(--df-space-1) 0; + min-width: 150px; + max-height: 200px; + overflow-y: auto; + box-shadow: 0 -2px 8px rgba(0,0,0,0.2); + z-index: 100; +} + +.chat-mention-popup.hidden { + display: none; +} + +.chat-mention-item { + padding: var(--df-space-1) var(--df-space-3); + cursor: pointer; + font-size: var(--df-font-size-sm); + font-family: var(--df-font-mono); +} + +.chat-mention-item:hover, +.chat-mention-item.selected { + background: color-mix(in srgb, var(--df-color-accent-default) 14%, var(--df-color-bg-raised)); + color: var(--df-color-accent-default); +} + +/* ── Pipe messages ─────────────────────────────────────────────── */ +.chat-pipe-output { + max-width: 80%; +} + +.chat-pipe-intermediate { + opacity: 1; +} + + +.chat-pipe-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: var(--df-font-size-xs); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + background: color-mix(in srgb, var(--df-color-accent-default) 18%, var(--df-color-bg-raised)); + color: var(--df-color-accent-default); + border: 1px solid color-mix(in srgb, var(--df-color-accent-default) 30%, transparent); + vertical-align: middle; +} + +.chat-pipe-chevron { + font-size: 9px; + color: var(--df-color-text-muted); + flex-shrink: 0; + transition: color 0.15s; +} + +.chat-pipe-toggle { + user-select: none; +} + +.chat-pipe-toggle:hover .chat-pipe-chevron { + color: var(--df-color-text-primary); +} + +.chat-pipe-toggle-open { + margin-bottom: var(--df-space-1); +} + +.chat-pipe-detail.hidden { + display: none; +} + +/* ── Pipe sidebar monitor ──────────────────────────────────────── */ +.chat-pipes-section { + margin-top: var(--df-space-3); + padding-top: var(--df-space-3); + border-top: 1px solid var(--df-color-border-default); +} + +.chat-pipes-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--df-space-2); + margin-bottom: var(--df-space-2); +} + +.chat-pipes-title { + display: flex; + align-items: center; + gap: var(--df-space-2); + font-size: var(--df-font-size-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--df-color-text-secondary); +} + +.chat-pipes-alert { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: var(--df-radius-full); + font-size: 10px; + font-weight: 600; + background: color-mix(in srgb, var(--df-color-danger, #ef4444) 18%, var(--df-color-bg-raised)); + color: var(--df-color-danger, #ef4444); + border: 1px solid color-mix(in srgb, var(--df-color-danger, #ef4444) 30%, transparent); +} + +.chat-pipes-alert.hidden { + display: none; +} + +.chat-pipes-toggle { + font-size: var(--df-font-size-xs); +} + +/* ── Pipe rows ─────────────────────────────────────────────────── */ +.chat-pipe-row { + border-bottom: 1px solid color-mix(in srgb, var(--df-color-border-default) 50%, transparent); +} + +.chat-pipe-row:last-child { + border-bottom: none; +} + +.chat-pipes-show-all { + width: 100%; + margin-top: var(--df-space-2); + justify-content: center; +} + +.chat-pipe-row-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: var(--df-space-1) 0; + background: none; + border: none; + color: var(--df-color-text-primary); + font-size: var(--df-font-size-xs); + cursor: pointer; + text-align: left; +} + +.chat-pipe-row-header:hover { + color: var(--df-color-accent-default); +} + +.chat-pipe-row-main { + display: flex; + align-items: center; + gap: var(--df-space-1); + min-width: 0; +} + +.chat-pipe-row-chevron { + font-size: 9px; + color: var(--df-color-text-muted); + flex-shrink: 0; + width: 10px; + text-align: center; +} + +.chat-pipe-row-badge { + font-family: var(--df-font-mono); + font-size: 10px; + font-weight: 600; + padding: 0 4px; + border-radius: 3px; + background: color-mix(in srgb, var(--df-color-accent-default) 14%, var(--df-color-bg-raised)); + color: var(--df-color-accent-default); +} + +.chat-pipe-row-mode { + color: var(--df-color-text-secondary); + font-size: 10px; + text-transform: lowercase; +} + +.chat-pipe-row-meta { + display: flex; + align-items: center; + gap: var(--df-space-1); + flex-shrink: 0; + font-size: 10px; +} + +.chat-pipe-row-status { + font-weight: 600; +} + +.chat-pipe-row-progress { + color: var(--df-color-text-muted); + font-family: var(--df-font-mono); +} + +/* ── Pipe detail (expanded) ────────────────────────────────────── */ +.chat-pipe-row-detail { + padding: var(--df-space-1) 0 var(--df-space-2) var(--df-space-3); + font-size: var(--df-font-size-xs); +} + +.chat-pipe-row-hint { + color: var(--df-color-text-muted); + font-style: italic; + font-size: var(--df-font-size-xs); + padding: 2px 0; + word-break: break-word; +} + +.chat-pipe-row-issue { + color: var(--df-color-danger, #ef4444); + font-size: 10px; + padding: 2px 0; + word-break: break-word; +} + +.chat-pipe-row-actions { + padding-top: var(--df-space-1); +} + +/* ── Pipe slot rows ────────────────────────────────────────────── */ +.chat-pipe-slot-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--df-space-1); + padding: 2px 0; + line-height: 1.4; +} + +.chat-pipe-slot-name { + color: var(--df-color-text-primary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-pipe-slot-state { + flex-shrink: 0; + color: var(--df-color-text-muted); + text-align: right; +} + +.chat-pipe-slot-submitted { + color: var(--df-color-success); +} + +.chat-pipe-slot-dead { + opacity: 0.7; +} + +.chat-pipe-slot-dead .chat-pipe-slot-state { + color: var(--df-color-danger, #ef4444); +} + +/* ── Lease countdown badges ────────────────────────────────────── */ +.pipe-lease-badge { + display: inline-block; + padding: 1px 5px; + border-radius: 3px; + font-family: var(--df-font-mono); + font-size: 10px; + font-weight: 600; + line-height: 1.4; +} + +.pipe-lease-badge.lease-ok { + background: color-mix(in srgb, var(--df-color-success) 16%, var(--df-color-bg-raised)); + color: var(--df-color-success); +} + +.pipe-lease-badge.lease-warn { + background: color-mix(in srgb, var(--df-color-warning, #f59e0b) 16%, var(--df-color-bg-raised)); + color: var(--df-color-warning, #f59e0b); +} + +.pipe-lease-badge.lease-critical { + background: color-mix(in srgb, var(--df-color-danger, #ef4444) 16%, var(--df-color-bg-raised)); + color: var(--df-color-danger, #ef4444); + animation: pipe-lease-pulse 1.2s ease-in-out infinite; +} + +.pipe-lease-badge.lease-overdue { + background: color-mix(in srgb, var(--df-color-danger, #ef4444) 24%, var(--df-color-bg-raised)); + color: var(--df-color-danger, #ef4444); + font-weight: 700; +} + +@keyframes pipe-lease-pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +/* ── Pipe timing drilldown ─────────────────────────────────────── */ +.chat-pipe-timing { + margin-top: var(--df-space-1); + padding-top: var(--df-space-1); + border-top: 1px solid color-mix(in srgb, var(--df-color-border-default) 40%, transparent); +} + +.chat-pipe-timing-header { + color: var(--df-color-text-secondary); + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 0; +} + +.chat-pipe-timing-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--df-space-1); + padding: 1px 0; +} + +.chat-pipe-timing-label { + color: var(--df-color-text-muted); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-pipe-timing-duration { + flex-shrink: 0; + font-family: var(--df-font-mono); + font-weight: 500; + color: var(--df-color-text-primary); +} + +/* ── Empty state ────────────────────────────────────────────────── */ +.chat-empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--df-color-text-muted); + gap: var(--df-space-2); +} + +.chat-empty-state .chat-empty-icon { + font-size: 48px; + opacity: 0.15; +} + +.chat-empty-state .chat-empty-hint { + font-size: var(--df-font-size-xs); + text-transform: none; + letter-spacing: normal; +} + +/* ── Responsive ─────────────────────────────────────────────────── */ +@media (max-width: 600px) { + .chat-members-panel { + display: none; + } + + .chat-rules-modal { + width: calc(100vw - 20px); + max-height: calc(100vh - 20px); + } + + .chat-rules-header, + .chat-rules-actions { + padding: var(--df-space-3); + } + + .chat-rules-actions { + flex-direction: column; + align-items: stretch; + } +} + +/* ── Brainstorm action panel ─────────────────────────────────────────────── */ + +.brainstorm-actions { + display: flex; + gap: var(--df-space-2); + margin-top: var(--df-space-2); + flex-wrap: wrap; +} + +/* Brainstorm buttons inherit .btn / .btn-sm / .btn-primary etc. from the + global style guide (style.css). No local overrides needed. */ diff --git a/src/apps/chat/public/page.js b/src/apps/chat/public/page.js new file mode 100644 index 0000000..e922839 --- /dev/null +++ b/src/apps/chat/public/page.js @@ -0,0 +1,2066 @@ +// ── Chat App — Page Module ──────────────────────────────────────── +// ES module that exports mount(container, ctx), unmount(container), +// and onProjectChange(project). + +import { escapeHtml, escapeAttr, sanitizeHtml } from '/shared-assets/ui-utils.js'; +import { dashboardSocket } from '/state.js'; +import { createHeader } from '/shared-ui/components/header.js'; +import { confirmModal } from '/shared-ui/components/modal.js'; +import { formatRecipientHeader, getMentionMatches, getPipeAssigneeMatches } from './mention-suggestions.js'; +import { DEFAULT_VISIBLE_TERMINAL, getVisiblePipeSummaries } from './pipe-visibility.js'; + +let _container = null; +let _socket = null; +let _members = []; +let _messages = []; +let _pipeEvents = []; +let _pipeSummaries = []; +let _pipeLeases = []; +let _pipeDeadLetters = []; +let _pipeStatusById = {}; +let _pipeStatusLoading = new Set(); +let _pipeTimingById = {}; +let _pipeTimingLoading = new Set(); +let _pipesCollapsed = false; +let _expandedPipeId = null; +let _showAllPipes = false; +let _pipesPollTimer = null; +let _leaseTickTimer = null; +let _autoScroll = true; +let _mentionIdx = -1; +let _voiceHandler = null; +let _rulesDraft = ''; +let _rulesLoaded = false; +let _tooltipTarget = null; +let _brainstorms = {}; // brainstormId -> { phase, ... } + +// Pipe slash-command state +const PIPE_COMMANDS = [ + { name: '/linear-pipe', hint: 'min 2 assignees', description: 'Sequential processing chain' }, + { name: '/merge-pipe', hint: 'min 3 assignees', description: 'Parallel fan-out + synthesizer' }, + { name: '/merge-all-pipe', hint: 'min 2 assignees', description: 'Parallel fan-out (all) + synthesizer' }, + { name: '/explain', hint: 'defaults to active LLMs', description: 'Teaching-oriented multi-LLM explanation' }, + { name: '/summarize', hint: 'defaults to active LLMs', description: 'Concise multi-LLM digest for long topics' }, + { name: '/brainstorm', hint: 'defaults to active LLMs', description: 'Multi-phase brainstorm: ideate → detail → finalize' }, +]; +let _popupMode = 'none'; // 'none' | 'command' | 'mention' + +const DRAFT_KEY_PREFIX = 'devglide-chat-draft'; +let _projectId = null; +let _markedReady = false; +let _mermaidReady = false; +let _mermaidFailed = false; +let _mermaidIdCounter = 0; +const PIPE_POLL_INTERVAL_MS = 8_000; + +function draftKey(projectId) { + return projectId ? `${DRAFT_KEY_PREFIX}:${projectId}` : DRAFT_KEY_PREFIX; +} + + +function loadScript(src, globalName) { + if (window[globalName]) return Promise.resolve(); + return new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${src}"]`); + if (existing) { + if (window[globalName]) { resolve(); return; } + existing.addEventListener('load', resolve, { once: true }); + existing.addEventListener('error', reject, { once: true }); + return; + } + const el = document.createElement('script'); + el.src = src; + el.onload = resolve; + el.onerror = reject; + document.head.appendChild(el); + }); +} + +function initMarked() { + if (typeof marked === 'undefined' || !marked.use) return; + const dangerousUrlRe = /^\s*(javascript|vbscript|data)\s*:/i; + marked.use({ + breaks: true, + renderer: { + html({ text }) { return escapeHtml(text); }, + code({ text, lang }) { + // Mermaid blocks: emit a placeholder that renderMermaidBlocks() will process + // after sanitization (since sanitizeHtml strips SVG). + if (lang === 'mermaid') { + const encoded = escapeAttr(text); + return `
`; + } + const escaped = escapeHtml(text); + const langClass = lang ? ` class="language-${escapeAttr(lang)}"` : ''; + const langLabel = lang ? `${escapeHtml(lang)}` : ''; + return `
${langLabel}${escaped}
`; + }, + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + if (dangerousUrlRe.test(href)) return text; + const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''; + return `${text}`; + }, + image({ href, title, text }) { + if (dangerousUrlRe.test(href)) return escapeHtml(text); + const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''; + return `${escapeAttr(text)}`; + }, + }, + }); + _markedReady = true; +} + +/** Render a message body as sanitized markdown HTML, preserving @mention highlights. */ +function renderMarkdown(text) { + if (!_markedReady || !text) return escapeHtml(text || ''); + try { + const html = sanitizeHtml(marked.parse(text)); + // Highlight @mentions in text nodes only (not inside attributes or code blocks) + const doc = new DOMParser().parseFromString(`
${html}
`, 'text/html'); + const SKIP_TAGS = new Set(['CODE', 'PRE', 'SCRIPT', 'STYLE']); + const walker = doc.createTreeWalker(doc.body.firstChild, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + let parent = node.parentElement; + while (parent && parent !== doc.body.firstChild) { + if (SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; + parent = parent.parentElement; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + const textNodes = []; + let n; + while ((n = walker.nextNode())) textNodes.push(n); + const mentionRe = /@([\w-]+)/g; + for (const tNode of textNodes) { + const val = tNode.nodeValue; + if (!mentionRe.test(val)) continue; + mentionRe.lastIndex = 0; + const frag = doc.createDocumentFragment(); + let lastIdx = 0; + let m; + while ((m = mentionRe.exec(val)) !== null) { + if (m.index > lastIdx) frag.appendChild(doc.createTextNode(val.slice(lastIdx, m.index))); + const span = doc.createElement('span'); + span.className = 'chat-mention'; + span.textContent = m[0]; + frag.appendChild(span); + lastIdx = m.index + m[0].length; + } + if (lastIdx < val.length) frag.appendChild(doc.createTextNode(val.slice(lastIdx))); + tNode.parentNode.replaceChild(frag, tNode); + } + return doc.body.firstChild.innerHTML; + } catch { + return escapeHtml(text); + } +} + +/** Initialize Mermaid with DevGlide dark theme. */ +function initMermaid() { + if (typeof mermaid === 'undefined' || !mermaid.initialize) return; + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'dark', + themeVariables: { + darkMode: true, + background: 'transparent', + primaryColor: '#1a3a3a', + primaryTextColor: '#adbac7', + primaryBorderColor: '#00afaf', + secondaryColor: '#2d333b', + secondaryTextColor: '#adbac7', + secondaryBorderColor: '#373e47', + tertiaryColor: '#22272e', + tertiaryTextColor: '#adbac7', + lineColor: '#00afaf', + textColor: '#adbac7', + mainBkg: '#1a3a3a', + nodeBorder: '#00afaf', + clusterBkg: '#22272e', + clusterBorder: '#373e47', + titleColor: '#adbac7', + edgeLabelBackground: '#2d333b', + nodeTextColor: '#adbac7', + }, + flowchart: { curve: 'basis', padding: 12 }, + fontFamily: 'var(--df-font-mono, monospace)', + fontSize: 13, + }); + _mermaidReady = true; +} + +/** + * Find all pending mermaid placeholders in a container and render them. + * Placeholders are
+ * produced by the marked renderer. This runs AFTER sanitizeHtml so the + * generated SVG is never stripped. + */ +async function renderMermaidBlocks(root) { + if (!_mermaidReady) { + // If mermaid load already failed, fall back immediately for new messages + if (_mermaidFailed) { + const pending = (root || _container)?.querySelectorAll('.chat-mermaid-pending'); + if (pending?.length) { + for (const el of pending) { + const src = el.getAttribute('data-mermaid-src') || ''; + const pre = document.createElement('pre'); + pre.className = 'chat-codeblock'; + const langLabel = document.createElement('span'); + langLabel.className = 'chat-code-lang'; + langLabel.textContent = 'mermaid'; + const code = document.createElement('code'); + code.className = 'language-mermaid'; + code.textContent = src; + pre.appendChild(langLabel); + pre.appendChild(code); + el.replaceWith(pre); + } + } + } + return; + } + const pending = (root || _container)?.querySelectorAll('.chat-mermaid-pending'); + if (!pending?.length) return; + + for (const el of pending) { + const src = el.getAttribute('data-mermaid-src'); + if (!src) continue; + const id = `chat-mermaid-${_mermaidIdCounter++}`; + try { + const { svg } = await mermaid.render(id, src); + el.className = 'chat-mermaid-rendered'; + el.removeAttribute('data-mermaid-src'); + el.innerHTML = svg; + } catch { + // Fallback: show source as a regular code block + el.className = ''; + const pre = document.createElement('pre'); + pre.className = 'chat-codeblock'; + const langLabel = document.createElement('span'); + langLabel.className = 'chat-code-lang'; + langLabel.textContent = 'mermaid'; + const code = document.createElement('code'); + code.className = 'language-mermaid'; + code.textContent = src; + pre.appendChild(langLabel); + pre.appendChild(code); + el.replaceWith(pre); + } + } +} + +/** + * Convert all pending mermaid placeholders to code-block fallbacks. + * Called when mermaid.js fails to load (CDN blocked, offline, CSP, etc.). + */ +function fallbackAllMermaidBlocks() { + const pending = _container?.querySelectorAll('.chat-mermaid-pending'); + if (!pending?.length) return; + for (const el of pending) { + const src = el.getAttribute('data-mermaid-src') || ''; + const pre = document.createElement('pre'); + pre.className = 'chat-codeblock'; + const langLabel = document.createElement('span'); + langLabel.className = 'chat-code-lang'; + langLabel.textContent = 'mermaid'; + const code = document.createElement('code'); + code.className = 'language-mermaid'; + code.textContent = src; + pre.appendChild(langLabel); + pre.appendChild(code); + el.replaceWith(pre); + } +} + +// ── Participant colors ────────────────────────────────────────────── +// Distinct hues keyed by visible pane number so participant colors stay +// stable across refreshes and independent of join order. +// Panes 1–9 use hand-picked colors; higher pane numbers get a generated +// HSL color via golden-angle spacing (~137.5°) for maximum hue separation. + +const PANE_COLORS = new Map([ + [1, '#60a5fa'], // blue + [2, '#f472b6'], // pink + [3, '#34d399'], // emerald + [4, '#fb923c'], // orange + [5, '#a78bfa'], // violet + [6, '#22d3ee'], // cyan + [7, '#fbbf24'], // amber + [8, '#e879f9'], // fuchsia + [9, '#f87171'], // red +]); +const DEFAULT_PARTICIPANT_COLOR = 'var(--df-color-text-muted)'; + +function getGeneratedParticipantColor(paneNumber) { + const hue = (paneNumber * 137.508) % 360; + return `hsl(${hue}, 70%, 65%)`; +} + +function getParticipantColor(participant) { + const paneNumber = participant?.paneNum ?? null; + if (!paneNumber) return DEFAULT_PARTICIPANT_COLOR; + return PANE_COLORS.get(paneNumber) ?? getGeneratedParticipantColor(paneNumber); +} + +function findParticipant(name) { + return _members.find((member) => member.name === name) ?? { name, paneId: null }; +} + +function createMemberStatusIndicator(state) { + const indicator = document.createElement('span'); + indicator.className = `chat-member-status ${state}`; + const tooltip = state === 'working' ? 'Working' : 'Idle'; + indicator.dataset.chatTooltip = tooltip; + indicator.setAttribute('aria-label', tooltip); + indicator.innerHTML = state === 'working' + ? `` + : ``; + return indicator; +} + +function getModeTitle(mode) { + return mode === 'auto-accept' + ? 'Auto mode: no approval prompts' + : mode === 'unrestricted' + ? 'Unrestricted mode: all permission checks bypassed' + : 'Safe mode: approval prompts enabled'; +} + +function getModeIconSvg(mode) { + if (mode === 'supervised') { + return ` + `; + } + + return ` + `; +} + +function createMemberModeIndicator(mode) { + const indicator = document.createElement('span'); + indicator.className = `chat-member-badge ${mode}`; + const tooltip = getModeTitle(mode); + indicator.dataset.chatTooltip = tooltip; + indicator.setAttribute('aria-label', tooltip); + indicator.innerHTML = getModeIconSvg(mode); + return indicator; +} + +function createMemberDetachedIndicator() { + const indicator = document.createElement('span'); + indicator.className = 'chat-member-status detached'; + const tooltip = 'Detached: MCP session closed, waiting for reclaim'; + indicator.dataset.chatTooltip = tooltip; + indicator.setAttribute('aria-label', tooltip); + indicator.innerHTML = ``; + return indicator; +} + +// ── Custom tooltip ────────────────────────────────────────────────── + +function showTooltip(target) { + const el = _container?.querySelector('#chat-tooltip'); + const text = target?.dataset?.chatTooltip; + if (!el || !text) return; + + el.textContent = text; + el.classList.remove('hidden'); + + const rect = target.getBoundingClientRect(); + const tipRect = el.getBoundingClientRect(); + const gap = 6; + let top = rect.top - tipRect.height - gap; + let left = rect.left + (rect.width / 2) - (tipRect.width / 2); + + if (top < gap) top = rect.bottom + gap; + left = Math.max(gap, Math.min(left, window.innerWidth - tipRect.width - gap)); + + el.style.top = `${top}px`; + el.style.left = `${left}px`; + _tooltipTarget = target; +} + +function hideTooltip() { + const el = _container?.querySelector('#chat-tooltip'); + if (!el) return; + el.classList.add('hidden'); + el.textContent = ''; + _tooltipTarget = null; +} + +function onTooltipOver(e) { + const target = e.target?.closest?.('[data-chat-tooltip]'); + if (!target || target === _tooltipTarget) return; + showTooltip(target); +} + +function onTooltipOut(e) { + const target = e.target?.closest?.('[data-chat-tooltip]'); + if (!target) return; + if (target.contains(e.relatedTarget)) return; + if (_tooltipTarget === target) hideTooltip(); +} + +// ── API helpers ───────────────────────────────────────────────────── + +async function api(path, opts) { + let scopedPath = path; + if (_projectId && !/[?&]projectId=/.test(path)) { + const [basePath, hash = ''] = path.split('#'); + const joiner = basePath.includes('?') ? '&' : '?'; + scopedPath = `${basePath}${joiner}projectId=${encodeURIComponent(_projectId)}${hash ? `#${hash}` : ''}`; + } + + return fetch('/api/chat' + scopedPath, { + headers: { 'Content-Type': 'application/json' }, + ...opts, + }); +} + +async function parseJsonSafely(response) { + try { + return await response.json(); + } catch { + return null; + } +} + +function mergeById(existing, incoming) { + const map = new Map(); + for (const item of existing || []) { + if (item?.id) map.set(item.id, item); + } + for (const item of incoming || []) { + if (item?.id) map.set(item.id, item); + } + return [...map.values()]; +} + +function normalizePipeEvent(event) { + if (!event || !event.pipeId) return null; + const fallbackId = [ + 'pipe', + event.pipeId, + event.type || 'event', + event.role || event.actionType || event.assignee || event.from || 'ui', + event.stage ?? '', + ].filter(Boolean).join('-'); + return { + ...event, + id: event.id || fallbackId, + ts: event.ts || new Date().toISOString(), + }; +} + +function getTimelineEntries() { + return [ + ..._messages.map(msg => ({ kind: 'message', id: msg.id, ts: msg.ts || '', payload: msg })), + ..._pipeEvents.map(event => ({ kind: 'pipe', id: event.id, ts: event.ts || '', payload: event })), + ].sort((a, b) => a.ts.localeCompare(b.ts) || a.id.localeCompare(b.id)); +} + +// ── HTML ──────────────────────────────────────────────────────────── + +const BODY_HTML = ` + ${createHeader({ + brand: 'Chat', + meta: '', + actions: ` + + + `, + })} + +
+
+
+
Members (0)
+
+
+
+
+
+ Pipes (0) + +
+ +
+
+
+
+ +
+
+ +
+ + + +
+
+
+ + + + + + + + +`; + +// ── Socket setup ──────────────────────────────────────────────────── +// Reuse the shared dashboard socket (same default namespace used by shell, +// dashboard, etc.) instead of opening a separate connection. + +function connectSocket() { + if (_socket) return; + _socket = dashboardSocket; + + _socket.on('chat:members', onMembers); + _socket.on('chat:join', onJoin); + _socket.on('chat:leave', onLeave); + _socket.on('chat:message', onMessage); + _socket.on('chat:cleared', onCleared); + _socket.on('chat:pipe', handlePipeEvent); + _socket.on('chat:error', onError); +} + +function disconnectSocket() { + if (_socket) { + _socket.off('chat:members', onMembers); + _socket.off('chat:join', onJoin); + _socket.off('chat:leave', onLeave); + _socket.off('chat:message', onMessage); + _socket.off('chat:cleared', onCleared); + _socket.off('chat:pipe', handlePipeEvent); + _socket.off('chat:error', onError); + // Don't disconnect — shared socket, other pages need it + _socket = null; + } +} + +// ── Brainstorm action panel ────────────────────────────────────────────────── + +function buildBrainstormActions(brainstormId, phase) { + const panel = document.createElement('div'); + panel.className = 'brainstorm-actions'; + panel.dataset.brainstormId = brainstormId; + + if (phase === 'ideas_review') { + panel.appendChild(makeBsBtn('Accept Idea', 'accept', () => brainstormAction(brainstormId, 'accept-idea'))); + panel.appendChild(makeBsBtn('Retry', 'retry', () => brainstormAction(brainstormId, 'retry-ideas'))); + panel.appendChild(makeBsBtn('Retry with Note', 'note', () => brainstormActionWithNote(brainstormId, 'retry-ideas'))); + } else if (phase === 'details_review') { + panel.appendChild(makeBsBtn('Finalize', 'accept', () => brainstormAction(brainstormId, 'finalize'))); + panel.appendChild(makeBsBtn('Adjust', 'note', () => brainstormActionWithNote(brainstormId, 'adjust-details'))); + panel.appendChild(makeBsBtn('Back to Ideas', 'retry', () => brainstormAction(brainstormId, 'back-to-ideas'))); + } + return panel; +} + +function makeBsBtn(label, variant, onClick) { + const btn = document.createElement('button'); + const variantClass = { accept: 'btn-primary', note: 'btn-secondary', retry: 'btn-ghost' }[variant] || 'btn-secondary'; + btn.className = `btn btn-sm ${variantClass}`; + btn.textContent = label; + btn.addEventListener('click', async () => { + const result = await onClick(); + if (result === false) return; // cancelled — keep buttons alive + const panel = btn.closest('.brainstorm-actions'); + if (panel) panel.querySelectorAll('button').forEach(b => { b.disabled = true; }); + }); + return btn; +} + +async function brainstormAction(brainstormId, action) { + try { + await api(`/brainstorms/${brainstormId}/${action}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + } catch { /* socket events will update the UI */ } +} + +function openNoteModal() { + return new Promise((resolve) => { + const overlay = _container?.querySelector('#chat-note-overlay'); + const textarea = _container?.querySelector('#chat-note-textarea'); + const submitBtn = _container?.querySelector('#chat-note-submit'); + const cancelBtn = _container?.querySelector('#chat-note-cancel'); + if (!overlay || !textarea) { resolve(null); return; } + + textarea.value = ''; + overlay.classList.remove('hidden'); + textarea.focus(); + + function cleanup() { + overlay.classList.add('hidden'); + submitBtn?.removeEventListener('click', onSubmit); + cancelBtn?.removeEventListener('click', onCancel); + overlay.removeEventListener('click', onBackdrop); + } + function onSubmit() { cleanup(); resolve(textarea.value || null); } + function onCancel() { cleanup(); resolve(undefined); } // undefined = cancelled + function onBackdrop(e) { if (e.target === overlay) { cleanup(); resolve(undefined); } } + + submitBtn?.addEventListener('click', onSubmit); + cancelBtn?.addEventListener('click', onCancel); + overlay.addEventListener('click', onBackdrop); + }); +} + + +async function brainstormActionWithNote(brainstormId, action) { + const note = await openNoteModal(); + if (note === undefined) return false; // user cancelled — keep buttons alive + try { + await api(`/brainstorms/${brainstormId}/${action}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: note || null }), + }); + } catch { /* socket events will update the UI */ } +} + +function getPipeTag(pipeId) { + return `#pipe-${pipeId}`; +} + +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function stripLeadingPipeTag(text, pipeId) { + if (!text || !pipeId) return text || ''; + const pattern = new RegExp(`^\\s*${escapeRegExp(getPipeTag(pipeId))}\\s*`, 'i'); + const stripped = text.replace(pattern, ''); + return stripped || text; +} + +function createSenderEl(senderText, color) { + const sender = document.createElement('div'); + sender.className = 'chat-msg-sender'; + sender.style.color = color; + sender.textContent = senderText; + return sender; +} + +function buildPipeMetaEl(pipeId, label) { + const meta = document.createElement('div'); + meta.className = 'chat-msg-meta chat-pipe-meta'; + + const badge = document.createElement('span'); + badge.className = 'chat-pipe-badge'; + badge.textContent = getPipeTag(pipeId); + meta.appendChild(badge); + + if (label) { + const labelEl = document.createElement('span'); + labelEl.className = 'chat-pipe-label'; + labelEl.textContent = label; + meta.appendChild(labelEl); + } + + return meta; +} + +function getPipeOutputLabel(role, stage) { + if (role === 'fan-out') return 'Fan-out output'; + if (stage) return `Stage ${stage} output`; + return 'Intermediate output'; +} + +function buildPipeHeaderEl(senderText, color, pipeId, label) { + const header = document.createElement('div'); + header.className = 'chat-msg-header'; + header.appendChild(createSenderEl(senderText, color)); + header.appendChild(buildPipeMetaEl(pipeId, label)); + return header; +} + +function buildPipeOutputEl({ id, from, to = null, pipeId, label, content, ts, color, extraClass = '', collapsible = false }) { + const el = document.createElement('div'); + el.className = ['chat-msg', 'from-llm', 'chat-pipe-output', extraClass].filter(Boolean).join(' '); + el.dataset.id = id; + el.style.borderLeftColor = color; + + const header = buildPipeHeaderEl(formatRecipientHeader(from || 'system', to), color, pipeId, label); + + const body = document.createElement('div'); + body.className = 'chat-msg-body chat-markdown'; + body.innerHTML = renderMarkdown(stripLeadingPipeTag(content || '', pipeId)); + + const time = document.createElement('div'); + time.className = 'chat-msg-time'; + time.textContent = formatTime(ts); + + if (collapsible) { + const chevron = document.createElement('span'); + chevron.className = 'chat-pipe-chevron'; + chevron.textContent = '\u25B6'; + header.prepend(chevron); + header.classList.add('chat-pipe-toggle'); + header.style.cursor = 'pointer'; + + const detail = document.createElement('div'); + detail.className = 'chat-pipe-detail hidden'; + detail.appendChild(body); + detail.appendChild(time); + + header.addEventListener('click', () => { + const open = detail.classList.toggle('hidden'); + chevron.textContent = open ? '\u25B6' : '\u25BC'; + header.classList.toggle('chat-pipe-toggle-open', !open); + }); + + el.appendChild(header); + el.appendChild(detail); + } else { + el.appendChild(header); + el.appendChild(body); + el.appendChild(time); + } + + return el; +} + +function appendRenderedPipeEventEl(event, doScroll = true) { + if (!event || event.type !== 'stage-output' || !event.pipeId) return; + + const listEl = _container?.querySelector('#chat-messages-list'); + if (!listEl) return; + + const empty = listEl.querySelector('.chat-empty-state'); + if (empty) empty.remove(); + + const color = getParticipantColor(findParticipant(event.from)); + const el = buildPipeOutputEl({ + id: event.id, + from: event.from, + pipeId: event.pipeId, + label: getPipeOutputLabel(event.role, event.stage), + content: event.content, + ts: event.ts, + color, + extraClass: 'chat-pipe-intermediate', + collapsible: true, + }); + + listEl.appendChild(el); + renderMermaidBlocks(el); + + if (doScroll) { + if (_autoScroll) { + scrollToBottom(); + } else { + showNewIndicator(); + } + } +} + +function appendPipeEventTimelineEl(event, doScroll = true) { + appendRenderedPipeEventEl(event, doScroll); +} + +function handlePipeEvent(event) { + const normalized = normalizePipeEvent(event); + if (!normalized) return; + if (_pipeEvents.some(existing => existing.id === normalized.id)) return; + _pipeEvents.push(normalized); + appendPipeEventTimelineEl(normalized); + if (['start', 'complete', 'failed', 'cancel'].includes(normalized.type)) { + fetchPipes(); + } +} + +function onMembers(members) { + _members = members; + renderMembers(); +} + +function onJoin(participant) { + const existing = _members.findIndex(m => m.name === participant.name); + if (existing >= 0) _members[existing] = participant; + else _members.push(participant); + renderMembers(); +} + +function onLeave({ name }) { + _members = _members.filter(m => m.name !== name); + renderMembers(); +} + +function onMessage(msg) { + // Deduplicate by id + if (_messages.some(m => m.id === msg.id)) return; + _messages.push(msg); + appendMessageEl(msg); +} + +function onError(payload) { + if (!payload?.error) return; + onMessage({ + id: `local-error-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ts: new Date().toISOString(), + from: 'system', + to: null, + body: payload.error, + type: 'system', + }); +} + +function onCleared() { + _messages = []; + _pipeEvents = []; + renderAllMessages(); +} + +function getPipeShortId(pipeId) { + return `#${String(pipeId || '').slice(0, 8)}`; +} + +function getPipeStatusSymbol(status) { + if (status === 'running') return 'O'; + if (status === 'completed') return 'OK'; + if (status === 'failed') return 'ERR'; + if (status === 'cancelled') return 'X'; + return '?'; +} + +function formatPipeCountdown(ms) { + if (ms == null) return 'leased'; + if (ms <= 0) return 'overdue'; + const totalSeconds = Math.ceil(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`; + return `${seconds}s`; +} + +function formatDurationMs(ms) { + if (ms == null) return '--'; + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`; + return `${seconds}s`; +} + +function renderPipeAlert() { + const alertEl = _container?.querySelector('#chat-pipes-alert'); + if (!alertEl) return; + if (_pipeDeadLetters.length === 0) { + alertEl.textContent = ''; + alertEl.classList.add('hidden'); + return; + } + alertEl.textContent = `! ${_pipeDeadLetters.length}`; + alertEl.classList.remove('hidden'); +} + +function getPipeSummary(pipeId) { + return _pipeSummaries.find(pipe => pipe.pipeId === pipeId) ?? null; +} + +function getPipeLeases(pipeId) { + return _pipeLeases.filter(lease => lease.pipeId === pipeId); +} + +function getPipeDeadLetters(pipeId) { + return _pipeDeadLetters.filter(entry => entry.pipeId === pipeId); +} + +function getPipeSlotStatus(slot, leases, deadLetters) { + const deadLetter = deadLetters.find(entry => entry.assignee === slot.assignee && entry.role === slot.role && entry.stage === slot.stage); + if (deadLetter) return deadLetter.status; + + if (slot.status === 'submitted') { + return slot.submittedAt ? `submitted ${formatTime(slot.submittedAt)}` : 'submitted'; + } + + const lease = leases.find(entry => entry.assignee === slot.assignee && entry.slotRole === slot.role && entry.stage === slot.stage); + if (lease) return formatPipeCountdown(lease.remainingMs); + return slot.status; +} + +function buildPipeSlotRow(slot, leases, deadLetters) { + const row = document.createElement('div'); + row.className = 'chat-pipe-slot-row'; + + const deadLetter = deadLetters.find(entry => entry.assignee === slot.assignee && entry.role === slot.role && entry.stage === slot.stage); + if (deadLetter) row.classList.add('chat-pipe-slot-dead'); + + const slotLabel = document.createElement('span'); + slotLabel.className = 'chat-pipe-slot-name'; + const stageLabel = slot.stage ? ` stage ${slot.stage}` : ''; + slotLabel.textContent = `${slot.assignee} (${slot.role}${stageLabel})`; + + const slotState = document.createElement('span'); + slotState.className = 'chat-pipe-slot-state'; + + const lease = !deadLetter && slot.status !== 'submitted' + ? leases.find(entry => entry.assignee === slot.assignee && entry.slotRole === slot.role && entry.stage === slot.stage) + : null; + + if (lease?.deadline) { + // Live countdown badge + slotState.classList.add('pipe-lease-badge'); + slotState.dataset.deadline = String(new Date(lease.deadline).getTime()); + const pipe = getPipeSummary(lease.pipeId); + slotState.dataset.timeout = String(pipe?.stageTimeoutMs ?? 0); + const remainingMs = new Date(lease.deadline).getTime() - Date.now(); + slotState.textContent = formatPipeCountdown(remainingMs); + // Initial color class + const pct = pipe?.stageTimeoutMs ? remainingMs / pipe.stageTimeoutMs : (remainingMs > 0 ? 1 : 0); + if (remainingMs <= 0) slotState.classList.add('lease-overdue'); + else if (pct < 0.25) slotState.classList.add('lease-critical'); + else if (pct < 0.5) slotState.classList.add('lease-warn'); + else slotState.classList.add('lease-ok'); + } else { + slotState.textContent = getPipeSlotStatus(slot, leases, deadLetters); + if (slot.status === 'submitted') slotState.classList.add('chat-pipe-slot-submitted'); + } + + row.appendChild(slotLabel); + row.appendChild(slotState); + return row; +} + +function buildPipeTimingEl(timing) { + const section = document.createElement('div'); + section.className = 'chat-pipe-timing'; + + const header = document.createElement('div'); + header.className = 'chat-pipe-timing-header'; + header.textContent = `Total: ${formatDurationMs(timing.totalDurationMs)}`; + section.appendChild(header); + + if (timing.stages?.length > 0) { + for (const stage of timing.stages) { + const row = document.createElement('div'); + row.className = 'chat-pipe-timing-row'; + + const label = document.createElement('span'); + label.className = 'chat-pipe-timing-label'; + const stageLabel = stage.stage != null ? `stage ${stage.stage}` : stage.role; + label.textContent = `${stage.assignee} (${stageLabel})`; + + const dur = document.createElement('span'); + dur.className = 'chat-pipe-timing-duration'; + dur.textContent = formatDurationMs(stage.durationMs); + + row.appendChild(label); + row.appendChild(dur); + section.appendChild(row); + } + } + + return section; +} + +function buildPipeDetailEl(pipe) { + const detail = document.createElement('div'); + detail.className = 'chat-pipe-row-detail'; + + const detailState = _pipeStatusById[pipe.pipeId]; + const deadLetters = getPipeDeadLetters(pipe.pipeId); + + if (!detailState && _pipeStatusLoading.has(pipe.pipeId)) { + const loading = document.createElement('div'); + loading.className = 'chat-pipe-row-hint'; + loading.textContent = 'Loading details...'; + detail.appendChild(loading); + } else if (detailState?.prompt) { + const prompt = document.createElement('div'); + prompt.className = 'chat-pipe-row-hint'; + prompt.textContent = detailState.prompt; + detail.appendChild(prompt); + } + + const slots = detailState?.slots ?? []; + const leases = detailState?.leases ?? getPipeLeases(pipe.pipeId); + if (slots.length > 0) { + for (const slot of slots) { + detail.appendChild(buildPipeSlotRow(slot, leases, deadLetters)); + } + } else { + const hint = document.createElement('div'); + hint.className = 'chat-pipe-row-hint'; + hint.textContent = pipe.status === 'running' ? 'Expand to inspect pipe state.' : 'No slot detail loaded.'; + detail.appendChild(hint); + } + + if (deadLetters.length > 0) { + const issue = document.createElement('div'); + issue.className = 'chat-pipe-row-issue'; + issue.textContent = deadLetters.map(entry => `${entry.assignee}: ${entry.reason}`).join(' | '); + detail.appendChild(issue); + } + + // Timing drilldown for terminal pipes + const timing = _pipeTimingById[pipe.pipeId]; + if (pipe.status !== 'running' && timing) { + detail.appendChild(buildPipeTimingEl(timing)); + } else if (pipe.status !== 'running' && _pipeTimingLoading.has(pipe.pipeId)) { + const loadingTiming = document.createElement('div'); + loadingTiming.className = 'chat-pipe-row-hint'; + loadingTiming.textContent = 'Loading timing...'; + detail.appendChild(loadingTiming); + } + + if (pipe.status === 'running') { + const actionRow = document.createElement('div'); + actionRow.className = 'chat-pipe-row-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn btn-ghost btn-sm'; + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Cancel pipe'; + cancelBtn.addEventListener('click', async (event) => { + event.stopPropagation(); + const confirmed = await confirmModal(_container, { + title: 'Cancel pipe?', + message: `Cancel pipe ${escapeHtml(getPipeShortId(pipe.pipeId))}? This will release all leases and stop pending stages.`, + confirmLabel: 'Cancel pipe', + confirmCls: 'btn-danger', + }); + if (!confirmed) return; + cancelBtn.disabled = true; + try { + await api(`/pipes/${pipe.pipeId}/cancel`, { method: 'POST' }); + await fetchPipes(); + } finally { + cancelBtn.disabled = false; + } + }); + + actionRow.appendChild(cancelBtn); + detail.appendChild(actionRow); + } + + return detail; +} + +function buildPipeRowEl(pipe) { + const row = document.createElement('div'); + row.className = 'chat-pipe-row'; + row.dataset.pipeId = pipe.pipeId; + + const header = document.createElement('button'); + header.type = 'button'; + header.className = 'chat-pipe-row-header'; + header.setAttribute('aria-expanded', String(_expandedPipeId === pipe.pipeId)); + + const left = document.createElement('span'); + left.className = 'chat-pipe-row-main'; + + const chevron = document.createElement('span'); + chevron.className = 'chat-pipe-row-chevron'; + chevron.textContent = _expandedPipeId === pipe.pipeId ? 'v' : '>'; + + const badge = document.createElement('span'); + badge.className = 'chat-pipe-row-badge'; + badge.textContent = getPipeShortId(pipe.pipeId); + + const mode = document.createElement('span'); + mode.className = 'chat-pipe-row-mode'; + mode.textContent = pipe.mode; + + left.appendChild(chevron); + left.appendChild(badge); + + left.appendChild(mode); + + const right = document.createElement('span'); + right.className = 'chat-pipe-row-meta'; + + const status = document.createElement('span'); + status.className = 'chat-pipe-row-status'; + status.textContent = getPipeStatusSymbol(pipe.status); + + const progress = document.createElement('span'); + progress.className = 'chat-pipe-row-progress'; + progress.textContent = `${pipe.slotSummary?.submitted ?? 0}/${pipe.slotSummary?.total ?? 0}`; + + right.appendChild(status); + right.appendChild(progress); + + header.appendChild(left); + header.appendChild(right); + header.addEventListener('click', async () => { + const nextExpanded = _expandedPipeId === pipe.pipeId ? null : pipe.pipeId; + _expandedPipeId = nextExpanded; + renderPipes(); + if (nextExpanded) await ensurePipeStatusLoaded(nextExpanded); + }); + + row.appendChild(header); + + if (_expandedPipeId === pipe.pipeId) { + row.appendChild(buildPipeDetailEl(pipe)); + } + + return row; +} + +function renderPipes() { + const listEl = _container?.querySelector('#chat-pipes-list'); + const titleEl = _container?.querySelector('#chat-pipes-title'); + const toggleEl = _container?.querySelector('#chat-pipes-toggle'); + if (!listEl) return; + + renderPipeAlert(); + if (toggleEl) { + toggleEl.textContent = _pipesCollapsed ? 'Show' : 'Hide'; + toggleEl.setAttribute('aria-expanded', String(!_pipesCollapsed)); + } + + const { + visiblePipes, + hiddenTerminalCount, + totalCount, + totalTerminalCount, + canToggleTerminalHistory, + } = getVisiblePipeSummaries(_pipeSummaries, { + expandedPipeId: _expandedPipeId, + showAll: _showAllPipes, + terminalLimit: DEFAULT_VISIBLE_TERMINAL, + }); + + if (titleEl) { + titleEl.textContent = hiddenTerminalCount > 0 + ? `Pipes (${visiblePipes.length} of ${totalCount})` + : `Pipes (${totalCount})`; + } + + listEl.innerHTML = ''; + listEl.classList.toggle('hidden', _pipesCollapsed); + if (_pipesCollapsed) return; + + if (visiblePipes.length === 0) { + const empty = document.createElement('div'); + empty.className = 'chat-pipe-row-hint'; + empty.textContent = 'No active or recent pipes.'; + listEl.appendChild(empty); + return; + } + + for (const pipe of visiblePipes) { + listEl.appendChild(buildPipeRowEl(pipe)); + } + + if (canToggleTerminalHistory) { + const historyToggle = document.createElement('button'); + historyToggle.className = 'btn btn-ghost btn-sm chat-pipes-show-all'; + historyToggle.type = 'button'; + historyToggle.textContent = _showAllPipes + ? 'Show fewer' + : `Show all history (${totalTerminalCount})`; + historyToggle.addEventListener('click', () => { + _showAllPipes = !_showAllPipes; + renderPipes(); + }); + listEl.appendChild(historyToggle); + } +} + +async function fetchPipes() { + try { + const [allRes, leasesRes, deadLettersRes] = await Promise.all([ + api('/pipes/all'), + api('/pipes/leases'), + api('/pipes/dead-letters'), + ]); + + if (allRes.ok) { + _pipeSummaries = await allRes.json(); + if (_expandedPipeId && !getPipeSummary(_expandedPipeId)) _expandedPipeId = null; + } + if (leasesRes.ok) _pipeLeases = await leasesRes.json(); + if (deadLettersRes.ok) _pipeDeadLetters = await deadLettersRes.json(); + + renderPipes(); + if (_expandedPipeId) ensurePipeStatusLoaded(_expandedPipeId, true); + } catch (err) { + console.error('[chat] Failed to fetch pipe monitor data:', err); + } +} + +async function ensurePipeStatusLoaded(pipeId, force = false) { + if (!pipeId || _pipeStatusLoading.has(pipeId)) return; + if (!force && _pipeStatusById[pipeId]) return; + + _pipeStatusLoading.add(pipeId); + renderPipes(); + try { + const res = await api(`/pipes/${pipeId}/status`); + if (!res.ok) return; + _pipeStatusById = { ..._pipeStatusById, [pipeId]: await res.json() }; + // Auto-fetch timing for terminal pipes + const pipe = getPipeSummary(pipeId); + if (pipe && pipe.status !== 'running') { + ensurePipeTimingLoaded(pipeId); + } + } catch (err) { + console.error(`[chat] Failed to load pipe status for ${pipeId}:`, err); + } finally { + _pipeStatusLoading.delete(pipeId); + renderPipes(); + } +} + +async function ensurePipeTimingLoaded(pipeId, force = false) { + if (!pipeId || _pipeTimingLoading.has(pipeId)) return; + if (!force && _pipeTimingById[pipeId]) return; + + _pipeTimingLoading.add(pipeId); + try { + const res = await api(`/pipes/${pipeId}/timing`); + if (!res.ok) return; + _pipeTimingById = { ..._pipeTimingById, [pipeId]: await res.json() }; + } catch (err) { + console.error(`[chat] Failed to load pipe timing for ${pipeId}:`, err); + } finally { + _pipeTimingLoading.delete(pipeId); + renderPipes(); + } +} + +function startPipePolling() { + stopPipePolling(); + _pipesPollTimer = setInterval(() => { fetchPipes(); }, PIPE_POLL_INTERVAL_MS); +} + +function stopPipePolling() { + if (_pipesPollTimer) { + clearInterval(_pipesPollTimer); + _pipesPollTimer = null; + } +} + +function startLeaseCountdown() { + stopLeaseCountdown(); + _leaseTickTimer = setInterval(() => { + const badges = _container?.querySelectorAll('.pipe-lease-badge[data-deadline]'); + if (!badges || badges.length === 0) return; + const now = Date.now(); + for (const badge of badges) { + const deadline = Number(badge.dataset.deadline); + const timeout = Number(badge.dataset.timeout) || 0; + if (!deadline) continue; + const remainingMs = deadline - now; + badge.textContent = formatPipeCountdown(remainingMs); + // Color-code by percentage of time remaining + const pct = timeout > 0 ? remainingMs / timeout : (remainingMs > 0 ? 1 : 0); + badge.classList.remove('lease-ok', 'lease-warn', 'lease-critical', 'lease-overdue'); + if (remainingMs <= 0) { + badge.classList.add('lease-overdue'); + } else if (pct < 0.25) { + badge.classList.add('lease-critical'); + } else if (pct < 0.5) { + badge.classList.add('lease-warn'); + } else { + badge.classList.add('lease-ok'); + } + } + }, 1000); +} + +function stopLeaseCountdown() { + if (_leaseTickTimer) { + clearInterval(_leaseTickTimer); + _leaseTickTimer = null; + } +} + + +// ── Rendering: Members ────────────────────────────────────────────── + +function renderMembers() { + const listEl = _container?.querySelector('#chat-members-list'); + const titleEl = _container?.querySelector('#chat-members-title'); + const countEl = _container?.querySelector('#chat-member-count'); + if (!listEl) return; + + // Always show "user" at top + const allMembers = [ + { name: 'user', kind: 'user', paneId: null, isUser: true }, + ..._members.filter(m => m.name !== 'user'), + ]; + + const onlineCount = allMembers.filter(m => m.isUser || !m.detached).length; + if (titleEl) titleEl.textContent = `Members (${allMembers.length})`; + if (countEl) countEl.textContent = `${onlineCount} online`; + + listEl.innerHTML = ''; + for (const m of allMembers) { + const item = document.createElement('div'); + item.className = 'chat-member-item'; + + const dot = document.createElement('span'); + const isConnected = m.isUser || (m.paneId && !m.detached); + dot.className = 'chat-member-dot ' + (isConnected ? 'connected' : m.detached ? 'detached' : 'disconnected'); + + const body = document.createElement('div'); + body.className = 'chat-member-body'; + + const name = document.createElement('span'); + name.className = 'chat-member-name'; + name.textContent = m.name; + name.title = m.name; + + const meta = document.createElement('div'); + meta.className = 'chat-member-meta'; + + // Assign unique color to LLM participants (skip dot color for detached — let CSS handle it) + if (!m.isUser) { + const color = getParticipantColor(m); + if (!m.detached) dot.style.background = color; + } + + body.appendChild(name); + + if (m.isUser) { + const tag = document.createElement('span'); + tag.className = 'chat-member-tag'; + tag.textContent = 'You'; + meta.appendChild(tag); + } else if (m.detached) { + meta.appendChild(createMemberDetachedIndicator()); + } else { + const state = m.status || 'idle'; + meta.appendChild(createMemberStatusIndicator(state)); + } + + if (!m.isUser) { + meta.appendChild(createMemberModeIndicator(m.permissionMode || 'supervised')); + } + + body.appendChild(meta); + + item.appendChild(dot); + item.appendChild(body); + listEl.appendChild(item); + } +} + +// ── Rendering: Messages ───────────────────────────────────────────── + +function renderAllMessages() { + const listEl = _container?.querySelector('#chat-messages-list'); + if (!listEl) return; + + listEl.innerHTML = ''; + const entries = getTimelineEntries(); + for (const entry of entries) { + if (entry.kind === 'message') appendMessageEl(entry.payload, false); + else appendPipeEventTimelineEl(entry.payload, false); + } + + if (listEl.children.length === 0) { + const empty = document.createElement('div'); + empty.className = 'chat-empty-state'; + empty.innerHTML = ` +
\u275D
+
No messages yet
+
Send a message or add an LLM from Shell, then join with chat_join
+ `; + listEl.appendChild(empty); + return; + } + renderMermaidBlocks(); + scrollToBottom(); +} + +function appendMessageEl(msg, doScroll = true) { + const listEl = _container?.querySelector('#chat-messages-list'); + if (!listEl) return; + + // Hide pipe control delivery messages (handoff/fan-out/synth prompts) from normal chat view + const pipeRole = msg.pipe?.role; + if (pipeRole && msg.type === 'system' && ['handoff', 'fan-out-request', 'synth-request'].includes(pipeRole)) return; + + // Remove empty state if present + const empty = listEl.querySelector('.chat-empty-state'); + if (empty) empty.remove(); + + let el = document.createElement('div'); + el.className = 'chat-msg'; + el.dataset.id = msg.id; + + if (msg.type === 'system' || msg.type === 'join' || msg.type === 'leave') { + el.classList.add('from-system'); + el.textContent = msg.body; + // Brainstorm review messages get action buttons + const bsMatch = msg.body.match(/#brainstorm-([a-z0-9]+)/); + if (bsMatch) { + const bsId = bsMatch[1]; + // During historical render (!doScroll) disable buttons if the brainstorm + // has advanced past the review phase or is no longer active. + // Live messages (doScroll) always get active buttons. + const stale = (phase) => !doScroll && (!_brainstorms[bsId] || _brainstorms[bsId].phase !== phase); + if (msg.body.includes('Ideas phase complete') || msg.body.includes('Returning to ideas phase')) { + const panel = buildBrainstormActions(bsId, 'ideas_review'); + if (stale('ideas_review')) panel.querySelectorAll('button').forEach(b => { b.disabled = true; }); + el.appendChild(panel); + } else if (msg.body.includes('Detail pass complete')) { + const panel = buildBrainstormActions(bsId, 'details_review'); + if (stale('details_review')) panel.querySelectorAll('button').forEach(b => { b.disabled = true; }); + el.appendChild(panel); + } + } + } else if (msg.from === 'user') { + el.classList.add('from-user'); + // Show a sender header only when the user explicitly addressed someone + // (e.g. `@all check status` → "@user → @all"). Unaddressed user messages + // stay header-less so they look like normal user bubbles. + if (msg.to) { + const userColor = getParticipantColor(findParticipant('user')); + el.appendChild(createSenderEl(formatRecipientHeader('user', msg.to), userColor)); + } + const body = document.createElement('div'); + body.className = 'chat-msg-body chat-markdown'; + body.innerHTML = renderMarkdown(msg.body); + const time = document.createElement('div'); + time.className = 'chat-msg-time'; + time.textContent = formatTime(msg.ts); + el.appendChild(body); + el.appendChild(time); + if (msg.unresolvedTargets?.length > 0) { + const warn = document.createElement('div'); + warn.className = 'chat-msg-unresolved-warn'; + warn.textContent = msg.unresolvedTargets.map(t => `⚠ @${t} not found — message not delivered via PTY`).join('\n'); + el.appendChild(warn); + } + } else if (pipeRole && pipeRole !== 'final' && ['stage-output', 'fan-out'].includes(pipeRole)) { + // ── Pipe intermediate: rendered separately via pipe-event channel ── + return; + } else { + // ── Regular LLM message or pipe final ── + const color = getParticipantColor(findParticipant(msg.from)); + const isPipeFinal = pipeRole === 'final' && msg.pipe?.pipeId; + if (isPipeFinal) { + el = buildPipeOutputEl({ + id: msg.id, + from: msg.from, + to: msg.to, + pipeId: msg.pipe.pipeId, + label: 'Final output', + content: msg.body, + ts: msg.ts, + color, + }); + } else { + el.classList.add('from-llm'); + el.style.borderLeftColor = color; + const sender = createSenderEl(formatRecipientHeader(msg.from, msg.to), color); + const body = document.createElement('div'); + body.className = 'chat-msg-body chat-markdown'; + body.innerHTML = renderMarkdown(msg.body); + const time = document.createElement('div'); + time.className = 'chat-msg-time'; + time.textContent = formatTime(msg.ts); + el.appendChild(sender); + el.appendChild(body); + el.appendChild(time); + } + } + + + listEl.appendChild(el); + renderMermaidBlocks(el); + + if (doScroll) { + if (_autoScroll) { + scrollToBottom(); + } else { + showNewIndicator(); + } + } +} + + +function formatTime(ts) { + try { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch { + return ''; + } +} + +function scrollToBottom() { + const listEl = _container?.querySelector('#chat-messages-list'); + if (listEl) { + listEl.scrollTop = listEl.scrollHeight; + } + hideNewIndicator(); +} + +function showNewIndicator() { + const el = _container?.querySelector('#chat-new-indicator'); + if (el) el.classList.remove('hidden'); +} + +function hideNewIndicator() { + const el = _container?.querySelector('#chat-new-indicator'); + if (el) el.classList.add('hidden'); +} + +function setRulesStatus(message, tone = 'info') { + const el = _container?.querySelector('#chat-rules-status'); + if (!el) return; + if (!message) { + el.textContent = ''; + el.className = 'chat-rules-status hidden'; + return; + } + el.textContent = message; + el.className = `chat-rules-status ${tone}`; +} + +function syncRulesDraftFromInput() { + const textarea = _container?.querySelector('#chat-rules-textarea'); + if (!textarea) return; + _rulesDraft = textarea.value; +} + +async function loadRules(force = false) { + if (_rulesLoaded && !force) return true; + setRulesStatus('Loading rules...'); + try { + const res = await api('/rules'); + const data = await parseJsonSafely(res); + if (!res.ok) throw new Error(data?.error || 'Failed to load rules'); + + const rules = typeof data?.rules === 'string' ? data.rules : ''; + _rulesDraft = rules; + _rulesLoaded = true; + const textarea = _container?.querySelector('#chat-rules-textarea'); + if (textarea) textarea.value = rules; + setRulesStatus(data?.isDefault ? 'Loaded default rules.' : 'Loaded project override rules.'); + return true; + } catch (err) { + setRulesStatus(err instanceof Error ? err.message : 'Failed to load rules.', 'error'); + return false; + } +} + +async function openRulesEditor() { + const overlay = _container?.querySelector('#chat-rules-overlay'); + if (!overlay) return; + overlay.classList.remove('hidden'); + const ok = await loadRules(); + if (ok) _container?.querySelector('#chat-rules-textarea')?.focus(); +} + +function closeRulesEditor() { + _container?.querySelector('#chat-rules-overlay')?.classList.add('hidden'); +} + +async function saveRules() { + syncRulesDraftFromInput(); + setRulesStatus('Saving rules...'); + try { + const res = await api('/rules', { + method: 'PUT', + body: JSON.stringify({ rules: _rulesDraft }), + }); + const data = await parseJsonSafely(res); + if (!res.ok) throw new Error(data?.error || 'Failed to save rules'); + _rulesLoaded = true; + setRulesStatus('Project rules saved.', 'success'); + } catch (err) { + setRulesStatus(err instanceof Error ? err.message : 'Failed to save rules.', 'error'); + } +} + +async function resetRules() { + setRulesStatus('Resetting rules...'); + try { + const res = await api('/rules', { method: 'DELETE' }); + const data = await parseJsonSafely(res); + if (!res.ok) throw new Error(data?.error || 'Failed to reset rules'); + _rulesLoaded = false; + await loadRules(true); + setRulesStatus('Project override removed. Using default rules.', 'success'); + } catch (err) { + setRulesStatus(err instanceof Error ? err.message : 'Failed to reset rules.', 'error'); + } +} + +// ── Input handling ────────────────────────────────────────────────── + +function autoResizeInput(el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 120) + 'px'; +} + +function sendMessage() { + const input = _container?.querySelector('#chat-input'); + if (!input) return; + if (!input.value.trim()) return; + const text = input.value; + + input.value = ''; + autoResizeInput(input); + sessionStorage.removeItem(draftKey(_projectId)); + closeMentionPopup(); + input.focus(); + + // Let the server resolve all @mentions from the message body + _socket.emit('chat:send', { message: text }); +} + +// ── @mention autocomplete ─────────────────────────────────────────── + +function onInputChange(e) { + const input = e.target; + autoResizeInput(input); + const val = input.value; + const cursorPos = input.selectionStart; + const before = val.substring(0, cursorPos); + + // ── Slash command autocomplete ── + // If input starts with '/' and no space yet, suggest pipe commands + const slashMatch = before.match(/^\/(\S*)$/); + if (slashMatch) { + const query = slashMatch[1].toLowerCase(); + const matches = PIPE_COMMANDS.filter(c => c.name.substring(1).startsWith(query)); + if (matches.length > 0) { + showCommandPopup(matches); + return; + } + } + + // ── Pipe assignee autocomplete ── + // If inside a pipe command (before ':'), autocomplete @mentions for connected LLM members only + const pipeAssigneeMatch = before.match(/^\/(linear-pipe|merge-pipe|merge-all-pipe|explain(?:-pipe)?|summarize(?:-pipe)?)\s+[^:]*@(\w*)$/); + if (pipeAssigneeMatch) { + const query = pipeAssigneeMatch[2].toLowerCase(); + const matches = getPipeAssigneeMatches(_members, query); + if (matches.length > 0) { + const atIdx = before.lastIndexOf('@'); + showMentionPopup(matches, atIdx); + return; + } + } + + // ── Regular @mention autocomplete ── + const atMatch = before.match(/@(\w*)$/); + if (atMatch) { + const query = atMatch[1].toLowerCase(); + const matches = getMentionMatches(_members, query); + + if (matches.length > 0) { + showMentionPopup(matches, atMatch.index); + return; + } + } + + closeMentionPopup(); +} + +function showCommandPopup(commands) { + const popup = _container?.querySelector('#chat-mention-popup'); + if (!popup) return; + + _mentionIdx = 0; + _popupMode = 'command'; + popup.innerHTML = ''; + popup.classList.remove('hidden'); + + for (let i = 0; i < commands.length; i++) { + const item = document.createElement('div'); + item.className = 'chat-mention-item' + (i === 0 ? ' selected' : ''); + item.innerHTML = `${escapeHtml(commands[i].name)} ${escapeHtml(commands[i].hint)}`; + item.dataset.command = commands[i].name; + item.addEventListener('click', () => insertCommand(commands[i].name)); + popup.appendChild(item); + } +} + +function insertCommand(command) { + const input = _container?.querySelector('#chat-input'); + if (!input) return; + const afterCursor = input.value.substring(input.selectionStart); + input.value = command + ' ' + afterCursor; + const newPos = command.length + 1; + input.setSelectionRange(newPos, newPos); + closeMentionPopup(); + input.focus(); +} + +function showMentionPopup(names, atIndex) { + const popup = _container?.querySelector('#chat-mention-popup'); + if (!popup) return; + + _mentionIdx = 0; + _popupMode = 'mention'; + popup.innerHTML = ''; + popup.classList.remove('hidden'); + + for (let i = 0; i < names.length; i++) { + const item = document.createElement('div'); + item.className = 'chat-mention-item' + (i === 0 ? ' selected' : ''); + if (names[i] === 'all') { + item.innerHTML = `@all Broadcast to all participants`; + } else { + item.textContent = '@' + names[i]; + } + item.dataset.name = names[i]; + item.addEventListener('click', () => insertMention(names[i], atIndex)); + popup.appendChild(item); + } +} + +function closeMentionPopup() { + const popup = _container?.querySelector('#chat-mention-popup'); + if (popup) { + popup.classList.add('hidden'); + popup.innerHTML = ''; + } + _mentionIdx = -1; + _popupMode = 'none'; +} + +function insertMention(name, atIndex) { + const input = _container?.querySelector('#chat-input'); + if (!input) return; + const val = input.value; + const before = val.substring(0, atIndex); + const afterCursor = val.substring(input.selectionStart); + input.value = before + '@' + name + ' ' + afterCursor; + const newPos = before.length + name.length + 2; + input.setSelectionRange(newPos, newPos); + closeMentionPopup(); + input.focus(); +} + +function onInputKeyDown(e) { + const popup = _container?.querySelector('#chat-mention-popup'); + const isPopupOpen = popup && !popup.classList.contains('hidden'); + + if (isPopupOpen) { + const items = popup.querySelectorAll('.chat-mention-item'); + if (e.key === 'ArrowDown') { + e.preventDefault(); + items[_mentionIdx]?.classList.remove('selected'); + _mentionIdx = (_mentionIdx + 1) % items.length; + items[_mentionIdx]?.classList.add('selected'); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + items[_mentionIdx]?.classList.remove('selected'); + _mentionIdx = (_mentionIdx - 1 + items.length) % items.length; + items[_mentionIdx]?.classList.add('selected'); + return; + } + if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault(); + const selected = items[_mentionIdx]; + if (selected) { + // Handle command popup vs mention popup + if (_popupMode === 'command' && selected.dataset.command) { + insertCommand(selected.dataset.command); + } else { + const input = _container?.querySelector('#chat-input'); + const before = input.value.substring(0, input.selectionStart); + const atMatch = before.match(/@(\w*)$/); + if (atMatch) { + insertMention(selected.dataset.name, atMatch.index); + } + } + } + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + closeMentionPopup(); + return; + } + } + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } +} + +// ── Data loading ──────────────────────────────────────────────────── + +async function loadInitialData() { + try { + const [messagesRes, pipeEventsRes, membersRes, brainstormsRes] = await Promise.all([ + api('/messages?limit=50'), + api('/pipe-events?limit=200'), + api('/members'), + api('/brainstorms'), + ]); + if (brainstormsRes.ok) { + _brainstorms = {}; + for (const bs of await brainstormsRes.json()) _brainstorms[bs.id] = bs; + } + if (messagesRes.ok) { + _messages = mergeById(_messages, await messagesRes.json()); + } + if (pipeEventsRes.ok) { + _pipeEvents = mergeById(_pipeEvents, await pipeEventsRes.json()); + } + if (membersRes.ok) { + _members = (await membersRes.json()).map(m => m.kind === 'llm' ? { ...m, status: 'idle' } : m); + } + renderMembers(); + await fetchPipes(); + renderAllMessages(); + } catch (err) { + console.error('[chat] Failed to load initial data:', err); + } +} + +// ── Event binding ─────────────────────────────────────────────────── + +function bindEvents() { + if (!_container) return; + + _container.addEventListener('mouseover', onTooltipOver); + _container.addEventListener('mouseout', onTooltipOut); + + _container.querySelector('#chat-send-btn')?.addEventListener('click', sendMessage); + + const input = _container.querySelector('#chat-input'); + if (input) { + input.addEventListener('keydown', onInputKeyDown); + input.addEventListener('input', onInputChange); + } + + _container.querySelector('#chat-btn-clear')?.addEventListener('click', async () => { + await api('/messages', { method: 'DELETE' }); + _messages = []; + _pipeEvents = []; + renderAllMessages(); + }); + + _container.querySelector('#chat-btn-rules')?.addEventListener('click', openRulesEditor); + _container.querySelector('#chat-rules-close')?.addEventListener('click', closeRulesEditor); + _container.querySelector('#chat-rules-save')?.addEventListener('click', saveRules); + _container.querySelector('#chat-rules-reset')?.addEventListener('click', resetRules); + _container.querySelector('#chat-pipes-toggle')?.addEventListener('click', () => { + _pipesCollapsed = !_pipesCollapsed; + renderPipes(); + }); + + _container.querySelector('#chat-rules-textarea')?.addEventListener('input', syncRulesDraftFromInput); + _container.querySelector('#chat-rules-overlay')?.addEventListener('click', (e) => { + if (e.target?.id === 'chat-rules-overlay') closeRulesEditor(); + }); + + // Auto-scroll detection + const listEl = _container.querySelector('#chat-messages-list'); + if (listEl) { + listEl.addEventListener('scroll', () => { + const threshold = 50; + _autoScroll = listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - threshold; + if (_autoScroll) hideNewIndicator(); + }); + } + + // New messages indicator click + _container.querySelector('#chat-new-indicator')?.addEventListener('click', scrollToBottom); +} + +// ── Exports ───────────────────────────────────────────────────────── + +export function mount(container, ctx) { + _container = container; + _projectId = ctx?.project?.id || null; + _messages = []; + _pipeEvents = []; + _members = []; + _pipeSummaries = []; + _pipeLeases = []; + _pipeDeadLetters = []; + _pipeStatusById = {}; + _pipeStatusLoading = new Set(); + _pipeTimingById = {}; + _pipeTimingLoading = new Set(); + _pipesCollapsed = false; + _expandedPipeId = null; + _showAllPipes = false; + _autoScroll = true; + _rulesDraft = ''; + _rulesLoaded = false; + + container.classList.add('page-chat', 'app-page'); + container.innerHTML = BODY_HTML; + + // Load marked.js for markdown rendering (reuse kanban's vendored copy) + loadScript('/app/kanban/vendor/marked.min.js', 'marked') + .then(() => { initMarked(); renderAllMessages(); }) + .catch(() => { /* graceful degradation — messages render as plain text */ }); + + // Load mermaid.js for chart rendering (CDN, ESM build) + loadScript('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js', 'mermaid') + .then(() => { initMermaid(); renderMermaidBlocks(); }) + .catch(() => { _mermaidFailed = true; fallbackAllMermaidBlocks(); }); + + bindEvents(); + loadInitialData(); + connectSocket(); + startPipePolling(); + startLeaseCountdown(); + + // Voice STT — insert transcribed text into chat input + _voiceHandler = (e) => { + const text = e.detail?.text; + if (!text) return; + const input = container.querySelector('#chat-input'); + if (!input) return; + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + input.value = input.value.slice(0, start) + text + input.value.slice(end); + input.selectionStart = input.selectionEnd = start + text.length; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.focus(); + }; + document.addEventListener('voice:result', _voiceHandler); + + // Restore draft text (scoped to current project) + const draft = sessionStorage.getItem(draftKey(_projectId)); + if (draft) { + const input = container.querySelector('#chat-input'); + if (input) { + input.value = draft; + autoResizeInput(input); + } + } + + // Auto-focus the input when navigating to chat + container.querySelector('#chat-input')?.focus(); +} + +export function unmount(container) { + // Save draft text before teardown (scoped to current project) + const input = container.querySelector('#chat-input'); + const key = draftKey(_projectId); + if (input?.value) { + sessionStorage.setItem(key, input.value); + } else { + sessionStorage.removeItem(key); + } + + if (_voiceHandler) { + document.removeEventListener('voice:result', _voiceHandler); + _voiceHandler = null; + } + closeMentionPopup(); + disconnectSocket(); + stopPipePolling(); + stopLeaseCountdown(); + container.classList.remove('page-chat', 'app-page'); + container.innerHTML = ''; + _container = null; + _projectId = null; + _messages = []; + _pipeEvents = []; + _members = []; + _pipeSummaries = []; + _pipeLeases = []; + _pipeDeadLetters = []; + _pipeStatusById = {}; + _pipeStatusLoading = new Set(); + _pipeTimingById = {}; + _pipeTimingLoading = new Set(); + _pipesCollapsed = false; + _expandedPipeId = null; + _showAllPipes = false; + _brainstorms = {}; + _rulesDraft = ''; + _rulesLoaded = false; + + _mermaidIdCounter = 0; + _mermaidFailed = false; +} + +export function onProjectChange(project) { + // Save current project's draft before switching + const input = _container?.querySelector('#chat-input'); + const oldKey = draftKey(_projectId); + if (input?.value) { + sessionStorage.setItem(oldKey, input.value); + } else { + sessionStorage.removeItem(oldKey); + } + + _projectId = project?.id || null; + _messages = []; + _pipeEvents = []; + _members = []; + _pipeSummaries = []; + _pipeLeases = []; + _pipeDeadLetters = []; + _pipeStatusById = {}; + _pipeStatusLoading = new Set(); + _pipeTimingById = {}; + _pipeTimingLoading = new Set(); + _pipesCollapsed = false; + _expandedPipeId = null; + _showAllPipes = false; + _brainstorms = {}; + _rulesDraft = ''; + _rulesLoaded = false; + + if (_container) { + // Restore the new project's draft (or clear) + const newDraft = sessionStorage.getItem(draftKey(_projectId)); + if (input) { + input.value = newDraft || ''; + autoResizeInput(input); + } + loadInitialData(); + } +} diff --git a/src/apps/chat/public/pipe-visibility.js b/src/apps/chat/public/pipe-visibility.js new file mode 100644 index 0000000..034ed24 --- /dev/null +++ b/src/apps/chat/public/pipe-visibility.js @@ -0,0 +1,52 @@ +export const DEFAULT_VISIBLE_TERMINAL = 10; + +export function getPipeStatusRank(status) { + return status === 'running' ? 0 : status === 'failed' ? 1 : status === 'cancelled' ? 2 : 3; +} + +export function sortPipeSummaries(pipes) { + return [...pipes].sort((a, b) => { + const statusDelta = getPipeStatusRank(a.status) - getPipeStatusRank(b.status); + if (statusDelta !== 0) return statusDelta; + return String(b.createdAt || '').localeCompare(String(a.createdAt || '')); + }); +} + +export function getVisiblePipeSummaries( + pipes, + { + expandedPipeId = null, + showAll = false, + terminalLimit = DEFAULT_VISIBLE_TERMINAL, + } = {}, +) { + const sorted = sortPipeSummaries(pipes); + const running = []; + const terminal = []; + + for (const pipe of sorted) { + if (pipe.status === 'running') running.push(pipe); + else terminal.push(pipe); + } + + const normalizedLimit = Number.isFinite(terminalLimit) + ? Math.max(0, Math.trunc(terminalLimit)) + : DEFAULT_VISIBLE_TERMINAL; + + const visibleTerminalIds = new Set( + showAll + ? terminal.map(pipe => pipe.pipeId) + : terminal.slice(0, normalizedLimit).map(pipe => pipe.pipeId), + ); + + if (expandedPipeId) visibleTerminalIds.add(expandedPipeId); + + const visibleTerminal = terminal.filter(pipe => visibleTerminalIds.has(pipe.pipeId)); + return { + visiblePipes: [...running, ...visibleTerminal], + hiddenTerminalCount: terminal.length - visibleTerminal.length, + totalCount: sorted.length, + totalTerminalCount: terminal.length, + canToggleTerminalHistory: terminal.length > normalizedLimit, + }; +} diff --git a/src/apps/chat/public/pipe-visibility.test.js b/src/apps/chat/public/pipe-visibility.test.js new file mode 100644 index 0000000..9ebbf7a --- /dev/null +++ b/src/apps/chat/public/pipe-visibility.test.js @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_VISIBLE_TERMINAL, getVisiblePipeSummaries, sortPipeSummaries } from './pipe-visibility.js'; + +function pipe(pipeId, status, createdAt) { + return { + pipeId, + status, + createdAt, + slotSummary: { total: 1, submitted: 0, leased: 0, pending: 1 }, + }; +} + +describe('sortPipeSummaries', () => { + it('keeps running pipes first, then terminal pipes by recency', () => { + const pipes = [ + pipe('completed-old', 'completed', '2026-04-02T10:00:00.000Z'), + pipe('running-old', 'running', '2026-04-02T09:00:00.000Z'), + pipe('failed-new', 'failed', '2026-04-02T12:00:00.000Z'), + pipe('running-new', 'running', '2026-04-02T13:00:00.000Z'), + pipe('cancelled-new', 'cancelled', '2026-04-02T11:00:00.000Z'), + ]; + + expect(sortPipeSummaries(pipes).map(entry => entry.pipeId)).toEqual([ + 'running-new', + 'running-old', + 'failed-new', + 'cancelled-new', + 'completed-old', + ]); + }); +}); + +describe('getVisiblePipeSummaries', () => { + it('caps terminal pipes while keeping all running pipes visible', () => { + const pipes = [ + pipe('running-a', 'running', '2026-04-02T15:00:00.000Z'), + pipe('running-b', 'running', '2026-04-02T14:00:00.000Z'), + pipe('completed-a', 'completed', '2026-04-02T13:00:00.000Z'), + pipe('completed-b', 'completed', '2026-04-02T12:00:00.000Z'), + pipe('failed-a', 'failed', '2026-04-02T11:00:00.000Z'), + ]; + + const result = getVisiblePipeSummaries(pipes, { terminalLimit: 2 }); + expect(result.visiblePipes.map(entry => entry.pipeId)).toEqual([ + 'running-a', + 'running-b', + 'failed-a', + 'completed-a', + ]); + expect(result.hiddenTerminalCount).toBe(1); + expect(result.canToggleTerminalHistory).toBe(true); + }); + + it('preserves an expanded terminal pipe outside the default cap', () => { + const pipes = [ + pipe('completed-1', 'completed', '2026-04-02T15:00:00.000Z'), + pipe('completed-2', 'completed', '2026-04-02T14:00:00.000Z'), + pipe('completed-3', 'completed', '2026-04-02T13:00:00.000Z'), + ]; + + const result = getVisiblePipeSummaries(pipes, { + expandedPipeId: 'completed-3', + terminalLimit: 2, + }); + + expect(result.visiblePipes.map(entry => entry.pipeId)).toEqual([ + 'completed-1', + 'completed-2', + 'completed-3', + ]); + expect(result.hiddenTerminalCount).toBe(0); + }); + + it('shows all terminal pipes when history is expanded', () => { + const pipes = [ + pipe('completed-1', 'completed', '2026-04-02T15:00:00.000Z'), + pipe('completed-2', 'completed', '2026-04-02T14:00:00.000Z'), + pipe('completed-3', 'completed', '2026-04-02T13:00:00.000Z'), + ]; + + const result = getVisiblePipeSummaries(pipes, { + showAll: true, + terminalLimit: 1, + }); + + expect(result.visiblePipes.map(entry => entry.pipeId)).toEqual([ + 'completed-1', + 'completed-2', + 'completed-3', + ]); + expect(result.hiddenTerminalCount).toBe(0); + }); + + it('uses the default terminal cap when none is supplied', () => { + const pipes = Array.from({ length: DEFAULT_VISIBLE_TERMINAL + 2 }, (_, index) => + pipe(`completed-${index + 1}`, 'completed', `2026-04-02T${String(20 - index).padStart(2, '0')}:00:00.000Z`), + ); + + const result = getVisiblePipeSummaries(pipes); + expect(result.visiblePipes).toHaveLength(DEFAULT_VISIBLE_TERMINAL); + expect(result.hiddenTerminalCount).toBe(2); + }); +}); + diff --git a/src/apps/chat/services/assignment-store.test.ts b/src/apps/chat/services/assignment-store.test.ts new file mode 100644 index 0000000..7230787 --- /dev/null +++ b/src/apps/chat/services/assignment-store.test.ts @@ -0,0 +1,664 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as assignmentStore from './assignment-store.js'; +import { createTestClock } from './clock.js'; + +beforeEach(() => { + assignmentStore._resetForTest(); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function createTestAssignment(overrides?: { + pipeId?: string; + stageId?: string; + payloadId?: string; + assignee?: string; + role?: 'stage-output' | 'fan-out' | 'final'; + stage?: number; +}) { + return assignmentStore.createAssignment( + overrides?.pipeId ?? 'pipe-1', + overrides?.stageId ?? 'linear:1', + overrides?.payloadId ?? 'payload-1', + overrides?.assignee ?? 'alice', + overrides?.role ?? 'stage-output', + 'proj-1', + { stage: overrides?.stage ?? 1 }, + ); +} + +/** Transition through the full happy path to 'submitted'. */ +function submitAssignment(assignmentId: string) { + assignmentStore.transitionAssignment(assignmentId, 'notified', 'proj-1'); + assignmentStore.transitionAssignment(assignmentId, 'acknowledged', 'proj-1'); + assignmentStore.transitionAssignment(assignmentId, 'payload_fetched', 'proj-1'); + assignmentStore.transitionAssignment(assignmentId, 'submitted', 'proj-1'); +} + +// ── deriveStageId ──────────────────────────────────────────────────────────── + +describe('deriveStageId', () => { + it('derives linear stage ID', () => { + expect(assignmentStore.deriveStageId('linear', 'stage-output', { stage: 3 })).toBe('linear:3'); + }); + + it('derives fan-out stage ID', () => { + expect(assignmentStore.deriveStageId('merge', 'fan-out', { assignee: 'bob' })).toBe('fan-out:bob'); + }); + + it('derives synth stage ID', () => { + expect(assignmentStore.deriveStageId('merge-all', 'final')).toBe('synth'); + }); +}); + +// ── createAssignment ───────────────────────────────────────────────────────── + +describe('createAssignment', () => { + it('creates an assignment with correct initial state', () => { + const result = createTestAssignment(); + expect(result.ok).toBe(true); + expect(result.assignment).toBeDefined(); + + const a = result.assignment!; + expect(a.pipeId).toBe('pipe-1'); + expect(a.stageId).toBe('linear:1'); + expect(a.payloadId).toBe('payload-1'); + expect(a.assignee).toBe('alice'); + expect(a.role).toBe('stage-output'); + expect(a.stage).toBe(1); + expect(a.status).toBe('assigned'); + expect(a.attempt).toBe(1); + expect(a.version).toBe(1); + expect(a.supersededBy).toBeNull(); + expect(a.supersedes).toBeNull(); + }); + + it('sets all timestamps to null except createdAt', () => { + const result = createTestAssignment(); + const a = result.assignment!; + expect(a.createdAt).toBeTruthy(); + expect(a.notifiedAt).toBeNull(); + expect(a.acknowledgedAt).toBeNull(); + expect(a.fetchedAt).toBeNull(); + expect(a.submittedAt).toBeNull(); + expect(a.expiredAt).toBeNull(); + expect(a.reassignedAt).toBeNull(); + expect(a.cancelledAt).toBeNull(); + }); + + it('rejects duplicate active assignment for same pipe+stageId', () => { + createTestAssignment(); + const result = createTestAssignment({ assignee: 'bob' }); + expect(result.ok).toBe(false); + expect(result.code).toBe('DUPLICATE_ACTIVE'); + }); + + it('allows new assignment after previous one reaches terminal state', () => { + const first = createTestAssignment(); + assignmentStore.transitionAssignment(first.assignment!.assignmentId, 'expired', 'proj-1'); + + const second = createTestAssignment({ assignee: 'bob' }); + expect(second.ok).toBe(true); + expect(second.assignment!.assignee).toBe('bob'); + }); + + it('increments attempt when superseding', () => { + const first = createTestAssignment(); + assignmentStore.transitionAssignment(first.assignment!.assignmentId, 'expired', 'proj-1'); + + const second = assignmentStore.createAssignment( + 'pipe-1', 'linear:1', 'payload-1', 'bob', 'stage-output', 'proj-1', + { stage: 1, supersedes: first.assignment!.assignmentId }, + ); + expect(second.ok).toBe(true); + expect(second.assignment!.attempt).toBe(2); + expect(second.assignment!.supersedes).toBe(first.assignment!.assignmentId); + }); +}); + +// ── transitionAssignment ───────────────────────────────────────────────────── + +describe('transitionAssignment', () => { + it('transitions through the happy path', () => { + const { assignment } = createTestAssignment(); + const id = assignment!.assignmentId; + + const r1 = assignmentStore.transitionAssignment(id, 'notified', 'proj-1'); + expect(r1.ok).toBe(true); + expect(r1.assignment!.status).toBe('notified'); + expect(r1.assignment!.notifiedAt).toBeTruthy(); + expect(r1.assignment!.version).toBe(2); + + const r2 = assignmentStore.transitionAssignment(id, 'acknowledged', 'proj-1'); + expect(r2.ok).toBe(true); + expect(r2.assignment!.acknowledgedAt).toBeTruthy(); + + const r3 = assignmentStore.transitionAssignment(id, 'payload_fetched', 'proj-1'); + expect(r3.ok).toBe(true); + expect(r3.assignment!.fetchedAt).toBeTruthy(); + + const r4 = assignmentStore.transitionAssignment(id, 'submitted', 'proj-1'); + expect(r4.ok).toBe(true); + expect(r4.assignment!.submittedAt).toBeTruthy(); + expect(r4.assignment!.version).toBe(5); + }); + + it('rejects invalid transition', () => { + const { assignment } = createTestAssignment(); + const result = assignmentStore.transitionAssignment( + assignment!.assignmentId, 'submitted', 'proj-1', + ); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TRANSITION'); + }); + + it('rejects transition on terminal assignment', () => { + const { assignment } = createTestAssignment(); + const id = assignment!.assignmentId; + assignmentStore.transitionAssignment(id, 'expired', 'proj-1'); + + const result = assignmentStore.transitionAssignment(id, 'notified', 'proj-1'); + expect(result.ok).toBe(false); + expect(result.code).toBe('ASSIGNMENT_TERMINAL'); + }); + + it('returns error for unknown assignment', () => { + const result = assignmentStore.transitionAssignment('nonexistent', 'notified', 'proj-1'); + expect(result.ok).toBe(false); + expect(result.code).toBe('ASSIGNMENT_NOT_FOUND'); + }); + + it('checks optimistic concurrency version', () => { + const { assignment } = createTestAssignment(); + const id = assignment!.assignmentId; + + const result = assignmentStore.transitionAssignment(id, 'notified', 'proj-1', { expectedVersion: 99 }); + expect(result.ok).toBe(false); + expect(result.code).toBe('VERSION_CONFLICT'); + + const ok = assignmentStore.transitionAssignment(id, 'notified', 'proj-1', { expectedVersion: 1 }); + expect(ok.ok).toBe(true); + }); + + it('allows direct transition to expired from any non-terminal state', () => { + const { assignment } = createTestAssignment(); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1'); + + const result = assignmentStore.transitionAssignment(assignment!.assignmentId, 'expired', 'proj-1'); + expect(result.ok).toBe(true); + expect(result.assignment!.expiredAt).toBeTruthy(); + }); + + it('allows direct transition to cancelled from any non-terminal state', () => { + const { assignment } = createTestAssignment(); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1'); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'acknowledged', 'proj-1'); + + const result = assignmentStore.transitionAssignment(assignment!.assignmentId, 'cancelled', 'proj-1'); + expect(result.ok).toBe(true); + expect(result.assignment!.cancelledAt).toBeTruthy(); + }); + + it('removes from active index on terminal transition', () => { + const { assignment } = createTestAssignment(); + const id = assignment!.assignmentId; + + expect(assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1')).toBeDefined(); + + submitAssignment(id); + + expect(assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1')).toBeUndefined(); + }); +}); + +// ── reassignAssignment ─────────────────────────────────────────────────────── + +describe('reassignAssignment', () => { + it('reassigns to a new participant', () => { + const { assignment } = createTestAssignment(); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1'); + + const result = assignmentStore.reassignAssignment( + assignment!.assignmentId, 'bob', 'proj-1', 'participant left', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + // Old assignment is reassigned + expect(result.old.status).toBe('reassigned'); + expect(result.old.reassignedAt).toBeTruthy(); + expect(result.old.reassignReason).toBe('participant left'); + expect(result.old.supersededBy).toBe(result.new.assignmentId); + + // New assignment is created + expect(result.new.assignee).toBe('bob'); + expect(result.new.status).toBe('assigned'); + expect(result.new.attempt).toBe(2); + expect(result.new.supersedes).toBe(result.old.assignmentId); + expect(result.new.pipeId).toBe('pipe-1'); + expect(result.new.stageId).toBe('linear:1'); + }); + + it('rejects reassignment of terminal assignment', () => { + const { assignment } = createTestAssignment(); + submitAssignment(assignment!.assignmentId); + + const result = assignmentStore.reassignAssignment( + assignment!.assignmentId, 'bob', 'proj-1', 'test', + ); + expect(result.ok).toBe(false); + }); + + it('new assignment becomes the active one', () => { + const { assignment } = createTestAssignment(); + const result = assignmentStore.reassignAssignment( + assignment!.assignmentId, 'bob', 'proj-1', 'test', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const active = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1'); + expect(active).toBeDefined(); + expect(active!.assignee).toBe('bob'); + }); +}); + +// ── cancelPipeAssignments ──────────────────────────────────────────────────── + +describe('cancelPipeAssignments', () => { + it('cancels all non-terminal assignments for a pipe', () => { + createTestAssignment({ stageId: 'linear:1', assignee: 'alice', stage: 1 }); + // Submit the first so it's terminal + const first = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1'); + submitAssignment(first!.assignmentId); + + // Create a second that will be cancelled + createTestAssignment({ stageId: 'linear:2', assignee: 'bob', stage: 2 }); + + const cancelled = assignmentStore.cancelPipeAssignments('pipe-1', 'proj-1'); + expect(cancelled).toHaveLength(1); + + // The submitted one should not be affected + const submitted = assignmentStore.getAssignment(first!.assignmentId, 'proj-1'); + expect(submitted!.status).toBe('submitted'); + + // The pending one should be cancelled + const bobAssignment = assignmentStore.getAssignmentsByPipe('pipe-1', 'proj-1') + .find(a => a.assignee === 'bob'); + expect(bobAssignment!.status).toBe('cancelled'); + }); +}); + +// ── Queries ────────────────────────────────────────────────────────────────── + +describe('queries', () => { + it('getAssignment returns assignment by ID', () => { + const { assignment } = createTestAssignment(); + const found = assignmentStore.getAssignment(assignment!.assignmentId, 'proj-1'); + expect(found).toBeDefined(); + expect(found!.assignmentId).toBe(assignment!.assignmentId); + }); + + it('getActiveAssignment returns the non-terminal assignment for a stage', () => { + createTestAssignment(); + const active = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1'); + expect(active).toBeDefined(); + expect(active!.status).toBe('assigned'); + }); + + it('getActiveAssignment returns undefined for terminal assignments', () => { + const { assignment } = createTestAssignment(); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'expired', 'proj-1'); + expect(assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1')).toBeUndefined(); + }); + + it('getAssignmentsByPipe returns all assignments including terminal', () => { + createTestAssignment({ stageId: 'linear:1', assignee: 'alice', stage: 1 }); + const first = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1'); + assignmentStore.transitionAssignment(first!.assignmentId, 'expired', 'proj-1'); + + createTestAssignment({ stageId: 'linear:2', assignee: 'bob', stage: 2 }); + + const all = assignmentStore.getAssignmentsByPipe('pipe-1', 'proj-1'); + expect(all).toHaveLength(2); + }); + + it('getActiveAssignmentsForParticipant lists pending work', () => { + createTestAssignment({ pipeId: 'pipe-1', stageId: 'linear:1', assignee: 'alice', stage: 1 }); + createTestAssignment({ pipeId: 'pipe-2', stageId: 'linear:1', assignee: 'alice', stage: 1 }); + + const active = assignmentStore.getActiveAssignmentsForParticipant('alice', 'proj-1'); + expect(active).toHaveLength(2); + }); + + it('getActiveAssignmentsForParticipant excludes terminal assignments', () => { + const { assignment } = createTestAssignment(); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'expired', 'proj-1'); + + const active = assignmentStore.getActiveAssignmentsForParticipant('alice', 'proj-1'); + expect(active).toHaveLength(0); + }); +}); + +// ── Assignment chain ───────────────────────────────────────────────────────── + +describe('getAssignmentChain', () => { + it('returns single assignment when no chain', () => { + const { assignment } = createTestAssignment(); + const chain = assignmentStore.getAssignmentChain(assignment!.assignmentId, 'proj-1'); + expect(chain).toHaveLength(1); + expect(chain[0].assignmentId).toBe(assignment!.assignmentId); + }); + + it('returns full chain across reassignments', () => { + const { assignment: a1 } = createTestAssignment(); + const r1 = assignmentStore.reassignAssignment(a1!.assignmentId, 'bob', 'proj-1', 'test'); + expect(r1.ok).toBe(true); + if (!r1.ok) return; + + const r2 = assignmentStore.reassignAssignment(r1.new.assignmentId, 'carol', 'proj-1', 'test2'); + expect(r2.ok).toBe(true); + if (!r2.ok) return; + + // Query from any point in the chain + const chain = assignmentStore.getAssignmentChain(r1.new.assignmentId, 'proj-1'); + expect(chain).toHaveLength(3); + expect(chain[0].assignee).toBe('alice'); + expect(chain[1].assignee).toBe('bob'); + expect(chain[2].assignee).toBe('carol'); + }); +}); + +// ── Stale access ───────────────────────────────────────────────────────────── + +describe('stale access', () => { + it('isStale returns true for reassigned assignments', () => { + const { assignment } = createTestAssignment(); + assignmentStore.reassignAssignment(assignment!.assignmentId, 'bob', 'proj-1', 'test'); + expect(assignmentStore.isStale(assignment!.assignmentId, 'proj-1')).toBe(true); + }); + + it('isStale returns false for active assignments', () => { + const { assignment } = createTestAssignment(); + expect(assignmentStore.isStale(assignment!.assignmentId, 'proj-1')).toBe(false); + }); + + it('staleAccessPolicy returns accept-silent for reassigned', () => { + const { assignment } = createTestAssignment(); + assignmentStore.reassignAssignment(assignment!.assignmentId, 'bob', 'proj-1', 'test'); + expect(assignmentStore.staleAccessPolicy(assignment!.assignmentId, 'proj-1')).toBe('accept-silent'); + }); + + it('staleAccessPolicy returns reject for expired', () => { + const { assignment } = createTestAssignment(); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'expired', 'proj-1'); + expect(assignmentStore.staleAccessPolicy(assignment!.assignmentId, 'proj-1')).toBe('reject'); + }); + + it('staleAccessPolicy returns ok for active', () => { + const { assignment } = createTestAssignment(); + expect(assignmentStore.staleAccessPolicy(assignment!.assignmentId, 'proj-1')).toBe('ok'); + }); +}); + +// ── toNotification ─────────────────────────────────────────────────────────── + +describe('toNotification', () => { + it('produces a compact notification envelope', () => { + const { assignment } = createTestAssignment(); + const notification = assignmentStore.toNotification(assignment!); + + expect(notification.assignmentId).toBe(assignment!.assignmentId); + expect(notification.pipeId).toBe('pipe-1'); + expect(notification.stageId).toBe('linear:1'); + expect(notification.role).toBe('stage-output'); + expect(notification.stage).toBe(1); + expect(notification.attempt).toBe(1); + expect(notification.payloadId).toBe('payload-1'); + // Should NOT contain content, timestamps, or chain info + expect(notification).not.toHaveProperty('content'); + expect(notification).not.toHaveProperty('createdAt'); + expect(notification).not.toHaveProperty('supersededBy'); + }); +}); + +// ── Cleanup ────────────────────────────────────────────────────────────────── + +describe('cleanupTerminalAssignments', () => { + it('removes terminal assignments older than TTL', () => { + const clock = createTestClock(); + assignmentStore.setClock(clock); + + createTestAssignment(); + const active = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1'); + assignmentStore.transitionAssignment(active!.assignmentId, 'expired', 'proj-1'); + + // Not enough time has passed + clock.advance(1000); + expect(assignmentStore.cleanupTerminalAssignments('proj-1', 5000)).toBe(0); + + // Now enough time has passed + clock.advance(5000); + expect(assignmentStore.cleanupTerminalAssignments('proj-1', 5000)).toBe(1); + }); + + it('does not remove active assignments', () => { + const clock = createTestClock(); + assignmentStore.setClock(clock); + + createTestAssignment(); + clock.advance(100_000); + expect(assignmentStore.cleanupTerminalAssignments('proj-1', 1000)).toBe(0); + }); +}); + +// ── Recovery ───────────────────────────────────────────────────────────────── + +describe('rehydrateFromEvents', () => { + it('recreates assignment from creation event', () => { + const events: assignmentStore.AssignmentRecoveryEvent[] = [ + { + type: 'assignment-created', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + payloadId: 'payload-1', + assignee: 'alice', + role: 'stage-output', + stage: 1, + }, + ]; + + const active = assignmentStore.rehydrateFromEvents(events, 'proj-1'); + expect(active).toContain('a-001'); + + const assignment = assignmentStore.getAssignment('a-001', 'proj-1'); + expect(assignment).toBeDefined(); + expect(assignment!.assignee).toBe('alice'); + expect(assignment!.status).toBe('assigned'); + }); + + it('replays transitions', () => { + const events: assignmentStore.AssignmentRecoveryEvent[] = [ + { + type: 'assignment-created', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + payloadId: 'payload-1', + assignee: 'alice', + role: 'stage-output', + stage: 1, + }, + { + type: 'assignment-transitioned', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + status: 'notified', + }, + { + type: 'assignment-transitioned', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + status: 'acknowledged', + }, + ]; + + const active = assignmentStore.rehydrateFromEvents(events, 'proj-1'); + expect(active).toContain('a-001'); + + const assignment = assignmentStore.getAssignment('a-001', 'proj-1'); + expect(assignment!.status).toBe('acknowledged'); + }); + + it('marks terminal assignments as not active', () => { + const events: assignmentStore.AssignmentRecoveryEvent[] = [ + { + type: 'assignment-created', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + payloadId: 'payload-1', + assignee: 'alice', + role: 'stage-output', + stage: 1, + }, + { + type: 'assignment-transitioned', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + status: 'notified', + }, + { + type: 'assignment-transitioned', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + status: 'acknowledged', + }, + { + type: 'assignment-transitioned', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + status: 'payload_fetched', + }, + { + type: 'assignment-transitioned', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + status: 'submitted', + }, + ]; + + const active = assignmentStore.rehydrateFromEvents(events, 'proj-1'); + expect(active).not.toContain('a-001'); + }); +}); + +// ── retryAssignment ────────────────────────────────────────────────────────── + +describe('retryAssignment', () => { + it('creates a new attempt with same assignee, marks old as superseded', () => { + const { assignment } = createTestAssignment(); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1'); + + const result = assignmentStore.retryAssignment( + assignment!.assignmentId, 'proj-1', 'transient failure', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + // Old assignment is superseded (not reassigned) + expect(result.old.status).toBe('superseded'); + expect(result.old.reassignReason).toBe('transient failure'); + expect(result.old.supersededBy).toBe(result.new.assignmentId); + + // New assignment has same assignee but incremented attempt + expect(result.new.assignee).toBe('alice'); // same assignee + expect(result.new.attempt).toBe(2); + expect(result.new.supersedes).toBe(result.old.assignmentId); + expect(result.new.status).toBe('assigned'); + }); + + it('rejects retry of terminal assignment', () => { + const { assignment } = createTestAssignment(); + submitAssignment(assignment!.assignmentId); + + const result = assignmentStore.retryAssignment( + assignment!.assignmentId, 'proj-1', 'test', + ); + expect(result.ok).toBe(false); + }); + + it('new retry becomes the active assignment', () => { + const { assignment } = createTestAssignment(); + const result = assignmentStore.retryAssignment( + assignment!.assignmentId, 'proj-1', 'retry', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const active = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1'); + expect(active).toBeDefined(); + expect(active!.assignmentId).toBe(result.new.assignmentId); + expect(active!.assignee).toBe('alice'); + }); +}); + +// ── Recovery timestamp fidelity ────────────────────────────────────────────── + +describe('recovery timestamp fidelity', () => { + it('preserves original event timestamps during rehydration', () => { + const events: assignmentStore.AssignmentRecoveryEvent[] = [ + { + type: 'assignment-created', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + payloadId: 'payload-1', + assignee: 'alice', + role: 'stage-output', + stage: 1, + ts: '2026-03-15T10:00:00.000Z', + }, + { + type: 'assignment-transitioned', + assignmentId: 'a-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + status: 'notified', + ts: '2026-03-15T10:00:05.000Z', + }, + ]; + + assignmentStore.rehydrateFromEvents(events, 'proj-1'); + const assignment = assignmentStore.getAssignment('a-001', 'proj-1'); + + // Timestamps should match the persisted events, not the current clock + expect(assignment!.createdAt).toBe('2026-03-15T10:00:00.000Z'); + expect(assignment!.notifiedAt).toBe('2026-03-15T10:00:05.000Z'); + }); +}); + +// ── Clock injection ────────────────────────────────────────────────────────── + +describe('clock injection', () => { + it('uses injected clock for timestamps', () => { + const clock = createTestClock(1700000000000); // 2023-11-14T22:13:20Z + assignmentStore.setClock(clock); + + const { assignment } = createTestAssignment(); + expect(assignment!.createdAt).toBe('2023-11-14T22:13:20.000Z'); + + clock.advance(5000); + assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1'); + + const updated = assignmentStore.getAssignment(assignment!.assignmentId, 'proj-1'); + expect(updated!.notifiedAt).toBe('2023-11-14T22:13:25.000Z'); + }); +}); diff --git a/src/apps/chat/services/assignment-store.ts b/src/apps/chat/services/assignment-store.ts new file mode 100644 index 0000000..4a5ee45 --- /dev/null +++ b/src/apps/chat/services/assignment-store.ts @@ -0,0 +1,724 @@ +import { randomUUID } from 'crypto'; +import type { PipeMode, AssignmentStatus } from '../types.js'; +import { TERMINAL_ASSIGNMENT_STATUSES, ASSIGNMENT_TRANSITIONS } from '../types.js'; +import type { Clock } from './clock.js'; +import { systemClock } from './clock.js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** A durable assignment representing a unit of work for a pipe stage. + * Assignments replace implicit one-shot delivery with a trackable entity + * that has its own lifecycle, survives reconnects, and supports reassignment. */ +export interface Assignment { + assignmentId: string; // stable UUID — immutable once created + pipeId: string; + stageId: string; // structured: "linear:1", "fan-out:alice", "synth" + payloadId: string; // references the authoritative payload in payload-store + assignee: string; // current participant name + role: 'stage-output' | 'fan-out' | 'final'; + stage?: number; // 1-indexed for linear pipes + status: AssignmentStatus; + attempt: number; // starts at 1, increments on retry (same assignee) + version: number; // optimistic concurrency — increments on every mutation + + // Timestamps (ISO 8601) + createdAt: string; + notifiedAt: string | null; + acknowledgedAt: string | null; + fetchedAt: string | null; + submittedAt: string | null; + expiredAt: string | null; + reassignedAt: string | null; + cancelledAt: string | null; + + // Reassignment chain — links assignments that replace each other + supersededBy: string | null; // assignmentId of the replacement + supersedes: string | null; // assignmentId this replaces + reassignReason: string | null; +} + +/** Error codes for assignment operations. */ +export type AssignmentErrorCode = + | 'ASSIGNMENT_NOT_FOUND' + | 'INVALID_TRANSITION' + | 'VERSION_CONFLICT' + | 'ASSIGNMENT_TERMINAL' + | 'DUPLICATE_ACTIVE'; + +/** Result of an assignment operation. */ +export interface AssignmentResult { + ok: boolean; + error?: string; + code?: AssignmentErrorCode; + assignment?: Assignment; +} + +/** Compact notification envelope — the minimal info sent via PTY instead of full payload. */ +export interface AssignmentNotification { + assignmentId: string; + pipeId: string; + stageId: string; + role: 'stage-output' | 'fan-out' | 'final'; + stage?: number; + attempt: number; + payloadId: string; +} + +// ── Storage ─────────────────────────────────────────────────────────────────── + +// projectId -> (assignmentId -> Assignment) +const stores = new Map>(); + +// projectId -> (pipeId:stageId -> assignmentId) — index for active assignment per stage +const activeIndex = new Map>(); + +// projectId -> (assigneeName -> Set) — index for assignments per participant +const participantIndex = new Map>>(); + +let clock: Clock = systemClock; + +/** Override the clock used for timestamps (for testing). */ +export function setClock(c: Clock): void { + clock = c; +} + +function getProjectStore(projectId: string | null): Map { + let store = stores.get(projectId); + if (!store) { store = new Map(); stores.set(projectId, store); } + return store; +} + +function getActiveIndex(projectId: string | null): Map { + let index = activeIndex.get(projectId); + if (!index) { index = new Map(); activeIndex.set(projectId, index); } + return index; +} + +function getParticipantIndex(projectId: string | null): Map> { + let index = participantIndex.get(projectId); + if (!index) { index = new Map(); participantIndex.set(projectId, index); } + return index; +} + +function activeKey(pipeId: string, stageId: string): string { + return `${pipeId}:${stageId}`; +} + +function addToParticipantIndex(assignee: string, assignmentId: string, projectId: string | null): void { + const index = getParticipantIndex(projectId); + let ids = index.get(assignee); + if (!ids) { ids = new Set(); index.set(assignee, ids); } + ids.add(assignmentId); +} + +function removeFromParticipantIndex(assignee: string, assignmentId: string, projectId: string | null): void { + const index = getParticipantIndex(projectId); + const ids = index.get(assignee); + if (ids) { + ids.delete(assignmentId); + if (ids.size === 0) index.delete(assignee); + } +} + +// ── Stage ID derivation ─────────────────────────────────────────────────────── + +/** Derive a structured stageId from pipe mode and role. + * Format: "linear:", "fan-out:", "synth" */ +export function deriveStageId( + mode: PipeMode, + role: 'stage-output' | 'fan-out' | 'final', + opts?: { stage?: number; assignee?: string }, +): string { + if (mode === 'linear') return `linear:${opts?.stage ?? 0}`; + if (role === 'fan-out') return `fan-out:${opts?.assignee ?? 'unknown'}`; + return 'synth'; +} + +// ── Assignment lifecycle ────────────────────────────────────────────────────── + +/** Create a new assignment for a pipe stage. + * If an active assignment already exists for this pipe+stageId, returns an error + * unless it has been superseded/reassigned. */ +export function createAssignment( + pipeId: string, + stageId: string, + payloadId: string, + assignee: string, + role: 'stage-output' | 'fan-out' | 'final', + projectId: string | null, + opts?: { stage?: number; supersedes?: string }, +): AssignmentResult { + const store = getProjectStore(projectId); + const aIndex = getActiveIndex(projectId); + const key = activeKey(pipeId, stageId); + + // Check for existing active assignment on this stage + const existingId = aIndex.get(key); + if (existingId) { + const existing = store.get(existingId); + if (existing && !TERMINAL_ASSIGNMENT_STATUSES.has(existing.status)) { + return { + ok: false, + code: 'DUPLICATE_ACTIVE', + error: `Active assignment ${existingId} already exists for ${key}`, + }; + } + } + + const attempt = opts?.supersedes + ? (store.get(opts.supersedes)?.attempt ?? 0) + 1 + : 1; + + const assignment: Assignment = { + assignmentId: randomUUID(), + pipeId, + stageId, + payloadId, + assignee, + role, + stage: opts?.stage, + status: 'assigned', + attempt, + version: 1, + createdAt: clock.isoNow(), + notifiedAt: null, + acknowledgedAt: null, + fetchedAt: null, + submittedAt: null, + expiredAt: null, + reassignedAt: null, + cancelledAt: null, + supersededBy: null, + supersedes: opts?.supersedes ?? null, + reassignReason: null, + }; + + store.set(assignment.assignmentId, assignment); + aIndex.set(key, assignment.assignmentId); + addToParticipantIndex(assignee, assignment.assignmentId, projectId); + + return { ok: true, assignment: { ...assignment } }; +} + +/** Transition an assignment to a new status. + * Validates the transition against the state machine and increments the version. + * Optionally checks the expected version for optimistic concurrency. */ +export function transitionAssignment( + assignmentId: string, + newStatus: AssignmentStatus, + projectId: string | null, + opts?: { expectedVersion?: number }, +): AssignmentResult { + const store = getProjectStore(projectId); + const assignment = store.get(assignmentId); + if (!assignment) { + return { ok: false, code: 'ASSIGNMENT_NOT_FOUND', error: `Assignment ${assignmentId} not found` }; + } + + if (TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) { + return { + ok: false, + code: 'ASSIGNMENT_TERMINAL', + error: `Assignment ${assignmentId} is in terminal status '${assignment.status}'`, + }; + } + + const allowed = ASSIGNMENT_TRANSITIONS[assignment.status]; + if (!allowed.includes(newStatus)) { + return { + ok: false, + code: 'INVALID_TRANSITION', + error: `Cannot transition from '${assignment.status}' to '${newStatus}'`, + }; + } + + if (opts?.expectedVersion !== undefined && opts.expectedVersion !== assignment.version) { + return { + ok: false, + code: 'VERSION_CONFLICT', + error: `Version conflict: expected ${opts.expectedVersion}, actual ${assignment.version}`, + }; + } + + const now = clock.isoNow(); + assignment.status = newStatus; + assignment.version++; + + // Set the corresponding timestamp + switch (newStatus) { + case 'notified': assignment.notifiedAt = now; break; + case 'acknowledged': assignment.acknowledgedAt = now; break; + case 'payload_fetched': assignment.fetchedAt = now; break; + case 'submitted': assignment.submittedAt = now; break; + case 'expired': assignment.expiredAt = now; break; + case 'reassigned': assignment.reassignedAt = now; break; + case 'cancelled': assignment.cancelledAt = now; break; + } + + // On terminal transition, remove from active index if this is the active assignment + if (TERMINAL_ASSIGNMENT_STATUSES.has(newStatus)) { + const aIndex = getActiveIndex(projectId); + const key = activeKey(assignment.pipeId, assignment.stageId); + if (aIndex.get(key) === assignmentId) { + aIndex.delete(key); + } + } + + return { ok: true, assignment: { ...assignment } }; +} + +/** Reassign an assignment to a different participant. + * Marks the current assignment as 'reassigned' and creates a new one for the new assignee. + * Returns both the old (reassigned) and new assignments. */ +export function reassignAssignment( + assignmentId: string, + newAssignee: string, + projectId: string | null, + reason: string, +): { ok: true; old: Assignment; new: Assignment } | { ok: false; error: string; code?: AssignmentErrorCode } { + const store = getProjectStore(projectId); + const old = store.get(assignmentId); + if (!old) { + return { ok: false, code: 'ASSIGNMENT_NOT_FOUND', error: `Assignment ${assignmentId} not found` }; + } + + if (TERMINAL_ASSIGNMENT_STATUSES.has(old.status)) { + return { + ok: false, + code: 'ASSIGNMENT_TERMINAL', + error: `Assignment ${assignmentId} is in terminal status '${old.status}'`, + }; + } + + // Mark the old assignment as reassigned + const now = clock.isoNow(); + old.status = 'reassigned'; + old.reassignedAt = now; + old.reassignReason = reason; + old.version++; + + // Remove from active index + const aIndex = getActiveIndex(projectId); + const key = activeKey(old.pipeId, old.stageId); + aIndex.delete(key); + + // Create the replacement assignment + const result = createAssignment( + old.pipeId, + old.stageId, + old.payloadId, + newAssignee, + old.role, + projectId, + { stage: old.stage, supersedes: old.assignmentId }, + ); + + if (!result.ok || !result.assignment) { + // Roll back the old assignment + old.status = 'assigned'; // restore — safe because we haven't updated chain yet + old.reassignedAt = null; + old.reassignReason = null; + old.version--; + aIndex.set(key, old.assignmentId); + return { ok: false, error: result.error ?? 'Failed to create replacement assignment' }; + } + + // Link the chain + old.supersededBy = result.assignment.assignmentId; + const newAssignment = store.get(result.assignment.assignmentId)!; + newAssignment.supersedes = old.assignmentId; + + return { + ok: true, + old: { ...old }, + new: { ...newAssignment }, + }; +} + +/** Retry an assignment with the same assignee (e.g., after a transient failure). + * Marks the current assignment as 'superseded' and creates a new one with incremented attempt. + * Unlike reassignAssignment, the assignee stays the same — this is a same-agent retry. */ +export function retryAssignment( + assignmentId: string, + projectId: string | null, + reason: string, +): { ok: true; old: Assignment; new: Assignment } | { ok: false; error: string; code?: AssignmentErrorCode } { + const store = getProjectStore(projectId); + const old = store.get(assignmentId); + if (!old) { + return { ok: false, code: 'ASSIGNMENT_NOT_FOUND', error: `Assignment ${assignmentId} not found` }; + } + + if (TERMINAL_ASSIGNMENT_STATUSES.has(old.status)) { + return { + ok: false, + code: 'ASSIGNMENT_TERMINAL', + error: `Assignment ${assignmentId} is in terminal status '${old.status}'`, + }; + } + + // Mark the old assignment as superseded + const now = clock.isoNow(); + old.status = 'superseded'; + old.reassignReason = reason; + old.version++; + + // Remove from active index + const aIndex = getActiveIndex(projectId); + const key = activeKey(old.pipeId, old.stageId); + aIndex.delete(key); + + // Create the replacement assignment for the same assignee + const result = createAssignment( + old.pipeId, + old.stageId, + old.payloadId, + old.assignee, // same assignee — this is a retry, not a reassignment + old.role, + projectId, + { stage: old.stage, supersedes: old.assignmentId }, + ); + + if (!result.ok || !result.assignment) { + // Roll back + old.status = 'assigned'; + old.reassignReason = null; + old.version--; + aIndex.set(key, old.assignmentId); + return { ok: false, error: result.error ?? 'Failed to create retry assignment' }; + } + + // Link the chain + old.supersededBy = result.assignment.assignmentId; + const newAssignment = store.get(result.assignment.assignmentId)!; + newAssignment.supersedes = old.assignmentId; + + return { + ok: true, + old: { ...old }, + new: { ...newAssignment }, + }; +} + +/** Cancel all non-terminal assignments for a pipe. + * Called when a pipe is cancelled or failed. Returns cancelled assignmentIds. */ +export function cancelPipeAssignments(pipeId: string, projectId: string | null): string[] { + const store = getProjectStore(projectId); + const cancelled: string[] = []; + + for (const assignment of store.values()) { + if (assignment.pipeId !== pipeId) continue; + if (TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) continue; + + assignment.status = 'cancelled'; + assignment.cancelledAt = clock.isoNow(); + assignment.version++; + cancelled.push(assignment.assignmentId); + + // Remove from active index + const aIndex = getActiveIndex(projectId); + const key = activeKey(assignment.pipeId, assignment.stageId); + if (aIndex.get(key) === assignment.assignmentId) { + aIndex.delete(key); + } + } + + return cancelled; +} + +// ── Queries ─────────────────────────────────────────────────────────────────── + +/** Get an assignment by ID. */ +export function getAssignment(assignmentId: string, projectId: string | null): Assignment | undefined { + return getProjectStore(projectId).get(assignmentId); +} + +/** Get the currently active (non-terminal) assignment for a pipe stage. + * Returns undefined if no active assignment exists. */ +export function getActiveAssignment( + pipeId: string, + stageId: string, + projectId: string | null, +): Assignment | undefined { + const aIndex = getActiveIndex(projectId); + const id = aIndex.get(activeKey(pipeId, stageId)); + if (!id) return undefined; + const assignment = getProjectStore(projectId).get(id); + if (!assignment || TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) return undefined; + return assignment; +} + +/** List all assignments for a pipe (including terminal ones for audit trail). */ +export function getAssignmentsByPipe(pipeId: string, projectId: string | null): Assignment[] { + const store = getProjectStore(projectId); + const result: Assignment[] = []; + for (const assignment of store.values()) { + if (assignment.pipeId === pipeId) result.push(assignment); + } + return result; +} + +/** Get all active (non-terminal) assignments for a participant. + * Used for reconnect recovery — the participant can see what work is pending. */ +export function getActiveAssignmentsForParticipant( + assignee: string, + projectId: string | null, +): Assignment[] { + const pIndex = getParticipantIndex(projectId); + const ids = pIndex.get(assignee); + if (!ids) return []; + const store = getProjectStore(projectId); + const result: Assignment[] = []; + for (const id of ids) { + const assignment = store.get(id); + if (assignment && !TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) { + result.push(assignment); + } + } + return result; +} + +/** Get the full reassignment chain for an assignment (oldest first). + * Follows the supersedes chain backward to the original assignment. */ +export function getAssignmentChain(assignmentId: string, projectId: string | null): Assignment[] { + const store = getProjectStore(projectId); + const chain: Assignment[] = []; + + // Walk backward to find the root + let current = store.get(assignmentId); + const visited = new Set(); + while (current && !visited.has(current.assignmentId)) { + visited.add(current.assignmentId); + chain.unshift(current); + if (current.supersedes) { + current = store.get(current.supersedes); + } else { + break; + } + } + + // Walk forward from root to find any successors not yet in the chain + let last = chain[chain.length - 1]; + while (last?.supersededBy) { + const next = store.get(last.supersededBy); + if (!next || visited.has(next.assignmentId)) break; + visited.add(next.assignmentId); + chain.push(next); + last = next; + } + + return chain; +} + +/** Build a compact notification envelope from an assignment. */ +export function toNotification(assignment: Assignment): AssignmentNotification { + return { + assignmentId: assignment.assignmentId, + pipeId: assignment.pipeId, + stageId: assignment.stageId, + role: assignment.role, + stage: assignment.stage, + attempt: assignment.attempt, + payloadId: assignment.payloadId, + }; +} + +/** Check whether an assignment is stale (has been reassigned or superseded). + * Stale assignments can still be read but not progressed. */ +export function isStale(assignmentId: string, projectId: string | null): boolean { + const assignment = getProjectStore(projectId).get(assignmentId); + if (!assignment) return true; + return assignment.status === 'reassigned' || assignment.status === 'superseded'; +} + +/** Check if a fetch/ack on a stale assignment should be silently accepted or rejected. + * After reassignment: ack/fetch are silently dropped (no error to the client). + * After cancel/expire: rejected with an error. */ +export function staleAccessPolicy( + assignmentId: string, + projectId: string | null, +): 'accept-silent' | 'reject' | 'ok' { + const assignment = getProjectStore(projectId).get(assignmentId); + if (!assignment) return 'reject'; + if (!TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) return 'ok'; + if (assignment.status === 'reassigned' || assignment.status === 'superseded') return 'accept-silent'; + return 'reject'; +} + +// ── Cleanup ─────────────────────────────────────────────────────────────────── + +/** Default retention for terminal assignments: 24 hours. */ +export const DEFAULT_ASSIGNMENT_TTL_MS = 24 * 60 * 60 * 1000; + +/** Remove terminal assignments older than the given TTL. + * Returns the number of assignments removed. */ +export function cleanupTerminalAssignments( + projectId: string | null, + ttlMs: number = DEFAULT_ASSIGNMENT_TTL_MS, +): number { + const store = getProjectStore(projectId); + const now = clock.now(); + let removed = 0; + + for (const [id, assignment] of store) { + if (!TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) continue; + + // Use the terminal timestamp for TTL calculation + const terminalTs = assignment.submittedAt + ?? assignment.expiredAt + ?? assignment.reassignedAt + ?? assignment.cancelledAt + ?? assignment.createdAt; + + if (now - new Date(terminalTs).getTime() >= ttlMs) { + store.delete(id); + removeFromParticipantIndex(assignment.assignee, id, projectId); + removed++; + } + } + + return removed; +} + +/** Get all projectIds that have assignment data in the store. */ +export function getTrackedProjectIds(): Array { + return [...stores.keys()]; +} + +// ── Recovery ────────────────────────────────────────────────────────────────── + +/** Assignment recovery event — persisted to JSONL for rehydration. */ +export interface AssignmentRecoveryEvent { + type: 'assignment-created' | 'assignment-transitioned' | 'assignment-reassigned' | 'assignment-cancelled'; + assignmentId: string; + pipeId: string; + stageId: string; + payloadId?: string; + assignee?: string; + role?: 'stage-output' | 'fan-out' | 'final'; + stage?: number; + status?: AssignmentStatus; + attempt?: number; + supersedes?: string; + newAssignee?: string; + reason?: string; + ts?: string; +} + +/** Restore persisted timestamps and version on a recovered assignment. + * Mutators stamp fresh timestamps during replay — this overwrites them + * with the original event timestamps so TTL, audit, and recovery semantics + * remain faithful to the original timeline. */ +function restoreEventTimestamp( + assignmentId: string, + projectId: string | null, + status: AssignmentStatus, + ts: string, + version?: number, +): void { + const assignment = getProjectStore(projectId).get(assignmentId); + if (!assignment) return; + + switch (status) { + case 'assigned': assignment.createdAt = ts; break; + case 'notified': assignment.notifiedAt = ts; break; + case 'acknowledged': assignment.acknowledgedAt = ts; break; + case 'payload_fetched': assignment.fetchedAt = ts; break; + case 'submitted': assignment.submittedAt = ts; break; + case 'expired': assignment.expiredAt = ts; break; + case 'reassigned': assignment.reassignedAt = ts; break; + case 'cancelled': assignment.cancelledAt = ts; break; + } + if (version !== undefined) assignment.version = version; +} + +/** Rehydrate assignment state from persisted events. + * Called on server restart. Preserves original event timestamps for TTL + * and audit fidelity. Returns assignmentIds that are still active. */ +export function rehydrateFromEvents( + events: AssignmentRecoveryEvent[], + projectId: string | null, +): string[] { + const active: string[] = []; + + for (const event of events) { + switch (event.type) { + case 'assignment-created': { + if (!event.payloadId || !event.assignee || !event.role) break; + createAssignment( + event.pipeId, + event.stageId, + event.payloadId, + event.assignee, + event.role, + projectId, + { stage: event.stage, supersedes: event.supersedes }, + ); + // Restore the assignmentId to match the persisted one + const store = getProjectStore(projectId); + const aIndex = getActiveIndex(projectId); + const key = activeKey(event.pipeId, event.stageId); + const generatedId = aIndex.get(key); + if (generatedId && generatedId !== event.assignmentId) { + const assignment = store.get(generatedId); + if (assignment) { + store.delete(generatedId); + assignment.assignmentId = event.assignmentId; + store.set(event.assignmentId, assignment); + aIndex.set(key, event.assignmentId); + // Fix participant index + removeFromParticipantIndex(event.assignee, generatedId, projectId); + addToParticipantIndex(event.assignee, event.assignmentId, projectId); + } + } + // Restore original creation timestamp + if (event.ts) { + restoreEventTimestamp(event.assignmentId, projectId, 'assigned', event.ts, event.attempt); + } + break; + } + case 'assignment-transitioned': { + if (!event.status) break; + transitionAssignment(event.assignmentId, event.status, projectId); + // Restore original event timestamp — overwrite the fresh one stamped by transitionAssignment + if (event.ts) { + restoreEventTimestamp(event.assignmentId, projectId, event.status, event.ts); + } + break; + } + case 'assignment-reassigned': { + if (!event.newAssignee) break; + reassignAssignment(event.assignmentId, event.newAssignee, projectId, event.reason ?? 'recovery'); + // Restore original reassignment timestamp on the old assignment + if (event.ts) { + restoreEventTimestamp(event.assignmentId, projectId, 'reassigned', event.ts); + } + break; + } + case 'assignment-cancelled': { + cancelPipeAssignments(event.pipeId, projectId); + break; + } + } + } + + // Collect active assignments + const store = getProjectStore(projectId); + for (const assignment of store.values()) { + if (!TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) { + active.push(assignment.assignmentId); + } + } + + return active; +} + +// ── Test helper ─────────────────────────────────────────────────────────────── + +/** Reset all in-memory state. For testing only. */ +export function _resetForTest(): void { + stores.clear(); + activeIndex.clear(); + participantIndex.clear(); + clock = systemClock; +} diff --git a/src/apps/chat/services/brainstorm-store.test.ts b/src/apps/chat/services/brainstorm-store.test.ts new file mode 100644 index 0000000..aa26077 --- /dev/null +++ b/src/apps/chat/services/brainstorm-store.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + createBrainstorm, + getBrainstorm, + updateBrainstorm, + listActiveBrainstorms, + linkChildPipe, + findBrainstormByChildPipe, + _resetForTest, +} from './brainstorm-store.js'; + +describe('brainstorm-store', () => { + beforeEach(() => { + _resetForTest(); + }); + + it('creates and retrieves a brainstorm record', () => { + const record = createBrainstorm('bs1', ['alice', 'bob'], 'design a cache', 'proj1'); + expect(record.id).toBe('bs1'); + expect(record.phase).toBe('ideas'); + expect(record.assignees).toEqual(['alice', 'bob']); + expect(record.prompt).toBe('design a cache'); + expect(record.candidateIdea).toBeNull(); + expect(record.acceptedIdea).toBeNull(); + expect(record.candidateDraft).toBeNull(); + expect(record.acceptedDraft).toBeNull(); + + const retrieved = getBrainstorm('bs1', 'proj1'); + expect(retrieved).toBe(record); // same reference + }); + + it('returns undefined for unknown brainstorm', () => { + expect(getBrainstorm('nonexistent', 'proj1')).toBeUndefined(); + }); + + it('updates a brainstorm record', () => { + createBrainstorm('bs1', ['alice', 'bob'], 'design a cache', 'proj1'); + const updated = updateBrainstorm('bs1', 'proj1', { phase: 'ideas_review', candidateIdea: 'great idea' }); + expect(updated?.phase).toBe('ideas_review'); + expect(updated?.candidateIdea).toBe('great idea'); + }); + + it('lists only active (non-complete) brainstorms', () => { + createBrainstorm('bs1', ['a', 'b'], 'topic1', 'proj1'); + createBrainstorm('bs2', ['a', 'b'], 'topic2', 'proj1'); + updateBrainstorm('bs1', 'proj1', { phase: 'complete' }); + + const active = listActiveBrainstorms('proj1'); + expect(active).toHaveLength(1); + expect(active[0].id).toBe('bs2'); + }); + + it('scopes brainstorms by project', () => { + createBrainstorm('bs1', ['a', 'b'], 'topic1', 'proj1'); + createBrainstorm('bs2', ['a', 'b'], 'topic2', 'proj2'); + + expect(getBrainstorm('bs1', 'proj1')).toBeDefined(); + expect(getBrainstorm('bs1', 'proj2')).toBeUndefined(); + expect(listActiveBrainstorms('proj1')).toHaveLength(1); + expect(listActiveBrainstorms('proj2')).toHaveLength(1); + }); + + it('links and finds child pipes', () => { + createBrainstorm('bs1', ['a', 'b'], 'topic', 'proj1'); + linkChildPipe('bs1', 'pipe-123', 'proj1'); + + const found = findBrainstormByChildPipe('pipe-123', 'proj1'); + expect(found).toBeDefined(); + expect(found?.id).toBe('bs1'); + }); + + it('returns undefined for unlinked child pipe', () => { + expect(findBrainstormByChildPipe('pipe-unknown', 'proj1')).toBeUndefined(); + }); + + it('child pipe lookup is project-scoped', () => { + createBrainstorm('bs1', ['a', 'b'], 'topic', 'proj1'); + linkChildPipe('bs1', 'pipe-123', 'proj1'); + + expect(findBrainstormByChildPipe('pipe-123', 'proj1')).toBeDefined(); + expect(findBrainstormByChildPipe('pipe-123', 'proj2')).toBeUndefined(); + }); + + it('_resetForTest clears all state including child pipe map', () => { + createBrainstorm('bs1', ['a', 'b'], 'topic', 'proj1'); + linkChildPipe('bs1', 'pipe-123', 'proj1'); + _resetForTest(); + + expect(getBrainstorm('bs1', 'proj1')).toBeUndefined(); + expect(findBrainstormByChildPipe('pipe-123', 'proj1')).toBeUndefined(); + }); +}); diff --git a/src/apps/chat/services/brainstorm-store.ts b/src/apps/chat/services/brainstorm-store.ts new file mode 100644 index 0000000..3be9f5a --- /dev/null +++ b/src/apps/chat/services/brainstorm-store.ts @@ -0,0 +1,111 @@ +// ── Brainstorm state (chat-local) ───────────────────────────────────────────── +// Thin workflow wrapper over existing merge-all and linear pipes. +// Tracks phase progression and user decisions — does NOT touch pipe reducer/store. + +export type BrainstormPhase = + | 'ideas' // merge-all child pipe running + | 'ideas_review' // waiting for user to accept/retry the idea + | 'details' // linear child pipe pass running + | 'details_review' // waiting for user to accept/adjust/finalize the detail pass + | 'finalizing' // final pass running + | 'complete'; // done + +export interface BrainstormRecord { + id: string; + assignees: string[]; + prompt: string; + phase: BrainstormPhase; + activeChildPipeId: string | null; + candidateIdea: string | null; + acceptedIdea: string | null; + candidateDraft: string | null; + acceptedDraft: string | null; + latestUserNote: string | null; + ideaIterations: number; + detailIterations: number; + createdAt: string; +} + +// projectId -> (brainstormId -> BrainstormRecord) +const stores = new Map>(); + +function getProjectStore(projectId: string | null): Map { + let store = stores.get(projectId); + if (!store) { + store = new Map(); + stores.set(projectId, store); + } + return store; +} + +export function createBrainstorm( + id: string, + assignees: string[], + prompt: string, + projectId: string | null, +): BrainstormRecord { + const store = getProjectStore(projectId); + const record: BrainstormRecord = { + id, + assignees, + prompt, + phase: 'ideas', + activeChildPipeId: null, + candidateIdea: null, + acceptedIdea: null, + candidateDraft: null, + acceptedDraft: null, + latestUserNote: null, + ideaIterations: 0, + detailIterations: 0, + createdAt: new Date().toISOString(), + }; + store.set(id, record); + return record; +} + +export function getBrainstorm(id: string, projectId: string | null): BrainstormRecord | undefined { + return getProjectStore(projectId).get(id); +} + +export function updateBrainstorm( + id: string, + projectId: string | null, + updates: Partial>, +): BrainstormRecord | undefined { + const record = getBrainstorm(id, projectId); + if (!record) return undefined; + Object.assign(record, updates); + return record; +} + +export function listActiveBrainstorms(projectId: string | null): BrainstormRecord[] { + const store = getProjectStore(projectId); + return [...store.values()].filter(r => r.phase !== 'complete'); +} + +// ── Child pipe → brainstorm mapping ────────────────────────────────────────── +// Tracks which child pipes belong to which brainstorm records. + +// "projectId:childPipeId" → brainstormId +const childPipeMap = new Map(); + +function childPipeKey(childPipeId: string, projectId: string | null): string { + return `${projectId ?? '__none__'}:${childPipeId}`; +} + +export function linkChildPipe(brainstormId: string, childPipeId: string, projectId: string | null): void { + childPipeMap.set(childPipeKey(childPipeId, projectId), brainstormId); +} + +export function findBrainstormByChildPipe(childPipeId: string, projectId: string | null): BrainstormRecord | undefined { + const brainstormId = childPipeMap.get(childPipeKey(childPipeId, projectId)); + if (!brainstormId) return undefined; + return getBrainstorm(brainstormId, projectId); +} + +/** Reset all in-memory state. For testing only. */ +export function _resetForTest(): void { + stores.clear(); + childPipeMap.clear(); +} diff --git a/src/apps/chat/services/chat-registry.pipe-submit.test.ts b/src/apps/chat/services/chat-registry.pipe-submit.test.ts new file mode 100644 index 0000000..cff3d66 --- /dev/null +++ b/src/apps/chat/services/chat-registry.pipe-submit.test.ts @@ -0,0 +1,1338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { globalPtys } from '../../shell/src/runtime/shell-state.js'; +import { setActiveProject } from '../../../project-context.js'; + +const chatStoreMock = vi.hoisted(() => { + let seq = 0; + const messages: any[] = []; + const pipeEvents: any[] = []; + return { + appendMessage: vi.fn((msg: Record) => { + const stored = { + id: `msg-${++seq}`, + ts: new Date('2026-01-01T00:00:00.000Z').toISOString(), + topic: null, + ...msg, + }; + messages.push(stored); + return stored; + }), + appendPipeEvent: vi.fn((event: Record) => { + const stored = { + id: `pipe-event-${++seq}`, + ts: new Date('2026-01-01T00:00:00.000Z').toISOString(), + ...event, + }; + pipeEvents.push(stored); + return stored; + }), + readMessages: vi.fn(() => [...messages]), + clearMessages: vi.fn(() => { + messages.length = 0; + pipeEvents.length = 0; + }), + reset: () => { + seq = 0; + messages.length = 0; + pipeEvents.length = 0; + }, + }; +}); + +vi.mock('./chat-store.js', () => ({ + appendMessage: chatStoreMock.appendMessage, + appendPipeEvent: chatStoreMock.appendPipeEvent, + readMessages: chatStoreMock.readMessages, + clearMessages: chatStoreMock.clearMessages, + saveParticipants: vi.fn(), + loadParticipants: vi.fn(() => []), +})); + +const registry = await import('./chat-registry.js'); + +describe('chat-registry store-backed pipe submissions', () => { + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + chatStoreMock.appendMessage.mockClear(); + chatStoreMock.appendPipeEvent.mockClear(); + chatStoreMock.readMessages.mockClear(); + chatStoreMock.clearMessages.mockClear(); + globalPtys.clear(); + setActiveProject({ id: 'project-chat', name: 'Chat', path: '/tmp/chat' }); + + for (const participant of registry.listParticipants()) { + registry.leave(participant.name); + } + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + globalPtys.clear(); + for (const participant of registry.listParticipants()) { + registry.leave(participant.name); + } + setActiveProject(null); + }); + + it('advances merge-all from blind fan-out to final synthesis', async () => { + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + + const startPromise = registry.send('user', `/merge-all-pipe @${alice.name} @${bob.name} review this`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const started = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(started?.mode).toBe('merge-all'); + expect(started?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`).sort()).toEqual([ + `${alice.name}:fan-out`, + `${bob.name}:fan-out`, + ]); + + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'alice analysis', 'project-chat'); + await vi.advanceTimersByTimeAsync(1_000); + const aliceResult = await aliceSubmit; + expect(aliceResult.ok).toBe(true); + expect(aliceResult.message?.pipe?.role).toBe('fan-out'); + // Alice has no more slots — her work is complete + expect(aliceResult.myWorkComplete).toBe(true); + expect(aliceResult.pendingStages).toBe(0); + + const bobFanOutSubmit = registry.submitPipeStage(pipeId!, bob.name, 'bob blind analysis', 'project-chat'); + await vi.advanceTimersByTimeAsync(2_000); + const bobFanOutResult = await bobFanOutSubmit; + expect(bobFanOutResult.ok).toBe(true); + expect(bobFanOutResult.message?.pipe?.role).toBe('fan-out'); + // Bob is the synthesizer (last assignee) — he still has the final slot pending + expect(bobFanOutResult.myWorkComplete).toBe(false); + expect(bobFanOutResult.pendingStages).toBe(1); + + const afterFanOut = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(afterFanOut?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`)).toEqual([ + `${bob.name}:final`, + ]); + + const bobFinalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'merged final', 'project-chat'); + // Final submit now broadcasts the result to all PTYs via the completion handler, + // which requires extra timer advancement (1000ms per participant for PTY delivery) + await vi.advanceTimersByTimeAsync(5_000); + const bobFinalResult = await bobFinalSubmit; + expect(bobFinalResult.ok).toBe(true); + expect(bobFinalResult.message?.pipe?.role).toBe('final'); + // Bob's final submission — all work complete + expect(bobFinalResult.myWorkComplete).toBe(true); + expect(bobFinalResult.pendingStages).toBe(0); + + const completed = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(completed?.status).toBe('completed'); + + registry.leave(alice.name); + registry.leave(bob.name); + }); + + it('defaults /explain to active attached LLMs and completes merge-all style orchestration', async () => { + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-c', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + const detached = registry.join('carol', 'llm', 'pane-c', 'carol', '\r'); + registry.join('user-self', 'user', null, null, '\r'); + registry.detach(detached.name); + + const startPromise = registry.send('user', '/explain explain this failure'); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const started = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(started?.mode).toBe('explain'); + expect(started?.assignees).toEqual([alice.name, bob.name]); + expect(started?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`).sort()).toEqual([ + `${alice.name}:fan-out`, + `${bob.name}:fan-out`, + ]); + + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'alice explanation', 'project-chat'); + await vi.advanceTimersByTimeAsync(1_000); + const aliceResult = await aliceSubmit; + expect(aliceResult.ok).toBe(true); + // Alice (non-synthesizer) has no more work after fan-out + expect(aliceResult.myWorkComplete).toBe(true); + expect(aliceResult.pendingStages).toBe(0); + + const bobFanOutSubmit = registry.submitPipeStage(pipeId!, bob.name, 'bob explanation', 'project-chat'); + await vi.advanceTimersByTimeAsync(2_000); + const bobFanOutResult = await bobFanOutSubmit; + expect(bobFanOutResult.ok).toBe(true); + // Bob (synthesizer) still has the final slot pending + expect(bobFanOutResult.myWorkComplete).toBe(false); + expect(bobFanOutResult.pendingStages).toBe(1); + + const afterFanOut = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(afterFanOut?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`)).toEqual([ + `${bob.name}:final`, + ]); + + const bobFinalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final explanation', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + const bobFinalResult = await bobFinalSubmit; + expect(bobFinalResult.ok).toBe(true); + expect(bobFinalResult.message?.pipe?.role).toBe('final'); + // Bob's final submission — all work complete + expect(bobFinalResult.myWorkComplete).toBe(true); + expect(bobFinalResult.pendingStages).toBe(0); + + const completed = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(completed?.status).toBe('completed'); + + registry.leave(alice.name); + registry.leave(bob.name); + registry.leave(detached.name); + registry.leave('user-self'); + }); + + it('defaults /summarize to active attached LLMs and completes merge-all style orchestration', async () => { + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-c', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + const detached = registry.join('carol', 'llm', 'pane-c', 'carol', '\r'); + registry.join('user-self', 'user', null, null, '\r'); + registry.detach(detached.name); + + const startPromise = registry.send('user', '/summarize summarize this long topic'); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const started = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(started?.mode).toBe('summarize'); + expect(started?.assignees).toEqual([alice.name, bob.name]); + expect(started?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`).sort()).toEqual([ + `${alice.name}:fan-out`, + `${bob.name}:fan-out`, + ]); + + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'alice summary', 'project-chat'); + await vi.advanceTimersByTimeAsync(1_000); + const aliceResult = await aliceSubmit; + expect(aliceResult.ok).toBe(true); + expect(aliceResult.myWorkComplete).toBe(true); + expect(aliceResult.pendingStages).toBe(0); + + const bobFanOutSubmit = registry.submitPipeStage(pipeId!, bob.name, 'bob summary', 'project-chat'); + await vi.advanceTimersByTimeAsync(2_000); + const bobFanOutResult = await bobFanOutSubmit; + expect(bobFanOutResult.ok).toBe(true); + expect(bobFanOutResult.myWorkComplete).toBe(false); + expect(bobFanOutResult.pendingStages).toBe(1); + + const afterFanOut = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(afterFanOut?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`)).toEqual([ + `${bob.name}:final`, + ]); + + const bobFinalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final summary', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + const bobFinalResult = await bobFinalSubmit; + expect(bobFinalResult.ok).toBe(true); + expect(bobFinalResult.message?.pipe?.role).toBe('final'); + expect(bobFinalResult.myWorkComplete).toBe(true); + expect(bobFinalResult.pendingStages).toBe(0); + + const completed = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(completed?.status).toBe('completed'); + + registry.leave(alice.name); + registry.leave(bob.name); + registry.leave(detached.name); + registry.leave('user-self'); + }); + + it('returns myWorkComplete for linear pipe stages', async () => { + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} analyze this`); + await vi.advanceTimersByTimeAsync(3_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const started = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(started?.mode).toBe('linear'); + + // Alice is stage 1 — she has exactly one slot, so after submission she's done + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat'); + await vi.advanceTimersByTimeAsync(3_000); + const aliceResult = await aliceSubmit; + expect(aliceResult.ok).toBe(true); + expect(aliceResult.myWorkComplete).toBe(true); + expect(aliceResult.pendingStages).toBe(0); + + // Bob is stage 2 (final) — he also has exactly one slot + const bobSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + const bobResult = await bobSubmit; + expect(bobResult.ok).toBe(true); + expect(bobResult.message?.pipe?.role).toBe('final'); + expect(bobResult.myWorkComplete).toBe(true); + expect(bobResult.pendingStages).toBe(0); + + const completed = registry.getPipeStoreStatus(pipeId!, 'project-chat'); + expect(completed?.status).toBe('completed'); + + registry.leave(alice.name); + registry.leave(bob.name); + }); + + it('does not emit a private pipe-step event for the final stage submission', async () => { + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} analyze this`); + await vi.advanceTimersByTimeAsync(3_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat'); + await vi.advanceTimersByTimeAsync(3_000); + await aliceSubmit; + + chatStoreMock.appendPipeEvent.mockClear(); + + const finalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + await finalSubmit; + + const stageOutputCalls = chatStoreMock.appendPipeEvent.mock.calls + .map(([event]) => event) + .filter((event: any) => event.type === 'stage-output'); + expect(stageOutputCalls).toEqual([]); + + registry.leave(alice.name); + registry.leave(bob.name); + }); + + it('preserves the pipe anchor on the public final chat message', async () => { + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} analyze this`); + await vi.advanceTimersByTimeAsync(3_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat'); + await vi.advanceTimersByTimeAsync(3_000); + await aliceSubmit; + + chatStoreMock.appendMessage.mockClear(); + + const finalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + await finalSubmit; + + const finalMessage = chatStoreMock.appendMessage.mock.calls + .map(([message]) => message) + .find((message: any) => message?.pipe?.role === 'final'); + + expect(finalMessage).toBeDefined(); + expect(finalMessage!.body).toBe(`#pipe-${pipeId} final output`); + + registry.leave(alice.name); + registry.leave(bob.name); + }); + + it('final output is NOT PTY-delivered to LLM participants (user-only delivery)', async () => { + const writesA: string[] = []; + const writesB: string[] = []; + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn((c: string) => { writesA.push(c); }) } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn((c: string) => { writesB.push(c); }) } as never, + chunks: [], + totalLen: 0, + }); + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do work`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + // Clear writes from pipe setup (handoff notifications) + writesA.length = 0; + writesB.length = 0; + + // Alice submits stage 1 + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat'); + await vi.advanceTimersByTimeAsync(3_000); + await aliceSubmit; + + // Clear writes from stage 1 handoff to bob + writesA.length = 0; + writesB.length = 0; + + // Bob submits final stage + const bobSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output content', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + await bobSubmit; + + // Neither LLM should have received the final output via PTY + const allWrites = [...writesA, ...writesB]; + const finalDeliveries = allWrites.filter(w => w.includes('final output content')); + expect(finalDeliveries).toEqual([]); + + registry.leave(alice.name); + registry.leave(bob.name); + }); + + it('final output message is persisted with to="user" (not broadcast)', async () => { + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do work`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId; + + const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat'); + await vi.advanceTimersByTimeAsync(3_000); + await aliceSubmit; + + chatStoreMock.appendMessage.mockClear(); + + const bobSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat'); + await vi.advanceTimersByTimeAsync(5_000); + await bobSubmit; + + const finalMessage = chatStoreMock.appendMessage.mock.calls + .map(([message]) => message) + .find((message: any) => message?.pipe?.role === 'final'); + + expect(finalMessage).toBeDefined(); + expect(finalMessage!.to).toBe('user'); + + registry.leave(alice.name); + registry.leave(bob.name); + }); +}); + +describe('readPipeOutput entitlement', () => { + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + chatStoreMock.appendMessage.mockClear(); + chatStoreMock.appendPipeEvent.mockClear(); + globalPtys.clear(); + setActiveProject({ id: 'project-read', name: 'Read', path: '/tmp/read' }); + for (const p of registry.listParticipants()) registry.leave(p.name); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + globalPtys.clear(); + for (const p of registry.listParticipants()) registry.leave(p.name); + setActiveProject(null); + }); + + function addPanes(...ids: string[]) { + for (const id of ids) { + globalPtys.set(id, { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 }); + } + } + + it('returns 404 for unknown pipe', () => { + const result = registry.readPipeOutput('nonexistent', 'alice', 'project-read'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(404); + }); + + it('returns 403 for non-assignee', async () => { + addPanes('p1', 'p2', 'p3'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + registry.join('carol', 'llm', 'p3', 'carol', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const result = registry.readPipeOutput(pipeId!, 'carol', 'project-read'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(403); + }); + + it('returns prompt payload for stage-1 caller', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const result = registry.readPipeOutput(pipeId!, alice.name, 'project-read'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.stagePayload).toContain('Prompt: do something'); + expect(result.data.previousOutput).toBeNull(); + } + }); + + it('returns previous stage output for linear downstream assignee', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + // Bob can't read yet — handoff for stage 2 not emitted + const premature = registry.readPipeOutput(pipeId!, bob.name, 'project-read'); + expect(premature.ok).toBe(false); + if (!premature.ok) expect(premature.status).toBe(409); + + // Alice submits stage 1 → triggers handoff to bob (stage 2) + const submitPromise = registry.submitPipeStage(pipeId!, alice.name, 'alice output', 'project-read'); + await vi.advanceTimersByTimeAsync(2_000); + await submitPromise; + + // Now bob can read alice's output + const result = registry.readPipeOutput(pipeId!, bob.name, 'project-read'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.previousOutput?.stage).toBe(1); + expect(result.data.previousOutput?.from).toBe(alice.name); + expect(result.data.previousOutput?.content).toBe('alice output'); + } + }); + + it('returns 409 for completed pipe', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + // Submit both stages to complete the pipe + const s1 = registry.submitPipeStage(pipeId!, alice.name, 'alice output', 'project-read'); + await vi.advanceTimersByTimeAsync(2_000); + await s1; + + const s2 = registry.submitPipeStage(pipeId!, bob.name, 'bob output', 'project-read'); + await vi.advanceTimersByTimeAsync(2_000); + await s2; + + // Pipe is now completed — reads should fail with 409 + const result = registry.readPipeOutput(pipeId!, bob.name, 'project-read'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(409); + }); + + it('returns fan-out outputs for synthesizer in merge mode', async () => { + addPanes('p1', 'p2', 'p3'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + const carol = registry.join('carol', 'llm', 'p3', 'carol', '\r'); + + const startPromise = registry.send('user', `/merge-pipe @${alice.name} @${bob.name} @${carol.name} review this`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + // Carol (synthesizer) can't read yet — synth not requested + const premature = registry.readPipeOutput(pipeId!, carol.name, 'project-read'); + expect(premature.ok).toBe(false); + if (!premature.ok) expect(premature.status).toBe(409); + + // Alice (fan-out) can read her stage payload before submitting + const fanOutPrompt = registry.readPipeOutput(pipeId!, alice.name, 'project-read'); + expect(fanOutPrompt.ok).toBe(true); + if (fanOutPrompt.ok) { + expect(fanOutPrompt.data.stagePayload).toContain('review this'); + expect(fanOutPrompt.data.fanOutOutputs).toBeUndefined(); + } + + // Submit fan-out outputs + const s1 = registry.submitPipeStage(pipeId!, alice.name, 'alice analysis', 'project-read'); + await vi.advanceTimersByTimeAsync(2_000); + await s1; + + const s2 = registry.submitPipeStage(pipeId!, bob.name, 'bob analysis', 'project-read'); + await vi.advanceTimersByTimeAsync(2_000); + await s2; + + // Now carol can read fan-out outputs + const result = registry.readPipeOutput(pipeId!, carol.name, 'project-read'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.fanOutOutputs).toHaveLength(2); + const fromNames = result.data.fanOutOutputs!.map(o => o.from).sort(); + expect(fromNames).toEqual([alice.name, bob.name]); + } + }); + + it('returns fan-out prompt payload for non-synth participant in merge mode', async () => { + addPanes('p1', 'p2', 'p3'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + const carol = registry.join('carol', 'llm', 'p3', 'carol', '\r'); + + const startPromise = registry.send('user', `/merge-pipe @${alice.name} @${bob.name} @${carol.name} review this`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const result = registry.readPipeOutput(pipeId!, alice.name, 'project-read'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.stagePayload).toContain('review this'); + expect(result.data.previousOutput).toBeNull(); + expect(result.data.fanOutOutputs).toBeUndefined(); + } + }); + + it('returns prompt payload for merge-all synthesizer during fan-out phase', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const startPromise = registry.send('user', `/explain @${alice.name} @${bob.name} explain this failure`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + const result = registry.readPipeOutput(pipeId!, bob.name, 'project-read'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.stagePayload).toContain('explain this failure'); + expect(result.data.previousOutput).toBeNull(); + expect(result.data.fanOutOutputs).toBeUndefined(); + } + }); + + it('cross-stage isolation: stage-3 cannot read stage-1 output (only stage-2)', async () => { + addPanes('p1', 'p2', 'p3'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + const carol = registry.join('carol', 'llm', 'p3', 'carol', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} @${carol.name} chain work`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + // Alice submits stage 1 + const s1 = registry.submitPipeStage(pipeId!, alice.name, 'stage-1 output', 'project-read'); + await vi.advanceTimersByTimeAsync(2_000); + await s1; + + // Bob can read stage 1 (previous to stage 2) + const bobRead = registry.readPipeOutput(pipeId!, bob.name, 'project-read'); + expect(bobRead.ok).toBe(true); + if (bobRead.ok) { + expect(bobRead.data.previousOutput?.stage).toBe(1); + expect(bobRead.data.previousOutput?.content).toBe('stage-1 output'); + } + + // Carol cannot read yet — handoff for stage 3 not emitted + const carolPremature = registry.readPipeOutput(pipeId!, carol.name, 'project-read'); + expect(carolPremature.ok).toBe(false); + if (!carolPremature.ok) expect(carolPremature.status).toBe(409); + + // Bob submits stage 2 + const s2 = registry.submitPipeStage(pipeId!, bob.name, 'stage-2 output', 'project-read'); + await vi.advanceTimersByTimeAsync(2_000); + await s2; + + // Now carol can read stage 2 (not stage 1) + const carolRead = registry.readPipeOutput(pipeId!, carol.name, 'project-read'); + expect(carolRead.ok).toBe(true); + if (carolRead.ok) { + expect(carolRead.data.previousOutput?.stage).toBe(2); + expect(carolRead.data.previousOutput?.content).toBe('stage-2 output'); + } + }); + + it('handoff prompt does not contain inline output markers', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const pipeId = registry.getActivePipes('project-read')[0]?.pipeId; + expect(pipeId).toBeDefined(); + + // Alice submits stage 1 → triggers handoff to bob + const submitPromise = registry.submitPipeStage(pipeId!, alice.name, 'big output here', 'project-read'); + await vi.advanceTimersByTimeAsync(2_000); + await submitPromise; + + // Handoff is delivered via PTY to bob's pane (p2) + const bobPty = globalPtys.get('p2') as { ptyProcess: { write: ReturnType } } | undefined; + expect(bobPty).toBeDefined(); + const writeCall = bobPty!.ptyProcess.write.mock.calls.find( + (args: unknown[]) => typeof args[0] === 'string' && args[0].includes(`#pipe-${pipeId}`) && args[0].includes('stage 2'), + ); + expect(writeCall).toBeDefined(); + const handoffText = writeCall![0] as string; + // Must NOT contain inline output + expect(handoffText).not.toContain('--- Previous stage output ---'); + expect(handoffText).not.toContain('big output here'); + // Must contain pipe_read_output instruction + expect(handoffText).toContain('pipe_read_output(pipeId='); + }); +}); + +const brainstormStore = await import('./brainstorm-store.js'); + +describe('brainstorm command handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + chatStoreMock.appendMessage.mockClear(); + brainstormStore._resetForTest(); + globalPtys.clear(); + setActiveProject({ id: 'project-bs', name: 'BS', path: '/tmp/bs' }); + for (const p of registry.listParticipants()) registry.leave(p.name); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + globalPtys.clear(); + for (const p of registry.listParticipants()) registry.leave(p.name); + setActiveProject(null); + }); + + function addPanes(...ids: string[]) { + for (const id of ids) { + globalPtys.set(id, { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 }); + } + } + + it('creates a brainstorm record and launches child pipe', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a caching layer`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const brainstorms = registry.getActiveBrainstorms('project-bs'); + expect(brainstorms).toHaveLength(1); + expect(brainstorms[0].prompt).toBe('design a caching layer'); + expect(brainstorms[0].assignees).toEqual([alice.name, bob.name]); + expect(brainstorms[0].phase).toBe('ideas'); + expect(brainstorms[0].activeChildPipeId).toBeDefined(); + expect(brainstorms[0].ideaIterations).toBe(1); + }); + + it('defaults to all active LLMs when no assignees specified', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const sendPromise = registry.send('user', `/brainstorm design a caching layer`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const brainstorms = registry.getActiveBrainstorms('project-bs'); + expect(brainstorms).toHaveLength(1); + expect(brainstorms[0].assignees.sort()).toEqual([alice.name, bob.name].sort()); + }); + + it('emits a start message with brainstorm anchor', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const brainstorms = registry.getActiveBrainstorms('project-bs'); + const startMsg = chatStoreMock.appendMessage.mock.calls + .map(([m]: [any]) => m) + .find((m: any) => typeof m?.body === 'string' && m.body.includes('#brainstorm-')); + expect(startMsg).toBeDefined(); + expect(startMsg.body).toContain(`#brainstorm-${brainstorms[0].id}`); + expect(startMsg.body).toContain('Phase: Ideas'); + }); + + it('rejects with error when fewer than 2 LLMs available', async () => { + addPanes('p1'); + registry.join('alice', 'llm', 'p1', 'alice', '\r'); + + await registry.send('user', `/brainstorm design a cache`); + + const errorMsg = chatStoreMock.appendMessage.mock.calls + .map(([m]: [any]) => m) + .find((m: any) => typeof m?.body === 'string' && m.body.includes('Brainstorm error')); + expect(errorMsg).toBeDefined(); + expect(errorMsg.body).toContain('at least 2'); + }); + + it('rejects when first leading @name is unknown', async () => { + addPanes('p1', 'p2'); + registry.join('alice', 'llm', 'p1', 'alice', '\r'); + registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + await registry.send('user', `/brainstorm @ghost design a cache`); + + const errorMsg = chatStoreMock.appendMessage.mock.calls + .map(([m]: [any]) => m) + .find((m: any) => typeof m?.body === 'string' && m.body.includes('Brainstorm error')); + expect(errorMsg).toBeDefined(); + expect(errorMsg.body).toContain('@ghost'); + }); + + it('transitions to ideas_review with candidateIdea when child pipe completes', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const bs = registry.getActiveBrainstorms('project-bs')[0]; + const childPipeId = bs.activeChildPipeId!; + expect(childPipeId).toBeDefined(); + + // Submit fan-out outputs from both LLMs, then bob submits final synthesis + const s1 = registry.submitPipeStage(childPipeId, alice.name, 'alice idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s1; + + const s2 = registry.submitPipeStage(childPipeId, bob.name, 'bob idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s2; + + // Bob is the synthesizer in merge-all — submits the final output + const s3 = registry.submitPipeStage(childPipeId, bob.name, 'synthesized idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s3; + + // Brainstorm should now be in ideas_review with candidateIdea set + const updated = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(updated?.phase).toBe('ideas_review'); + expect(updated?.candidateIdea).toBeTruthy(); + expect(updated?.acceptedIdea).toBeNull(); + expect(updated?.activeChildPipeId).toBeNull(); + }); + + it('retry re-launches a new child pipe with user note', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const bs = registry.getActiveBrainstorms('project-bs')[0]; + const firstChildId = bs.activeChildPipeId!; + + // Complete the first idea round (fan-out + synthesis) + const s1 = registry.submitPipeStage(firstChildId, alice.name, 'alice idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s1; + const s2 = registry.submitPipeStage(firstChildId, bob.name, 'bob idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s2; + const s3 = registry.submitPipeStage(firstChildId, bob.name, 'synthesized idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s3; + + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('ideas_review'); + + // Retry with a note + const retryPromise = registry.brainstormRetryIdeas(bs.id, 'focus on Redis', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + const retried = await retryPromise; + expect(retried).toBe(true); + + const afterRetry = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(afterRetry?.phase).toBe('ideas'); + expect(afterRetry?.activeChildPipeId).not.toBe(firstChildId); + expect(afterRetry?.activeChildPipeId).toBeTruthy(); + expect(afterRetry?.ideaIterations).toBe(2); + expect(afterRetry?.latestUserNote).toBe('focus on Redis'); + }); + + it('accept promotes candidateIdea to acceptedIdea and advances to details', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const bs = registry.getActiveBrainstorms('project-bs')[0]; + const childPipeId = bs.activeChildPipeId!; + + // Complete the idea round (fan-out + synthesis) + const s1 = registry.submitPipeStage(childPipeId, alice.name, 'alice idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s1; + const s2 = registry.submitPipeStage(childPipeId, bob.name, 'bob idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s2; + const s3 = registry.submitPipeStage(childPipeId, bob.name, 'synthesized idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s3; + + const preAccept = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(preAccept?.phase).toBe('ideas_review'); + expect(preAccept?.candidateIdea).toBeTruthy(); + const candidateBeforeAccept = preAccept!.candidateIdea; + + // Accept the idea (launches detail pipe) + const acceptPromise = registry.brainstormAcceptIdea(bs.id, 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + const accepted = await acceptPromise; + expect(accepted).toBe(true); + + const afterAccept = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(afterAccept?.phase).toBe('details'); + expect(afterAccept?.acceptedIdea).toBe(candidateBeforeAccept); + expect(afterAccept?.candidateIdea).toBeNull(); + }); + + it('reject retry when not in ideas_review phase', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const bs = registry.getActiveBrainstorms('project-bs')[0]; + // Still in 'ideas' phase (pipe running), retry should fail + const retried = await registry.brainstormRetryIdeas(bs.id, null, 'project-bs'); + expect(retried).toBe(false); + }); + + /** Helper: run a brainstorm through idea acceptance, returning the record. */ + async function runThroughIdeaAcceptance(alice: any, bob: any) { + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const bs = registry.getActiveBrainstorms('project-bs')[0]; + const childId = bs.activeChildPipeId!; + + // Complete merge-all idea round (fan-out + synthesis) + const s1 = registry.submitPipeStage(childId, alice.name, 'alice idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s1; + const s2 = registry.submitPipeStage(childId, bob.name, 'bob idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s2; + const s3 = registry.submitPipeStage(childId, bob.name, 'synthesized idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s3; + + // Accept the idea → launches detail pipe + const acceptPromise = registry.brainstormAcceptIdea(bs.id, 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await acceptPromise; + + return bs; + } + + it('accept idea launches linear detail pipe', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const bs = await runThroughIdeaAcceptance(alice, bob); + const record = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(record?.phase).toBe('details'); + expect(record?.acceptedIdea).toBeTruthy(); + expect(record?.activeChildPipeId).toBeTruthy(); + expect(record?.detailIterations).toBe(1); + }); + + it('detail pipe completion transitions to details_review with candidateDraft', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const bs = await runThroughIdeaAcceptance(alice, bob); + const detailPipeId = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!; + + // Complete the linear detail pipe (alice stage 1 → bob stage 2 final) + const d1 = registry.submitPipeStage(detailPipeId, alice.name, 'alice details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await d1; + const d2 = registry.submitPipeStage(detailPipeId, bob.name, 'bob final details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await d2; + + const record = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(record?.phase).toBe('details_review'); + expect(record?.candidateDraft).toBeTruthy(); + expect(record?.activeChildPipeId).toBeNull(); + }); + + it('finalize accepts draft and launches final pass, then completes brainstorm', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const bs = await runThroughIdeaAcceptance(alice, bob); + const detailPipeId = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!; + + // Complete detail pipe + const d1 = registry.submitPipeStage(detailPipeId, alice.name, 'alice details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await d1; + const d2 = registry.submitPipeStage(detailPipeId, bob.name, 'bob final details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await d2; + + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('details_review'); + + // Finalize → launches final pass + const finalizePromise = registry.brainstormFinalize(bs.id, 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await finalizePromise; + + const afterFinalize = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(afterFinalize?.phase).toBe('finalizing'); + expect(afterFinalize?.acceptedDraft).toBeTruthy(); + + // Complete the final pass (single assignee: alice) + const finalPipeId = afterFinalize!.activeChildPipeId!; + const f1 = registry.submitPipeStage(finalPipeId, alice.name, 'final comprehensive document', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await f1; + + const completed = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(completed?.phase).toBe('complete'); + }); + + it('back to ideas returns to ideas_review from details_review', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const bs = await runThroughIdeaAcceptance(alice, bob); + const detailPipeId = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!; + + // Complete detail pipe + const d1 = registry.submitPipeStage(detailPipeId, alice.name, 'alice details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await d1; + const d2 = registry.submitPipeStage(detailPipeId, bob.name, 'bob final details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await d2; + + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('details_review'); + + const backed = await registry.brainstormBackToIdeas(bs.id, 'project-bs'); + expect(backed).toBe(true); + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('ideas_review'); + }); + + it('adjust relaunches detail pass with user note', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const bs = await runThroughIdeaAcceptance(alice, bob); + const firstDetailId = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!; + + // Complete detail pipe + const d1 = registry.submitPipeStage(firstDetailId, alice.name, 'alice details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await d1; + const d2 = registry.submitPipeStage(firstDetailId, bob.name, 'bob final details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await d2; + + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('details_review'); + + // Adjust with a note + const adjustPromise = registry.brainstormAdjustDetails(bs.id, 'add error handling section', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + const adjusted = await adjustPromise; + expect(adjusted).toBe(true); + + const afterAdjust = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(afterAdjust?.phase).toBe('details'); + expect(afterAdjust?.activeChildPipeId).not.toBe(firstDetailId); + expect(afterAdjust?.activeChildPipeId).toBeTruthy(); + expect(afterAdjust?.detailIterations).toBe(2); + expect(afterAdjust?.latestUserNote).toBe('add error handling section'); + }); + + it('idea acceptance clears latestUserNote so it does not leak into detail phase', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + // Start brainstorm and complete idea round + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const bs = registry.getActiveBrainstorms('project-bs')[0]; + const childId = bs.activeChildPipeId!; + + const s1 = registry.submitPipeStage(childId, alice.name, 'alice idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s1; + const s2 = registry.submitPipeStage(childId, bob.name, 'bob idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s2; + const s3 = registry.submitPipeStage(childId, bob.name, 'synthesized idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await s3; + + // Retry with a note + const retryPromise = registry.brainstormRetryIdeas(bs.id, 'focus on Redis', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await retryPromise; + + // Complete second idea round + const childId2 = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!; + const r1 = registry.submitPipeStage(childId2, alice.name, 'alice redis idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await r1; + const r2 = registry.submitPipeStage(childId2, bob.name, 'bob redis idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await r2; + const r3 = registry.submitPipeStage(childId2, bob.name, 'synthesized redis idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await r3; + + // Note should still be set before acceptance + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.latestUserNote).toBe('focus on Redis'); + + // Accept idea → should clear the note + const acceptPromise = registry.brainstormAcceptIdea(bs.id, 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await acceptPromise; + + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.latestUserNote).toBeNull(); + }); + + it('brainstorm does not add a new PipeMode — child pipes use existing modes', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await sendPromise; + + const bs = registry.getActiveBrainstorms('project-bs')[0]; + // The child pipe should be a standard merge-all pipe, not a new "brainstorm" mode + const childPipeStatus = registry.getPipeStoreStatus(bs.activeChildPipeId!, 'project-bs'); + expect(childPipeStatus?.mode).toBe('merge-all'); + }); + + it('full end-to-end brainstorm flow: start → ideas → accept → details → finalize → complete', async () => { + addPanes('p1', 'p2'); + const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r'); + const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r'); + + // Phase 1: Start brainstorm → merge-all idea round + const startPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`); + await vi.advanceTimersByTimeAsync(2_000); + await startPromise; + + const bs = registry.getActiveBrainstorms('project-bs')[0]; + expect(bs.phase).toBe('ideas'); + + // Complete merge-all: fan-out + synthesis + const ideaPipeId = bs.activeChildPipeId!; + let sub = registry.submitPipeStage(ideaPipeId, alice.name, 'alice idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); await sub; + sub = registry.submitPipeStage(ideaPipeId, bob.name, 'bob idea', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); await sub; + sub = registry.submitPipeStage(ideaPipeId, bob.name, 'merged idea summary', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); await sub; + + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('ideas_review'); + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.candidateIdea).toBeTruthy(); + + // Phase 2: Accept idea → linear detail round + const acceptPromise = registry.brainstormAcceptIdea(bs.id, 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await acceptPromise; + + const afterAccept = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(afterAccept?.phase).toBe('details'); + expect(afterAccept?.acceptedIdea).toBeTruthy(); + const detailPipeId = afterAccept!.activeChildPipeId!; + + // Verify child pipe is a standard linear mode + expect(registry.getPipeStoreStatus(detailPipeId, 'project-bs')?.mode).toBe('linear'); + + // Complete linear: alice stage 1 → bob stage 2 (final) + sub = registry.submitPipeStage(detailPipeId, alice.name, 'alice details', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); await sub; + sub = registry.submitPipeStage(detailPipeId, bob.name, 'detailed draft', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); await sub; + + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('details_review'); + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.candidateDraft).toBeTruthy(); + + // Phase 3: Finalize → single assignee final pass + const finalizePromise = registry.brainstormFinalize(bs.id, 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); + await finalizePromise; + + const afterFinalize = registry.getBrainstormRecord(bs.id, 'project-bs'); + expect(afterFinalize?.phase).toBe('finalizing'); + expect(afterFinalize?.acceptedDraft).toBeTruthy(); + const finalPipeId = afterFinalize!.activeChildPipeId!; + + // Complete final pass + sub = registry.submitPipeStage(finalPipeId, alice.name, 'final comprehensive document', 'project-bs'); + await vi.advanceTimersByTimeAsync(2_000); await sub; + + expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('complete'); + // Brainstorm should no longer appear in active list + expect(registry.getActiveBrainstorms('project-bs')).toHaveLength(0); + }); +}); diff --git a/src/apps/chat/services/chat-registry.targeted-delivery.test.ts b/src/apps/chat/services/chat-registry.targeted-delivery.test.ts new file mode 100644 index 0000000..4162b27 --- /dev/null +++ b/src/apps/chat/services/chat-registry.targeted-delivery.test.ts @@ -0,0 +1,663 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { globalPtys } from '../../shell/src/runtime/shell-state.js'; +import { setActiveProject } from '../../../project-context.js'; + +const chatStoreMock = vi.hoisted(() => { + let seq = 0; + return { + appendMessage: vi.fn((msg: { from: string; to: string | null; body: string; type: string }) => ({ + id: `msg-${++seq}`, + ts: new Date('2026-01-01T00:00:00.000Z').toISOString(), + topic: null, + ...msg, + })), + appendPipeEvent: vi.fn((event: Record) => ({ + id: `pipe-event-${++seq}`, + ts: new Date('2026-01-01T00:00:00.000Z').toISOString(), + ...event, + })), + clearMessages: vi.fn(), + readMessages: vi.fn(() => []), + reset: () => { seq = 0; }, + }; +}); + +vi.mock('./chat-store.js', () => ({ + appendMessage: chatStoreMock.appendMessage, + appendPipeEvent: chatStoreMock.appendPipeEvent, + clearMessages: chatStoreMock.clearMessages, + readMessages: chatStoreMock.readMessages, + saveParticipants: vi.fn(), + loadParticipants: vi.fn(() => []), +})); + +const registry = await import('./chat-registry.js'); + +function pty(from: string, body: string): string { + return `[DevGlide Chat] @${from}: ${body}`; +} + +async function flushDeliveryQueue(): Promise { + await vi.advanceTimersByTimeAsync(0); +} + +// ═══════════════════════════════════════════════════════════════════ +// UNIT TESTS — parseTargetTokens (pure function, no state) +// ═══════════════════════════════════════════════════════════════════ + +describe('parseTargetTokens', () => { + it('extracts a single @mention', () => { + expect(registry.parseTargetTokens('@claude-7 do X')).toEqual(['claude-7']); + }); + + it('extracts @all', () => { + expect(registry.parseTargetTokens('@all heads up')).toEqual(['all']); + }); + + it('extracts multiple @mentions', () => { + expect(registry.parseTargetTokens('@claude-7 @codex-14 review this')).toEqual(['claude-7', 'codex-14']); + }); + + it('returns empty for no mentions', () => { + expect(registry.parseTargetTokens('no mentions here')).toEqual([]); + }); + + it('extracts @user as a token', () => { + expect(registry.parseTargetTokens('@user done')).toEqual(['user']); + }); + + it('extracts @team-prefixed tokens', () => { + expect(registry.parseTargetTokens('@team-ui-squad go')).toEqual(['team-ui-squad']); + }); + + it('strips trailing punctuation from tokens', () => { + expect(registry.parseTargetTokens('@claude-7, @codex-14: check')).toEqual(['claude-7', 'codex-14']); + }); + + it('deduplicates repeated mentions', () => { + expect(registry.parseTargetTokens('@claude-7 and @claude-7 again')).toEqual(['claude-7']); + }); + + it('merges explicit to param for user senders', () => { + expect(registry.parseTargetTokens('@codex-14 review', 'claude-7', 'user')).toEqual(['claude-7', 'codex-14']); + }); + + it('merges explicit to param for LLM senders (issue 2 fix)', () => { + expect(registry.parseTargetTokens('@codex-14 review', 'claude-7', 'llm')).toEqual(['claude-7', 'codex-14']); + }); + + it('deduplicates to param when also in body', () => { + expect(registry.parseTargetTokens('@claude-7 check', 'claude-7', 'user')).toEqual(['claude-7']); + }); + + // ── Code-aware mention extraction (self-loop bug fix) ───────────── + // Mentions inside inline code spans (`...`) and fenced code blocks (```...```) + // are example syntax, not actual addressees. The parser must skip them. + + it('ignores @mention inside inline code span', () => { + expect(registry.parseTargetTokens('use `@claude-7 fix` to assign')).toEqual([]); + }); + + it('ignores @mention inside fenced code block', () => { + const body = 'example:\n```\n@claude-7 do this\n```\nplease'; + expect(registry.parseTargetTokens(body)).toEqual([]); + }); + + it('ignores @mention inside fenced code block with language tag', () => { + const body = '```ts\nconst x = "@claude-7";\n```'; + expect(registry.parseTargetTokens(body)).toEqual([]); + }); + + it('still captures real prose mentions when code blocks exist', () => { + const body = '@codex-14 see example: `@claude-7 fix` — got it?'; + expect(registry.parseTargetTokens(body)).toEqual(['codex-14']); + }); + + it('does not capture regex literal characters as a mention token', () => { + // Real-world: claude-3 explained the bug using `/@(\\S+)/g` in a code block + // and the parser captured "(\S+)/g" as a recipient. + const body = 'the parser uses `/@(\\S+)/g` to scan'; + expect(registry.parseTargetTokens(body)).toEqual([]); + }); + + it('handles mentions split across prose and code without leaking', () => { + const body = '@codex-14 here is the bug:\n```\n@self-loop here\n```\nfix it'; + expect(registry.parseTargetTokens(body)).toEqual(['codex-14']); + }); + + // ── Markdown-immune mention parsing (recipient-garbage bug) ───────── + // Real-world: a chat message containing markdown bold around a mention + // like `**Coordination, @codex-3:**` previously captured `codex-3:**` + // as a token because /@(\S+)/g is too greedy and the trailing-punct + // strip only handled `[,.:;!?]+`. + + it('does not capture trailing markdown-bold marker as part of mention', () => { + expect(registry.parseTargetTokens('**Coordination, @codex-3:**')).toEqual(['codex-3']); + }); + + it('does not capture leading markdown bold as part of mention', () => { + expect(registry.parseTargetTokens('**@user @codex-3** review')).toEqual(['user', 'codex-3']); + }); + + it('does not capture trailing underscore emphasis as part of mention', () => { + expect(registry.parseTargetTokens('emphasised _@claude-7_ here')).toEqual(['claude-7']); + }); + + it('does not capture trailing parenthesis as part of mention', () => { + expect(registry.parseTargetTokens('(see @codex-14) for context')).toEqual(['codex-14']); + }); + + it('does not capture trailing tilde or asterisk decoration', () => { + expect(registry.parseTargetTokens('~@claude-7~ *@codex-14*')).toEqual(['claude-7', 'codex-14']); + }); + + // ── Comma-separated `to` param (parser stored it as one literal) ──── + // Real-world: an MCP caller passed `to: "codex-3,pi-1"` and the parser + // stored that whole string as a single token, which then leaked into + // both the unresolved targets AND the displayed `msg.to` header. + + it('splits comma-separated to param into separate tokens', () => { + expect(registry.parseTargetTokens('hello', 'codex-7,pi-1', 'llm')).toEqual(['codex-7', 'pi-1']); + }); + + it('splits comma+space-separated to param', () => { + expect(registry.parseTargetTokens('hello', 'codex-7, pi-1', 'llm')).toEqual(['codex-7', 'pi-1']); + }); + + it('dedupes comma-separated to param against body @mentions', () => { + expect(registry.parseTargetTokens('@codex-7 review', 'codex-7,pi-1', 'llm')) + .toEqual(['codex-7', 'pi-1']); + }); + + it('drops empty entries from comma-separated to param', () => { + expect(registry.parseTargetTokens('hi', 'codex-7,,pi-1,', 'llm')).toEqual(['codex-7', 'pi-1']); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UNIT TESTS — expandToRecipients (state-dependent) +// ═══════════════════════════════════════════════════════════════════ + +describe('expandToRecipients', () => { + let p1: { name: string }; + let p2: { name: string }; + let p3: { name: string }; + + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + globalPtys.clear(); + setActiveProject({ id: 'project-test', name: 'Test', path: '/tmp/test' }); + for (const p of registry.listParticipants()) registry.leave(p.name); + + globalPtys.set('pane-7', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 }); + globalPtys.set('pane-14', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 }); + globalPtys.set('pane-15', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 }); + p1 = registry.join('claude', 'llm', 'pane-7', 'claude', '\r'); + p2 = registry.join('codex', 'llm', 'pane-14', 'codex', '\r'); + p3 = registry.join('cursor', 'llm', 'pane-15', 'cursor', '\r'); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + globalPtys.clear(); + for (const p of registry.listParticipants()) registry.leave(p.name); + setActiveProject(null); + }); + + it('expands @all to all participants except sender', () => { + const result = registry.expandToRecipients(['all'], p1.name, 'project-test'); + expect(result.recipients.sort()).toEqual([p2.name, p3.name].sort()); + expect(result.concreteAssignees).toEqual([]); + }); + + it('resolves a known participant', () => { + const result = registry.expandToRecipients([p2.name], p1.name, 'project-test'); + expect(result.recipients).toEqual([p2.name]); + expect(result.concreteAssignees).toEqual([p2.name]); + }); + + it('returns empty for unknown participant', () => { + const result = registry.expandToRecipients(['nonexistent'], p1.name, 'project-test'); + expect(result.recipients).toEqual([]); + expect(result.concreteAssignees).toEqual([]); + }); + + it('returns empty recipients for semantic-only targets (user, system)', () => { + const result = registry.expandToRecipients(['user'], p1.name, 'project-test'); + expect(result.recipients).toEqual([]); + expect(result.concreteAssignees).toEqual([]); + }); + + it('deduplicates @all + individual mention', () => { + const result = registry.expandToRecipients(['all', p2.name], p1.name, 'project-test'); + expect(result.recipients.sort()).toEqual([p2.name, p3.name].sort()); + expect(result.concreteAssignees).toEqual([p2.name]); + }); + + it('excludes detached participants from recipients but keeps in concreteAssignees', () => { + registry.detach(p2.name); + const result = registry.expandToRecipients([p2.name], p1.name, 'project-test'); + expect(result.recipients).toEqual([]); + expect(result.concreteAssignees).toEqual([p2.name]); + }); + + it('excludes self from recipients', () => { + const result = registry.expandToRecipients([p1.name], p1.name, 'project-test'); + expect(result.recipients).toEqual([]); + expect(result.concreteAssignees).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UNIT TESTS — buildDeliveryPlan +// ═══════════════════════════════════════════════════════════════════ + +describe('buildDeliveryPlan', () => { + let agent1: { name: string }; + let agent2: { name: string }; + + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + globalPtys.clear(); + setActiveProject({ id: 'project-test', name: 'Test', path: '/tmp/test' }); + for (const p of registry.listParticipants()) registry.leave(p.name); + + globalPtys.set('pane-7', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 }); + globalPtys.set('pane-14', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 }); + agent1 = registry.join('claude', 'llm', 'pane-7', 'claude', '\r'); + agent2 = registry.join('codex', 'llm', 'pane-14', 'codex', '\r'); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + globalPtys.clear(); + for (const p of registry.listParticipants()) registry.leave(p.name); + setActiveProject(null); + }); + + it('user with no mentions: fallbackBroadcast=true', () => { + const plan = registry.buildDeliveryPlan('user', 'hello everyone', undefined, 'user', 'project-test'); + expect(plan.targetLabels).toEqual([]); + expect(plan.recipients).toEqual([]); + expect(plan.fallbackBroadcast).toBe(true); + }); + + it('user with @specific: targeted, no fallback', () => { + const plan = registry.buildDeliveryPlan('user', `@${agent1.name} implement this`, undefined, 'user', 'project-test'); + expect(plan.targetLabels).toEqual([agent1.name]); + expect(plan.recipients).toEqual([agent1.name]); + expect(plan.concreteAssignees).toEqual([agent1.name]); + expect(plan.fallbackBroadcast).toBe(false); + }); + + it('LLM with no mentions: no fallback, no recipients', () => { + const plan = registry.buildDeliveryPlan(agent1.name, 'thinking out loud', undefined, 'llm', 'project-test'); + expect(plan.targetLabels).toEqual([]); + expect(plan.recipients).toEqual([]); + expect(plan.fallbackBroadcast).toBe(false); + }); + + it('LLM with @specific: targeted delivery', () => { + const plan = registry.buildDeliveryPlan(agent1.name, `@${agent2.name} review this`, undefined, 'llm', 'project-test'); + expect(plan.recipients).toEqual([agent2.name]); + expect(plan.concreteAssignees).toEqual([agent2.name]); + expect(plan.fallbackBroadcast).toBe(false); + }); + + it('user with @unknown: no fallback (issue 1 fix — had target intent)', () => { + const plan = registry.buildDeliveryPlan('user', '@nonexistent check this', undefined, 'user', 'project-test'); + // `targetLabels` now contains only validated display targets — unresolved + // garbage is excluded so the dashboard never renders it as a "to" header. + // The "had target intent" semantics live in `fallbackBroadcast=false` and + // the unresolved name is reported separately via `unresolvedTargets`. + expect(plan.targetLabels).toEqual([]); + expect(plan.unresolvedTargets).toEqual(['nonexistent']); + expect(plan.recipients).toEqual([]); + expect(plan.fallbackBroadcast).toBe(false); + }); + + it('user with @user only: no fallback (semantic-only target = had intent)', () => { + const plan = registry.buildDeliveryPlan(agent1.name, '@user done!', undefined, 'llm', 'project-test'); + expect(plan.targetLabels).toEqual([]); + expect(plan.recipients).toEqual([]); + expect(plan.fallbackBroadcast).toBe(false); + }); + + it('@all sets fallbackBroadcast=false (explicit broadcast resolved)', () => { + const plan = registry.buildDeliveryPlan('user', '@all check status', undefined, 'user', 'project-test'); + expect(plan.targetLabels).toEqual(['all']); + expect(plan.recipients.sort()).toEqual([agent1.name, agent2.name].sort()); + expect(plan.concreteAssignees).toEqual([]); + expect(plan.fallbackBroadcast).toBe(false); + }); + + // ── Self-loop guard (rendered as "claude-2 → claude-2") ───────────── + // Even if a sender's own alias somehow ends up in the token list (e.g. + // legacy data, bug, or an explicit `to` param), the displayed + // targetLabels should never include the sender — there is no such thing + // as sending a message to yourself. + + it('strips sender alias from targetLabels when echoed in body prose', () => { + // Simulates an LLM that types its own alias in prose for whatever reason. + const body = `@${agent2.name} and @${agent1.name} both — heads up`; + const plan = registry.buildDeliveryPlan(agent1.name, body, undefined, 'llm', 'project-test'); + expect(plan.targetLabels).toEqual([agent2.name]); + expect(plan.targetLabels).not.toContain(agent1.name); + }); + + it('strips sender alias from targetLabels when passed via to param', () => { + const plan = registry.buildDeliveryPlan(agent1.name, 'hello', agent1.name, 'llm', 'project-test'); + expect(plan.targetLabels).not.toContain(agent1.name); + }); + + it('LLM with only own alias in code example: targetLabels empty (real-world bug)', () => { + // The exact shape of the message that produced "claude-2 → claude-2" + // in the chat history: code-fence example containing the sender's own + // alias. After the parser fix this should not even tokenize, and after + // the sender-strip defense it cannot leak even if it did. + const body = 'try one of:\n```\n@claude fix\n@claude implement\n```'; + const plan = registry.buildDeliveryPlan(agent1.name, body, undefined, 'llm', 'project-test'); + expect(plan.targetLabels).toEqual([]); + expect(plan.recipients).toEqual([]); + }); + + // ── targetLabels must contain only validated targets (display sanity) ── + // Real-world bug: targetLabels was built from raw `tokens`, so any + // garbage the parser captured (markdown leftovers, unknown names, the + // literal comma-string from a comma-separated `to` param) leaked into + // the persisted `msg.to` and the dashboard renderer showed it as the + // "to" line — e.g. `claude-2 → mention,codex-3:**`. + + it('targetLabels excludes @mention to nonexistent participant', () => { + const plan = registry.buildDeliveryPlan(agent1.name, '@nobody-here please', undefined, 'llm', 'project-test'); + expect(plan.targetLabels).toEqual([]); + }); + + it('targetLabels still keeps @all literally (it is a valid display target)', () => { + const plan = registry.buildDeliveryPlan(agent1.name, '@all heads up', undefined, 'llm', 'project-test'); + expect(plan.targetLabels).toEqual(['all']); + }); + + it('targetLabels excludes @mention captured as raw token (defense in depth)', () => { + // Even if the parser regressed and produced a garbage token, the + // display layer must not surface it. Verified by mixing a real and a + // fake mention: only the real one should appear in targetLabels. + const body = `@${agent2.name} and @ghost-rider please`; + const plan = registry.buildDeliveryPlan(agent1.name, body, undefined, 'llm', 'project-test'); + expect(plan.targetLabels).toEqual([agent2.name]); + }); + + it('targetLabels handles comma-split to param targeting two real recipients', () => { + const plan = registry.buildDeliveryPlan(agent1.name, 'multi target', `${agent2.name},${agent2.name}`, 'llm', 'project-test'); + // Same name twice is deduped → exactly one entry + expect(plan.targetLabels).toEqual([agent2.name]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// INTEGRATION TESTS — send() targeted delivery behavior +// ═══════════════════════════════════════════════════════════════════ + +describe('send() targeted PTY delivery', () => { + let writes1: string[]; + let writes2: string[]; + let writes3: string[]; + let a1: { name: string }; + let a2: { name: string }; + let a3: { name: string }; + + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + chatStoreMock.appendMessage.mockClear(); + globalPtys.clear(); + setActiveProject({ id: 'project-test', name: 'Test', path: '/tmp/test' }); + for (const p of registry.listParticipants()) registry.leave(p.name); + + writes1 = []; + writes2 = []; + writes3 = []; + globalPtys.set('pane-7', { ptyProcess: { write: vi.fn((c: string) => { writes1.push(c); }) } as never, chunks: [], totalLen: 0 }); + globalPtys.set('pane-14', { ptyProcess: { write: vi.fn((c: string) => { writes2.push(c); }) } as never, chunks: [], totalLen: 0 }); + globalPtys.set('pane-15', { ptyProcess: { write: vi.fn((c: string) => { writes3.push(c); }) } as never, chunks: [], totalLen: 0 }); + a1 = registry.join('claude', 'llm', 'pane-7', 'claude', '\r'); + a2 = registry.join('codex', 'llm', 'pane-14', 'codex', '\r'); + a3 = registry.join('cursor', 'llm', 'pane-15', 'cursor', '\r'); + // Clear mocks AFTER joins — join messages don't pollute send() assertions + chatStoreMock.appendMessage.mockClear(); + writes1.length = 0; + writes2.length = 0; + writes3.length = 0; + }); + + /** Advance timers enough for N sequential PTY deliveries to complete */ + async function drainDeliveries(count: number): Promise { + for (let i = 0; i < count + 1; i++) { + await flushDeliveryQueue(); + await vi.advanceTimersByTimeAsync(1100); + await flushDeliveryQueue(); + } + } + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + globalPtys.clear(); + for (const p of registry.listParticipants()) registry.leave(p.name); + setActiveProject(null); + }); + + it('LLM @mention delivers to target only', async () => { + const p = registry.send(a1.name, `@${a2.name} review this`); + await drainDeliveries(1); + await p; + + expect(writes2[0]).toBe(pty(a1.name, `@${a2.name} review this`)); + expect(writes1).toEqual([]); // sender excluded + expect(writes3).toEqual([]); // a3 not mentioned + }); + + it('LLM with no @mention delivers to nobody', async () => { + const p = registry.send(a1.name, 'thinking out loud'); + await flushDeliveryQueue(); + await p; + + expect(writes1).toEqual([]); + expect(writes2).toEqual([]); + expect(writes3).toEqual([]); + }); + + it('LLM @all delivers to all except sender', async () => { + const p = registry.send(a1.name, '@all heads up everyone'); + await drainDeliveries(2); + await p; + + expect(writes2[0]).toBe(pty(a1.name, '@all heads up everyone')); + expect(writes3[0]).toBe(pty(a1.name, '@all heads up everyone')); + expect(writes1).toEqual([]); // sender excluded + }); + + it('user with no @mention broadcasts to all (Option B)', async () => { + const p = registry.send('user', 'hello everyone'); + await drainDeliveries(3); + await p; + + expect(writes1[0]).toBe(pty('user', 'hello everyone')); + expect(writes2[0]).toBe(pty('user', 'hello everyone')); + expect(writes3[0]).toBe(pty('user', 'hello everyone')); + }); + + it('user @specific delivers to target only', async () => { + const p = registry.send('user', `@${a1.name} implement this`); + await drainDeliveries(1); + await p; + + expect(writes1[0]).toBe(pty('user', `@${a1.name} implement this`)); + expect(writes2).toEqual([]); // not mentioned + expect(writes3).toEqual([]); // not mentioned + }); + + it('message is always persisted regardless of delivery', async () => { + const p = registry.send(a1.name, 'no mentions'); + await flushDeliveryQueue(); + await p; + expect(chatStoreMock.appendMessage).toHaveBeenCalledTimes(1); + }); + + it('deliveredTo is included in persisted message for targeted delivery', async () => { + const p = registry.send('user', `@${a1.name} do X`); + await drainDeliveries(1); + await p; + + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.deliveredTo).toBe(1); + }); + + it('deliveredTo is absent when LLM has no mentions', async () => { + const p = registry.send(a1.name, 'no delivery'); + await flushDeliveryQueue(); + await p; + + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.deliveredTo).toBeUndefined(); + }); + + it('msg.to stores "all" for @all messages', async () => { + const p = registry.send(a1.name, '@all broadcast'); + await drainDeliveries(2); + await p; + + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0]; + expect(sendCall.to).toBe('all'); + }); + + // ── `to` param delivery tests (issue 2 coverage) ─────────────────── + + it('LLM with no body mention but to=target delivers to target only', async () => { + const p = registry.send(a1.name, 'review this please', a2.name); + await drainDeliveries(1); + await p; + + expect(writes2[0]).toBe(pty(a1.name, 'review this please')); + expect(writes1).toEqual([]); // sender + expect(writes3).toEqual([]); // not targeted + }); + + it('user with no body mention but to=target delivers to target only (no Option B)', async () => { + const p = registry.send('user', 'implement this', a1.name); + await drainDeliveries(1); + await p; + + expect(writes1[0]).toBe(pty('user', 'implement this')); + expect(writes2).toEqual([]); // not targeted + expect(writes3).toEqual([]); // not targeted + }); + + it('union: to=targetA and body @targetB delivers to both', async () => { + const p = registry.send(a1.name, `@${a3.name} check this too`, a2.name); + await drainDeliveries(2); + await p; + + expect(writes2[0]).toBe(pty(a1.name, `@${a3.name} check this too`)); + expect(writes3[0]).toBe(pty(a1.name, `@${a3.name} check this too`)); + expect(writes1).toEqual([]); // sender + }); + + it('@all does NOT set participant status to working (concreteAssignees safety)', async () => { + const before = registry.listParticipants().map(p => ({ name: p.name, status: p.status })); + + const p = registry.send('user', '@all check status'); + await drainDeliveries(3); + await p; + + const after = registry.listParticipants().map(p => ({ name: p.name, status: p.status })); + expect(after).toEqual(before); + }); + + // ── msg.to display format (recipient-garbage bug) ──────────────────── + // The persisted `msg.to` field is what the dashboard renderer reads to + // build the `@sender → @t1, @t2` header. It must contain ONLY validated + // names (or 'all' for broadcast), separated by ", " for multi-target, + // never the literal comma-string from a comma-separated `to` param. + + it('msg.to is single name for one targeted recipient', async () => { + const p = registry.send(a1.name, `@${a2.name} review this`); + await drainDeliveries(1); + await p; + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.to).toBe(a2.name); + }); + + it('msg.to is comma-space separated for multiple targeted recipients', async () => { + const p = registry.send(a1.name, `@${a2.name} @${a3.name} review`); + await drainDeliveries(2); + await p; + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.to).toBe(`${a2.name}, ${a3.name}`); + }); + + it('msg.to omits markdown-leaked garbage even with bold-wrapped mention', async () => { + const p = registry.send(a1.name, `**Coordination, @${a2.name}:** stand down`); + await drainDeliveries(1); + await p; + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + // No `:**`, no `mention`, no garbage — just the validated participant. + expect(sendCall.to).toBe(a2.name); + }); + + it('msg.to omits unresolved garbage when body has fake mention', async () => { + // Even if a peer LLM produces an @mention to a nonexistent name, + // msg.to must only contain validated participants. + const p = registry.send(a1.name, `@${a2.name} @ghost-here please`); + await drainDeliveries(1); + await p; + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.to).toBe(a2.name); + // The unresolved one is reported separately, not in `to`. + expect(sendCall.unresolvedTargets).toContain('ghost-here'); + }); + + it('msg.to handles comma-split to param targeting two real recipients', async () => { + // Caller passed `to: "a2,a3"` — server must split, not store as one token. + const p = registry.send(a1.name, 'multi target', `${a2.name},${a3.name}`); + await drainDeliveries(2); + await p; + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.to).toBe(`${a2.name}, ${a3.name}`); + }); + + // ── Implicit broadcast header (codex review feedback) ────────────── + // The user's example header `@user → @all` should appear for ALL user + // broadcasts, including unaddressed ones (Option B fallback). Without + // this, the dashboard renders no header for the user's typical pattern + // of plain unaddressed messages, which leaves the addressing intent + // invisible. + + it('msg.to is "all" for implicit user broadcast (no @mention, fallback)', async () => { + const p = registry.send('user', 'hello everyone'); + await drainDeliveries(3); + await p; + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.to).toBe('all'); + }); + + it('msg.to is "all" for unaddressed system message (system also fallbacks)', async () => { + const p = registry.send('system', 'server restarted'); + await drainDeliveries(3); + await p; + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.to).toBe('all'); + }); + + it('msg.to stays null for LLM with no @mention (LLMs do not fallback)', async () => { + const p = registry.send(a1.name, 'thinking out loud'); + await flushDeliveryQueue(); + await p; + const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record; + expect(sendCall.to).toBeNull(); + }); +}); diff --git a/src/apps/chat/services/chat-registry.test.ts b/src/apps/chat/services/chat-registry.test.ts new file mode 100644 index 0000000..7063b6d --- /dev/null +++ b/src/apps/chat/services/chat-registry.test.ts @@ -0,0 +1,881 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { globalPtys } from '../../shell/src/runtime/shell-state.js'; +import { setActiveProject } from '../../../project-context.js'; + +const chatStoreMock = vi.hoisted(() => { + let seq = 0; + return { + appendMessage: vi.fn((msg: { from: string; to: string | null; body: string; type: string }) => ({ + id: `msg-${++seq}`, + ts: new Date('2026-01-01T00:00:00.000Z').toISOString(), + topic: null, + ...msg, + })), + appendPipeEvent: vi.fn((event: Record) => ({ + id: `pipe-event-${++seq}`, + ts: new Date('2026-01-01T00:00:00.000Z').toISOString(), + ...event, + })), + clearMessages: vi.fn(), + readMessages: vi.fn(() => []), + reset: () => { + seq = 0; + }, + }; +}); + +vi.mock('./chat-store.js', () => ({ + appendMessage: chatStoreMock.appendMessage, + appendPipeEvent: chatStoreMock.appendPipeEvent, + clearMessages: chatStoreMock.clearMessages, + readMessages: chatStoreMock.readMessages, + saveParticipants: vi.fn(), + loadParticipants: vi.fn(() => []), +})); + +const registry = await import('./chat-registry.js'); + +/** Format a chat message as it would appear in PTY delivery to an LLM participant. */ +function pty( + from: string, + body: string, + options?: { assignedBy?: 'pipe' | null }, +): string { + const tags = ['DevGlide Chat']; + if (options?.assignedBy) tags.push(`Assigned by: ${options.assignedBy}`); + return `[${tags.join(' | ')}] @${from}: ${body}`; +} + +async function flushDeliveryQueue(): Promise { + await vi.advanceTimersByTimeAsync(0); +} + +describe('chat-registry PTY delivery', () => { + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + chatStoreMock.appendMessage.mockClear(); + chatStoreMock.appendPipeEvent.mockClear(); + chatStoreMock.clearMessages.mockClear(); + chatStoreMock.readMessages.mockReset(); + chatStoreMock.readMessages.mockReturnValue([]); + globalPtys.clear(); + setActiveProject({ id: 'project-chat', name: 'Chat', path: '/tmp/chat' }); + + for (const participant of registry.listParticipants()) { + registry.leave(participant.name); + } + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + globalPtys.clear(); + for (const participant of registry.listParticipants()) { + registry.leave(participant.name); + } + setActiveProject(null); + }); + + it('serializes back-to-back deliveries to the same pane', async () => { + const writes: string[] = []; + globalPtys.set('pane-1', { + ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + + const participant = registry.join('codex', 'llm', 'pane-1', 'codex', '\r'); + + const firstSend = registry.send('user', 'first'); + const secondSend = registry.send('user', 'second'); + await flushDeliveryQueue(); + + expect(writes).toEqual([ + pty('user', 'first'), + ]); + + // First submit key after 1000ms (PTY_SUBMIT_DELAY_MS) + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + + expect(writes).toEqual([ + pty('user', 'first'), + '\r', + pty('user', 'second'), + ]); + + // Second message: submit key after 1000ms + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + + expect(writes).toEqual([ + pty('user', 'first'), + '\r', + pty('user', 'second'), + '\r', + ]); + + await firstSend; + await secondSend; + + registry.leave(participant.name); + }); + + it('skips the delayed submit when the participant detaches before it fires', async () => { + const writes: string[] = []; + globalPtys.set('pane-2', { + ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + + const participant = registry.join('claude', 'llm', 'pane-2', 'claude', '\r'); + + const sendPromise = registry.send('user', 'hello'); + await flushDeliveryQueue(); + registry.detach(participant.name); + + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + + expect(writes).toEqual([ + pty('user', 'hello'), + ]); + + await sendPromise; + + registry.leave(participant.name); + }); + + it('skips the delayed submit after same-pane detach and reclaim', async () => { + const writes: string[] = []; + globalPtys.set('pane-4', { + ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + + const participant = registry.join('codex', 'llm', 'pane-4', 'codex', '\r'); + + const sendPromise = registry.send('user', 'reclaim-race'); + await flushDeliveryQueue(); + registry.detach(participant.name); + const reclaimed = registry.join('codex', 'llm', 'pane-4', 'codex', '\r'); + + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + + expect(reclaimed.name).toBe(participant.name); + expect(writes).toEqual([ + pty('user', 'reclaim-race'), + ]); + + await sendPromise; + + registry.leave(participant.name); + }); + + it('announces REST-backed MCP adoption as a session upgrade', () => { + globalPtys.set('pane-upgrade', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + const participant = registry.join('codex', 'llm', 'pane-upgrade', 'codex', '\r', 'project-chat', 'rest'); + chatStoreMock.appendMessage.mockClear(); + + const reclaimed = registry.join('codex', 'llm', 'pane-upgrade', 'codex', '\r', 'project-chat', 'mcp'); + + expect(reclaimed.name).toBe(participant.name); + expect(reclaimed.joinedVia).toBe('mcp'); + expect(chatStoreMock.appendMessage).toHaveBeenCalledWith(expect.objectContaining({ + from: participant.name, + body: `${participant.name} session upgraded (pane-upgrade)`, + type: 'join', + }), 'project-chat'); + + registry.leave(participant.name); + }); + + it('keeps reconnected wording for detached reclaim', () => { + globalPtys.set('pane-reconnect', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + const participant = registry.join('codex', 'llm', 'pane-reconnect', 'codex', '\r', 'project-chat', 'mcp'); + registry.detach(participant.name); + chatStoreMock.appendMessage.mockClear(); + + const reclaimed = registry.join('codex', 'llm', 'pane-reconnect', 'codex', '\r', 'project-chat', 'mcp'); + + expect(reclaimed.name).toBe(participant.name); + expect(chatStoreMock.appendMessage).toHaveBeenCalledWith(expect.objectContaining({ + from: participant.name, + body: `${participant.name} reconnected (pane-reconnect)`, + type: 'join', + }), 'project-chat'); + + registry.leave(participant.name); + }); + + it('skips the delayed submit when the pane closes before it fires', async () => { + const writes: string[] = []; + globalPtys.set('pane-3', { + ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + + const participant = registry.join('cursor', 'llm', 'pane-3', 'cursor', '\r'); + + const sendPromise = registry.send('user', 'close-soon'); + await flushDeliveryQueue(); + globalPtys.delete('pane-3'); + + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + + expect(writes).toEqual([ + pty('user', 'close-soon'), + ]); + expect(registry.getParticipant(participant.name)?.paneId).toBeNull(); + + await sendPromise; + + registry.leave(participant.name); + }); + + it('delivers across panes sequentially', async () => { + const writesA: string[] = []; + const writesB: string[] = []; + + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn((chunk: string) => { writesA.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn((chunk: string) => { writesB.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + + const first = registry.join('first', 'llm', 'pane-a', 'claude', '\r'); + const second = registry.join('second', 'llm', 'pane-b', 'codex', '\r'); + + const sendPromise = registry.send('user', 'ordered'); + await flushDeliveryQueue(); + + expect(writesA).toEqual([pty('user', 'ordered')]); + expect(writesB).toEqual([]); + + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + + expect(writesA).toEqual([pty('user', 'ordered'), '\r']); + expect(writesB).toEqual([pty('user', 'ordered')]); + + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + + expect(writesB).toEqual([pty('user', 'ordered'), '\r']); + + await sendPromise; + + registry.leave(first.name); + registry.leave(second.name); + }); + + it('broadcasts mentioned messages to every same-project participant except the sender', async () => { + const writesA: string[] = []; + const writesB: string[] = []; + const writesSender: string[] = []; + + globalPtys.set('pane-a', { + ptyProcess: { write: vi.fn((chunk: string) => { writesSender.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-b', { + ptyProcess: { write: vi.fn((chunk: string) => { writesA.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-c', { + ptyProcess: { write: vi.fn((chunk: string) => { writesB.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + + const sender = registry.join('sender', 'llm', 'pane-a', 'codex', '\r'); + const target = registry.join('target', 'llm', 'pane-b', 'claude', '\r'); + const observer = registry.join('observer', 'llm', 'pane-c', 'cursor', '\r'); + + const sendPromise = registry.send(sender.name, `@${target.name} please handle this`); + await flushDeliveryQueue(); + + // Targeted delivery: only @mentioned target receives PTY, observer does not + expect(writesSender).toEqual([]); + expect(writesA).toEqual([pty(sender.name, `@${target.name} please handle this`)]); + expect(writesB).toEqual([]); // observer not mentioned — no delivery + + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + + // Observer still empty — targeted delivery means non-mentioned participants don't receive + expect(writesB).toEqual([]); + + await vi.advanceTimersByTimeAsync(1000); + await sendPromise; + + registry.leave(sender.name); + registry.leave(target.name); + registry.leave(observer.name); + }); + + it('adds pipe authority tags for compact pipe handoff notifications', async () => { + const writes: string[] = []; + + globalPtys.set('pane-pipe-a', { + ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-pipe-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const first = registry.join('alice', 'llm', 'pane-pipe-a', 'claude', '\r'); + const second = registry.join('bob', 'llm', 'pane-pipe-b', 'codex', '\r'); + + const sendPromise = registry.send('user', `/linear-pipe @${first.name} @${second.name} : audit the last changes`); + await flushDeliveryQueue(); + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + await sendPromise; + + expect(writes.some(chunk => chunk.includes('[DevGlide Chat | Assigned by: pipe] @system: #pipe-'))).toBe(true); + expect(writes.some(chunk => chunk.includes('Inspect assignment: pipe_get_assignment'))).toBe(true); + + registry.leave(first.name); + registry.leave(second.name); + }); + + it('does not append interaction reminder to any participant', async () => { + const llmWrites: string[] = []; + const userWrites: string[] = []; + + globalPtys.set('pane-llm', { + ptyProcess: { write: vi.fn((chunk: string) => { llmWrites.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-user', { + ptyProcess: { write: vi.fn((chunk: string) => { userWrites.push(chunk); }) } as never, + chunks: [], + totalLen: 0, + }); + + const llm = registry.join('claude', 'llm', 'pane-llm', 'claude', '\r'); + const user = registry.join('tester', 'user', 'pane-user', null, '\r'); + + // A third participant sends a message — both should receive it via PTY + globalPtys.set('pane-sender', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + const sender = registry.join('codex', 'llm', 'pane-sender', 'codex', '\r'); + + // Use @all so the LLM message is broadcast (LLMs without @mention get no delivery) + const sendPromise = registry.send(sender.name, '@all hello everyone'); + + // Drain all deliveries + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + await sendPromise; + + // Neither LLM nor user participant gets the reminder + const llmMsg = llmWrites.find((w) => w.startsWith('[DevGlide Chat]')); + expect(llmMsg).toBeDefined(); + expect(llmMsg).not.toContain(''); + + const userMsg = userWrites.find((w) => w.startsWith('[DevGlide Chat]')); + expect(userMsg).toBeDefined(); + expect(userMsg).not.toContain(''); + + // Stored message has no reminder + const stored = chatStoreMock.appendMessage.mock.calls.find( + (c: unknown[]) => (c[0] as { body: string }).body === '@all hello everyone', + ); + expect(stored).toBeDefined(); + expect((stored![0] as { body: string }).body).not.toContain(''); + + registry.leave(llm.name); + registry.leave(user.name); + registry.leave(sender.name); + }); + + it('marks assigned participants as working and returns them to idle after inactivity', async () => { + globalPtys.set('pane-status-working', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const worker = registry.join('codex', 'llm', 'pane-status-working', 'codex', '\r'); + + const sendPromise = registry.send('user', `@${worker.name} fix the rendering bug`); + expect(registry.getParticipant(worker.name)?.status).toBe('working'); + + await vi.advanceTimersByTimeAsync(30_000); + await flushDeliveryQueue(); + await sendPromise; + + expect(registry.getParticipant(worker.name)?.status).toBe('idle'); + + registry.leave(worker.name); + }); + + it('marks explicit review assignments as working', async () => { + globalPtys.set('pane-status-review', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const reviewer = registry.join('claude', 'llm', 'pane-status-review', 'claude', '\r'); + + const sendPromise = registry.send('user', `@${reviewer.name} verify the fix`); + + expect(registry.getParticipant(reviewer.name)?.status).toBe('working'); + + // Drain the delivery chain (submit delay) + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + await sendPromise; + + registry.leave(reviewer.name); + }); + + it('can still enumerate a joined project after the global active project changes', () => { + globalPtys.set('pane-5', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + const participant = registry.join('claude', 'llm', 'pane-5', 'claude', '\r'); + setActiveProject({ id: 'project-other', name: 'Other', path: '/tmp/other' }); + + expect(registry.listParticipants()).toEqual([]); + expect(registry.listParticipants('project-chat').map((p) => p.name)).toEqual([participant.name]); + + registry.leave(participant.name, 'project-chat'); + }); + + it('same display name in two projects does not collide', () => { + globalPtys.set('pane-p1', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-p2', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + + // Join project-chat (active) as claude + const p1 = registry.join('claude', 'llm', 'pane-p1', 'claude', '\r'); + + // Switch to project-other and join as claude with same pane num + setActiveProject({ id: 'project-other', name: 'Other', path: '/tmp/other' }); + const p2 = registry.join('claude', 'llm', 'pane-p2', 'claude', '\r', 'project-other'); + + // Both should coexist — same display name, different projects + expect(p1.name).toMatch(/^claude/); + expect(p2.name).toMatch(/^claude/); + expect(p1.projectId).toBe('project-chat'); + expect(p2.projectId).toBe('project-other'); + + // Each project sees only its own participant + expect(registry.listParticipants('project-chat').map(p => p.name)).toEqual([p1.name]); + expect(registry.listParticipants('project-other').map(p => p.name)).toEqual([p2.name]); + + // Leaving one does not affect the other + registry.leave(p2.name, 'project-other'); + expect(registry.listParticipants('project-chat').map(p => p.name)).toEqual([p1.name]); + + registry.leave(p1.name, 'project-chat'); + }); +}); + +describe('chat-registry PTY status detection (idle/working)', () => { + let dataListeners: Array<(data: string) => void>; + + function createPtyWithOnData(paneId: string) { + dataListeners = []; + const mockEntry = { + ptyProcess: { + write: vi.fn(), + onData: vi.fn((listener: (data: string) => void) => { + dataListeners.push(listener); + return { + dispose: vi.fn(() => { + const idx = dataListeners.indexOf(listener); + if (idx >= 0) dataListeners.splice(idx, 1); + }), + }; + }), + } as never, + chunks: [] as string[], + totalLen: 0, + }; + globalPtys.set(paneId, mockEntry); + return mockEntry; + } + + function emitPtyData(paneId: string, data: string) { + const entry = globalPtys.get(paneId) as { chunks: string[]; totalLen: number }; + if (entry) { + entry.chunks.push(data); + entry.totalLen += data.length; + } + for (const listener of [...dataListeners]) { + listener(data); + } + } + + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + chatStoreMock.appendMessage.mockClear(); + chatStoreMock.clearMessages.mockClear(); + chatStoreMock.readMessages.mockReset(); + chatStoreMock.readMessages.mockReturnValue([]); + globalPtys.clear(); + dataListeners = []; + setActiveProject({ id: 'project-chat', name: 'Chat', path: '/tmp/chat' }); + + for (const participant of registry.listParticipants()) { + registry.leave(participant.name); + } + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + globalPtys.clear(); + for (const participant of registry.listParticipants()) { + registry.leave(participant.name); + } + setActiveProject(null); + }); + + // ── PTY-driven working status ────────────────────────────────── + + it('sets working on nontrivial PTY output', async () => { + createPtyWithOnData('pane-pty-w1'); + const participant = registry.join('claude', 'llm', 'pane-pty-w1', 'claude', '\r'); + expect(registry.getParticipant(participant.name)?.status).toBe('idle'); + + emitPtyData('pane-pty-w1', 'Compiling src/main.ts...'); + + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + registry.leave(participant.name); + }); + + it('returns to idle after PTY inactivity timeout (8s)', async () => { + createPtyWithOnData('pane-pty-w2'); + const participant = registry.join('claude', 'llm', 'pane-pty-w2', 'claude', '\r'); + + emitPtyData('pane-pty-w2', 'Building...'); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // After 8s of silence → idle + await vi.advanceTimersByTimeAsync(8000); + + expect(registry.getParticipant(participant.name)?.status).toBe('idle'); + + registry.leave(participant.name); + }); + + it('does not set working on ANSI-only / whitespace-only output', async () => { + createPtyWithOnData('pane-pty-w3'); + const participant = registry.join('claude', 'llm', 'pane-pty-w3', 'claude', '\r'); + + // Pure ANSI escape (cursor move) — no printable content + emitPtyData('pane-pty-w3', '\x1b[2J\x1b[H'); + + expect(registry.getParticipant(participant.name)?.status).toBe('idle'); + + registry.leave(participant.name); + }); + + it('keeps working status during PTY activity after review assignment', async () => { + createPtyWithOnData('pane-pty-w4'); + const participant = registry.join('claude', 'llm', 'pane-pty-w4', 'claude', '\r'); + + const sendPromise = registry.send('user', `@${participant.name} verify the fix`); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // PTY output should keep the participant in working + emitPtyData('pane-pty-w4', 'Reading file...'); + + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // Drain the delivery chain (submit delay) + await vi.advanceTimersByTimeAsync(1000); + await flushDeliveryQueue(); + await sendPromise; + + registry.leave(participant.name); + }); + + // ── Prompt detection holds working ────────────────────────────── + + it('holds working when PTY output matches a prompt pattern (prevents idle)', async () => { + createPtyWithOnData('pane-prompt-1'); + const participant = registry.join('claude', 'llm', 'pane-prompt-1', 'claude', '\r'); + + emitPtyData('pane-prompt-1', 'Allow Edit /src/file.ts'); + + // Nontrivial output → working immediately + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // After quiescence (2000ms), prompt detected → idle timer cancelled, stays working + await vi.advanceTimersByTimeAsync(2000); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // Even after the normal 8s idle timeout, still working (prompt holds it) + await vi.advanceTimersByTimeAsync(8000); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + registry.leave(participant.name); + }); + + it('detects MCP tool permission prompts with double underscores', async () => { + createPtyWithOnData('pane-prompt-mcp'); + const participant = registry.join('claude', 'llm', 'pane-prompt-mcp', 'claude', '\r'); + + emitPtyData('pane-prompt-mcp', 'Allow mcp__devglide-chat__chat_send({"message":"hello"})'); + await vi.advanceTimersByTimeAsync(2000); + + // Prompt holds working indefinitely + await vi.advanceTimersByTimeAsync(8000); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + registry.leave(participant.name); + }); + + it('detects generic yes/no prompts and holds working', async () => { + createPtyWithOnData('pane-prompt-yn'); + const participant = registry.join('codex', 'llm', 'pane-prompt-yn', 'codex', '\r'); + + emitPtyData('pane-prompt-yn', 'Do you want to overwrite? (y/n)'); + await vi.advanceTimersByTimeAsync(2000); + + // Prompt holds working + await vi.advanceTimersByTimeAsync(8000); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + registry.leave(participant.name); + }); + + it('releases working→idle after prompt is answered (new nontrivial output)', async () => { + createPtyWithOnData('pane-prompt-answered'); + const participant = registry.join('claude', 'llm', 'pane-prompt-answered', 'claude', '\r'); + + // Trigger prompt hold + emitPtyData('pane-prompt-answered', 'Allow Bash npm test'); + await vi.advanceTimersByTimeAsync(2000); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // User responds — nontrivial output arrives, prompt flag clears after quiescence + emitPtyData('pane-prompt-answered', 'Running tests...'); + await vi.advanceTimersByTimeAsync(2000); + + // Still working but now the idle timer is active + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // After 8s of inactivity → idle (prompt no longer holding) + await vi.advanceTimersByTimeAsync(8000); + expect(registry.getParticipant(participant.name)?.status).toBe('idle'); + + registry.leave(participant.name); + }); + + it('does not re-trigger prompt hold from stale text after user responds', async () => { + createPtyWithOnData('pane-prompt-retrigger'); + const participant = registry.join('claude', 'llm', 'pane-prompt-retrigger', 'claude', '\r'); + + // First: prompt appears → held working + emitPtyData('pane-prompt-retrigger', 'Allow Edit /src/file.ts'); + await vi.advanceTimersByTimeAsync(2000); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // User responds → new output clears prompt flag + emitPtyData('pane-prompt-retrigger', 'File saved successfully'); + await vi.advanceTimersByTimeAsync(2000); + + // Delta buffer was cleared so old "Allow Edit" should NOT re-trigger hold + // After 8s → should go idle + await vi.advanceTimersByTimeAsync(8000); + expect(registry.getParticipant(participant.name)?.status).toBe('idle'); + + registry.leave(participant.name); + }); + + it('chat-injected PTY text does not clear prompt hold', async () => { + createPtyWithOnData('pane-prompt-chat-injected'); + const participant = registry.join('claude', 'llm', 'pane-prompt-chat-injected', 'claude', '\r'); + + // Prompt detected → held working + emitPtyData('pane-prompt-chat-injected', 'Allow WebFetch https://example.com'); + await vi.advanceTimersByTimeAsync(2000); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + // Chat-injected text arrives — should NOT clear the prompt hold + emitPtyData('pane-prompt-chat-injected', '[DevGlide Chat | Assigned by: user] @codex-2: checking now'); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(8000); + + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + registry.leave(participant.name); + }); + + it('detects lowercase allow prompts', async () => { + createPtyWithOnData('pane-prompt-lowercase'); + const participant = registry.join('claude', 'llm', 'pane-prompt-lowercase', 'claude', '\r'); + + emitPtyData('pane-prompt-lowercase', 'allow webfetch https://example.com'); + await vi.advanceTimersByTimeAsync(2000); + + // Prompt holds working + await vi.advanceTimersByTimeAsync(8000); + expect(registry.getParticipant(participant.name)?.status).toBe('working'); + + registry.leave(participant.name); + }); + + // ── Watcher lifecycle ───────────────────────────────────────── + + it('cleans up watcher on leave', async () => { + const mockEntry = createPtyWithOnData('pane-prompt-6'); + const participant = registry.join('claude', 'llm', 'pane-prompt-6', 'claude', '\r'); + + expect((mockEntry.ptyProcess as { onData: ReturnType }).onData).toHaveBeenCalled(); + + registry.leave(participant.name); + + expect(dataListeners.length).toBe(0); + }); + + it('cleans up watcher on detach', async () => { + createPtyWithOnData('pane-prompt-7'); + const participant = registry.join('claude', 'llm', 'pane-prompt-7', 'claude', '\r'); + + registry.detach(participant.name); + + expect(dataListeners.length).toBe(0); + + registry.leave(participant.name); + }); +}); + +describe('chat-registry cross-project isolation', () => { + beforeEach(() => { + vi.useFakeTimers(); + chatStoreMock.reset(); + chatStoreMock.appendMessage.mockClear(); + chatStoreMock.readMessages.mockReset(); + chatStoreMock.readMessages.mockReturnValue([]); + globalPtys.clear(); + globalPtys.set('pane-xproj-a', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + globalPtys.set('pane-xproj-b', { + ptyProcess: { write: vi.fn() } as never, + chunks: [], + totalLen: 0, + }); + setActiveProject({ id: 'project-a', name: 'A', path: '/tmp/a' }); + for (const p of registry.listParticipants('project-a')) registry.leave(p.name, 'project-a'); + for (const p of registry.listParticipants('project-b')) registry.leave(p.name, 'project-b'); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + for (const p of registry.listParticipants('project-a')) registry.leave(p.name, 'project-a'); + for (const p of registry.listParticipants('project-b')) registry.leave(p.name, 'project-b'); + globalPtys.clear(); + setActiveProject(null); + }); + + it('listParticipants excludes participants from other projects', () => { + // Use distinct base names so cross-project identity is unambiguous + const a = registry.join('alice', 'llm', 'pane-xproj-a', 'claude', '\r', 'project-a'); + const b = registry.join('bob', 'llm', 'pane-xproj-b', 'claude', '\r', 'project-b'); + + const aList = registry.listParticipants('project-a'); + const bList = registry.listParticipants('project-b'); + + expect(aList.map((p) => p.name)).toEqual([a.name]); + expect(bList.map((p) => p.name)).toEqual([b.name]); + expect(aList.every((p) => p.projectId === 'project-a')).toBe(true); + expect(bList.every((p) => p.projectId === 'project-b')).toBe(true); + }); + + it('getParticipant(name) without projectId never returns a cross-project match', () => { + // Only project-b has a participant named "solo"; active project is project-a + const b = registry.join('solo', 'llm', 'pane-xproj-b', 'claude', '\r', 'project-b'); + // Sanity: the participant exists in project-b + expect(registry.getParticipant(b.name, 'project-b')?.projectId).toBe('project-b'); + // Legacy call (no projectId) must NOT leak the project-b match through the active (project-a) scope + expect(registry.getParticipant(b.name)).toBeUndefined(); + }); + + it('getParticipantByPaneId(paneId) without projectId never returns a cross-project match', () => { + const b = registry.join('bob', 'llm', 'pane-xproj-b', 'claude', '\r', 'project-b'); + // Sanity: explicit project lookup works + expect(registry.getParticipantByPaneId('pane-xproj-b', 'project-b')?.name).toBe(b.name); + // Active project is project-a — lookup without projectId must NOT leak project-b + expect(registry.getParticipantByPaneId('pane-xproj-b')).toBeUndefined(); + }); + + it('send does not PTY-deliver to a cross-project @mention target', async () => { + // Distinct base names so @bob exists only in project-b and cannot resolve in project-a + registry.join('alice', 'llm', 'pane-xproj-a', 'claude', '\r', 'project-a'); + const b = registry.join('bob', 'llm', 'pane-xproj-b', 'claude', '\r', 'project-b'); + const writeA = (globalPtys.get('pane-xproj-a')!.ptyProcess as unknown as { write: ReturnType }).write; + const writeB = (globalPtys.get('pane-xproj-b')!.ptyProcess as unknown as { write: ReturnType }).write; + writeA.mockClear(); + writeB.mockClear(); + + // User in project-a @-mentions a name that exists only in project-b. + const sendPromise = registry.send('user', `@${b.name} please implement`, undefined, 'project-a'); + await flushDeliveryQueue(); + const msg = await sendPromise; + + // project-b participant must not have been written to + expect(writeB).not.toHaveBeenCalled(); + // project-a participant must not be the target either — the token was unresolved + expect(writeA).not.toHaveBeenCalled(); + // The persisted message must list the cross-project token as unresolved + expect(msg.unresolvedTargets ?? []).toContain(b.name); + }); +}); diff --git a/src/apps/chat/services/chat-registry.ts b/src/apps/chat/services/chat-registry.ts new file mode 100644 index 0000000..a12ae4a --- /dev/null +++ b/src/apps/chat/services/chat-registry.ts @@ -0,0 +1,2386 @@ +import type { Namespace } from 'socket.io'; +import type { ChatParticipant, ChatMessage, PipeMessageMeta, PipeUiEvent } from '../types.js'; +import { globalPtys, dashboardState, getShellNsp } from '../../shell/src/runtime/shell-state.js'; +import { appendMessage, appendPipeEvent, readMessages, clearMessages, saveParticipants, loadParticipants, discoverPersistedPipeIds, readAllPipeEvents, removePipeFiles } from './chat-store.js'; +import type { PersistedParticipant } from './chat-store.js'; +import { getActiveProject, onProjectChange } from '../../../project-context.js'; +import { isPipeCommand, parsePipeCommand, isPipeParseError, validatePipeAssigneeCount, isBrainstormCommand, parseBrainstormCommand } from './pipe-parser.js'; +import * as brainstormStore from './brainstorm-store.js'; +import * as pipeReducer from './pipe-reducer.js'; +import * as pipeStore from './pipe-store.js'; +import * as pipeDelivery from './pipe-delivery.js'; +import * as assignmentQueries from './pipe-assignment-queries.js'; +import * as provenance from './pipe-provenance.js'; +import * as materializer from './pipe-assignment-materializer.js'; +import * as payloadStore from './payload-store.js'; +import { stripAnsi } from './terminal-utils.js'; + +// In-memory participant registry +const participants = new Map(); +let chatNsp: Namespace | null = null; +const paneDeliveryQueues = new Map>(); +const participantSessionEpochs = new Map(); +const participantStatusTimers = new Map>(); +const panePromptWatchers = new Map void }>(); + +const PTY_SUBMIT_DELAY_MS = 1000; + +const PARTICIPANT_IDLE_TIMEOUT_MS = 30_000; +const PROMPT_QUIESCENCE_MS = 2000; +const PANE_DISCONNECT_TIMEOUT_MS = 10_000; // 10 seconds before auto-removal + +// ── Pipe reliability constants ────────────────────────────────────────────── +const PIPE_WATCHDOG_INTERVAL_MS = 5_000; // 5 seconds — pane liveness + deadline check + +const paneDisconnectTimers = new Map>(); + +// ── Pipe stage deadline timers ────────────────────────────────────────────── +// Keyed by "pipeId:assignee" — one timer per active lease +const stageDeadlineTimers = new Map>(); +let pipeWatchdogInterval: ReturnType | null = null; + +function bumpParticipantSessionEpoch(name: string, projectId?: string | null): number { + const key = participantKey(name, projectId); + const next = (participantSessionEpochs.get(key) ?? 0) + 1; + participantSessionEpochs.set(key, next); + return next; +} + +function currentParticipantSessionEpoch(name: string, projectId?: string | null): number { + return participantSessionEpochs.get(participantKey(name, projectId)) ?? 0; +} + +function participantKey(name: string, projectId?: string | null): string { + return `${projectId ?? '__none__'}:${name}`; +} + +function getParticipantExact(name: string, projectId?: string | null): ChatParticipant | undefined { + return participants.get(participantKey(name, projectId)); +} + +function activeProjectId(): string | null { + return getActiveProject()?.id ?? null; +} + +function resolveProjectId(projectId?: string | null): string | null { + return projectId ?? activeProjectId(); +} + +function emitMembers(projectId?: string | null): void { + emitToProject('chat:members', listParticipants(projectId), projectId); +} + +function clearParticipantStatusTimer(name: string, projectId?: string | null): void { + const key = participantKey(name, projectId); + const timer = participantStatusTimers.get(key); + if (timer) { + clearTimeout(timer); + participantStatusTimers.delete(key); + } +} + +function setParticipantStatus( + name: string, + projectId: string | null, + status: ChatParticipant['status'], + resetIdleTimer = true, +): void { + const participant = getParticipantExact(name, projectId); + if (!participant || participant.kind !== 'llm') return; + const changed = participant.status !== status; + participant.status = status; + if (resetIdleTimer) { + clearParticipantStatusTimer(name, projectId); + if (status !== 'idle') { + const key = participantKey(name, projectId); + participantStatusTimers.set(key, setTimeout(() => { + participantStatusTimers.delete(key); + const current = getParticipantExact(name, projectId); + if (!current || current.kind !== 'llm') return; + current.status = 'idle'; + emitMembers(projectId); + }, PARTICIPANT_IDLE_TIMEOUT_MS)); + } + } + if (changed) emitMembers(projectId); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getMessageAuthority( + targetName: string, + msg: ChatMessage, +): 'pipe' | null { + if ( + msg.from === 'system' + && msg.pipe?.targetAssignee === targetName + && (msg.pipe.role === 'handoff' || msg.pipe.role === 'fan-out-request' || msg.pipe.role === 'synth-request') + ) { + return 'pipe'; + } + + return null; +} + +export function _getMessageAuthorityForTest( + targetName: string, + _projectId: string | null, + msg: ChatMessage, +): 'pipe' | null { + return getMessageAuthority(targetName, msg); +} + +function formatPtyHeader(targetName: string, msg: ChatMessage): string { + const tags = ['DevGlide Chat']; + + const authority = getMessageAuthority(targetName, msg); + if (authority) tags.push(`Assigned by: ${authority}`); + + return `[${tags.join(' | ')}]`; +} + +function markAssignedParticipantStatus(body: string, targetName: string): ChatParticipant['status'] | null { + const lowered = body.toLowerCase(); + const targetMention = `@${targetName.toLowerCase()}`; + const reviewTerms = '(verify|verification|review|check|inspect|validate|confirm|test)'; + const reviewRe = new RegExp(`(${escapeRegExp(targetMention)}\\b[^\\n]{0,120}\\b${reviewTerms}\\b|\\b${reviewTerms}\\b[^\\n]{0,120}${escapeRegExp(targetMention)}\\b)`, 'i'); + if (reviewRe.test(lowered)) return 'working'; + + const workTerms = '(fix|handle|implement|patch|update|investigate|look\\s+into|take|pick\\s+up|work\\s+on|resolve|debug)'; + const workRe = new RegExp(`(${escapeRegExp(targetMention)}\\b[^\\n]{0,120}\\b${workTerms}\\b|\\b${workTerms}\\b[^\\n]{0,120}${escapeRegExp(targetMention)}\\b)`, 'i'); + return workRe.test(lowered) ? 'working' : null; +} + + +// ── PTY activity & prompt detection ─────────────────────────────────────── +// Watches linked pane output for: +// 1. Nontrivial output → set 'working' (with inactivity timer → 'idle') +// 2. Known prompt patterns (y/n, tool approval) → hold 'working' (cancel idle timer) +// 3. New nontrivial output after prompt → clear prompt flag, resume normal idle cycle +// +// Prompt detection uses a delta buffer (output since last quiescence check) +// rather than the full scrollback tail, preventing stale prompts from +// re-triggering after the user has already responded. + +/** Returns true if text contains printable (non-whitespace) characters after ANSI stripping. */ +function hasNontrivialContent(rawData: string): boolean { + const stripped = stripAnsi(rawData); + return /\S/.test(stripped); +} + +function hasNontrivialText(text: string): boolean { + return /\S/.test(text); +} + +function isChatInjectedOutput(text: string): boolean { + return /^\[DevGlide Chat(?: \| Assigned by: [a-z-]+)*\] @\S+:/m.test(text.trim()); +} + +const AWAITING_USER_PATTERNS: RegExp[] = [ + // Claude Code tool permission prompts + /Allow\s+(?:Read|Edit|Write|Bash|MultiEdit|NotebookEdit|Glob|Grep|WebFetch|WebSearch|Agent|Skill|mcp_+[\w-]+)/i, + // "wants to use/run" phrasing (Claude Code, similar tools) + /wants to (?:use|read|edit|write|run|execute|create|delete)\b/i, + // Generic yes/no confirmation at end of line + /\(y\/n\)\s*$/m, + /\[y\/n\]\s*$/im, + /\[yes\/no\]\s*$/im, + // Press to continue + /press (?:enter|any key|y) to (?:continue|proceed|confirm)/i, + // Generic approval / permission prompts + /\b(?:approval|permission)\b.{0,80}\b(?:required|needed|requested)\b/i, + /\b(?:approve|allow|confirm)\b.{0,80}\b(?:tool|command|action|request)\b/i, +]; + +function matchesPromptPattern(text: string): boolean { + return AWAITING_USER_PATTERNS.some(re => re.test(text)); +} + +const PTY_WORKING_IDLE_TIMEOUT_MS = 8000; + +function startPanePromptWatcher(name: string, projectId: string | null, paneId: string): void { + const key = participantKey(name, projectId); + stopPanePromptWatcher(key); + + const entry = globalPtys.get(paneId); + if (!entry?.ptyProcess?.onData) return; + + let quiescenceTimer: ReturnType | null = null; + let idleTimer: ReturnType | null = null; + let promptVisible = false; + // Delta buffer: collects output since the last quiescence check, + // so prompt detection only scans recent output, not stale history. + let deltaBuffer = ''; + + const disposable = entry.ptyProcess.onData((data: string) => { + deltaBuffer += data; + const participant = getParticipantExact(name, projectId); + + // PTY-driven working: nontrivial output → set working + if (hasNontrivialContent(data)) { + if (participant && participant.kind === 'llm' && !participant.detached) { + setParticipantStatus(name, projectId, 'working', false); + // Reset inactivity timer → idle (unless a prompt is holding working) + if (idleTimer) clearTimeout(idleTimer); + if (!promptVisible) { + idleTimer = setTimeout(() => { + idleTimer = null; + const p = getParticipantExact(name, projectId); + if (p && p.kind === 'llm' && p.status === 'working') { + setParticipantStatus(name, projectId, 'idle'); + } + }, PTY_WORKING_IDLE_TIMEOUT_MS); + } + } + } + + // Debounce: check for prompt pattern after output settles + if (quiescenceTimer) clearTimeout(quiescenceTimer); + quiescenceTimer = setTimeout(() => { + quiescenceTimer = null; + const participant = getParticipantExact(name, projectId); + if (!participant || participant.kind !== 'llm' || participant.detached) return; + + // Scan only the delta buffer (output since last check), not full scrollback + const stripped = stripAnsi(deltaBuffer); + deltaBuffer = ''; + + if (matchesPromptPattern(stripped)) { + // Prompt detected → hold working, cancel idle timer + promptVisible = true; + if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } + setParticipantStatus(name, projectId, 'working', false); + return; + } + + if (promptVisible) { + // New output after prompt — if nontrivial and not chat-injected, prompt was answered + if (!hasNontrivialText(stripped) || isChatInjectedOutput(stripped)) return; + + promptVisible = false; + setParticipantStatus(name, projectId, 'working', false); + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + idleTimer = null; + const p = getParticipantExact(name, projectId); + if (p && p.kind === 'llm' && p.status === 'working') { + setParticipantStatus(name, projectId, 'idle'); + } + }, PTY_WORKING_IDLE_TIMEOUT_MS); + return; + } + + // No prompt, no special state — let the idle timer run its course + }, PROMPT_QUIESCENCE_MS); + }); + + panePromptWatchers.set(key, { + dispose: () => { + disposable.dispose(); + if (quiescenceTimer) clearTimeout(quiescenceTimer); + if (idleTimer) clearTimeout(idleTimer); + }, + }); +} + +function stopPanePromptWatcher(key: string): void { + const watcher = panePromptWatchers.get(key); + if (watcher) { + watcher.dispose(); + panePromptWatchers.delete(key); + } +} + +// ── Participant persistence ────────────────────────────────────────────────── + +/** Persist current LLM participants to disk for a given project. */ +export function persistParticipantsForProject(projectId: string | null): void { + if (!projectId) return; + const llmParticipants: PersistedParticipant[] = []; + for (const p of participants.values()) { + if (p.kind !== 'llm' || p.projectId !== projectId) continue; + llmParticipants.push({ + name: p.name, + model: p.model, + paneId: p.paneId, + projectId: p.projectId, + submitKey: p.submitKey, + joinedAt: p.joinedAt, + lastSeen: p.lastSeen, + joinedVia: p.joinedVia, + permissionMode: p.permissionMode, + }); + } + saveParticipants(llmParticipants, projectId); +} + +/** Restore participants from disk after server restart. + * Only reattaches participants whose pane still exists and matches exactly. + * Returns arrays of restored and failed participants. */ +export function restoreParticipants(projectId: string | null): { restored: string[]; failed: string[] } { + if (!projectId) return { restored: [], failed: [] }; + const persisted = loadParticipants(projectId); + if (persisted.length === 0) return { restored: [], failed: [] }; + + const restored: string[] = []; + const failed: string[] = []; + + for (const p of persisted) { + // Only reattach if pane + project match exactly + if (!p.paneId || !globalPtys.has(p.paneId)) { + failed.push(p.name); + continue; + } + + // Check the pane still belongs to this project + const paneInfo = dashboardState.panes.find(d => d.id === p.paneId); + if (paneInfo?.projectId && p.projectId && paneInfo.projectId !== p.projectId) { + failed.push(p.name); + continue; + } + + // Reattach — create participant in detached state, ready for reclaim + const key = participantKey(p.name, p.projectId); + if (participants.has(key)) continue; // already exists (shouldn't happen after restart) + + const participant: ChatParticipant = { + name: p.name, + kind: 'llm', + model: p.model, + paneId: p.paneId, + paneNum: getPaneDisplayNumber(p.paneId), + projectId: p.projectId, + submitKey: p.submitKey, + joinedAt: p.joinedAt, + lastSeen: new Date().toISOString(), + detached: true, // detached until the MCP session reclaims + status: 'idle', + joinedVia: p.joinedVia ?? null, + permissionMode: p.permissionMode ?? paneInfo?.permissionMode ?? 'supervised', + }; + participants.set(key, participant); + bumpParticipantSessionEpoch(p.name, p.projectId); + restored.push(p.name); + } + + // Do NOT persist here — failed entries should stay in the file so they + // remain available for manual rejoin. They will be removed when the + // participant explicitly leaves or the disconnect timeout fires. + + return { restored, failed }; +} + +export function setChatNsp(nsp: Namespace): void { + chatNsp = nsp; +} + +/** Emit to all dashboard clients viewing the given project (or active project). */ +function emitToProject(event: string, data: unknown, projectId?: string | null): void { + const pid = projectId ?? activeProjectId(); + if (!chatNsp) return; + if (pid) { + chatNsp.to(`project:${pid}`).emit(event, data); + } else { + // Fallback: no project context — broadcast to all (shouldn't happen in practice) + chatNsp.emit(event, data); + } +} + +function emitPipeEvent(event: Omit, projectId?: string | null): PipeUiEvent { + const stored = appendPipeEvent(event, projectId); + emitToProject('chat:pipe', stored, projectId); + return stored; +} + +function ensurePipeAnchor(body: string, pipeId: string): string { + const anchor = `#pipe-${pipeId}`; + return body.includes(anchor) ? body : `${anchor} ${body}`; +} + +// Emit refreshed member list when the active project changes +onProjectChange((project) => { + emitMembers(project?.id); +}); + +export function getChatNsp(): Namespace | null { + return chatNsp; +} + +// ── Identity-based name assignment ────────────────────────────────────────── +// Names are derived from hint (the `name` param from chat_join) + the numeric +// suffix from the pane ID (e.g. "claude-5" for pane-5, "codex-4" for pane-4). +// The `hint` is preferred over `model` so that agents with a stable identity +// label (like "codex") keep that label regardless of which backend model they +// report. + +/** Extract the numeric suffix from the pane ID (e.g. "pane-5" → 5). */ +function getPaneDisplayNumber(paneId: string | null): number | null { + if (!paneId) return null; + const match = paneId.match(/-(\d+)$/); + return match ? Number(match[1]) : null; +} + +/** Normalize the identity base from hint/model (e.g. "claude", "codex"). */ +export function deriveNameBase(hint: string, model: string | null): string { + return (hint || model || 'agent').toLowerCase().replace(/[^a-z0-9-]/g, ''); +} + +/** Derive a name from hint/model + pane display number (e.g. "claude-1"). */ +function deriveUniqueName(hint: string, model: string | null, paneId: string | null, projectId: string | null): string { + // Prefer hint (the name param from chat_join) over model — this ensures + // agents like "codex" keep a stable identity even if model varies. + const base = deriveNameBase(hint, model); + const paneNum = getPaneDisplayNumber(paneId); + + // Use model-paneNumber format (e.g. "claude-1") + const name = paneNum ? `${base}-${paneNum}` : base; + + // If somehow still taken within this project, append a sequential suffix + const usedNames = new Set( + [...participants.values()] + .filter((p) => p.projectId === projectId) + .map((p) => p.name) + ); + if (!usedNames.has(name)) return name; + + let i = 1; + while (usedNames.has(`${name}-${i}`)) i++; + return `${name}-${i}`; +} + +/** Update the shell pane tab title to show the chat name. */ +function permissionModeLabel(mode?: string | null): string { + if (!mode || mode === 'supervised') return ''; + return mode === 'auto-accept' ? ' [AUTO]' : ' [UNRESTRICTED]'; +} + +function updatePaneTitle(paneId: string, chatName: string): void { + const pane = dashboardState.panes.find(p => p.id === paneId); + if (!pane) return; + pane.chatName = chatName; + const modeLabel = permissionModeLabel(pane.permissionMode); + pane.title = `${pane.num}: ${chatName}${modeLabel}`; + // Notify shell page to update the tab (separate from terminal:cwd so CWD changes don't overwrite) + getShellNsp()?.emit('state:pane-chat-name', { id: paneId, chatName: `${chatName}${modeLabel}` }); +} + +/** Find an existing participant that can be reclaimed by projectId + paneId + identity. + * The pane is the stable anchor, and the name base (e.g. "claude", "codex") must match + * the existing participant's name prefix so a different agent on the same pane won't + * steal the wrong alias. */ +function findReclaimCandidate(paneId: string | null, nameBase: string, projectId: string | null): ChatParticipant | null { + if (!paneId) return null; + for (const p of participants.values()) { + if (p.paneId === paneId && p.projectId === projectId && (p.name === nameBase || p.name.startsWith(`${nameBase}-`))) return p; + } + return null; +} + +export function join( + name: string, + kind: 'user' | 'llm', + paneId: string | null, + model: string | null = null, + submitKey: string = '\r', + projectId?: string | null, + joinedVia?: 'rest' | 'mcp' | null, +): ChatParticipant { + const now = new Date().toISOString(); + const resolvedProjectId = resolveProjectId(projectId); + + // Claim-or-create: try to reclaim an existing participant by paneId + identity + const nameBase = deriveNameBase(name, model); + const existing = findReclaimCandidate(paneId, nameBase, resolvedProjectId); + if (existing) { + const wasDetached = existing.detached; + const previousJoinVia = existing.joinedVia ?? null; + // Reattach: keep the same alias, update session fields + existing.detached = false; + existing.paneId = paneId; + existing.paneNum = getPaneDisplayNumber(paneId); + existing.model = model; // refresh — model may vary between sessions + existing.submitKey = submitKey; + existing.lastSeen = now; + existing.status = 'idle'; + existing.joinedVia = joinedVia ?? existing.joinedVia ?? null; + const reclaimPane = paneId ? dashboardState.panes.find(p => p.id === paneId) : null; + existing.permissionMode = reclaimPane?.permissionMode ?? existing.permissionMode ?? 'supervised'; + clearParticipantStatusTimer(existing.name, resolvedProjectId); + bumpParticipantSessionEpoch(existing.name, resolvedProjectId); + // Cancel any pending auto-removal timer + const disconnectKey = participantKey(existing.name, resolvedProjectId); + const disconnectTimer = paneDisconnectTimers.get(disconnectKey); + if (disconnectTimer) { clearTimeout(disconnectTimer); paneDisconnectTimers.delete(disconnectKey); } + + if (paneId) updatePaneTitle(paneId, existing.name); + + const joinAnnouncement = + !wasDetached && previousJoinVia === 'rest' && joinedVia === 'mcp' + ? 'session upgraded' + : 'reconnected'; + const msg = appendMessage({ + from: existing.name, + to: null, + body: `${existing.name} ${joinAnnouncement}${paneId ? ` (${paneId})` : ''}`, + type: 'join', + }, existing.projectId); + emitToProject('chat:join', existing, existing.projectId); + emitToProject('chat:message', msg, existing.projectId); + emitMembers(existing.projectId); + + if (paneId) startPanePromptWatcher(existing.name, existing.projectId, paneId); + persistParticipantsForProject(existing.projectId); + + // Reconcile any pending pipe assignments after reconnect + if (existing.kind === 'llm') { + reconcileOnReconnect(existing.name, existing.projectId); + } + + return { ...existing }; + } + + // No reclaim candidate — derive name from model/identity + const uniqueName = deriveUniqueName(name, model, paneId, resolvedProjectId); + const paneInfo = paneId ? dashboardState.panes.find(p => p.id === paneId) : null; + const participant: ChatParticipant = { + name: uniqueName, + kind, + model, + paneId, + paneNum: getPaneDisplayNumber(paneId), + projectId: resolvedProjectId, + submitKey, + joinedAt: now, + lastSeen: now, + status: kind === 'llm' ? 'idle' : undefined, + detached: false, + joinedVia: joinedVia ?? null, + permissionMode: paneInfo?.permissionMode ?? 'supervised', + }; + participants.set(participantKey(uniqueName, resolvedProjectId), participant); + bumpParticipantSessionEpoch(uniqueName, resolvedProjectId); + + // Update the pane tab to show the chat name + if (paneId) updatePaneTitle(paneId, uniqueName); + + const msg = appendMessage({ + from: uniqueName, + to: null, + body: `${uniqueName} joined${paneId ? ` (${paneId})` : ''}`, + type: 'join', + }, participant.projectId); + emitToProject('chat:join', participant, participant.projectId); + emitToProject('chat:message', msg, participant.projectId); + emitMembers(participant.projectId); + + if (paneId) startPanePromptWatcher(uniqueName, participant.projectId, paneId); + persistParticipantsForProject(participant.projectId); + + return { ...participant }; +} + +export function leave(name: string, projectId?: string | null): boolean { + const participant = projectId !== undefined + ? getParticipantExact(name, projectId) + : getParticipant(name); + if (!participant) return false; + const pid = participant.projectId; + const removed = participants.delete(participantKey(name, pid)); + if (removed) { + const key = participantKey(name, pid); + clearParticipantStatusTimer(name, pid); + stopPanePromptWatcher(key); + participantSessionEpochs.delete(key); + const disconnectTimer = paneDisconnectTimers.get(key); + if (disconnectTimer) { clearTimeout(disconnectTimer); paneDisconnectTimers.delete(key); } + const msg = appendMessage({ + from: name, + to: null, + body: `${name} left`, + type: 'leave', + }, pid); + emitToProject('chat:leave', { name }, pid); + emitToProject('chat:message', msg, pid); + emitMembers(pid); + persistParticipantsForProject(pid); + + // Fail-fast: cancel any running pipes this participant is in + failPipesForParticipant(name, pid, 'left'); + } + return removed; +} + +/** Mark a participant as detached (MCP session closed but pane still alive). + * The alias stays reserved so a subsequent join from the same pane + model reclaims it. */ +export function detach(name: string, projectId?: string | null): boolean { + const participant = projectId !== undefined + ? getParticipantExact(name, projectId) + : getParticipant(name); + if (!participant) return false; + participant.detached = true; + clearParticipantStatusTimer(name, participant.projectId); + stopPanePromptWatcher(participantKey(name, participant.projectId)); + bumpParticipantSessionEpoch(name, participant.projectId); + emitMembers(participant.projectId); + + // Fail-fast: cancel any running pipes this participant is in + failPipesForParticipant(name, participant.projectId, 'detached'); + + // Start auto-removal timer — if not reclaimed within timeout, fully remove + const key = participantKey(name, participant.projectId); + const existing = paneDisconnectTimers.get(key); + if (existing) clearTimeout(existing); + paneDisconnectTimers.set(key, setTimeout(() => { + paneDisconnectTimers.delete(key); + const p = getParticipantExact(name, participant.projectId); + if (p && p.detached) { + leave(name, participant.projectId); + } + }, PANE_DISCONNECT_TIMEOUT_MS)); + + return true; +} + +function pruneStaleParticipants(): void { + for (const participant of [...participants.values()]) { + if (participant.kind !== 'llm' || !participant.paneId) continue; + if (globalPtys.has(participant.paneId)) continue; + // Pane is gone — detach gracefully instead of removing + disconnectParticipant(participant.name, participant.projectId, 'pane disappeared'); + } +} + +/** Gracefully disconnect a participant: unlink pane, keep in registry, start auto-removal timer. */ +function disconnectParticipant(name: string, projectId: string | null, reason: string): void { + const participant = getParticipantExact(name, projectId); + if (!participant) return; + + participant.paneId = null; + participant.detached = true; + clearParticipantStatusTimer(name, projectId); + stopPanePromptWatcher(participantKey(name, projectId)); + bumpParticipantSessionEpoch(name, projectId); + emitMembers(projectId); + persistParticipantsForProject(projectId); + + // Fail-fast: cancel any running pipes this participant is in + const pipeReason = reason === 'pane closed' ? 'pane-closed' : 'detached'; + failPipesForParticipant(name, projectId, pipeReason as 'left' | 'detached' | 'pane-closed'); + + // Start auto-removal timer — if not reclaimed within timeout, fully remove + const key = participantKey(name, projectId); + const existing = paneDisconnectTimers.get(key); + if (existing) clearTimeout(existing); + paneDisconnectTimers.set(key, setTimeout(() => { + paneDisconnectTimers.delete(key); + const p = getParticipantExact(name, projectId); + if (p && p.detached) { + leave(name, projectId); + } + }, PANE_DISCONNECT_TIMEOUT_MS)); +} + +export async function send(from: string, body: string, to?: string, projectId?: string | null): Promise { + pruneStaleParticipants(); + + // Update lastSeen — use project-scoped lookup when available + const resolvedPid = resolveProjectId(projectId); + const sender = resolvedPid ? getParticipantExact(from, resolvedPid) : getParticipant(from); + if (sender) sender.lastSeen = new Date().toISOString(); + + // Determine sender kind for routing rules + const senderKind = sender?.kind ?? (from === 'user' ? 'user' : 'llm'); + + // Use the sender's project — NOT the global active project. + // For dashboard/user sends (no participant record), fall back to activeProjectId(). + const senderProjectId = sender?.projectId ?? activeProjectId(); + const resolvedSenderProjectId = resolveProjectId(senderProjectId); + + // ─── Brainstorm command detection (user-only) ────────────────────── + if (from === 'user' && isBrainstormCommand(body)) { + return handleBrainstormCommand(body, resolvedSenderProjectId); + } + + // ─── Pipe command detection (user-only) ──────────────────────────── + if (from === 'user' && isPipeCommand(body)) { + return handlePipeCommand(body, resolvedSenderProjectId); + } + + // ─── Pipe response detection (LLM-only, log-centric) ────────────── + // For store-tracked pipes, chat_send is NEVER treated as a pipe response. + // Participants must use pipe_submit for store-tracked pipes. + let pipeMeta: PipeMessageMeta | undefined; + if (from !== 'system' && from !== 'user' && resolvedSenderProjectId) { + pipeMeta = detectPipeResponse(from, body, resolvedSenderProjectId); + // If the detected pipe is tracked in the store, suppress auto-detection. + // This prevents regular chat from being classified as pipe output. + if (pipeMeta && pipeStore.getPipe(pipeMeta.pipeId, resolvedSenderProjectId)) { + pipeMeta = undefined; + } + // Ensure #pipe-{id} anchor is always in the stored body for searchability + if (pipeMeta) body = ensurePipeAnchor(body, pipeMeta.pipeId); + } + + // ─── Build delivery plan (targeted PTY delivery) ──────────────────── + const plan = buildDeliveryPlan(from, body, to, senderKind, resolvedSenderProjectId); + + if (sender?.kind === 'llm' && sender.projectId === resolvedSenderProjectId && sender.status && sender.status !== 'idle') { + setParticipantStatus(sender.name, resolvedSenderProjectId, sender.status); + } + // Status side-effects use concreteAssignees only — NOT recipients. + // This prevents @all from setting every agent to "working". + if (senderKind === 'user') { + for (const targetName of plan.concreteAssignees) { + const status = markAssignedParticipantStatus(body, targetName); + if (status) setParticipantStatus(targetName, resolvedSenderProjectId, status); + } + } + + // Display `to` field — what the dashboard renders as `@sender → `. + // For explicit targets, list the validated names. For implicit user/system + // broadcasts (Option B fallback), show "all" so the header reads + // `@user → @all` instead of being silently absent. + const displayTo = plan.targetLabels.length === 1 + ? plan.targetLabels[0] + : plan.targetLabels.length > 1 + ? plan.targetLabels.join(', ') + : plan.fallbackBroadcast + ? 'all' + : null; + + // ─── Compute delivery count BEFORE persisting ────────────────────── + // So deliveredTo is included in the persisted message and socket emit. + let expectedDeliveryCount: number; + if (plan.recipients.length > 0) { + expectedDeliveryCount = plan.recipients.length; + } else if (plan.fallbackBroadcast) { + // Count broadcast targets (Option B fallback) + expectedDeliveryCount = 0; + for (const p of participants.values()) { + if (p.name !== from && p.paneId && p.projectId === resolvedSenderProjectId) { + expectedDeliveryCount++; + } + } + } else { + expectedDeliveryCount = 0; + } + + const msg = appendMessage({ + from, + to: displayTo, + body, + type: 'message', + ...(pipeMeta ? { pipe: pipeMeta } : {}), + ...(expectedDeliveryCount > 0 ? { deliveredTo: expectedDeliveryCount } : {}), + ...(plan.unresolvedTargets.length > 0 ? { unresolvedTargets: plan.unresolvedTargets } : {}), + }, resolvedSenderProjectId); + + // Emit to dashboard clients viewing this project only + emitToProject('chat:message', msg, resolvedSenderProjectId); + + // ─── Targeted PTY delivery ───────────────────────────────────────── + // Deliver only to resolved recipients. If no recipients and sender is + // user/system, fall back to broadcast (Option B backward compat). + // LLM messages with no @mention: NO PTY delivery (token savings). + if (plan.recipients.length > 0) { + for (const name of plan.recipients) { + await deliverToPty(name, resolvedSenderProjectId, msg); + } + } else if (plan.fallbackBroadcast) { + // Option B: unaddressed user/system messages still broadcast + for (const p of participants.values()) { + if (p.name !== from && p.paneId && p.projectId === resolvedSenderProjectId) { + await deliverToPty(p.name, resolvedSenderProjectId, msg); + } + } + } + // LLM with no @mention and no fallback: no PTY delivery. + // Message is persisted (above) and visible in dashboard — just not PTY-injected. + + // ─── Pipe reducer: check if this message triggers next step ──────── + // NOTE: pipeMeta is only set for legacy pipes NOT tracked in the store. + // Store-tracked pipes are suppressed above — they require pipe_submit. + if (pipeMeta) { + runPipeReducer(pipeMeta.pipeId, resolvedSenderProjectId) + .catch(err => console.error('[pipe] reducer failed:', err)); + } + + return msg; +} + +// ─── Targeted PTY Delivery — Two-stage target resolution ──────────── + +/** Reserved pseudo-targets that are semantic only (no PTY delivery). */ +const SEMANTIC_ONLY_TARGETS = new Set(['user', 'system']); + +/** Strip fenced code blocks (```...```) and inline code spans (`...`) from + * body text so the @mention regex doesn't pick up example syntax as real + * recipients. Replaces them with whitespace so character offsets stay sane + * and adjacent tokens don't accidentally fuse together. */ +function stripCodeRegions(body: string): string { + // Fenced first (greedy on whole blocks; non-greedy on the inner content). + // Matches ``` optionally followed by a language tag, then anything until + // the next ``` on its own boundary. + let stripped = body.replace(/```[\s\S]*?```/g, (m) => ' '.repeat(m.length)); + // Inline code spans — single backticks. Run after fenced so we don't bite + // into the fence markers themselves. + stripped = stripped.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length)); + return stripped; +} + +/** Extract raw @mention tokens from message body (pure string parsing, no state). + * Returns tokens like ["all"], ["claude-7", "codex-14"], ["team-ui"], or []. + * Handles explicit `to` param (which may be comma-separated), merging with + * body @mentions. Mentions inside inline code spans and fenced code blocks + * are ignored — they are example syntax, not real addressees. */ +// senderKind kept in signature for backward compatibility / future use; the +// merge behavior is identical for user and llm senders. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function parseTargetTokens(body: string, to?: string, senderKind?: 'user' | 'llm'): string[] { + const tokens: string[] = []; + + // Explicit `to` param — split on commas, trim, lowercase, drop empties. + // Real-world: callers sometimes pass `to: "codex-3,pi-1"` instead of a + // single name. Splitting here keeps the rest of the pipeline simple and + // prevents the literal comma-string from leaking into displayed `msg.to`. + if (to) { + for (const raw of to.split(',')) { + const normalized = raw.trim().toLowerCase(); + if (normalized && !tokens.includes(normalized)) tokens.push(normalized); + } + } + + // Scan body for all @mentions, but only outside code regions. The + // capture is restricted to `[a-zA-Z0-9-]+` (letters, digits, hyphens) + // so markdown formatting (`**`, `_`, `~`), trailing punctuation, and + // parentheses cannot leak into the token. Note: underscore is excluded + // because it's a markdown emphasis marker (`_@claude_`); chat aliases + // in DevGlide use `-` as the separator. This is the parser-side + // defense; `buildDeliveryPlan` adds a second defense by filtering + // tokens that don't resolve to a real participant. + const scannable = stripCodeRegions(body); + const regex = /@([a-zA-Z0-9-]+)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(scannable)) !== null) { + const token = match[1]; + if (token && !tokens.includes(token)) tokens.push(token); + } + + return tokens; +} + +/** Expand raw target tokens into concrete participant names for PTY delivery. + * Returns { recipients, concreteAssignees } — recipients is the full delivery list, + * concreteAssignees is direct @mentions only (no group expansions) for status side-effects. */ +export function expandToRecipients( + tokens: string[], + from: string, + projectId: string | null, +): { recipients: string[]; concreteAssignees: string[]; unresolvedTargets: string[] } { + const recipientSet = new Set(); + const concreteSet = new Set(); + const unresolvedSet = new Set(); + const pid = resolveProjectId(projectId); + + for (const token of tokens) { + if (token === 'all') { + // @all → every live, non-detached participant except sender + for (const p of participants.values()) { + if (p.name !== from && p.projectId === pid && !p.detached && p.paneId) { + recipientSet.add(p.name); + } + } + // @all does NOT add to concreteAssignees — it's a group expansion + } else if (SEMANTIC_ONLY_TARGETS.has(token)) { + // @user, @system — semantic only, no PTY delivery target + continue; + } else { + // Individual participant name + const p = getParticipantExact(token, pid); + if (p && p.projectId === pid && p.name !== from) { + concreteSet.add(p.name); // direct @mention → concrete assignee (always, for status) + // Only add to recipients if live and deliverable (not detached, has pane) + if (!p.detached && p.paneId) { + recipientSet.add(p.name); + } + } else if (!p || p.projectId !== pid) { + // Token doesn't match any known participant in this project + unresolvedSet.add(token); + } + } + } + + return { + recipients: [...recipientSet], + concreteAssignees: [...concreteSet], + unresolvedTargets: [...unresolvedSet], + }; +} + +/** Build a complete delivery plan from message content and sender context. + * Combines token parsing + recipient expansion + fallback logic. */ +export function buildDeliveryPlan( + from: string, + body: string, + to: string | undefined, + senderKind: 'user' | 'llm', + projectId: string | null, +): import('../types.js').DeliveryPlan { + const tokens = parseTargetTokens(body, to, senderKind); + const { recipients, concreteAssignees, unresolvedTargets } = expandToRecipients(tokens, from, projectId); + + // Determine fallback: ONLY truly unaddressed user/system messages broadcast (Option B). + // If the sender wrote @mentions that didn't resolve (typo, offline, semantic-only), + // that is NOT "unaddressed" — they intended a target, it just failed. No fallback. + const hadTargetIntent = tokens.length > 0; + const fallbackBroadcast = !hadTargetIntent && recipients.length === 0 + && (senderKind === 'user' || from === 'system'); + + // Display label list: only tokens that resolved to a real participant + // or the literal `all` group expansion. Unresolved garbage (markdown + // leaks, typos, the literal comma-string from a comma-separated `to` + // param, semantic-only `user`/`system`) is excluded so the dashboard + // renderer never shows it. Sender alias is also excluded — sending to + // yourself is nonsense. Order from `tokens` is preserved. + const fromLower = from.toLowerCase(); + const concreteSet = new Set(concreteAssignees); + const targetLabels: string[] = []; + for (const token of tokens) { + if (token === fromLower) continue; + if (token === 'all' || concreteSet.has(token)) { + if (!targetLabels.includes(token)) targetLabels.push(token); + } + } + + return { targetLabels, recipients, concreteAssignees, fallbackBroadcast, unresolvedTargets }; +} + +/** @deprecated Use buildDeliveryPlan() instead. Kept for backward compatibility during migration. */ +function resolveTargets(from: string, body: string, to?: string, senderKind?: 'user' | 'llm', projectId?: string | null): string[] { + const plan = buildDeliveryPlan(from, body, to, senderKind ?? 'llm', projectId ?? null); + return plan.concreteAssignees; +} + +function deliverToPty(targetName: string, projectId: string | null, msg: ChatMessage): Promise { + const target = getParticipantExact(targetName, projectId); + if (!target?.paneId || target.detached) { + return Promise.resolve(); + } + + const paneId = target.paneId; + const sessionEpoch = currentParticipantSessionEpoch(targetName, projectId); + const previous = paneDeliveryQueues.get(paneId) ?? Promise.resolve(); + const next = previous + .catch(() => {}) + .then(async () => { + const liveTarget = getParticipantExact(targetName, projectId); + if (!liveTarget?.paneId || liveTarget.detached || liveTarget.paneId !== paneId || currentParticipantSessionEpoch(targetName, projectId) !== sessionEpoch) { + return; + } + + const entry = globalPtys.get(paneId); + if (!entry) { + disconnectParticipant(targetName, projectId, 'pane disappeared during delivery'); + return; + } + + const header = formatPtyHeader(targetName, msg); + let formatted = `${header} @${msg.from}: ${msg.body}`; + + // Write with retry — if the initial write fails, retry once after a short delay + let writeOk = false; + for (let attempt = 0; attempt < 2; attempt++) { + try { + const ptyEntry = attempt === 0 ? entry : globalPtys.get(paneId); + if (!ptyEntry) { + disconnectParticipant(targetName, projectId, 'pane disappeared during delivery retry'); + return; + } + ptyEntry.ptyProcess.write(formatted); + writeOk = true; + break; + } catch (err) { + if (attempt === 0) { + console.warn(`[chat] PTY write failed for ${targetName}, retrying in 500ms:`, err); + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + console.error(`[chat] PTY write retry failed for ${targetName}, disconnecting:`, err); + disconnectParticipant(targetName, projectId, 'pane write failed'); + return; + } + } + } + if (!writeOk) return; + + await new Promise((resolve) => setTimeout(resolve, PTY_SUBMIT_DELAY_MS)); + + const refreshed = getParticipantExact(targetName, projectId); + if (!refreshed?.paneId || refreshed.detached || refreshed.paneId !== paneId || currentParticipantSessionEpoch(targetName, projectId) !== sessionEpoch) { + return; + } + + const refreshedEntry = globalPtys.get(paneId); + if (!refreshedEntry) { + disconnectParticipant(targetName, projectId, 'pane disappeared before submit'); + return; + } + + refreshedEntry.ptyProcess.write(refreshed.submitKey); + }) + .finally(() => { + if (paneDeliveryQueues.get(paneId) === next) { + paneDeliveryQueues.delete(paneId); + } + }); + + paneDeliveryQueues.set(paneId, next); + return next; +} + +export function listParticipants(projectId?: string | null): ChatParticipant[] { + pruneStaleParticipants(); + + const pid = resolveProjectId(projectId); + const result: ChatParticipant[] = []; + for (const p of participants.values()) { + // Only return participants that belong to the active project + if (p.projectId === pid) { + result.push({ ...p }); + } + } + result.sort((a, b) => a.name.localeCompare(b.name)); + return result; +} + +function comparePipeAssigneeOrder(a: ChatParticipant, b: ChatParticipant): number { + const byJoin = a.joinedAt.localeCompare(b.joinedAt); + if (byJoin !== 0) return byJoin; + return a.name.localeCompare(b.name); +} + +export function listDefaultPipeAssignees(projectId?: string | null): ChatParticipant[] { + pruneStaleParticipants(); + + const pid = resolveProjectId(projectId); + return [...participants.values()] + .filter((participant) => + participant.projectId === pid + && participant.kind === 'llm' + && !participant.detached + && !!participant.paneId) + .sort(comparePipeAssigneeOrder); +} + +export function getParticipant(name: string, projectId?: string | null): ChatParticipant | undefined { + // Exact lookup when projectId is provided + if (projectId !== undefined) return getParticipantExact(name, projectId); + // No projectId supplied: scope strictly to the active project — never fall back + // to a cross-project match, even when it is the only match by name. + const pid = activeProjectId(); + return getParticipantExact(name, pid); +} + +export function getParticipantByPaneId(paneId: string, projectId?: string | null): ChatParticipant | undefined { + pruneStaleParticipants(); + + // Determine the scope: explicit projectId if given, otherwise the active project. + // Under no circumstances return a participant whose projectId differs from the scope. + const pid = projectId !== undefined ? projectId : activeProjectId(); + for (const participant of participants.values()) { + if (participant.paneId === paneId && participant.projectId === pid) return participant; + } + return undefined; +} + +/** Clear chat history for the active project and notify dashboard clients. */ +export function clearHistory(projectId?: string | null): void { + const pid = resolveProjectId(projectId); + clearMessages(pid); + emitToProject('chat:cleared', {}, pid); +} + +/** Clean up stale terminal pipes from both in-memory store and disk. + * Removes completed/failed/cancelled pipes older than the TTL. + * Returns the count of removed pipes. */ +export function cleanupStalePipes(projectId?: string | null, ttlMs?: number): number { + const pid = resolveProjectId(projectId); + const removed = pipeStore.cleanupTerminalPipes(pid, ttlMs); + if (removed.length > 0) { + removePipeFiles(removed, pid); + console.log(`[pipe] Cleaned up ${removed.length} stale pipe(s): ${removed.join(', ')}`); + } + return removed.length; +} + +/** Recover active pipes from persisted event logs after server restart. + * Rebuilds in-memory pipe state from per-pipe events files. + * Pipes that were running at shutdown are rehydrated; the reducer is re-run + * for each recovered pipe so leases can be re-granted when participants rejoin. + * Returns the count of recovered running pipes. */ +export function recoverPipes(projectId?: string | null): number { + const pid = resolveProjectId(projectId); + const pipeIds = discoverPersistedPipeIds(pid); + if (pipeIds.length === 0) return 0; + + // Collect all events across all pipe files + const allEvents: import('./pipe-store.js').PipeRecoveryEvent[] = []; + for (const pipeId of pipeIds) { + // Skip if already in memory (shouldn't happen after fresh start) + if (pipeStore.getPipe(pipeId, pid)) continue; + + const events = readAllPipeEvents(pipeId, pid); + for (const event of events) { + allEvents.push({ + type: event.type, + pipeId: event.pipeId, + mode: event.mode ?? undefined, + assignees: event.assignees, + prompt: event.prompt, + stageTimeoutMs: event.stageTimeoutMs, + timeoutPolicy: event.timeoutPolicy, + from: event.from, + role: event.role, + stage: event.stage, + content: event.content, + }); + } + } + + const runningPipeIds = pipeStore.rehydrateFromEvents(allEvents, pid); + + if (runningPipeIds.length > 0) { + console.log(`[pipe] Recovered ${runningPipeIds.length} running pipe(s) from disk: ${runningPipeIds.join(', ')}`); + startPipeWatchdog(); + } + + return runningPipeIds.length; +} + +// ── Reconnect assignment reconciliation ────────────────────────────────────── + +/** Reconcile pipe assignments when a participant reconnects (or joins for the + * first time after server restart with recovered pipes). + * + * For each running pipe where the participant has pending or leased slots, + * re-run the reducer so that: + * - Pending slots get a lease grant + PTY handoff delivery + * - Leased slots whose deadline expired get released and reset to pending + * - Leased slots still within deadline get re-delivered to the now-live pane + * + * Returns the number of pipes that were reconciled. */ +export function reconcileOnReconnect(name: string, projectId: string | null): number { + const assignments = pipeStore.getAssignmentsForParticipant(name, projectId); + if (assignments.length === 0) return 0; + + const pipeIds = new Set(); + for (const a of assignments) { + if (a.slotStatus === 'pending' || a.slotStatus === 'leased') { + pipeIds.add(a.pipeId); + } + } + + if (pipeIds.size === 0) return 0; + + for (const pipeId of pipeIds) { + const lease = pipeStore.getActiveLease(name, projectId); + if (lease && lease.pipeId === pipeId && pipeStore.isLeaseExpired(lease)) { + pipeStore.releaseLease(name, projectId); + const pipe = pipeStore.getPipe(pipeId, projectId); + if (pipe) { + const slots = pipe.slots.get(name); + if (slots) { + for (const slot of slots) { + if (slot.status === 'leased') slot.status = 'pending'; + } + } + } + } + + runPipeReducer(pipeId, projectId).catch((err) => { + console.error(`[pipe] reconcileOnReconnect reducer error for pipe #${pipeId}:`, err); + }); + } + + console.log(`[pipe] Reconciled ${pipeIds.size} pipe(s) for reconnected participant "${name}"`); + return pipeIds.size; +} + + +/** Handle pane closure — gracefully disconnect participants linked to this pane. + * Participants are detached (not removed) so they can reclaim within the timeout window. + * Scoped by projectId to avoid affecting participants from other projects. */ +export function onPaneClosed(paneId: string, projectId?: string | null): void { + for (const p of [...participants.values()]) { + if (p.paneId === paneId && (projectId == null || p.projectId === projectId)) { + disconnectParticipant(p.name, p.projectId, 'pane closed'); + } + } +} + + +// ── Pipe stage deadline management ────────────────────────────────────────── + +function deadlineKey(pipeId: string, assignee: string): string { + return `${pipeId}:${assignee}`; +} + +/** Start a deadline timer for a leased pipe stage. + * When the timer fires, the timeout policy is applied. */ +function startStageDeadline( + pipeId: string, + assignee: string, + projectId: string | null, + timeoutMs: number, + policy: import('../types.js').PipeTimeoutPolicy, +): void { + if (timeoutMs <= 0) return; // no timeout configured + const key = deadlineKey(pipeId, assignee); + const existing = stageDeadlineTimers.get(key); + if (existing) clearTimeout(existing); + stageDeadlineTimers.set(key, setTimeout(() => { + stageDeadlineTimers.delete(key); + handleStageTimeout(pipeId, assignee, projectId, policy); + }, timeoutMs)); +} + +/** Clear a specific stage deadline (e.g. after successful submit). */ +function clearStageDeadline(pipeId: string, assignee: string): void { + const key = deadlineKey(pipeId, assignee); + const timer = stageDeadlineTimers.get(key); + if (timer) { + clearTimeout(timer); + stageDeadlineTimers.delete(key); + } +} + +/** Clear all deadline timers for a pipe (e.g. when pipe reaches terminal state). */ +function clearAllDeadlinesForPipe(pipeId: string): void { + for (const [key, timer] of stageDeadlineTimers) { + if (key.startsWith(`${pipeId}:`)) { + clearTimeout(timer); + stageDeadlineTimers.delete(key); + } + } +} + +/** Handle a stage timeout by applying the configured policy. */ +function handleStageTimeout( + pipeId: string, + assignee: string, + projectId: string | null, + policy: import('../types.js').PipeTimeoutPolicy, +): void { + const pipe = pipeStore.getPipe(pipeId, projectId); + if (!pipe || pipe.status !== 'running') return; + + if (policy === 'escalate') { + // Notify user, keep pipe running — user decides what to do + const escalateMsg = appendMessage({ + from: 'system', to: null, + body: `#pipe-${pipeId} Stage timeout: @${assignee} has not responded within the deadline ` + + `(${Math.round(pipe.stageTimeoutMs / 1000)}s). The pipe is still running. ` + + `Cancel with \`/cancel-pipe ${pipeId}\` or wait for the participant to respond.`, + type: 'system', + }, projectId); + emitToProject('chat:message', escalateMsg, projectId); + return; + } + + // 'fail' (default) or 'reassign' (not yet implemented — falls through to fail) + clearAllDeadlinesForPipe(pipeId); + const releasedAssignees = pipeStore.markPipeStatus(pipeId, 'failed', projectId); + provenance.recordProvenance(projectId, { pipeId, event: 'failed', actor: 'system', actorKind: 'system', metadata: { reason: 'timeout', assignee, policy } }); + const policyNote = policy === 'reassign' + ? ' (reassign policy not yet supported — pipe failed instead)' + : ''; + const failMsg = appendMessage({ + from: 'system', to: null, + body: `#pipe-${pipeId} Pipe timed out: @${assignee} did not submit within the deadline ` + + `(${Math.round(pipe.stageTimeoutMs / 1000)}s).${policyNote}`, + type: 'system', + pipe: { pipeId, mode: pipe.mode, role: 'failed', reason: 'timeout' }, + }, projectId); + emitToProject('chat:message', failMsg, projectId); + emitPipeEvent({ type: 'failed', pipeId, reason: 'timeout' }, projectId); + drainPendingPipes(releasedAssignees, projectId); +} + +// ── Pipe liveness watchdog ────────────────────────────────────────────────── + +const PIPE_CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes +let lastCleanupAt = 0; + +/** Periodic watchdog that checks pane liveness for active pipe leaseholders + * and enforces stage deadlines. Runs every PIPE_WATCHDOG_INTERVAL_MS. */ +function pipeWatchdogTick(): void { + // 1. Prune stale participants (detect disappeared panes) + pruneStaleParticipants(); + + // 2. Check stage deadlines for leases that passed their deadline but whose + // timer hasn't fired yet (defensive — timers should handle this, but + // the watchdog catches edge cases like clock drift or timer GC) + const now = Date.now(); + for (const [, lease] of pipeStore.getAllActiveLeases()) { + if (!lease.deadline) continue; + const deadlineMs = new Date(lease.deadline).getTime(); + if (now >= deadlineMs && !stageDeadlineTimers.has(deadlineKey(lease.pipeId, lease.assignee))) { + // Deadline passed and no active timer — resolve immediately + const pipe = pipeStore.getPipe(lease.pipeId, null) ?? + findPipeAcrossProjects(lease.pipeId); + if (pipe && pipe.status === 'running') { + handleStageTimeout(lease.pipeId, lease.assignee, findProjectForPipe(lease.pipeId), pipe.timeoutPolicy); + } + } + } + + // 3. Periodic cleanup of terminal pipes (throttled to every 10 minutes) + // Iterates all projects with pipe data, not just the active one. + if (now - lastCleanupAt >= PIPE_CLEANUP_INTERVAL_MS) { + lastCleanupAt = now; + for (const pid of pipeStore.getTrackedProjectIds()) { + const removed = pipeStore.cleanupTerminalPipes(pid); + if (removed.length > 0) { + removePipeFiles(removed, pid); + } + } + } +} + +/** Find a pipe by scanning all project stores. */ +function findPipeAcrossProjects(pipeId: string): import('./pipe-store.js').StoredPipe | undefined { + // Try active project first, then scan all + const pid = activeProjectId(); + const pipe = pipeStore.getPipe(pipeId, pid); + if (pipe) return pipe; + return undefined; +} + +/** Find the projectId that owns a pipe by checking the active project. */ +function findProjectForPipe(pipeId: string): string | null { + const pid = activeProjectId(); + if (pipeStore.getPipe(pipeId, pid)) return pid; + return null; +} + +/** Start the pipe watchdog interval. Idempotent — safe to call multiple times. */ +export function startPipeWatchdog(): void { + if (pipeWatchdogInterval) return; + pipeWatchdogInterval = setInterval(pipeWatchdogTick, PIPE_WATCHDOG_INTERVAL_MS); + // Don't prevent Node from exiting + if (pipeWatchdogInterval.unref) pipeWatchdogInterval.unref(); +} + +/** Stop the pipe watchdog. Exported for test cleanup. */ +export function stopPipeWatchdog(): void { + if (pipeWatchdogInterval) { + clearInterval(pipeWatchdogInterval); + pipeWatchdogInterval = null; + } +} + +/** Clear all deadline timers. Exported for test cleanup. */ +export function clearAllDeadlineTimers(): void { + for (const [key, timer] of stageDeadlineTimers) { + clearTimeout(timer); + stageDeadlineTimers.delete(key); + } +} + +// ── Pipe orchestration (log-centric reducer model) ─────────────────────────── + +async function handlePipeCommand(body: string, projectId: string | null): Promise { + const parsed = parsePipeCommand(body, (name) => getParticipantExact(name, projectId) != null); + if (isPipeParseError(parsed)) { + const userMsg = appendMessage({ from: 'user', to: null, body, type: 'message' }, projectId); + emitToProject('chat:message', userMsg, projectId); + const errorMsg = appendMessage({ + from: 'system', to: null, + body: `Pipe error: ${parsed.error}`, + type: 'system', + }, projectId); + emitToProject('chat:message', errorMsg, projectId); + return userMsg; + } + + // Store command (not PTY-delivered) + const userMsg = appendMessage({ from: 'user', to: null, body, type: 'message' }, projectId); + emitToProject('chat:message', userMsg, projectId); + + const resolvedAssignees = parsed.assignees.length > 0 + ? parsed.assignees + : listDefaultPipeAssignees(projectId).map((participant) => participant.name); + + const countError = validatePipeAssigneeCount(parsed.mode, resolvedAssignees.length); + if (countError) { + const detail = parsed.assignees.length === 0 + ? ' No eligible default LLM assignees were available.' + : ''; + const errorMsg = appendMessage({ + from: 'system', to: null, + body: `Pipe error: ${countError}${detail}`, + type: 'system', + }, projectId); + emitToProject('chat:message', errorMsg, projectId); + return userMsg; + } + + // Validate all assignees are connected, live LLM participants + const invalid: string[] = []; + const reasons: string[] = []; + for (const a of resolvedAssignees) { + const p = getParticipantExact(a, projectId); + if (!p) { invalid.push(a); reasons.push(`@${a} not found`); continue; } + if (p.kind !== 'llm') { invalid.push(a); reasons.push(`@${a} is not an LLM`); continue; } + if (p.detached) { invalid.push(a); reasons.push(`@${a} is detached`); continue; } + if (!p.paneId) { invalid.push(a); reasons.push(`@${a} has no pane`); continue; } + } + if (invalid.length > 0) { + const errorMsg = appendMessage({ + from: 'system', to: null, + body: `Pipe error: invalid assignees (${reasons.join('; ')}). All assignees must be connected LLM participants with a live pane.`, + type: 'system', + }, projectId); + emitToProject('chat:message', errorMsg, projectId); + return userMsg; + } + + // Write start message to log with pipe metadata + const pipeId = pipeReducer.generatePipeId(); + const resolved = { ...parsed, assignees: resolvedAssignees }; + const desc = pipeReducer.getStartDescription(resolved); + + // Create pipe in the isolated stage store (with timeout config) + pipeStore.createPipe(pipeId, parsed.mode, resolvedAssignees, parsed.prompt, projectId, { + stageTimeoutMs: parsed.stageTimeoutMs, + timeoutPolicy: parsed.timeoutPolicy, + }); + provenance.recordProvenance(projectId, { pipeId, event: 'created', actor: 'user', actorKind: 'user', metadata: { mode: parsed.mode, assignees: resolvedAssignees } }); + + // Ensure the pipe watchdog is running + startPipeWatchdog(); + + const startMsg = appendMessage({ + from: 'system', to: null, + body: `#pipe-${pipeId} Pipe started (${parsed.mode}): ${desc}`, + type: 'system', + pipe: { + pipeId, + mode: parsed.mode, + role: 'start', + assignees: resolvedAssignees, + prompt: parsed.prompt, + }, + }, projectId); + emitToProject('chat:message', startMsg, projectId); + emitPipeEvent({ + type: 'start', pipeId, mode: parsed.mode, + assignees: resolvedAssignees, + prompt: parsed.prompt, + stageTimeoutMs: parsed.stageTimeoutMs ?? pipeStore.DEFAULT_STAGE_TIMEOUT_MS, + timeoutPolicy: parsed.timeoutPolicy ?? 'fail', + }, projectId); + + // Run reducer to emit initial handoff/fan-out + await runPipeReducer(pipeId, projectId); + + return userMsg; +} + +/** Detect if an LLM message is a response to an active pipe. + * Uses #pipe-{id} in the body as primary discriminator; falls back to + * most-recently-prompted pipe if no explicit tag is present. */ +function detectPipeResponse(from: string, body: string, projectId: string | null): PipeMessageMeta | undefined { + // ── Store-backed detection (primary) ── + const explicitMatch = body.match(/#pipe-([a-f0-9]+)/); + if (explicitMatch) { + const storedPipe = pipeStore.getPipe(explicitMatch[1], projectId); + if (storedPipe && storedPipe.status === 'running') { + const state = pipeReducer.buildStateFromStore(storedPipe); + const meta = pipeReducer.matchResponse(state, from); + if (meta) return meta; + } + } + + const senderPipeIds = pipeStore.getActivePipesForParticipant(from, projectId); + for (const pipeId of senderPipeIds) { + const storedPipe = pipeStore.getPipe(pipeId, projectId); + if (!storedPipe || storedPipe.status !== 'running') continue; + const state = pipeReducer.buildStateFromStore(storedPipe); + const meta = pipeReducer.matchResponse(state, from); + if (meta) return meta; + } + + // ── Log-backed fallback (recovery / legacy pipes not in store) ── + const messages = readMessages({ limit: 10000 }, projectId); + const pipeIds = new Set(); + for (const msg of messages) { + if (msg.pipe?.pipeId) pipeIds.add(msg.pipe.pipeId); + } + if (pipeIds.size === 0) return undefined; + + if (explicitMatch && pipeIds.has(explicitMatch[1])) { + const state = pipeReducer.derivePipeState(messages, explicitMatch[1]); + if (state && state.status === 'running') { + const meta = pipeReducer.matchResponse(state, from); + if (meta) return meta; + } + } + + // Last resort: find the most recently prompted pipe for this sender in the log + let lastPromptedPipeId: string | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg.pipe) continue; + const role = msg.pipe.role; + if ( + msg.pipe.targetAssignee === from && + (role === 'handoff' || role === 'fan-out-request' || role === 'synth-request') + ) { + lastPromptedPipeId = msg.pipe.pipeId; + break; + } + } + + if (lastPromptedPipeId) { + const state = pipeReducer.derivePipeState(messages, lastPromptedPipeId); + if (state && state.status === 'running') { + const meta = pipeReducer.matchResponse(state, from); + if (meta) return meta; + } + } + + return undefined; +} + +/** Run the pipe reducer: derive state from pipe store, compute next actions, execute them. + * Pipe instructions and intermediate outputs NEVER enter chat history. + * Only the final result is appended as a public chat message. */ +async function runPipeReducer(pipeId: string, projectId: string | null): Promise { + const storedPipe = pipeStore.getPipe(pipeId, projectId); + if (!storedPipe || storedPipe.status !== 'running') return; + + // Build state entirely from pipe store — no log scanning + const state = pipeReducer.buildStateFromStore(storedPipe); + + // Check for completion — broadcast final result as a public chat message + if (state.hasFinal) { + clearAllDeadlinesForPipe(pipeId); + pipeDelivery.cancelAllDeliveries(pipeId, projectId); + materializer.cancelPipeAssignments(pipeId, projectId); + pipeStore.markPipeStatus(pipeId, 'completed', projectId); + provenance.recordProvenance(projectId, { pipeId, event: 'completed', actor: 'system', actorKind: 'system' }); + + // Read the final output from pipe state and persist it for the user. + // Final output is user-only: persisted in chat history and emitted to + // dashboard via Socket.IO, but NOT PTY-delivered to LLM participants. + // This prevents long output from cluttering LLM terminals. + const finalContent = readFinalOutput(pipeId, projectId); + if (finalContent) { + const resultMsg = appendMessage({ + from: finalContent.from, to: 'user', + body: ensurePipeAnchor(finalContent.body, pipeId), + type: 'message', + pipe: { pipeId, mode: state.mode, role: 'final' }, + }, projectId); + emitToProject('chat:message', resultMsg, projectId); + // No PTY delivery — user sees it on dashboard only. + } + + emitPipeEvent({ type: 'complete', pipeId }, projectId); + + // Check if this pipe is a brainstorm child — advance brainstorm state + await advanceBrainstormOnChildComplete(pipeId, projectId); + return; + } + + const actions = pipeReducer.computeNextActions(state); + for (const action of actions) { + // Grant lease to target assignee in the store + const leaseResult = pipeStore.grantLease(pipeId, action.targetAssignee, projectId); + if (!leaseResult.ok) { + pipeStore.addPendingPipe(action.targetAssignee, projectId, pipeId); + // Emit queued diagnostic to dashboard UI only — NOT to chat history + emitPipeEvent({ + type: 'queued', + pipeId, + assignee: action.targetAssignee, + reason: leaseResult.error, + }, projectId); + continue; + } + + // Start stage deadline timer for this lease + startStageDeadline(pipeId, action.targetAssignee, projectId, storedPipe.stageTimeoutMs, storedPipe.timeoutPolicy); + provenance.recordProvenance(projectId, { pipeId, event: 'stage-granted', actor: 'system', actorKind: 'system', stage: action.type === 'handoff' ? action.stage : undefined, role: action.type, metadata: { assignee: action.targetAssignee } }); + + // Track emission in pipe store (replaces appendMessage to chat history) + pipeStore.markEmitted(pipeId, action.type, action.type === 'handoff' ? action.stage : action.targetAssignee, projectId); + + // Materialize assignment + payload for lifecycle tracking + const materialized = materializer.materializeAssignment(pipeId, state.mode, action, projectId); + + // Transport-layer: create delivery record for re-notify tracking + pipeDelivery.createDelivery( + pipeId, action.targetAssignee, action.type, action.body, projectId, action.stage, + ); + + // Format compact notification — PTY gets a pointer, not the full payload + const notification = pipeDelivery.formatCompactNotification( + pipeId, + state.mode, + action.type, + action.targetAssignee, + state.assignees.length, + action.stage, + ); + + // Construct compact delivery message for PTY injection — NOT stored in chat history. + const deliveryMsg: import('../types.js').ChatMessage = { + id: `pipe-${pipeId}-${action.type}-${action.targetAssignee}`, + ts: new Date().toISOString(), + from: 'system', + to: action.targetAssignee, + body: notification.body, + type: 'system', + pipe: action.pipe, + }; + + // Emit to dashboard UI as a pipe event (not chat:message — stays out of chat rendering) + emitPipeEvent({ + type: 'instruction', + pipeId, + actionType: action.type, + assignee: action.targetAssignee, + stage: action.stage, + }, projectId); + + // PTY deliver only to the target assignee + const target = getParticipantExact(action.targetAssignee, projectId); + if (target?.paneId && !target.detached) { + await deliverToPty(action.targetAssignee, projectId, deliveryMsg); + // Transition assignment lifecycle: assigned → notified (after successful PTY delivery) + if (materialized) { + materializer.transitionAssignmentStatus(materialized.assignmentId, 'notified', projectId); + } + // Transport-layer: record notification attempt for re-notify tracking + pipeDelivery.recordNotification(pipeId, action.targetAssignee, projectId); + pipeDelivery.startRenotifyTimer(pipeId, action.targetAssignee, projectId, handleRenotify); + } + } +} + +/** Re-notify: re-delivers compact notification to a tardy assignee. */ +function handleRenotify(pipeId: string, assignee: string, projectId: string | null): void { + const record = pipeDelivery.getDelivery(pipeId, assignee, projectId); + if (!record || record.state !== 'notified') return; + const pipe = pipeStore.getPipe(pipeId, projectId); + if (!pipe || pipe.status !== 'running') return; + const target = getParticipantExact(assignee, projectId); + if (!target?.paneId || target.detached) return; + const notification = pipeDelivery.formatCompactNotification( + pipeId, + pipe.mode, + record.role as 'handoff' | 'fan-out-request' | 'synth-request', + assignee, + pipe.assignees.length, + record.stage, + ); + const renotifyMsg: import('../types.js').ChatMessage = { + id: `pipe-${pipeId}-renotify-${assignee}-${record.notifyAttempts}`, + ts: new Date().toISOString(), from: 'system', to: assignee, + body: notification.body, type: 'system', pipe: notification.pipe, + }; + deliverToPty(assignee, projectId, renotifyMsg) + .then(() => { + pipeDelivery.recordNotification(pipeId, assignee, projectId); + pipeDelivery.startRenotifyTimer(pipeId, assignee, projectId, handleRenotify); + }) + .catch(err => console.error(`[pipe] re-notify failed for ${assignee}:`, err)); +} + +/** Read the final output content from pipe state. */ +function readFinalOutput(pipeId: string, projectId: string | null): { from: string; body: string } | null { + const pipe = pipeStore.getPipe(pipeId, projectId); + if (!pipe) return null; + for (const [, slotList] of pipe.slots) { + for (const slot of slotList) { + if (slot.role === 'final' && slot.status === 'submitted' && slot.content) { + return { from: slot.assignee, body: slot.content }; + } + } + } + return null; +} + +/** Drain pending pipe queues for a list of assignees whose leases were just released. + * Re-runs the reducer for each blocked pipe so handoffs can be retried. */ +function drainPendingPipes(assignees: string[], projectId: string | null): void { + for (const assignee of assignees) { + const pendingPipeIds = pipeStore.popPendingPipes(assignee, projectId); + for (const pendingPipeId of pendingPipeIds) { + runPipeReducer(pendingPipeId, projectId) + .catch(err => console.error('[pipe] pending reducer failed:', err)); + } + } +} + +/** Fail-fast: cancel running pipes when a participant becomes unavailable. */ +function failPipesForParticipant( + name: string, + projectId: string | null, + reason: 'left' | 'detached' | 'pane-closed', +): void { + // Use the active pipe index for O(1) lookup instead of scanning full history + const activePipeIds = pipeStore.getActivePipesForParticipant(name, projectId); + + for (const pipeId of activePipeIds) { + // Idempotency: check pipe is still running in the store + const storedPipe = pipeStore.getPipe(pipeId, projectId); + if (!storedPipe || storedPipe.status !== 'running') continue; + + // Clear all deadline timers and delivery tracking for this pipe + clearAllDeadlinesForPipe(pipeId); + pipeDelivery.cancelAllDeliveries(pipeId, projectId); + materializer.cancelPipeAssignments(pipeId, projectId); + + // Update store — releases leases for this pipe's assignees + const releasedAssignees = pipeStore.markPipeStatus(pipeId, 'failed', projectId); + + provenance.recordProvenance(projectId, { pipeId, event: 'failed', actor: 'system', actorKind: 'system', metadata: { reason, unavailableParticipant: name } }); + + // Post failure to chat history (public lifecycle event) + const failMsg = appendMessage({ + from: 'system', to: null, + body: `#pipe-${pipeId} Pipe stopped: @${name} became unavailable (${reason}).`, + type: 'system', + pipe: { + pipeId, + mode: storedPipe.mode, + role: 'failed', + reason, + }, + }, projectId); + emitToProject('chat:message', failMsg, projectId); + emitPipeEvent({ type: 'failed', pipeId }, projectId); + + // Drain pending queues for released assignees — unblock any pipes waiting for their lease + drainPendingPipes(releasedAssignees, projectId); + } +} + +/** Cancel a running pipe by user request. */ +export async function cancelPipeRun(pipeId: string, projectId?: string | null): Promise { + const pid = resolveProjectId(projectId); + const pipe = pipeStore.getPipe(pipeId, pid); + if (!pipe || pipe.status !== 'running') return false; + + // Clear all deadline timers and delivery tracking for this pipe + clearAllDeadlinesForPipe(pipeId); + pipeDelivery.cancelAllDeliveries(pipeId, pid); + materializer.cancelPipeAssignments(pipeId, pid); + + // Update store — releases leases for this pipe's assignees + const releasedAssignees = pipeStore.markPipeStatus(pipeId, 'cancelled', pid); + provenance.recordProvenance(pid, { pipeId, event: 'cancelled', actor: 'user', actorKind: 'user' }); + + const cancelMsg = appendMessage({ + from: 'system', to: null, + body: `#pipe-${pipeId} Pipe cancelled.`, + type: 'system', + pipe: { + pipeId, + mode: pipe.mode, + role: 'cancelled', + reason: 'cancelled-by-user', + }, + }, pid); + emitToProject('chat:message', cancelMsg, pid); + emitPipeEvent({ type: 'cancel', pipeId }, pid); + + // Drain pending queues for released assignees — unblock any pipes waiting for their lease + drainPendingPipes(releasedAssignees, pid); + + return true; +} + +/** Submit a stage artifact via the dedicated pipe_submit path. + * Validates lease, stores content, posts a display message, and advances the pipeline. */ +export async function submitPipeStage( + pipeId: string, + from: string, + content: string, + projectId: string | null, +): Promise<{ ok: boolean; error?: string; code?: string; message?: ChatMessage; myWorkComplete?: boolean; pendingStages?: number }> { + // Validate and store in the pipe stage store + const result = pipeStore.submitStage(pipeId, from, content, projectId, true); + if (!result.ok) return { ok: false, error: result.error, code: result.code }; + + // Clear the stage deadline and delivery tracking — submit was successful + pipeDelivery.recordSubmission(pipeId, from, projectId); + // Complete the active assignment for this participant on this pipe + const activeAssignments = materializer.getActiveAssignmentsForParticipant(from, pipeId, projectId); + for (const a of activeAssignments) { + materializer.completeAssignment(a.assignmentId, projectId); + } + clearStageDeadline(pipeId, from); + provenance.recordProvenance(projectId, { pipeId, event: 'stage-submitted', actor: from, actorKind: getParticipant(from, projectId)?.kind ?? 'llm', stage: result.slot?.stage, role: result.slot?.role }); + + // Determine pipe role for the chat message metadata + const storedPipe = pipeStore.getPipe(pipeId, projectId); + if (!storedPipe) return { ok: false, error: 'Pipe not found after submit' }; + + const slot = result.slot; + let role: PipeMessageMeta['role'] = 'stage-output'; + if (slot?.role === 'final') role = 'final'; + else if (slot?.role === 'fan-out') role = 'fan-out'; + + const pipeMeta: PipeMessageMeta = { + pipeId, + mode: storedPipe.mode, + role, + stage: slot?.stage, + }; + + // Emit non-final stage submissions as pipe-step events — NOT as chat:message. + // The completion handler is the sole emitter of the public final result via chat:message. + const body = ensurePipeAnchor(content, pipeId); + const msg: import('../types.js').ChatMessage = { + id: `pipe-${pipeId}-${role}-${from}`, + ts: new Date().toISOString(), + from, + to: null, + body, + type: 'message', + pipe: pipeMeta, + }; + if (role !== 'final') { + emitPipeEvent({ type: 'stage-output', pipeId, from, role, stage: slot?.stage, content: body }, projectId); + } + + // Run the reducer to advance the pipeline + await runPipeReducer(pipeId, projectId); + + // Lease was released by submitStage — drain pending queues to unblock + // any pipes that were waiting for this participant's lease. + drainPendingPipes([from], projectId); + + // Check if the submitter has remaining unsubmitted slots in this pipe + const updatedPipe = pipeStore.getPipe(pipeId, projectId); + const assigneeSlots = updatedPipe?.slots.get(from) ?? []; + const pendingSlots = assigneeSlots.filter(s => s.status !== 'submitted'); + const myWorkComplete = pendingSlots.length === 0; + const pendingStages = pendingSlots.length; + + return { ok: true, message: msg, myWorkComplete, pendingStages }; +} + +/** Get pipe status from the store. */ +export function getPipeStoreStatus(pipeId: string, projectId?: string | null) { + return pipeStore.getPipeStatus(pipeId, resolveProjectId(projectId)); +} + +/** Get active pipes for a project (from pipe store). */ +export function getActivePipes(projectId?: string | null): Array<{ pipeId: string; mode: string; status: string }> { + const pid = resolveProjectId(projectId); + return pipeStore.listActivePipes(pid).map(p => ({ pipeId: p.pipeId, mode: p.mode, status: p.status })); +} + +/** Get a specific pipe's state (from pipe store). */ +export function getPipeRun(pipeId: string, projectId?: string | null): { pipeId: string; mode: string; status: string; projectId: string | null } | undefined { + const pid = resolveProjectId(projectId); + const pipe = pipeStore.getPipe(pipeId, pid); + if (!pipe) return undefined; + return { pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status, projectId: pid }; +} + +// ── Pipe assignment queries (caller-scoped) ────────────────────────────────── + +/** List all assignments for a participant. */ +export function listAssignments(callerName: string, projectId?: string | null) { + const pid = resolveProjectId(projectId); + return assignmentQueries.getAssignmentsForParticipant(callerName, pid); +} + +/** Get assignment details for a participant on a specific pipe. */ +export function getAssignment(pipeId: string, callerName: string, projectId?: string | null) { + const pid = resolveProjectId(projectId); + return assignmentQueries.getAssignmentForPipe(pipeId, callerName, pid); +} + +// ── Pipe output read (caller-scoped) ────────────────────────────────────────── + +export interface PipeReadOutputResult { + pipeId: string; + mode: string; + stagePayload?: string | null; + previousOutput?: { stage: number; from: string; content: string } | null; + fanOutOutputs?: Array<{ from: string; content: string }>; +} + +/** Read the pipe output that the caller is entitled to right now. + * Linear pipes: returns previous stage output for the current downstream assignee. + * Merge pipes: returns fan-out outputs for the synthesizer after synth-request. */ +export function readPipeOutput( + pipeId: string, + callerName: string, + projectId?: string | null, +): { ok: true; data: PipeReadOutputResult } | { ok: false; status: number; error: string } { + const pid = resolveProjectId(projectId); + const pipe = pipeStore.getPipe(pipeId, pid); + if (!pipe) return { ok: false, status: 404, error: `Pipe #${pipeId} not found` }; + if (pipe.status !== 'running') { + return { ok: false, status: 409, error: `Pipe #${pipeId} is ${pipe.status} — output reads are only allowed while the pipe is running` }; + } + + const assigneeIndex = pipe.assignees.indexOf(callerName); + if (assigneeIndex === -1) { + return { ok: false, status: 403, error: `${callerName} is not an assignee of pipe #${pipeId}` }; + } + + // Lease-aware read guard: reject reads from assignees with expired leases. + // Must run BEFORE recordFetch so rejected reads don't suppress re-notify. + const callerLease = pipeStore.getActiveLease(callerName, pid); + if (callerLease?.pipeId === pipeId && pipeStore.isLeaseExpired(callerLease)) { + return { ok: false, status: 403, error: `Lease for ${callerName} on pipe #${pipeId} has expired (deadline: ${callerLease.deadline}). Output read rejected.` }; + } + + // Record fetch acknowledgment — only after authorization succeeds + pipeDelivery.recordFetch(pipeId, callerName, pid); + + // Transition assignment lifecycle: notified → payload_fetched + const activeAssignments = materializer.getActiveAssignmentsForParticipant(callerName, pipeId, pid); + for (const a of activeAssignments) { + if (a.status === 'notified' || a.status === 'acknowledged') { + materializer.transitionAssignmentStatus(a.assignmentId, a.status === 'notified' ? 'acknowledged' : 'payload_fetched', pid); + // If we went notified→acknowledged, also advance to payload_fetched + if (a.status === 'notified') { + materializer.transitionAssignmentStatus(a.assignmentId, 'payload_fetched', pid); + } + } + } + + // Read the authoritative assignment payload for this caller on this pipe. + const currentAssignment = activeAssignments.find(a => a.assignee === callerName) ?? null; + const stagePayload = currentAssignment + ? (payloadStore.getPayload(currentAssignment.payloadId, pid)?.content ?? null) + : null; + + if (pipe.mode === 'linear') { + const callerStage = assigneeIndex + 1; + if (callerStage === 1) { + if (!stagePayload) { + return { ok: false, status: 409, error: 'Stage 1 has no previous input to read' }; + } + return { ok: true, data: { pipeId: pipe.pipeId, mode: pipe.mode, stagePayload, previousOutput: null } }; + } + if (!pipe.emittedHandoffs.has(callerStage)) { + return { ok: false, status: 409, error: `Handoff for stage ${callerStage} has not been emitted yet` }; + } + const prevStage = callerStage - 1; + const output = pipeStore.getStageOutput(pipeId, prevStage, pid); + if (!output) { + return { ok: false, status: 409, error: `Stage ${prevStage} output not yet submitted` }; + } + return { + ok: true, + data: { + pipeId: pipe.pipeId, + mode: pipe.mode, + stagePayload, + previousOutput: { stage: prevStage, from: output.from, content: output.body }, + }, + }; + } + + // merge / merge-all / explain / summarize + if (currentAssignment?.role === 'fan-out') { + if (!stagePayload) { + return { ok: false, status: 409, error: 'No stage input available for your fan-out assignment' }; + } + return { + ok: true, + data: { pipeId: pipe.pipeId, mode: pipe.mode, stagePayload, previousOutput: null }, + }; + } + + const synthesizer = pipe.assignees[pipe.assignees.length - 1]; + if (callerName !== synthesizer) { + return { ok: false, status: 403, error: `Only the synthesizer (@${synthesizer}) can read fan-out outputs` }; + } + if (!pipe.emittedSynthRequest) { + return { ok: false, status: 409, error: 'Synth request has not been emitted yet' }; + } + const isMergeAll = pipe.mode === 'merge-all' || pipe.mode === 'explain' || pipe.mode === 'summarize'; + const outputs = pipeStore.getFanOutOutputs(pipeId, pid); + const fanOutOutputs: Array<{ from: string; content: string }> = []; + for (const [assignee, content] of outputs) { + if (isMergeAll && assignee === synthesizer) continue; + fanOutOutputs.push({ from: assignee, content }); + } + return { + ok: true, + data: { pipeId: pipe.pipeId, mode: pipe.mode, stagePayload, fanOutOutputs }, + }; +} + +// ── Brainstorm command handling ─────────────────────────────────────────────── + +async function handleBrainstormCommand(body: string, projectId: string | null): Promise { + const parsed = parseBrainstormCommand(body, (name) => getParticipantExact(name, projectId) != null); + if ('error' in parsed) { + const userMsg = appendMessage({ from: 'user', to: null, body, type: 'message' }, projectId); + emitToProject('chat:message', userMsg, projectId); + const errorMsg = appendMessage({ + from: 'system', to: null, + body: `Brainstorm error: ${parsed.error}`, + type: 'system', + }, projectId); + emitToProject('chat:message', errorMsg, projectId); + return userMsg; + } + + const userMsg = appendMessage({ from: 'user', to: null, body, type: 'message' }, projectId); + emitToProject('chat:message', userMsg, projectId); + + // Resolve assignees (default to all active LLMs if none specified) + const resolvedAssignees = parsed.assignees.length > 0 + ? parsed.assignees + : listDefaultPipeAssignees(projectId).map(p => p.name); + + if (resolvedAssignees.length < 2) { + const detail = parsed.assignees.length === 0 + ? ' No eligible default LLM assignees were available.' + : ''; + const errorMsg = appendMessage({ + from: 'system', to: null, + body: `Brainstorm error: at least 2 LLM participants are required.${detail}`, + type: 'system', + }, projectId); + emitToProject('chat:message', errorMsg, projectId); + return userMsg; + } + + // Validate assignees are connected LLMs + const invalid: string[] = []; + const reasons: string[] = []; + for (const a of resolvedAssignees) { + const p = getParticipantExact(a, projectId); + if (!p) { invalid.push(a); reasons.push(`@${a} not found`); continue; } + if (p.kind !== 'llm') { invalid.push(a); reasons.push(`@${a} is not an LLM`); continue; } + if (p.detached) { invalid.push(a); reasons.push(`@${a} is detached`); continue; } + if (!p.paneId) { invalid.push(a); reasons.push(`@${a} has no pane`); continue; } + } + if (invalid.length > 0) { + const errorMsg = appendMessage({ + from: 'system', to: null, + body: `Brainstorm error: invalid assignees (${reasons.join('; ')}).`, + type: 'system', + }, projectId); + emitToProject('chat:message', errorMsg, projectId); + return userMsg; + } + + // Create brainstorm record + const brainstormId = pipeReducer.generatePipeId(); + brainstormStore.createBrainstorm(brainstormId, resolvedAssignees, parsed.prompt, projectId); + + const assigneeList = resolvedAssignees.map(a => `@${a}`).join(', '); + const startMsg = appendMessage({ + from: 'system', to: null, + body: `#brainstorm-${brainstormId} Brainstorm started: ${assigneeList}\nTopic: ${parsed.prompt}\nPhase: Ideas`, + type: 'system', + }, projectId); + emitToProject('chat:message', startMsg, projectId); + + // Launch the first idea round (merge-all child pipe) + await launchBrainstormIdeaRound(brainstormId, projectId); + + return userMsg; +} + +/** Launch (or re-launch on retry) a merge-all child pipe for the brainstorm idea phase. */ +async function launchBrainstormIdeaRound(brainstormId: string, projectId: string | null): Promise { + const record = brainstormStore.getBrainstorm(brainstormId, projectId); + if (!record) return; + + const prompt = record.latestUserNote + ? `${record.prompt}\n\nUser note: ${record.latestUserNote}` + : record.prompt; + + const childPipeId = pipeReducer.generatePipeId(); + pipeStore.createPipe(childPipeId, 'merge-all', record.assignees, prompt, projectId); + brainstormStore.linkChildPipe(brainstormId, childPipeId, projectId); + brainstormStore.updateBrainstorm(brainstormId, projectId, { + activeChildPipeId: childPipeId, + phase: 'ideas', + ideaIterations: record.ideaIterations + 1, + }); + + const desc = pipeReducer.getStartDescription({ mode: 'merge-all', assignees: record.assignees, prompt }); + const pipeStartMsg = appendMessage({ + from: 'system', to: null, + body: `#pipe-${childPipeId} Pipe started (merge-all): ${desc}`, + type: 'system', + pipe: { pipeId: childPipeId, mode: 'merge-all' as const, role: 'start' as const, assignees: record.assignees, prompt }, + }, projectId); + emitToProject('chat:message', pipeStartMsg, projectId); + emitPipeEvent({ + type: 'start', pipeId: childPipeId, mode: 'merge-all', + assignees: record.assignees, prompt, + stageTimeoutMs: pipeStore.DEFAULT_STAGE_TIMEOUT_MS, + timeoutPolicy: 'fail', + }, projectId); + + await runPipeReducer(childPipeId, projectId); +} + +/** Called when a child pipe completes — advances the brainstorm phase if applicable. */ +async function advanceBrainstormOnChildComplete(childPipeId: string, projectId: string | null): Promise { + const record = brainstormStore.findBrainstormByChildPipe(childPipeId, projectId); + if (!record || record.activeChildPipeId !== childPipeId) return; + + if (record.phase === 'ideas') { + const finalOutput = readFinalOutput(childPipeId, projectId); + brainstormStore.updateBrainstorm(record.id, projectId, { + phase: 'ideas_review', + activeChildPipeId: null, + candidateIdea: finalOutput?.body ?? null, + }); + + const reviewMsg = appendMessage({ + from: 'system', to: null, + body: `#brainstorm-${record.id} Ideas phase complete (iteration ${record.ideaIterations}).\nReview the merged idea above and choose:\n• Accept — advance to detail phase\n• Retry — rerun idea generation\n• Retry with note — add guidance and rerun`, + type: 'system', + }, projectId); + emitToProject('chat:message', reviewMsg, projectId); + return; + } + + if (record.phase === 'details') { + const finalOutput = readFinalOutput(childPipeId, projectId); + brainstormStore.updateBrainstorm(record.id, projectId, { + phase: 'details_review', + activeChildPipeId: null, + candidateDraft: finalOutput?.body ?? null, + }); + + const reviewMsg = appendMessage({ + from: 'system', to: null, + body: `#brainstorm-${record.id} Detail pass complete (iteration ${record.detailIterations}).\nReview the detailed draft above and choose:\n• Finalize — accept draft and generate final output\n• Adjust — retry detail pass with guidance\n• Back to Ideas — return to idea phase`, + type: 'system', + }, projectId); + emitToProject('chat:message', reviewMsg, projectId); + return; + } + + if (record.phase === 'finalizing') { + brainstormStore.updateBrainstorm(record.id, projectId, { + phase: 'complete', + activeChildPipeId: null, + }); + + const completeMsg = appendMessage({ + from: 'system', to: null, + body: `#brainstorm-${record.id} Brainstorm complete.`, + type: 'system', + }, projectId); + emitToProject('chat:message', completeMsg, projectId); + } +} + +/** Re-launch the idea round with an optional user note (called by approve/retry endpoints). */ +export async function brainstormRetryIdeas(brainstormId: string, userNote: string | null, projectId?: string | null): Promise { + const pid = resolveProjectId(projectId); + const record = brainstormStore.getBrainstorm(brainstormId, pid); + if (!record || record.phase !== 'ideas_review') return false; + + brainstormStore.updateBrainstorm(brainstormId, pid, { latestUserNote: userNote }); + await launchBrainstormIdeaRound(brainstormId, pid); + return true; +} + +/** Accept the current idea and launch detail phase (linear child pipe). */ +export async function brainstormAcceptIdea(brainstormId: string, projectId?: string | null): Promise { + const pid = resolveProjectId(projectId); + const record = brainstormStore.getBrainstorm(brainstormId, pid); + if (!record || record.phase !== 'ideas_review') return false; + + brainstormStore.updateBrainstorm(brainstormId, pid, { + acceptedIdea: record.candidateIdea, + candidateIdea: null, + latestUserNote: null, + }); + + const acceptMsg = appendMessage({ + from: 'system', to: null, + body: `#brainstorm-${brainstormId} Idea accepted. Advancing to detail phase.`, + type: 'system', + }, pid); + emitToProject('chat:message', acceptMsg, pid); + + await launchBrainstormDetailRound(brainstormId, pid); + return true; +} + +/** Launch (or re-launch on adjust) a linear child pipe for the brainstorm detail phase. */ +async function launchBrainstormDetailRound(brainstormId: string, projectId: string | null): Promise { + const record = brainstormStore.getBrainstorm(brainstormId, projectId); + if (!record) return; + + let prompt = `Brainstorm detail phase — deepen the following accepted idea:\n\n${record.acceptedIdea}\n\nAdd implementation details, architecture considerations, trade-offs, and concrete next steps.`; + if (record.latestUserNote) { + prompt += `\n\nUser note: ${record.latestUserNote}`; + } + + const childPipeId = pipeReducer.generatePipeId(); + pipeStore.createPipe(childPipeId, 'linear', record.assignees, prompt, projectId); + brainstormStore.linkChildPipe(brainstormId, childPipeId, projectId); + brainstormStore.updateBrainstorm(brainstormId, projectId, { + activeChildPipeId: childPipeId, + phase: 'details', + detailIterations: record.detailIterations + 1, + }); + + const desc = pipeReducer.getStartDescription({ mode: 'linear', assignees: record.assignees, prompt }); + const pipeStartMsg = appendMessage({ + from: 'system', to: null, + body: `#pipe-${childPipeId} Pipe started (linear): ${desc}`, + type: 'system', + pipe: { pipeId: childPipeId, mode: 'linear' as const, role: 'start' as const, assignees: record.assignees, prompt }, + }, projectId); + emitToProject('chat:message', pipeStartMsg, projectId); + emitPipeEvent({ + type: 'start', pipeId: childPipeId, mode: 'linear', + assignees: record.assignees, prompt, + stageTimeoutMs: pipeStore.DEFAULT_STAGE_TIMEOUT_MS, + timeoutPolicy: 'fail', + }, projectId); + + await runPipeReducer(childPipeId, projectId); +} + +/** Launch the finalize pass — single LLM produces the final structured output. */ +async function launchBrainstormFinalizeRound(brainstormId: string, projectId: string | null): Promise { + const record = brainstormStore.getBrainstorm(brainstormId, projectId); + if (!record) return; + + const prompt = `Brainstorm finalize — produce the final comprehensive document.\n\nAccepted Idea:\n${record.acceptedIdea}\n\nAccepted Detail Draft:\n${record.acceptedDraft}\n\nCreate a complete, structured output covering: concept, architecture, trade-offs, decisions, and next steps.`; + + // Use a single assignee (first in list) for the final pass + const finalAssignees = [record.assignees[0]]; + const childPipeId = pipeReducer.generatePipeId(); + pipeStore.createPipe(childPipeId, 'linear', finalAssignees, prompt, projectId); + brainstormStore.linkChildPipe(brainstormId, childPipeId, projectId); + brainstormStore.updateBrainstorm(brainstormId, projectId, { + activeChildPipeId: childPipeId, + phase: 'finalizing', + }); + + const desc = pipeReducer.getStartDescription({ mode: 'linear', assignees: finalAssignees, prompt }); + const pipeStartMsg = appendMessage({ + from: 'system', to: null, + body: `#pipe-${childPipeId} Pipe started (linear): ${desc}`, + type: 'system', + pipe: { pipeId: childPipeId, mode: 'linear' as const, role: 'start' as const, assignees: finalAssignees, prompt }, + }, projectId); + emitToProject('chat:message', pipeStartMsg, projectId); + emitPipeEvent({ + type: 'start', pipeId: childPipeId, mode: 'linear', + assignees: finalAssignees, prompt, + stageTimeoutMs: pipeStore.DEFAULT_STAGE_TIMEOUT_MS, + timeoutPolicy: 'fail', + }, projectId); + + await runPipeReducer(childPipeId, projectId); +} + +/** Adjust and retry the current detail pass with a user note. */ +export async function brainstormAdjustDetails(brainstormId: string, userNote: string | null, projectId?: string | null): Promise { + const pid = resolveProjectId(projectId); + const record = brainstormStore.getBrainstorm(brainstormId, pid); + if (!record || record.phase !== 'details_review') return false; + + brainstormStore.updateBrainstorm(brainstormId, pid, { latestUserNote: userNote, candidateDraft: null }); + await launchBrainstormDetailRound(brainstormId, pid); + return true; +} + +/** Accept the current detail draft and launch the finalize phase. */ +export async function brainstormFinalize(brainstormId: string, projectId?: string | null): Promise { + const pid = resolveProjectId(projectId); + const record = brainstormStore.getBrainstorm(brainstormId, pid); + if (!record || record.phase !== 'details_review') return false; + + brainstormStore.updateBrainstorm(brainstormId, pid, { + acceptedDraft: record.candidateDraft, + candidateDraft: null, + }); + + const acceptMsg = appendMessage({ + from: 'system', to: null, + body: `#brainstorm-${brainstormId} Details accepted. Generating final output.`, + type: 'system', + }, pid); + emitToProject('chat:message', acceptMsg, pid); + + await launchBrainstormFinalizeRound(brainstormId, pid); + return true; +} + +/** Go back to ideas phase from detail review. */ +export async function brainstormBackToIdeas(brainstormId: string, projectId?: string | null): Promise { + const pid = resolveProjectId(projectId); + const record = brainstormStore.getBrainstorm(brainstormId, pid); + if (!record || record.phase !== 'details_review') return false; + + brainstormStore.updateBrainstorm(brainstormId, pid, { + phase: 'ideas_review', + candidateDraft: null, + acceptedDraft: null, + latestUserNote: null, + }); + + const backMsg = appendMessage({ + from: 'system', to: null, + body: `#brainstorm-${brainstormId} Returning to ideas phase. Review the idea and choose: Accept, Retry, or Retry with note.`, + type: 'system', + }, pid); + emitToProject('chat:message', backMsg, pid); + return true; +} + +// ── Brainstorm accessors ───────────────────────────────────────────────────── + +export function getBrainstormRecord(id: string, projectId?: string | null) { + return brainstormStore.getBrainstorm(id, resolveProjectId(projectId)); +} + +export function getActiveBrainstorms(projectId?: string | null) { + return brainstormStore.listActiveBrainstorms(resolveProjectId(projectId)); +} + +// ── Pipe observability ────────────────────────────────────────────────────── + +export function getPipeTimingSummary(pipeId: string, projectId?: string | null) { + return pipeStore.getPipeTimingSummary(pipeId, resolveProjectId(projectId)); +} +export function getRuntimeLeaseStatuses(projectId?: string | null) { + return pipeStore.getRuntimeLeaseStatuses(resolveProjectId(projectId)); +} +export function getDeadLetterEntries(projectId?: string | null) { + return pipeStore.getDeadLetterEntries(resolveProjectId(projectId)); +} +export function listAllPipes(projectId?: string | null) { + return pipeStore.listAllPipes(resolveProjectId(projectId)); +} +export function getPipeProvenance(pipeId: string, projectId?: string | null) { + return provenance.getProvenanceForPipe(pipeId, resolveProjectId(projectId)); +} +export function queryPipeProvenance( + projectId?: string | null, + filters?: { pipeId?: string; actor?: string; event?: string; since?: string }, +) { + return provenance.queryProvenance(resolveProjectId(projectId), filters as Parameters[1]); +} diff --git a/src/apps/chat/services/chat-rules.test.ts b/src/apps/chat/services/chat-rules.test.ts new file mode 100644 index 0000000..a5048e1 --- /dev/null +++ b/src/apps/chat/services/chat-rules.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; + +const TEST_ROOT = join(process.cwd(), '.tmp', 'devglide-chat-rules-tests'); + +vi.mock('../../../packages/paths.js', () => ({ + projectDataDir: (projectId: string, sub: string) => join(TEST_ROOT, projectId, sub), +})); + +const { + DEFAULT_RULES, + deleteProjectRules, + getDefaultRules, + getEffectiveRules, + hasProjectRules, + saveProjectRules, +} = await import('./chat-rules.js'); + +const TEST_PROJECT_ID = 'chat-rules-test-project'; +const TEST_CHAT_DIR = join(TEST_ROOT, TEST_PROJECT_ID, 'chat'); +const TEST_RULES_PATH = join(TEST_CHAT_DIR, 'rules.md'); + +afterEach(() => { + rmSync(TEST_CHAT_DIR, { recursive: true, force: true }); +}); + +describe('chat-rules', () => { + it('returns the hardcoded default rules when no project override exists', () => { + expect(getDefaultRules()).toBe(DEFAULT_RULES); + expect(getEffectiveRules(TEST_PROJECT_ID)).toBe(DEFAULT_RULES); + expect(hasProjectRules(TEST_PROJECT_ID)).toBe(false); + expect(DEFAULT_RULES).toContain('Default: discussion only.'); + expect(DEFAULT_RULES).toContain('Execution requires explicit assignment.'); + expect(DEFAULT_RULES).toContain('Pipes use `pipe_submit` only.'); + expect(DEFAULT_RULES).toContain('User-directed replies should start with `@user`.'); + }); + + it('saves and resolves a project-specific override', () => { + const override = '## Project Rules\n\nOnly reply when asked.'; + + saveProjectRules(TEST_PROJECT_ID, override); + + expect(hasProjectRules(TEST_PROJECT_ID)).toBe(true); + expect(existsSync(TEST_RULES_PATH)).toBe(true); + expect(readFileSync(TEST_RULES_PATH, 'utf8')).toBe(override); + expect(getEffectiveRules(TEST_PROJECT_ID)).toBe(override); + }); + + it('deletes a project override and falls back to defaults', () => { + saveProjectRules(TEST_PROJECT_ID, 'temporary override'); + + expect(deleteProjectRules(TEST_PROJECT_ID)).toBe(true); + expect(hasProjectRules(TEST_PROJECT_ID)).toBe(false); + expect(getEffectiveRules(TEST_PROJECT_ID)).toBe(DEFAULT_RULES); + expect(deleteProjectRules(TEST_PROJECT_ID)).toBe(false); + }); +}); diff --git a/src/apps/chat/services/chat-rules.ts b/src/apps/chat/services/chat-rules.ts new file mode 100644 index 0000000..c8dfdd4 --- /dev/null +++ b/src/apps/chat/services/chat-rules.ts @@ -0,0 +1,95 @@ +import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { getActiveProject } from '../../../project-context.js'; +import { projectDataDir } from '../../../packages/paths.js'; + +const RULES_FILENAME = 'rules.md'; + +/** Default rules of engagement - hardcoded, returned when no per-project override exists. */ +export const DEFAULT_RULES = `## Rules of Engagement + +1. **Default: discussion only.** + Every message is discussion by default. You may analyze, explain, recommend, and ask questions. Do not run commands, edit files, or make persistent changes unless explicitly assigned. + +2. **Execution requires explicit assignment.** + Execution is allowed only when the user addresses you by name and gives an action verb, for example: \`@yourname implement\`, \`@yourname fix\`, \`@yourname review\`, \`@yourname revert\`, or when you receive a pipe stage assignment. + +3. **No assignment = no action.** + These do **not** count as permission: agreement, consensus, another agent's suggestion, or your own initiative. If the message is ambiguous, treat it as discussion only. + +4. **Pipes use \`pipe_submit\` only.** + For pipe stages, submit with \`pipe_submit\`. \`chat_send\` does not submit pipe work. + +5. **All chat responses must use \`chat_send\`.** + Do not respond by outputting text in your own shell — other participants cannot see it. The chat room is the shared channel; your shell is private. + +6. **User-directed replies should start with \`@user\`.** + When replying to the human user in chat, begin the message with \`@user\` so the intended recipient is explicit to both the user and other LLM participants. + +7. **Respond selectively.** + Respond when you are \`@mentioned\`, or when the user sends an unaddressed message and you have new information. Stay silent when another agent is addressed, when you have nothing new to add, or when in doubt. + +8. **Assigned agent only.** + Only the assigned agent may execute. Non-assigned agents must not take over. They may speak only to correct a clear factual error or prevent wasted work. + +9. **No self-approval.** + Do not self-approve your own work. If review is required, it must be done by the user or a different assigned participant. + +10. **Claims are not proof.** + Do not say work is implemented, fixed, reverted, or verified unless you actually did or checked it. In a shared workspace, claims remain untrusted until independently verified. + +11. **Targeted PTY delivery — address who should receive.** + Delivery recipients are resolved from the \`to\` param and body @mentions combined. Use \`@all\` to reach every participant. LLM messages with no recipients in either field are persisted in history but not PTY-delivered to any agent terminal. Always address the intended recipient(s) — via @mention in the body or the \`to\` param — so your message actually reaches them. +`; + +/** Get the rules file path for a specific project. */ +function getRulesPath(projectId: string): string { + const dir = projectDataDir(projectId, 'chat'); + return join(dir, RULES_FILENAME); +} + +/** Get the effective rules for the active project (per-project override or default). */ +export function getEffectiveRules(projectId?: string | null): string { + const pid = projectId ?? getActiveProject()?.id; + if (pid) { + const rulesPath = getRulesPath(pid); + if (existsSync(rulesPath)) { + try { + const content = readFileSync(rulesPath, 'utf8').trim(); + if (content) return content; + } catch { + // Fall through to default + } + } + } + return DEFAULT_RULES; +} + +/** Get the hardcoded default rules (for reference/reset). */ +export function getDefaultRules(): string { + return DEFAULT_RULES; +} + +/** Save per-project rules override. */ +export function saveProjectRules(projectId: string, rules: string): void { + const dir = projectDataDir(projectId, 'chat'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, RULES_FILENAME), rules, 'utf8'); +} + +/** Delete per-project rules override (reverts to default). */ +export function deleteProjectRules(projectId: string): boolean { + const rulesPath = getRulesPath(projectId); + if (existsSync(rulesPath)) { + unlinkSync(rulesPath); + return true; + } + return false; +} + +/** Check whether a per-project override exists. */ +export function hasProjectRules(projectId?: string | null): boolean { + const pid = projectId ?? getActiveProject()?.id; + if (!pid) return false; + return existsSync(getRulesPath(pid)); +} diff --git a/src/apps/chat/services/chat-store.test.ts b/src/apps/chat/services/chat-store.test.ts new file mode 100644 index 0000000..080aeac --- /dev/null +++ b/src/apps/chat/services/chat-store.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const TEST_ROOT = join(tmpdir(), 'devglide-chat-store-tests'); + +vi.mock('../../../packages/paths.js', () => ({ + projectDataDir: (projectId: string, sub: string) => join(TEST_ROOT, projectId, sub), +})); + +vi.mock('../../../project-context.js', () => ({ + getActiveProject: () => ({ id: 'chat-store-project', name: 'Chat Store', path: '/tmp/chat-store-project' }), +})); + +const { appendMessage, appendPipeEvent, clearMessages, readMessages, readPipeEvents } = await import('./chat-store.js'); + +afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); +}); + +describe('chat-store', () => { + it('persists and reads messages', () => { + appendMessage({ + from: 'user', + to: null, + body: 'Hello world', + type: 'message', + }); + + const messages = readMessages(); + expect(messages).toHaveLength(1); + expect(messages[0]?.body).toBe('Hello world'); + }); + + it('clears persisted message history', () => { + appendMessage({ + from: 'user', + to: null, + body: 'test message', + type: 'message', + }); + + clearMessages(); + + expect(readMessages()).toEqual([]); + }); +}); + +describe('per-pipe JSONL storage', () => { + it('dual-writes pipe messages to both unified and per-pipe files', () => { + appendMessage({ + from: 'system', + to: null, + body: '#pipe-abc123 Stage handoff', + type: 'system', + pipe: { pipeId: 'abc123', mode: 'linear', role: 'handoff', stage: 1 } as any, + }); + + appendMessage({ + from: 'user', + to: null, + body: 'Regular chat message', + type: 'message', + }); + + // Unified log has both messages + const all = readMessages({ limit: 100 }); + expect(all).toHaveLength(2); + + // Per-pipe read returns only the pipe message + const pipeMessages = readMessages({ limit: 100, pipeId: 'abc123' }); + expect(pipeMessages).toHaveLength(1); + expect(pipeMessages[0]?.body).toBe('#pipe-abc123 Stage handoff'); + }); + + it('reads from per-pipe file without parsing unified log', () => { + // Write a pipe message (creates per-pipe file) + appendMessage({ + from: 'claude-1', + to: null, + body: '#pipe-def456 My output', + type: 'message', + pipe: { pipeId: 'def456', mode: 'merge-all', role: 'fan-out' } as any, + }); + + // Per-pipe file should exist + expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'def456.jsonl'))).toBe(true); + + // Reading with pipeId should use the per-pipe file + const result = readMessages({ limit: 100, pipeId: 'def456' }); + expect(result).toHaveLength(1); + expect(result[0]?.from).toBe('claude-1'); + }); + + it('falls back to unified log for pipes without per-pipe file', () => { + // Simulate pre-migration data: write directly to unified log with pipe metadata + // by appending a message, then deleting the per-pipe file + appendMessage({ + from: 'system', + to: null, + body: '#pipe-old123 Legacy handoff', + type: 'system', + pipe: { pipeId: 'old123', mode: 'linear', role: 'handoff', stage: 1 } as any, + }); + + // Delete the per-pipe file to simulate pre-migration state + const pipePath = join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'old123.jsonl'); + if (existsSync(pipePath)) { + rmSync(pipePath); + } + + // Should fall back to unified log and filter by pipeId + const result = readMessages({ limit: 100, pipeId: 'old123' }); + expect(result).toHaveLength(1); + expect(result[0]?.body).toBe('#pipe-old123 Legacy handoff'); + }); + + it('clearMessages removes per-pipe files', () => { + appendMessage({ + from: 'system', + to: null, + body: '#pipe-xyz789 Test', + type: 'system', + pipe: { pipeId: 'xyz789', mode: 'linear', role: 'handoff', stage: 1 } as any, + }); + + expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'xyz789.jsonl'))).toBe(true); + + clearMessages(); + + expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'xyz789.jsonl'))).toBe(false); + expect(readMessages({ limit: 100 })).toEqual([]); + }); + + it('persists pipe UI events without leaking them into chat history', () => { + appendPipeEvent({ + type: 'stage-output', + pipeId: 'evt123', + from: 'claude-1', + role: 'stage-output', + stage: 1, + content: '#pipe-evt123 intermediate analysis', + }); + + expect(readMessages({ limit: 100 })).toEqual([]); + + const allEvents = readPipeEvents({ limit: 100 }); + expect(allEvents).toHaveLength(1); + expect(allEvents[0]?.content).toBe('#pipe-evt123 intermediate analysis'); + + const pipeEvents = readPipeEvents({ limit: 100, pipeId: 'evt123' }); + expect(pipeEvents).toHaveLength(1); + expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'evt123.events.jsonl'))).toBe(true); + }); + + it('clearMessages removes persisted pipe UI events', () => { + appendPipeEvent({ + type: 'instruction', + pipeId: 'evt999', + assignee: 'codex-2', + actionType: 'handoff', + stage: 2, + }); + + expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipe-events.jsonl'))).toBe(true); + expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'evt999.events.jsonl'))).toBe(true); + + clearMessages(); + + expect(readPipeEvents({ limit: 100 })).toEqual([]); + expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipe-events.jsonl'))).toBe(false); + expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'evt999.events.jsonl'))).toBe(false); + }); +}); diff --git a/src/apps/chat/services/chat-store.ts b/src/apps/chat/services/chat-store.ts new file mode 100644 index 0000000..7fd3238 --- /dev/null +++ b/src/apps/chat/services/chat-store.ts @@ -0,0 +1,309 @@ +import { mkdirSync, appendFileSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; +import type { ChatMessage, PipeUiEvent } from '../types.js'; +import { getActiveProject } from '../../../project-context.js'; +import { projectDataDir } from '../../../packages/paths.js'; + +/** + * Resolve the chat data directory for a given project. + * An explicit projectId avoids relying on the global active-project singleton, + * which can point to a different project when the user switches the dashboard. + */ +function getChatDir(projectId?: string | null): string | null { + const pid = projectId ?? getActiveProject()?.id; + if (!pid) return null; + return projectDataDir(pid, 'chat'); +} + +function getMessagesPath(projectId?: string | null): string | null { + const dir = getChatDir(projectId); + if (!dir) return null; + mkdirSync(dir, { recursive: true }); + return join(dir, 'messages.jsonl'); +} + +function getPipeMessagesPath(pipeId: string, projectId?: string | null): string | null { + const dir = getChatDir(projectId); + if (!dir) return null; + const pipesDir = join(dir, 'pipes'); + mkdirSync(pipesDir, { recursive: true }); + return join(pipesDir, `${pipeId}.jsonl`); +} + +function getPipeEventsLogPath(projectId?: string | null): string | null { + const dir = getChatDir(projectId); + if (!dir) return null; + mkdirSync(dir, { recursive: true }); + return join(dir, 'pipe-events.jsonl'); +} + +function getPipeEventsPath(pipeId: string, projectId?: string | null): string | null { + const dir = getChatDir(projectId); + if (!dir) return null; + const pipesDir = join(dir, 'pipes'); + mkdirSync(pipesDir, { recursive: true }); + return join(pipesDir, `${pipeId}.events.jsonl`); +} + +export function appendMessage(msg: Omit, projectId?: string | null): ChatMessage { + const full: ChatMessage = { + id: randomUUID(), + ts: new Date().toISOString(), + ...msg, + }; + + const line = JSON.stringify(full) + '\n'; + const filePath = getMessagesPath(projectId); + if (filePath) { + appendFileSync(filePath, line); + } + + // Dual-write: pipe messages also go to a per-pipe JSONL file for fast scoped reads + if (full.pipe?.pipeId) { + const pipePath = getPipeMessagesPath(full.pipe.pipeId, projectId); + if (pipePath) { + appendFileSync(pipePath, line); + } + } + + return full; +} + +export function readMessages(opts?: { limit?: number; since?: string; pipeId?: string }, projectId?: string | null): ChatMessage[] { + // Fast path: read from per-pipe JSONL file when pipeId is specified. + // Falls back to scanning the unified log if the per-pipe file doesn't exist + // (backward compatibility with pipe data written before per-pipe storage). + let filePath: string | null; + let needsPipeFilter = false; + if (opts?.pipeId) { + const pipePath = getPipeMessagesPath(opts.pipeId, projectId); + if (pipePath && existsSync(pipePath)) { + filePath = pipePath; + } else { + filePath = getMessagesPath(projectId); + needsPipeFilter = true; + } + } else { + filePath = getMessagesPath(projectId); + } + if (!filePath || !existsSync(filePath)) return []; + + const raw = readFileSync(filePath, 'utf8').trim(); + if (!raw) return []; + + let messages: ChatMessage[] = raw + .split('\n') + .filter(Boolean) + .map((line) => { + try { return JSON.parse(line) as ChatMessage; } + catch { return null; } + }) + .filter((m): m is ChatMessage => m !== null); + + // Fallback: filter by pipeId when reading from the unified log + if (needsPipeFilter && opts?.pipeId) { + messages = messages.filter((m) => m.pipe?.pipeId === opts.pipeId); + } + + if (opts?.since) { + const sinceDate = new Date(opts.since).getTime(); + messages = messages.filter((m) => new Date(m.ts).getTime() > sinceDate); + } + + const limit = opts?.limit ?? 50; + if (messages.length > limit) { + messages = messages.slice(-limit); + } + + return messages; +} + +export function appendPipeEvent(event: Omit, projectId?: string | null): PipeUiEvent { + const full: PipeUiEvent = { + id: randomUUID(), + ts: new Date().toISOString(), + ...event, + }; + + const line = JSON.stringify(full) + '\n'; + const filePath = getPipeEventsLogPath(projectId); + if (filePath) { + appendFileSync(filePath, line); + } + + const perPipePath = getPipeEventsPath(full.pipeId, projectId); + if (perPipePath) { + appendFileSync(perPipePath, line); + } + + return full; +} + +export function readPipeEvents( + opts?: { limit?: number; since?: string; pipeId?: string }, + projectId?: string | null, +): PipeUiEvent[] { + let filePath: string | null; + let needsPipeFilter = false; + if (opts?.pipeId) { + const pipePath = getPipeEventsPath(opts.pipeId, projectId); + if (pipePath && existsSync(pipePath)) { + filePath = pipePath; + } else { + filePath = getPipeEventsLogPath(projectId); + needsPipeFilter = true; + } + } else { + filePath = getPipeEventsLogPath(projectId); + } + if (!filePath || !existsSync(filePath)) return []; + + const raw = readFileSync(filePath, 'utf8').trim(); + if (!raw) return []; + + let events: PipeUiEvent[] = raw + .split('\n') + .filter(Boolean) + .map((line) => { + try { return JSON.parse(line) as PipeUiEvent; } + catch { return null; } + }) + .filter((event): event is PipeUiEvent => event !== null); + + if (needsPipeFilter && opts?.pipeId) { + events = events.filter((event) => event.pipeId === opts.pipeId); + } + + if (opts?.since) { + const sinceDate = new Date(opts.since).getTime(); + events = events.filter((event) => new Date(event.ts).getTime() > sinceDate); + } + + const limit = opts?.limit ?? 50; + if (events.length > limit) { + events = events.slice(-limit); + } + + return events; +} + + +// ── Participant persistence ────────────────────────────────────────────────── + +export interface PersistedParticipant { + name: string; + model: string | null; + paneId: string | null; + projectId: string | null; + submitKey: string; + joinedAt: string; + lastSeen: string; + joinedVia?: 'rest' | 'mcp' | null; + permissionMode?: 'supervised' | 'auto-accept' | 'unrestricted' | null; +} + +function getParticipantsPath(projectId?: string | null): string | null { + const dir = getChatDir(projectId); + if (!dir) return null; + mkdirSync(dir, { recursive: true }); + return join(dir, 'participants.json'); +} + +export function saveParticipants(participants: PersistedParticipant[], projectId?: string | null): void { + const filePath = getParticipantsPath(projectId); + if (!filePath) return; + writeFileSync(filePath, JSON.stringify(participants, null, 2)); +} + +export function loadParticipants(projectId?: string | null): PersistedParticipant[] { + const filePath = getParticipantsPath(projectId); + if (!filePath || !existsSync(filePath)) return []; + try { + const raw = readFileSync(filePath, 'utf8'); + return JSON.parse(raw) as PersistedParticipant[]; + } catch { + return []; + } +} + + +export function clearMessages(projectId?: string | null): void { + const filePath = getMessagesPath(projectId); + if (filePath && existsSync(filePath)) { + writeFileSync(filePath, ''); + } + const pipeEventsPath = getPipeEventsLogPath(projectId); + if (pipeEventsPath && existsSync(pipeEventsPath)) { + unlinkSync(pipeEventsPath); + } + // Also clear per-pipe JSONL files + const dir = getChatDir(projectId); + if (dir) { + const pipesDir = join(dir, 'pipes'); + if (existsSync(pipesDir)) { + for (const file of readdirSync(pipesDir)) { + if (file.endsWith('.jsonl')) { + unlinkSync(join(pipesDir, file)); + } + } + } + } +} + + +// ── Pipe message queries ───────────────────────────────────────────────────── + +/** Read all messages that carry pipe metadata for a given pipeId. + * Uses the per-pipe JSONL file for O(pipe messages) instead of O(all messages). */ +export function readPipeMessages(pipeId: string, projectId?: string | null): ChatMessage[] { + return readMessages({ limit: 10000, pipeId }, projectId); +} + +// ── Pipe recovery ─────────────────────────────────────────────────────────── + +/** Discover all pipe IDs that have per-pipe event files on disk. + * Returns pipeIds extracted from filenames matching `{pipeId}.events.jsonl`. */ +export function discoverPersistedPipeIds(projectId?: string | null): string[] { + const dir = getChatDir(projectId); + if (!dir) return []; + const pipesDir = join(dir, 'pipes'); + if (!existsSync(pipesDir)) return []; + const pipeIds: string[] = []; + for (const file of readdirSync(pipesDir)) { + const match = file.match(/^([a-f0-9]+)\.events\.jsonl$/); + if (match) pipeIds.push(match[1]); + } + return pipeIds; +} + +/** Remove per-pipe JSONL files for the given pipeIds. */ +export function removePipeFiles(pipeIds: string[], projectId?: string | null): void { + const dir = getChatDir(projectId); + if (!dir) return; + const pipesDir = join(dir, 'pipes'); + for (const pipeId of pipeIds) { + for (const suffix of ['.jsonl', '.events.jsonl']) { + const filePath = join(pipesDir, `${pipeId}${suffix}`); + if (existsSync(filePath)) { + try { unlinkSync(filePath); } catch { /* ignore */ } + } + } + } +} + +/** Read all events for a specific pipe from its per-pipe events file. */ +export function readAllPipeEvents(pipeId: string, projectId?: string | null): PipeUiEvent[] { + const filePath = getPipeEventsPath(pipeId, projectId); + if (!filePath || !existsSync(filePath)) return []; + const raw = readFileSync(filePath, 'utf8').trim(); + if (!raw) return []; + return raw + .split('\n') + .filter(Boolean) + .map((line) => { + try { return JSON.parse(line) as PipeUiEvent; } + catch { return null; } + }) + .filter((e): e is PipeUiEvent => e !== null); +} diff --git a/src/apps/chat/services/clock.ts b/src/apps/chat/services/clock.ts new file mode 100644 index 0000000..977bc14 --- /dev/null +++ b/src/apps/chat/services/clock.ts @@ -0,0 +1,30 @@ +/** Injectable clock interface for deterministic testing of time-dependent logic. */ +export interface Clock { + now(): number; + isoNow(): string; +} + +/** Default clock backed by system time. */ +export const systemClock: Clock = { + now: () => Date.now(), + isoNow: () => new Date().toISOString(), +}; + +/** Controllable clock for deterministic tests. */ +export interface TestClock extends Clock { + advance(ms: number): void; + set(ms: number): void; + currentMs(): number; +} + +/** Create a controllable clock for tests. */ +export function createTestClock(startMs: number = 1767225600000): TestClock { + let ms = startMs; + return { + now: () => ms, + isoNow: () => new Date(ms).toISOString(), + advance(delta: number) { ms += delta; }, + set(value: number) { ms = value; }, + currentMs: () => ms, + }; +} diff --git a/src/apps/chat/services/payload-store.test.ts b/src/apps/chat/services/payload-store.test.ts new file mode 100644 index 0000000..b6e50a1 --- /dev/null +++ b/src/apps/chat/services/payload-store.test.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as payloadStore from './payload-store.js'; +import { createTestClock } from './clock.js'; + +beforeEach(() => { + payloadStore._resetForTest(); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function createTestPayload(overrides?: { + pipeId?: string; + stageId?: string; + content?: string; + producedBy?: string; + sourceStage?: number; +}) { + return payloadStore.createPayload( + overrides?.pipeId ?? 'pipe-1', + overrides?.stageId ?? 'linear:1', + overrides?.content ?? 'test payload content', + 'proj-1', + { + producedBy: overrides?.producedBy ?? 'alice', + sourceStage: overrides?.sourceStage, + }, + ); +} + +// ── createPayload ──────────────────────────────────────────────────────────── + +describe('createPayload', () => { + it('creates a payload with correct initial state', () => { + const result = createTestPayload(); + expect(result.ok).toBe(true); + expect(result.payload).toBeDefined(); + + const p = result.payload!; + expect(p.pipeId).toBe('pipe-1'); + expect(p.stageId).toBe('linear:1'); + expect(p.content).toBe('test payload content'); + expect(p.contentVersion).toBe(1); + expect(p.status).toBe('active'); + expect(p.producedBy).toBe('alice'); + expect(p.archivedAt).toBeNull(); + expect(p.deletedAt).toBeNull(); + }); + + it('computes SHA-256 content hash', () => { + const result = createTestPayload(); + const p = result.payload!; + expect(p.contentHash).toBeTruthy(); + expect(p.contentHash).toHaveLength(64); // SHA-256 hex = 64 chars + }); + + it('computes byte length correctly', () => { + const result = createTestPayload({ content: 'hello' }); + expect(result.payload!.sizeBytes).toBe(5); + }); + + it('handles multi-byte characters in size calculation', () => { + const result = createTestPayload({ content: '日本語' }); // 3 chars, 9 bytes in UTF-8 + expect(result.payload!.sizeBytes).toBe(9); + }); + + it('rejects payload exceeding size limit', () => { + payloadStore.setMaxPayloadBytes(10); + const result = createTestPayload({ content: 'this exceeds the limit' }); + expect(result.ok).toBe(false); + expect(result.code).toBe('PAYLOAD_TOO_LARGE'); + }); + + it('indexes payload by stage', () => { + createTestPayload(); + const found = payloadStore.getPayloadByStage('pipe-1', 'linear:1', 'proj-1'); + expect(found).toBeDefined(); + expect(found!.content).toBe('test payload content'); + }); +}); + +// ── getPayload ─────────────────────────────────────────────────────────────── + +describe('getPayload', () => { + it('returns payload by ID', () => { + const { payload } = createTestPayload(); + const found = payloadStore.getPayload(payload!.payloadId, 'proj-1'); + expect(found).toBeDefined(); + expect(found!.payloadId).toBe(payload!.payloadId); + }); + + it('returns undefined for deleted payloads', () => { + const { payload } = createTestPayload(); + payloadStore.deletePayload(payload!.payloadId, 'proj-1'); + expect(payloadStore.getPayload(payload!.payloadId, 'proj-1')).toBeUndefined(); + }); + + it('returns undefined for nonexistent IDs', () => { + expect(payloadStore.getPayload('nonexistent', 'proj-1')).toBeUndefined(); + }); +}); + +// ── getPayloadMeta ─────────────────────────────────────────────────────────── + +describe('getPayloadMeta', () => { + it('returns metadata with content redacted', () => { + const { payload } = createTestPayload(); + const meta = payloadStore.getPayloadMeta(payload!.payloadId, 'proj-1'); + expect(meta).toBeDefined(); + expect(meta!.content).toBe('[redacted]'); + expect(meta!.pipeId).toBe('pipe-1'); + }); + + it('returns metadata even for deleted payloads', () => { + const { payload } = createTestPayload(); + payloadStore.deletePayload(payload!.payloadId, 'proj-1'); + const meta = payloadStore.getPayloadMeta(payload!.payloadId, 'proj-1'); + expect(meta).toBeDefined(); + expect(meta!.status).toBe('deleted'); + }); +}); + +// ── fetchPayloadContent ────────────────────────────────────────────────────── + +describe('fetchPayloadContent', () => { + it('returns content with integrity verification', () => { + const { payload } = createTestPayload(); + const result = payloadStore.fetchPayloadContent(payload!.payloadId, 'proj-1'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toBe('test payload content'); + expect(result.contentHash).toBe(payload!.contentHash); + expect(result.contentVersion).toBe(1); + }); + + it('rejects fetch for deleted payloads', () => { + const { payload } = createTestPayload(); + payloadStore.deletePayload(payload!.payloadId, 'proj-1'); + const result = payloadStore.fetchPayloadContent(payload!.payloadId, 'proj-1'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('PAYLOAD_DELETED'); + }); + + it('rejects fetch for nonexistent payloads', () => { + const result = payloadStore.fetchPayloadContent('nonexistent', 'proj-1'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('PAYLOAD_NOT_FOUND'); + }); +}); + +// ── updatePayloadContent ───────────────────────────────────────────────────── + +describe('updatePayloadContent', () => { + it('updates content and increments version', () => { + const { payload } = createTestPayload(); + const result = payloadStore.updatePayloadContent( + payload!.payloadId, 'updated content', 'proj-1', + ); + expect(result.ok).toBe(true); + expect(result.payload!.content).toBe('updated content'); + expect(result.payload!.contentVersion).toBe(2); + expect(result.payload!.contentHash).not.toBe(payload!.contentHash); + }); + + it('rejects update on deleted payload', () => { + const { payload } = createTestPayload(); + payloadStore.deletePayload(payload!.payloadId, 'proj-1'); + const result = payloadStore.updatePayloadContent( + payload!.payloadId, 'new', 'proj-1', + ); + expect(result.ok).toBe(false); + expect(result.code).toBe('PAYLOAD_DELETED'); + }); + + it('rejects update exceeding size limit', () => { + const { payload } = createTestPayload(); + payloadStore.setMaxPayloadBytes(10); + const result = payloadStore.updatePayloadContent( + payload!.payloadId, 'this is way too long', 'proj-1', + ); + expect(result.ok).toBe(false); + expect(result.code).toBe('PAYLOAD_TOO_LARGE'); + }); +}); + +// ── archivePayload ─────────────────────────────────────────────────────────── + +describe('archivePayload', () => { + it('marks payload as archived', () => { + const { payload } = createTestPayload(); + const result = payloadStore.archivePayload(payload!.payloadId, 'proj-1'); + expect(result.ok).toBe(true); + expect(result.payload!.status).toBe('archived'); + expect(result.payload!.archivedAt).toBeTruthy(); + }); + + it('archived payloads are still readable', () => { + const { payload } = createTestPayload(); + payloadStore.archivePayload(payload!.payloadId, 'proj-1'); + const found = payloadStore.getPayload(payload!.payloadId, 'proj-1'); + expect(found).toBeDefined(); + expect(found!.content).toBe('test payload content'); + }); + + it('rejects archive on deleted payload', () => { + const { payload } = createTestPayload(); + payloadStore.deletePayload(payload!.payloadId, 'proj-1'); + const result = payloadStore.archivePayload(payload!.payloadId, 'proj-1'); + expect(result.ok).toBe(false); + }); +}); + +// ── deletePayload ──────────────────────────────────────────────────────────── + +describe('deletePayload', () => { + it('soft-deletes payload (removes content, preserves metadata)', () => { + const { payload } = createTestPayload(); + const result = payloadStore.deletePayload(payload!.payloadId, 'proj-1'); + expect(result.ok).toBe(true); + expect(result.payload!.status).toBe('deleted'); + expect(result.payload!.content).toBe(''); + expect(result.payload!.sizeBytes).toBe(0); + expect(result.payload!.deletedAt).toBeTruthy(); + }); +}); + +// ── archivePipePayloads ────────────────────────────────────────────────────── + +describe('archivePipePayloads', () => { + it('archives all active payloads for a pipe', () => { + createTestPayload({ stageId: 'linear:1' }); + createTestPayload({ stageId: 'linear:2' }); + createTestPayload({ pipeId: 'pipe-2', stageId: 'linear:1' }); // different pipe + + const count = payloadStore.archivePipePayloads('pipe-1', 'proj-1'); + expect(count).toBe(2); + + // Different pipe should be unaffected + const other = payloadStore.getPayloadByStage('pipe-2', 'linear:1', 'proj-1'); + expect(other!.status).toBe('active'); + }); +}); + +// ── cleanupExpiredPayloads ─────────────────────────────────────────────────── + +describe('cleanupExpiredPayloads', () => { + it('removes archived payloads older than TTL', () => { + const clock = createTestClock(); + payloadStore.setClock(clock); + + const { payload } = createTestPayload(); + payloadStore.archivePayload(payload!.payloadId, 'proj-1'); + + // Not enough time + clock.advance(1000); + expect(payloadStore.cleanupExpiredPayloads('proj-1', 5000)).toBe(0); + + // Enough time + clock.advance(5000); + expect(payloadStore.cleanupExpiredPayloads('proj-1', 5000)).toBe(1); + }); + + it('does not remove active payloads', () => { + const clock = createTestClock(); + payloadStore.setClock(clock); + + createTestPayload(); + clock.advance(100_000); + expect(payloadStore.cleanupExpiredPayloads('proj-1', 1000)).toBe(0); + }); + + it('removes deleted payloads after TTL', () => { + const clock = createTestClock(); + payloadStore.setClock(clock); + + const { payload } = createTestPayload(); + payloadStore.deletePayload(payload!.payloadId, 'proj-1'); + + clock.advance(10_000); + expect(payloadStore.cleanupExpiredPayloads('proj-1', 5000)).toBe(1); + }); +}); + +// ── getStorageStats ────────────────────────────────────────────────────────── + +describe('getStorageStats', () => { + it('computes correct stats', () => { + createTestPayload({ stageId: 'linear:1', content: 'hello' }); // 5 bytes + createTestPayload({ stageId: 'linear:2', content: 'world!' }); // 6 bytes + const { payload: p3 } = createTestPayload({ stageId: 'linear:3', content: 'test' }); // 4 bytes + payloadStore.archivePayload(p3!.payloadId, 'proj-1'); + + const stats = payloadStore.getStorageStats('proj-1'); + expect(stats.totalPayloads).toBe(3); + expect(stats.activePayloads).toBe(2); + expect(stats.archivedPayloads).toBe(1); + expect(stats.deletedPayloads).toBe(0); + expect(stats.activeBytes).toBe(11); + expect(stats.totalBytes).toBe(15); + }); +}); + +// ── getPayloadsByPipe ──────────────────────────────────────────────────────── + +describe('getPayloadsByPipe', () => { + it('lists active and archived payloads, excludes deleted', () => { + createTestPayload({ stageId: 'linear:1' }); + const { payload: p2 } = createTestPayload({ stageId: 'linear:2' }); + payloadStore.archivePayload(p2!.payloadId, 'proj-1'); + const { payload: p3 } = createTestPayload({ stageId: 'linear:3' }); + payloadStore.deletePayload(p3!.payloadId, 'proj-1'); + + const payloads = payloadStore.getPayloadsByPipe('pipe-1', 'proj-1'); + expect(payloads).toHaveLength(2); // active + archived, not deleted + }); +}); + +// ── Recovery ───────────────────────────────────────────────────────────────── + +describe('rehydrateFromEvents', () => { + it('recreates payload from creation event', () => { + const events: payloadStore.PayloadRecoveryEvent[] = [ + { + type: 'payload-created', + payloadId: 'p-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + content: 'recovered content', + producedBy: 'alice', + sourceStage: 0, + }, + ]; + + const active = payloadStore.rehydrateFromEvents(events, 'proj-1'); + expect(active).toContain('p-001'); + + const payload = payloadStore.getPayload('p-001', 'proj-1'); + expect(payload).toBeDefined(); + expect(payload!.content).toBe('recovered content'); + expect(payload!.producedBy).toBe('alice'); + }); + + it('replays archive events', () => { + const events: payloadStore.PayloadRecoveryEvent[] = [ + { + type: 'payload-created', + payloadId: 'p-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + content: 'test', + }, + { + type: 'payload-archived', + payloadId: 'p-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + }, + ]; + + const active = payloadStore.rehydrateFromEvents(events, 'proj-1'); + expect(active).not.toContain('p-001'); + + const payload = payloadStore.getPayload('p-001', 'proj-1'); + expect(payload!.status).toBe('archived'); + }); +}); + +// ── Recovery timestamp fidelity ────────────────────────────────────────────── + +describe('recovery timestamp fidelity', () => { + it('preserves original event timestamps during rehydration', () => { + const events: payloadStore.PayloadRecoveryEvent[] = [ + { + type: 'payload-created', + payloadId: 'p-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + content: 'test content', + producedBy: 'alice', + ts: '2026-03-15T10:00:00.000Z', + }, + { + type: 'payload-archived', + payloadId: 'p-001', + pipeId: 'pipe-1', + stageId: 'linear:1', + ts: '2026-03-15T12:00:00.000Z', + }, + ]; + + payloadStore.rehydrateFromEvents(events, 'proj-1'); + const payload = payloadStore.getPayload('p-001', 'proj-1'); + + // Timestamps should match the persisted events, not the current clock + expect(payload!.createdAt).toBe('2026-03-15T10:00:00.000Z'); + expect(payload!.archivedAt).toBe('2026-03-15T12:00:00.000Z'); + expect(payload!.updatedAt).toBe('2026-03-15T12:00:00.000Z'); + }); +}); + +// ── Clock injection ────────────────────────────────────────────────────────── + +describe('clock injection', () => { + it('uses injected clock for timestamps', () => { + const clock = createTestClock(1700000000000); + payloadStore.setClock(clock); + + const { payload } = createTestPayload(); + expect(payload!.createdAt).toBe('2023-11-14T22:13:20.000Z'); + + clock.advance(3000); + payloadStore.updatePayloadContent(payload!.payloadId, 'updated', 'proj-1'); + + const updated = payloadStore.getPayload(payload!.payloadId, 'proj-1'); + expect(updated!.updatedAt).toBe('2023-11-14T22:13:23.000Z'); + }); +}); diff --git a/src/apps/chat/services/payload-store.ts b/src/apps/chat/services/payload-store.ts new file mode 100644 index 0000000..4278bc0 --- /dev/null +++ b/src/apps/chat/services/payload-store.ts @@ -0,0 +1,511 @@ +import { randomUUID, createHash } from 'crypto'; +import type { PayloadStatus } from '../types.js'; +import type { Clock } from './clock.js'; +import { systemClock } from './clock.js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** An authoritative payload that holds stage input or output content. + * Payloads are stored separately from assignments so they can be fetched + * on demand rather than pushed in full via PTY. */ +export interface Payload { + payloadId: string; // stable UUID — immutable once created + pipeId: string; + stageId: string; // matches the assignment's stageId + content: string; // the actual payload content (markdown/text) + contentHash: string; // SHA-256 hex digest for integrity verification + contentVersion: number; // increments if content is updated (rare — mostly immutable) + sizeBytes: number; // byte length of content (UTF-8) + status: PayloadStatus; + + // Timestamps (ISO 8601) + createdAt: string; + updatedAt: string; // last mutation time (content update or status change) + archivedAt: string | null; + deletedAt: string | null; + + // Provenance + producedBy: string | null; // participant who produced this content + sourceStage: number | null; // the stage number that produced this output (for linear input payloads) +} + +/** Error codes for payload operations. */ +export type PayloadErrorCode = + | 'PAYLOAD_NOT_FOUND' + | 'PAYLOAD_DELETED' + | 'PAYLOAD_TOO_LARGE' + | 'HASH_MISMATCH'; + +/** Result of a payload operation. */ +export interface PayloadResult { + ok: boolean; + error?: string; + code?: PayloadErrorCode; + payload?: Payload; +} + +// ── Configuration ───────────────────────────────────────────────────────────── + +/** Maximum payload size in bytes (default 2 MB). Prevents runaway content from exhausting memory. */ +export const DEFAULT_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024; + +/** Default retention for archived payloads: 24 hours. + * Active payloads are never cleaned up — only archived/deleted ones are eligible. */ +export const DEFAULT_PAYLOAD_TTL_MS = 24 * 60 * 60 * 1000; + +// ── Storage ─────────────────────────────────────────────────────────────────── + +// projectId -> (payloadId -> Payload) +const stores = new Map>(); + +// projectId -> (pipeId:stageId -> payloadId) — latest payload per stage +const stageIndex = new Map>(); + +let clock: Clock = systemClock; +let maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES; + +/** Override the clock used for timestamps (for testing). */ +export function setClock(c: Clock): void { + clock = c; +} + +/** Override the maximum payload size (for testing). */ +export function setMaxPayloadBytes(max: number): void { + maxPayloadBytes = max; +} + +function getProjectStore(projectId: string | null): Map { + let store = stores.get(projectId); + if (!store) { store = new Map(); stores.set(projectId, store); } + return store; +} + +function getStageIndex(projectId: string | null): Map { + let index = stageIndex.get(projectId); + if (!index) { index = new Map(); stageIndex.set(projectId, index); } + return index; +} + +function stageKey(pipeId: string, stageId: string): string { + return `${pipeId}:${stageId}`; +} + +function computeHash(content: string): string { + return createHash('sha256').update(content, 'utf8').digest('hex'); +} + +function byteLength(content: string): number { + return Buffer.byteLength(content, 'utf8'); +} + +// ── Payload lifecycle ───────────────────────────────────────────────────────── + +/** Create a new payload for a pipe stage. + * Content is stored in-memory with a SHA-256 integrity hash. + * Returns an error if content exceeds the size limit. */ +export function createPayload( + pipeId: string, + stageId: string, + content: string, + projectId: string | null, + opts?: { producedBy?: string; sourceStage?: number }, +): PayloadResult { + const size = byteLength(content); + if (size > maxPayloadBytes) { + return { + ok: false, + code: 'PAYLOAD_TOO_LARGE', + error: `Payload size ${size} bytes exceeds limit of ${maxPayloadBytes} bytes`, + }; + } + + const now = clock.isoNow(); + const payload: Payload = { + payloadId: randomUUID(), + pipeId, + stageId, + content, + contentHash: computeHash(content), + contentVersion: 1, + sizeBytes: size, + status: 'active', + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + producedBy: opts?.producedBy ?? null, + sourceStage: opts?.sourceStage ?? null, + }; + + const store = getProjectStore(projectId); + store.set(payload.payloadId, payload); + + // Update stage index + const sIndex = getStageIndex(projectId); + sIndex.set(stageKey(pipeId, stageId), payload.payloadId); + + return { ok: true, payload: { ...payload } }; +} + +/** Get a payload by ID. Returns undefined if not found. + * Deleted payloads return undefined — use getPayloadMeta for audit queries. */ +export function getPayload(payloadId: string, projectId: string | null): Payload | undefined { + const payload = getProjectStore(projectId).get(payloadId); + if (!payload || payload.status === 'deleted') return undefined; + return payload; +} + +/** Get payload metadata without content (for status checks and audit). + * Returns the payload even if deleted, but with content redacted. */ +export function getPayloadMeta( + payloadId: string, + projectId: string | null, +): Omit & { content: '[redacted]' } | undefined { + const payload = getProjectStore(projectId).get(payloadId); + if (!payload) return undefined; + return { ...payload, content: '[redacted]' }; +} + +/** Get the latest payload for a specific pipe stage. */ +export function getPayloadByStage( + pipeId: string, + stageId: string, + projectId: string | null, +): Payload | undefined { + const sIndex = getStageIndex(projectId); + const id = sIndex.get(stageKey(pipeId, stageId)); + if (!id) return undefined; + return getPayload(id, projectId); +} + +/** Fetch payload content with integrity verification. + * Returns the content only if the hash matches. + * This is the authoritative fetch path — clients call this to get the real payload. */ +export function fetchPayloadContent( + payloadId: string, + projectId: string | null, +): { ok: true; content: string; contentHash: string; contentVersion: number } | { ok: false; error: string; code: PayloadErrorCode } { + const payload = getProjectStore(projectId).get(payloadId); + if (!payload) { + return { ok: false, code: 'PAYLOAD_NOT_FOUND', error: `Payload ${payloadId} not found` }; + } + if (payload.status === 'deleted') { + return { ok: false, code: 'PAYLOAD_DELETED', error: `Payload ${payloadId} has been deleted` }; + } + + // Integrity check — verify hash still matches content + const currentHash = computeHash(payload.content); + if (currentHash !== payload.contentHash) { + return { ok: false, code: 'HASH_MISMATCH', error: `Payload ${payloadId} integrity check failed` }; + } + + return { + ok: true, + content: payload.content, + contentHash: payload.contentHash, + contentVersion: payload.contentVersion, + }; +} + +/** Update the content of a payload (rare — mainly for error correction). + * Increments contentVersion and recomputes the hash. */ +export function updatePayloadContent( + payloadId: string, + newContent: string, + projectId: string | null, +): PayloadResult { + const store = getProjectStore(projectId); + const payload = store.get(payloadId); + if (!payload) { + return { ok: false, code: 'PAYLOAD_NOT_FOUND', error: `Payload ${payloadId} not found` }; + } + if (payload.status === 'deleted') { + return { ok: false, code: 'PAYLOAD_DELETED', error: `Payload ${payloadId} has been deleted` }; + } + + const size = byteLength(newContent); + if (size > maxPayloadBytes) { + return { + ok: false, + code: 'PAYLOAD_TOO_LARGE', + error: `Payload size ${size} bytes exceeds limit of ${maxPayloadBytes} bytes`, + }; + } + + payload.content = newContent; + payload.contentHash = computeHash(newContent); + payload.contentVersion++; + payload.sizeBytes = size; + payload.updatedAt = clock.isoNow(); + + return { ok: true, payload: { ...payload } }; +} + +// ── Status transitions ──────────────────────────────────────────────────────── + +/** Archive a payload — marks it as no longer needed but retains content for TTL period. + * Typically called when the assignment using this payload reaches a terminal state. */ +export function archivePayload(payloadId: string, projectId: string | null): PayloadResult { + const store = getProjectStore(projectId); + const payload = store.get(payloadId); + if (!payload) { + return { ok: false, code: 'PAYLOAD_NOT_FOUND', error: `Payload ${payloadId} not found` }; + } + if (payload.status === 'deleted') { + return { ok: false, code: 'PAYLOAD_DELETED', error: `Payload ${payloadId} already deleted` }; + } + + payload.status = 'archived'; + payload.archivedAt = clock.isoNow(); + payload.updatedAt = payload.archivedAt; + + return { ok: true, payload: { ...payload } }; +} + +/** Soft-delete a payload — removes content but preserves metadata for audit. + * Content is replaced with an empty string and hash is zeroed. */ +export function deletePayload(payloadId: string, projectId: string | null): PayloadResult { + const store = getProjectStore(projectId); + const payload = store.get(payloadId); + if (!payload) { + return { ok: false, code: 'PAYLOAD_NOT_FOUND', error: `Payload ${payloadId} not found` }; + } + + payload.content = ''; + payload.contentHash = computeHash(''); + payload.sizeBytes = 0; + payload.status = 'deleted'; + payload.deletedAt = clock.isoNow(); + payload.updatedAt = payload.deletedAt; + + return { ok: true, payload: { ...payload } }; +} + +/** Archive all active payloads for a pipe. + * Called when a pipe reaches a terminal state. Returns the count of archived payloads. */ +export function archivePipePayloads(pipeId: string, projectId: string | null): number { + const store = getProjectStore(projectId); + let count = 0; + for (const payload of store.values()) { + if (payload.pipeId === pipeId && payload.status === 'active') { + payload.status = 'archived'; + payload.archivedAt = clock.isoNow(); + payload.updatedAt = payload.archivedAt; + count++; + } + } + return count; +} + +// ── Cleanup ─────────────────────────────────────────────────────────────────── + +/** Remove archived/deleted payloads older than the given TTL. + * Active payloads are never removed — archive them first. + * Returns the number of payloads removed from memory. */ +export function cleanupExpiredPayloads( + projectId: string | null, + ttlMs: number = DEFAULT_PAYLOAD_TTL_MS, +): number { + const store = getProjectStore(projectId); + const now = clock.now(); + let removed = 0; + + for (const [id, payload] of store) { + if (payload.status === 'active') continue; + + const refTs = payload.deletedAt ?? payload.archivedAt ?? payload.updatedAt; + if (now - new Date(refTs).getTime() >= ttlMs) { + store.delete(id); + + // Clean up stage index + const sIndex = getStageIndex(projectId); + const key = stageKey(payload.pipeId, payload.stageId); + if (sIndex.get(key) === id) { + sIndex.delete(key); + } + + removed++; + } + } + + return removed; +} + +/** List all payloads for a pipe (active and archived, excluding deleted). */ +export function getPayloadsByPipe(pipeId: string, projectId: string | null): Payload[] { + const store = getProjectStore(projectId); + const result: Payload[] = []; + for (const payload of store.values()) { + if (payload.pipeId === pipeId && payload.status !== 'deleted') { + result.push(payload); + } + } + return result; +} + +/** Get aggregate storage stats for a project. */ +export function getStorageStats(projectId: string | null): { + totalPayloads: number; + activePayloads: number; + archivedPayloads: number; + deletedPayloads: number; + totalBytes: number; + activeBytes: number; +} { + const store = getProjectStore(projectId); + let total = 0, active = 0, archived = 0, deleted = 0; + let totalBytes = 0, activeBytes = 0; + + for (const payload of store.values()) { + total++; + totalBytes += payload.sizeBytes; + switch (payload.status) { + case 'active': active++; activeBytes += payload.sizeBytes; break; + case 'archived': archived++; break; + case 'deleted': deleted++; break; + } + } + + return { totalPayloads: total, activePayloads: active, archivedPayloads: archived, deletedPayloads: deleted, totalBytes, activeBytes }; +} + +/** Get all projectIds that have payload data in the store. */ +export function getTrackedProjectIds(): Array { + return [...stores.keys()]; +} + +// ── Recovery ────────────────────────────────────────────────────────────────── + +/** Payload recovery event — persisted alongside assignment events. */ +export interface PayloadRecoveryEvent { + type: 'payload-created' | 'payload-updated' | 'payload-archived' | 'payload-deleted'; + payloadId: string; + pipeId: string; + stageId: string; + content?: string; // only on 'created' and 'updated' + producedBy?: string; + sourceStage?: number; + ts?: string; +} + +/** Restore persisted timestamp on a recovered payload. + * Mutators stamp fresh timestamps during replay — this overwrites them + * with the original event timestamps so TTL and audit remain faithful. */ +function restorePayloadTimestamp( + payloadId: string, + projectId: string | null, + eventType: PayloadRecoveryEvent['type'], + ts: string, +): void { + const payload = getProjectStore(projectId).get(payloadId); + if (!payload) return; + + switch (eventType) { + case 'payload-created': + payload.createdAt = ts; + payload.updatedAt = ts; + break; + case 'payload-updated': + payload.updatedAt = ts; + break; + case 'payload-archived': + payload.archivedAt = ts; + payload.updatedAt = ts; + break; + case 'payload-deleted': + payload.deletedAt = ts; + payload.updatedAt = ts; + break; + } +} + +/** Rehydrate payload state from persisted events. + * Called on server restart. Preserves original event timestamps for TTL + * and audit fidelity. Returns payloadIds that are still active. */ +export function rehydrateFromEvents( + events: PayloadRecoveryEvent[], + projectId: string | null, +): string[] { + const active: string[] = []; + + for (const event of events) { + switch (event.type) { + case 'payload-created': { + if (event.content === undefined) break; + const result = createPayload( + event.pipeId, + event.stageId, + event.content, + projectId, + { producedBy: event.producedBy, sourceStage: event.sourceStage }, + ); + if (result.ok && result.payload) { + // Fix the payloadId to match persisted one + const store = getProjectStore(projectId); + const generated = result.payload.payloadId; + if (generated !== event.payloadId) { + const payload = store.get(generated); + if (payload) { + store.delete(generated); + payload.payloadId = event.payloadId; + store.set(event.payloadId, payload); + // Fix stage index + const sIndex = getStageIndex(projectId); + const key = stageKey(event.pipeId, event.stageId); + if (sIndex.get(key) === generated) { + sIndex.set(key, event.payloadId); + } + } + } + // Restore original creation timestamp + if (event.ts) { + restorePayloadTimestamp(event.payloadId, projectId, 'payload-created', event.ts); + } + } + break; + } + case 'payload-updated': { + if (event.content === undefined) break; + updatePayloadContent(event.payloadId, event.content, projectId); + if (event.ts) { + restorePayloadTimestamp(event.payloadId, projectId, 'payload-updated', event.ts); + } + break; + } + case 'payload-archived': { + archivePayload(event.payloadId, projectId); + if (event.ts) { + restorePayloadTimestamp(event.payloadId, projectId, 'payload-archived', event.ts); + } + break; + } + case 'payload-deleted': { + deletePayload(event.payloadId, projectId); + if (event.ts) { + restorePayloadTimestamp(event.payloadId, projectId, 'payload-deleted', event.ts); + } + break; + } + } + } + + // Collect active payloads + const store = getProjectStore(projectId); + for (const payload of store.values()) { + if (payload.status === 'active') { + active.push(payload.payloadId); + } + } + + return active; +} + +// ── Test helper ─────────────────────────────────────────────────────────────── + +/** Reset all in-memory state. For testing only. */ +export function _resetForTest(): void { + stores.clear(); + stageIndex.clear(); + clock = systemClock; + maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES; +} diff --git a/src/apps/chat/services/pipe-assignment-materializer.test.ts b/src/apps/chat/services/pipe-assignment-materializer.test.ts new file mode 100644 index 0000000..e2a3ff6 --- /dev/null +++ b/src/apps/chat/services/pipe-assignment-materializer.test.ts @@ -0,0 +1,532 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as materializer from './pipe-assignment-materializer.js'; +import * as assignmentStore from './assignment-store.js'; +import * as payloadStore from './payload-store.js'; + +const PROJECT = 'test-project'; + +beforeEach(() => { + assignmentStore._resetForTest(); + payloadStore._resetForTest(); +}); + +// ── materializeAssignment ─────────────────────────────────────────────────── + +describe('materializeAssignment', () => { + it('creates assignment + payload for a handoff action', () => { + const result = materializer.materializeAssignment('pipe-1', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body: 'Analyze this code', + }, PROJECT); + + expect(result).not.toBeNull(); + expect(result!.assignee).toBe('alice'); + expect(result!.role).toBe('stage-output'); + expect(result!.stage).toBe(1); + expect(result!.stageId).toBe('linear:1'); + expect(result!.assignmentId).toBeTruthy(); + expect(result!.payloadId).toBeTruthy(); + }); + + it('creates assignment + payload for a fan-out-request action', () => { + const result = materializer.materializeAssignment('pipe-2', 'merge', { + type: 'fan-out-request', + targetAssignee: 'bob', + body: 'Give your opinion', + }, PROJECT); + + expect(result).not.toBeNull(); + expect(result!.assignee).toBe('bob'); + expect(result!.role).toBe('fan-out'); + expect(result!.stageId).toBe('fan-out:bob'); + expect(result!.stage).toBeUndefined(); + }); + + it('creates assignment + payload for a synth-request action', () => { + const result = materializer.materializeAssignment('pipe-3', 'merge', { + type: 'synth-request', + targetAssignee: 'charlie', + body: 'Synthesize all outputs', + }, PROJECT); + + expect(result).not.toBeNull(); + expect(result!.assignee).toBe('charlie'); + expect(result!.role).toBe('final'); + expect(result!.stageId).toBe('synth'); + }); + + it('returns null when payload creation fails (e.g. too large)', () => { + payloadStore.setMaxPayloadBytes(10); // very small limit + const result = materializer.materializeAssignment('pipe-4', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body: 'This body exceeds the tiny payload limit', + }, PROJECT); + + expect(result).toBeNull(); + }); +}); + +// ── Notification envelope ─────────────────────────────────────────────────── + +describe('notification envelope', () => { + it('contains correct fields in materialized result', () => { + const result = materializer.materializeAssignment('pipe-n', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 2, + body: 'Stage 2 prompt', + }, PROJECT); + + expect(result).not.toBeNull(); + const n = result!.notification; + expect(n.assignmentId).toBe(result!.assignmentId); + expect(n.pipeId).toBe('pipe-n'); + expect(n.stageId).toBe('linear:2'); + expect(n.role).toBe('stage-output'); + expect(n.stage).toBe(2); + expect(n.attempt).toBe(1); + expect(n.payloadId).toBe(result!.payloadId); + }); + + it('can retrieve notification for an existing assignment', () => { + const result = materializer.materializeAssignment('pipe-n2', 'merge', { + type: 'fan-out-request', + targetAssignee: 'bob', + body: 'Fan out prompt', + }, PROJECT); + + const notification = materializer.getAssignmentNotification(result!.assignmentId, PROJECT); + expect(notification).not.toBeNull(); + expect(notification!.assignmentId).toBe(result!.assignmentId); + expect(notification!.pipeId).toBe('pipe-n2'); + expect(notification!.role).toBe('fan-out'); + expect(notification!.payloadId).toBe(result!.payloadId); + }); + + it('returns null for non-existent assignment', () => { + const notification = materializer.getAssignmentNotification('nonexistent', PROJECT); + expect(notification).toBeNull(); + }); +}); + +// ── Payload integrity ─────────────────────────────────────────────────────── + +describe('payload integrity', () => { + it('stores content with SHA-256 hash', () => { + const body = 'Important analysis content'; + const result = materializer.materializeAssignment('pipe-hash', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body, + }, PROJECT); + + expect(result).not.toBeNull(); + + // Verify payload content and hash via payload store + const fetchResult = payloadStore.fetchPayloadContent(result!.payloadId, PROJECT); + expect(fetchResult.ok).toBe(true); + if (fetchResult.ok) { + expect(fetchResult.content).toBe(body); + expect(fetchResult.contentHash).toBeTruthy(); + expect(fetchResult.contentHash.length).toBe(64); // SHA-256 hex is 64 chars + } + }); + + it('stores content that matches the action body', () => { + const body = 'Exact content to verify'; + const result = materializer.materializeAssignment('pipe-content', 'merge', { + type: 'fan-out-request', + targetAssignee: 'bob', + body, + }, PROJECT); + + const payload = payloadStore.getPayload(result!.payloadId, PROJECT); + expect(payload).toBeDefined(); + expect(payload!.content).toBe(body); + expect(payload!.pipeId).toBe('pipe-content'); + expect(payload!.stageId).toBe('fan-out:bob'); + }); +}); + +// ── materializePipeAssignments (linear) ───────────────────────────────────── + +describe('materializePipeAssignments — linear', () => { + it('creates only stage 1 assignment for a linear pipe', () => { + const results = materializer.materializePipeAssignments( + 'pipe-lin', 'linear', ['alice', 'bob', 'charlie'], 'Analyze step by step', PROJECT, + ); + + expect(results).toHaveLength(1); + expect(results[0].assignee).toBe('alice'); + expect(results[0].stage).toBe(1); + expect(results[0].role).toBe('stage-output'); + expect(results[0].stageId).toBe('linear:1'); + }); + + it('creates payload with the prompt as content', () => { + const prompt = 'Linear pipe prompt'; + const results = materializer.materializePipeAssignments( + 'pipe-lin2', 'linear', ['alice', 'bob'], prompt, PROJECT, + ); + + const payload = payloadStore.getPayload(results[0].payloadId, PROJECT); + expect(payload!.content).toBe(prompt); + }); +}); + +// ── materializeNextLinearAssignment ───────────────────────────────────────── + +describe('materializeNextLinearAssignment', () => { + it('creates stage 2 assignment after stage 1 completes', () => { + // Materialize stage 1 + const stage1 = materializer.materializePipeAssignments( + 'pipe-next', 'linear', ['alice', 'bob', 'charlie'], 'Initial prompt', PROJECT, + ); + expect(stage1).toHaveLength(1); + + // Complete stage 1 (transition through lifecycle) + materializer.transitionAssignmentStatus(stage1[0].assignmentId, 'notified', PROJECT); + materializer.transitionAssignmentStatus(stage1[0].assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(stage1[0].assignmentId, 'payload_fetched', PROJECT); + materializer.completeAssignment(stage1[0].assignmentId, PROJECT); + + // Materialize stage 2 + const stage2 = materializer.materializeNextLinearAssignment( + 'pipe-next', 1, ['alice', 'bob', 'charlie'], 'Stage 1 output', PROJECT, + ); + + expect(stage2).not.toBeNull(); + expect(stage2!.assignee).toBe('bob'); + expect(stage2!.stage).toBe(2); + expect(stage2!.stageId).toBe('linear:2'); + expect(stage2!.role).toBe('stage-output'); + }); + + it('creates stage 3 assignment after stage 2 completes', () => { + // Set up stage 1 and complete it + materializer.materializePipeAssignments( + 'pipe-s3', 'linear', ['a', 'b', 'c'], 'prompt', PROJECT, + ); + const assignments = assignmentStore.getAssignmentsByPipe('pipe-s3', PROJECT); + const s1 = assignments[0]; + assignmentStore.transitionAssignment(s1.assignmentId, 'notified', PROJECT); + assignmentStore.transitionAssignment(s1.assignmentId, 'acknowledged', PROJECT); + assignmentStore.transitionAssignment(s1.assignmentId, 'payload_fetched', PROJECT); + assignmentStore.transitionAssignment(s1.assignmentId, 'submitted', PROJECT); + + // Materialize and complete stage 2 + const s2 = materializer.materializeNextLinearAssignment( + 'pipe-s3', 1, ['a', 'b', 'c'], 'output-1', PROJECT, + ); + assignmentStore.transitionAssignment(s2!.assignmentId, 'notified', PROJECT); + assignmentStore.transitionAssignment(s2!.assignmentId, 'acknowledged', PROJECT); + assignmentStore.transitionAssignment(s2!.assignmentId, 'payload_fetched', PROJECT); + assignmentStore.transitionAssignment(s2!.assignmentId, 'submitted', PROJECT); + + // Materialize stage 3 + const s3 = materializer.materializeNextLinearAssignment( + 'pipe-s3', 2, ['a', 'b', 'c'], 'output-2', PROJECT, + ); + expect(s3).not.toBeNull(); + expect(s3!.assignee).toBe('c'); + expect(s3!.stage).toBe(3); + }); + + it('returns null when all stages are complete', () => { + materializer.materializePipeAssignments( + 'pipe-done', 'linear', ['alice', 'bob'], 'prompt', PROJECT, + ); + // Complete stage 1 + const assignments = assignmentStore.getAssignmentsByPipe('pipe-done', PROJECT); + assignmentStore.transitionAssignment(assignments[0].assignmentId, 'notified', PROJECT); + assignmentStore.transitionAssignment(assignments[0].assignmentId, 'acknowledged', PROJECT); + assignmentStore.transitionAssignment(assignments[0].assignmentId, 'payload_fetched', PROJECT); + assignmentStore.transitionAssignment(assignments[0].assignmentId, 'submitted', PROJECT); + + // Complete stage 2 + const s2 = materializer.materializeNextLinearAssignment( + 'pipe-done', 1, ['alice', 'bob'], 'output-1', PROJECT, + ); + assignmentStore.transitionAssignment(s2!.assignmentId, 'notified', PROJECT); + assignmentStore.transitionAssignment(s2!.assignmentId, 'acknowledged', PROJECT); + assignmentStore.transitionAssignment(s2!.assignmentId, 'payload_fetched', PROJECT); + assignmentStore.transitionAssignment(s2!.assignmentId, 'submitted', PROJECT); + + // No stage 3 — should return null + const s3 = materializer.materializeNextLinearAssignment( + 'pipe-done', 2, ['alice', 'bob'], 'output-2', PROJECT, + ); + expect(s3).toBeNull(); + }); +}); + +// ── materializePipeAssignments (merge) ────────────────────────────────────── + +describe('materializePipeAssignments — merge', () => { + it('creates fan-out assignments for all assignees except synthesizer', () => { + const results = materializer.materializePipeAssignments( + 'pipe-merge', 'merge', ['alice', 'bob', 'charlie'], 'Compare approaches', PROJECT, + ); + + // merge: last assignee is synthesizer, rest are fan-out + expect(results).toHaveLength(2); + expect(results[0].assignee).toBe('alice'); + expect(results[0].role).toBe('fan-out'); + expect(results[0].stageId).toBe('fan-out:alice'); + expect(results[1].assignee).toBe('bob'); + expect(results[1].role).toBe('fan-out'); + expect(results[1].stageId).toBe('fan-out:bob'); + }); + + it('does not create synthesizer assignment during initial materialization', () => { + const results = materializer.materializePipeAssignments( + 'pipe-merge2', 'merge', ['alice', 'bob', 'charlie'], 'prompt', PROJECT, + ); + + // No assignment for charlie (synthesizer) + const assigneeNames = results.map(r => r.assignee); + expect(assigneeNames).not.toContain('charlie'); + }); +}); + +// ── materializePipeAssignments (merge-all) ────────────────────────────────── + +describe('materializePipeAssignments — merge-all', () => { + it('creates fan-out assignments for ALL assignees including synthesizer', () => { + const results = materializer.materializePipeAssignments( + 'pipe-mall', 'merge-all', ['alice', 'bob', 'charlie'], 'Everyone weighs in', PROJECT, + ); + + expect(results).toHaveLength(3); + expect(results[0].assignee).toBe('alice'); + expect(results[1].assignee).toBe('bob'); + expect(results[2].assignee).toBe('charlie'); + results.forEach(r => { + expect(r.role).toBe('fan-out'); + }); + }); +}); + +// ── materializePipeAssignments (explain / summarize) ──────────────────────── + +describe('materializePipeAssignments — explain', () => { + it('creates fan-out assignments for all assignees (explain is merge-all style)', () => { + const results = materializer.materializePipeAssignments( + 'pipe-exp', 'explain', ['alice', 'bob'], 'Explain closures', PROJECT, + ); + + expect(results).toHaveLength(2); + expect(results[0].assignee).toBe('alice'); + expect(results[1].assignee).toBe('bob'); + }); +}); + +describe('materializePipeAssignments — summarize', () => { + it('creates fan-out assignments for all assignees (summarize is merge-all style)', () => { + const results = materializer.materializePipeAssignments( + 'pipe-sum', 'summarize', ['alice', 'bob', 'charlie'], 'Summarize findings', PROJECT, + ); + + expect(results).toHaveLength(3); + }); +}); + +// ── materializeSynthAssignment ────────────────────────────────────────────── + +describe('materializeSynthAssignment', () => { + it('creates a synth assignment with final role', () => { + const result = materializer.materializeSynthAssignment( + 'pipe-synth', 'merge', 'charlie', 'Synthesize: alice said X, bob said Y', PROJECT, + ); + + expect(result).not.toBeNull(); + expect(result!.assignee).toBe('charlie'); + expect(result!.role).toBe('final'); + expect(result!.stageId).toBe('synth'); + expect(result!.stage).toBeUndefined(); + }); + + it('stores the synthesis prompt as payload content', () => { + const synthBody = 'Combine outputs from alice and bob'; + const result = materializer.materializeSynthAssignment( + 'pipe-synth2', 'merge', 'charlie', synthBody, PROJECT, + ); + + const payload = payloadStore.getPayload(result!.payloadId, PROJECT); + expect(payload!.content).toBe(synthBody); + }); +}); + +// ── completeAssignment ────────────────────────────────────────────────────── + +describe('completeAssignment', () => { + it('transitions assignment to submitted', () => { + const result = materializer.materializeAssignment('pipe-comp', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body: 'Do something', + }, PROJECT); + + // Walk through lifecycle + materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT); + + const ok = materializer.completeAssignment(result!.assignmentId, PROJECT); + expect(ok).toBe(true); + + // Verify assignment is in submitted status + const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT); + expect(assignment!.status).toBe('submitted'); + }); + + it('fast-forwards from assigned to submitted (supports submit-without-fetch)', () => { + const result = materializer.materializeAssignment('pipe-comp2', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body: 'Work', + }, PROJECT); + + // completeAssignment fast-forwards through all intermediate states + // This supports the observed LLM pattern of submitting without calling pipe_read_output + const ok = materializer.completeAssignment(result!.assignmentId, PROJECT); + expect(ok).toBe(true); + + const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT); + expect(assignment!.status).toBe('submitted'); + }); +}); + +// ── transitionAssignmentStatus ────────────────────────────────────────────── + +describe('transitionAssignmentStatus', () => { + it('transitions through the full lifecycle', () => { + const result = materializer.materializeAssignment('pipe-trans', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body: 'Task', + }, PROJECT); + + const id = result!.assignmentId; + + const a1 = materializer.transitionAssignmentStatus(id, 'notified', PROJECT); + expect(a1).not.toBeNull(); + expect(a1!.status).toBe('notified'); + + const a2 = materializer.transitionAssignmentStatus(id, 'acknowledged', PROJECT); + expect(a2!.status).toBe('acknowledged'); + + const a3 = materializer.transitionAssignmentStatus(id, 'payload_fetched', PROJECT); + expect(a3!.status).toBe('payload_fetched'); + + const a4 = materializer.transitionAssignmentStatus(id, 'submitted', PROJECT); + expect(a4!.status).toBe('submitted'); + }); + + it('returns null for invalid transition', () => { + const result = materializer.materializeAssignment('pipe-invalid', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body: 'Task', + }, PROJECT); + + // Can't go directly to payload_fetched from assigned + const a = materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT); + expect(a).toBeNull(); + }); + + it('returns null for non-existent assignment', () => { + const a = materializer.transitionAssignmentStatus('nonexistent', 'notified', PROJECT); + expect(a).toBeNull(); + }); +}); + +// ── cancelPipeAssignments ─────────────────────────────────────────────────── + +describe('cancelPipeAssignments', () => { + it('cancels all active assignments for a pipe', () => { + // Create fan-out assignments + materializer.materializePipeAssignments( + 'pipe-cancel', 'merge', ['alice', 'bob', 'charlie'], 'prompt', PROJECT, + ); + + const cancelled = materializer.cancelPipeAssignments('pipe-cancel', PROJECT); + expect(cancelled).toHaveLength(2); // alice and bob fan-outs + + // Verify all are cancelled + const assignments = assignmentStore.getAssignmentsByPipe('pipe-cancel', PROJECT); + for (const a of assignments) { + expect(a.status).toBe('cancelled'); + } + }); + + it('returns empty array when no active assignments exist', () => { + const cancelled = materializer.cancelPipeAssignments('nonexistent-pipe', PROJECT); + expect(cancelled).toHaveLength(0); + }); + + it('does not cancel already terminal assignments', () => { + const results = materializer.materializePipeAssignments( + 'pipe-partial-cancel', 'merge', ['alice', 'bob', 'charlie'], 'prompt', PROJECT, + ); + + // Complete alice's assignment + const aliceId = results[0].assignmentId; + assignmentStore.transitionAssignment(aliceId, 'notified', PROJECT); + assignmentStore.transitionAssignment(aliceId, 'acknowledged', PROJECT); + assignmentStore.transitionAssignment(aliceId, 'payload_fetched', PROJECT); + assignmentStore.transitionAssignment(aliceId, 'submitted', PROJECT); + + // Cancel remaining — should only cancel bob's + const cancelled = materializer.cancelPipeAssignments('pipe-partial-cancel', PROJECT); + expect(cancelled).toHaveLength(1); + + // Alice should still be submitted + const alice = assignmentStore.getAssignment(aliceId, PROJECT); + expect(alice!.status).toBe('submitted'); + }); +}); + +// ── Role derivation ───────────────────────────────────────────────────────── + +describe('role derivation', () => { + it('handoff action maps to stage-output role', () => { + const result = materializer.materializeAssignment('pipe-role1', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body: 'prompt', + }, PROJECT); + + expect(result!.role).toBe('stage-output'); + }); + + it('fan-out-request action maps to fan-out role', () => { + const result = materializer.materializeAssignment('pipe-role2', 'merge', { + type: 'fan-out-request', + targetAssignee: 'bob', + body: 'prompt', + }, PROJECT); + + expect(result!.role).toBe('fan-out'); + }); + + it('synth-request action maps to final role', () => { + const result = materializer.materializeAssignment('pipe-role3', 'merge', { + type: 'synth-request', + targetAssignee: 'charlie', + body: 'synthesize', + }, PROJECT); + + expect(result!.role).toBe('final'); + }); +}); diff --git a/src/apps/chat/services/pipe-assignment-materializer.ts b/src/apps/chat/services/pipe-assignment-materializer.ts new file mode 100644 index 0000000..1573df6 --- /dev/null +++ b/src/apps/chat/services/pipe-assignment-materializer.ts @@ -0,0 +1,260 @@ +import * as assignmentStore from './assignment-store.js'; +import * as payloadStore from './payload-store.js'; +import type { PipeMode, AssignmentStatus } from '../types.js'; + +/** Result of materializing assignments for a pipe action. */ +export interface MaterializedAssignment { + assignmentId: string; + payloadId: string; + stageId: string; + assignee: string; + role: 'stage-output' | 'fan-out' | 'final'; + stage?: number; + notification: assignmentStore.AssignmentNotification; +} + +/** + * Materialize an assignment + payload for a pipe action. + * Called by the reducer orchestration when an action is emitted. + * Creates the payload first (content), then the assignment (referencing payloadId). + * Returns the materialized assignment or null if creation failed. + */ +export function materializeAssignment( + pipeId: string, + mode: PipeMode, + action: { + type: 'handoff' | 'fan-out-request' | 'synth-request'; + targetAssignee: string; + stage?: number; + body: string; + }, + projectId: string | null, +): MaterializedAssignment | null { + // Derive role from action type + const role = actionTypeToRole(action.type); + const stageId = assignmentStore.deriveStageId(mode, role, { + stage: action.stage, + assignee: action.targetAssignee, + }); + + // Create payload first + const payloadResult = payloadStore.createPayload(pipeId, stageId, action.body, projectId, { + sourceStage: action.stage ? action.stage - 1 : undefined, + }); + if (!payloadResult.ok || !payloadResult.payload) return null; + + // Create assignment referencing the payload + const assignResult = assignmentStore.createAssignment( + pipeId, stageId, payloadResult.payload.payloadId, + action.targetAssignee, role, projectId, + { stage: action.stage }, + ); + if (!assignResult.ok || !assignResult.assignment) return null; + + const a = assignResult.assignment; + return { + assignmentId: a.assignmentId, + payloadId: payloadResult.payload.payloadId, + stageId, + assignee: a.assignee, + role, + stage: a.stage, + notification: { + assignmentId: a.assignmentId, + pipeId, + stageId, + role, + stage: a.stage, + attempt: a.attempt, + payloadId: payloadResult.payload.payloadId, + }, + }; +} + +/** + * Materialize all assignments for a newly created pipe. + * For merge/merge-all modes, creates fan-out assignments upfront. + * For linear, only the first stage assignment is created (rest on demand). + */ +export function materializePipeAssignments( + pipeId: string, + mode: PipeMode, + assignees: string[], + prompt: string, + projectId: string | null, +): MaterializedAssignment[] { + const results: MaterializedAssignment[] = []; + + if (mode === 'linear') { + // Linear: only materialize stage 1 — subsequent stages are created on submission + const result = materializeAssignment(pipeId, mode, { + type: 'handoff', + targetAssignee: assignees[0], + stage: 1, + body: prompt, + }, projectId); + if (result) results.push(result); + } else { + // Merge / merge-all / explain / summarize: materialize all fan-out assignments + const isMergeAll = mode === 'merge-all' || mode === 'explain' || mode === 'summarize'; + const fanOutAssignees = isMergeAll ? assignees : assignees.slice(0, -1); + + for (const assignee of fanOutAssignees) { + const result = materializeAssignment(pipeId, mode, { + type: 'fan-out-request', + targetAssignee: assignee, + body: prompt, + }, projectId); + if (result) results.push(result); + } + } + + return results; +} + +/** + * Transition an assignment through the delivery lifecycle. + * Returns the updated assignment or null if transition failed. + */ +export function transitionAssignmentStatus( + assignmentId: string, + newStatus: AssignmentStatus, + projectId: string | null, +): assignmentStore.Assignment | null { + const result = assignmentStore.transitionAssignment(assignmentId, newStatus, projectId); + return result.ok ? (result.assignment ?? null) : null; +} + +/** + * Handle submission: walk assignment through any needed intermediate transitions + * to reach 'submitted', then archive the payload. + * Handles cases where assignee submits without explicit fetch (legacy path). + */ +export function completeAssignment( + assignmentId: string, + projectId: string | null, +): boolean { + const assignment = assignmentStore.getAssignment(assignmentId, projectId); + if (!assignment) return false; + + // Walk through intermediate states if needed (legacy path: submit without fetch) + const stepsToSubmitted: import('../types.js').AssignmentStatus[] = [ + 'notified', 'acknowledged', 'payload_fetched', 'submitted', + ]; + const currentIdx = stepsToSubmitted.indexOf(assignment.status); + const targetIdx = stepsToSubmitted.indexOf('submitted'); + + if (assignment.status === 'assigned') { + // Fast-forward from assigned through all intermediate states + for (const step of stepsToSubmitted) { + const r = assignmentStore.transitionAssignment(assignmentId, step, projectId); + if (!r.ok) return false; + } + } else if (currentIdx >= 0 && currentIdx < targetIdx) { + // Walk from current position to submitted + for (let i = currentIdx + 1; i <= targetIdx; i++) { + const r = assignmentStore.transitionAssignment(assignmentId, stepsToSubmitted[i], projectId); + if (!r.ok) return false; + } + } else if (assignment.status !== 'submitted') { + // Direct transition attempt + const r = assignmentStore.transitionAssignment(assignmentId, 'submitted', projectId); + if (!r.ok) return false; + } + + payloadStore.archivePayload(assignment.payloadId, projectId); + return true; +} + +/** + * Cancel all assignments for a pipe (on pipe cancel/failure). + * Also archives all associated payloads. + * Returns the cancelled assignmentIds. + */ +export function cancelPipeAssignments( + pipeId: string, + projectId: string | null, +): string[] { + const cancelled = assignmentStore.cancelPipeAssignments(pipeId, projectId); + payloadStore.archivePipePayloads(pipeId, projectId); + return cancelled; +} + +/** + * Materialize the next assignment when a linear stage completes. + * Called after a stage submission to create the assignment for the next stage. + */ +export function materializeNextLinearAssignment( + pipeId: string, + completedStage: number, + assignees: string[], + body: string, + projectId: string | null, +): MaterializedAssignment | null { + const nextStage = completedStage + 1; + if (nextStage > assignees.length) return null; + + return materializeAssignment(pipeId, 'linear', { + type: 'handoff', + targetAssignee: assignees[nextStage - 1], + stage: nextStage, + body, + }, projectId); +} + +/** + * Materialize the synthesizer assignment after all fan-outs complete. + */ +export function materializeSynthAssignment( + pipeId: string, + mode: PipeMode, + synthesizer: string, + synthBody: string, + projectId: string | null, +): MaterializedAssignment | null { + return materializeAssignment(pipeId, mode, { + type: 'synth-request', + targetAssignee: synthesizer, + body: synthBody, + }, projectId); +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function actionTypeToRole( + actionType: 'handoff' | 'fan-out-request' | 'synth-request', +): 'stage-output' | 'fan-out' | 'final' { + if (actionType === 'synth-request') return 'final'; + if (actionType === 'fan-out-request') return 'fan-out'; + return 'stage-output'; +} + +/** + * Get the assignment notification envelope for an existing assignment. + * Used for re-delivery after reconnect. + */ +export function getAssignmentNotification( + assignmentId: string, + projectId: string | null, +): assignmentStore.AssignmentNotification | null { + const assignment = assignmentStore.getAssignment(assignmentId, projectId); + if (!assignment) return null; + return assignmentStore.toNotification(assignment); +} + +/** + * Get active (non-terminal) assignments for a participant on a specific pipe. + * Used during submission to find the assignment to complete. + */ +export function getActiveAssignmentsForParticipant( + assignee: string, + pipeId: string, + projectId: string | null, +): assignmentStore.Assignment[] { + const all = assignmentStore.getAssignmentsByPipe(pipeId, projectId); + return all.filter((a: assignmentStore.Assignment) => a.assignee === assignee && !isTerminal(a.status)); +} + +function isTerminal(status: import('../types.js').AssignmentStatus): boolean { + return ['submitted', 'expired', 'reassigned', 'superseded', 'cancelled'].includes(status); +} diff --git a/src/apps/chat/services/pipe-assignment-queries.ts b/src/apps/chat/services/pipe-assignment-queries.ts new file mode 100644 index 0000000..29d8fdd --- /dev/null +++ b/src/apps/chat/services/pipe-assignment-queries.ts @@ -0,0 +1,53 @@ +/** + * Pipe assignment query functions for lease-aware authorization. + * + * Re-exports getAssignmentsForParticipant from pipe-store (authoritative) + * and adds getAssignmentForPipe for single-pipe lookups. + * Used by pipe_list_assignments and pipe_get_assignment MCP tools/REST endpoints. + */ +import type { PipeMode, PipeStatus } from '../types.js'; +import type { PipeSlot } from './pipe-store.js'; +import * as pipeStore from './pipe-store.js'; + +// Re-export the authoritative type and list function from pipe-store +export type { ParticipantAssignment } from './pipe-store.js'; +export { getAssignmentsForParticipant } from './pipe-store.js'; + +/** Get a single assignment's details for a participant on a specific pipe. + * Returns the most relevant slot (leased > pending > submitted). */ +export function getAssignmentForPipe( + pipeId: string, + assignee: string, + projectId: string | null, +): pipeStore.ParticipantAssignment | undefined { + const pipe = pipeStore.getPipe(pipeId, projectId); + if (!pipe) return undefined; + const slots = pipe.slots.get(assignee); + if (!slots || slots.length === 0) return undefined; + + const lease = pipeStore.getActiveLease(assignee, projectId); + const sorted = [...slots].sort((a, b) => { + const order: Record = { leased: 0, pending: 1, submitted: 2 }; + return (order[a.status] ?? 3) - (order[b.status] ?? 3); + }); + + const slot = sorted[0]; + const isLeasedSlot = lease?.pipeId === pipeId + && lease.slotRole === slot.role + && (lease.stage === slot.stage || (lease.stage === undefined && slot.stage === undefined)); + + let leaseStatus: pipeStore.ParticipantAssignment['leaseStatus'] = 'none'; + let deadline: string | null = null; + let grantedAt: string | null = null; + + if (isLeasedSlot && lease) { + leaseStatus = pipeStore.isLeaseExpired(lease) ? 'expired' : 'active'; + deadline = lease.deadline; + grantedAt = lease.grantedAt; + } + + return { + pipeId, mode: pipe.mode, role: slot.role, stage: slot.stage, + slotStatus: slot.status, leaseStatus, deadline, grantedAt, pipeStatus: pipe.status, + }; +} diff --git a/src/apps/chat/services/pipe-delivery.test.ts b/src/apps/chat/services/pipe-delivery.test.ts new file mode 100644 index 0000000..3b45f6d --- /dev/null +++ b/src/apps/chat/services/pipe-delivery.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as delivery from './pipe-delivery.js'; +import { createTestClock } from './clock.js'; + +const PROJECT = 'test-project'; + +beforeEach(() => { + delivery._resetForTest(); +}); + +// ── Delivery lifecycle ────────────────────────────────────────────────────── + +describe('delivery state machine', () => { + it('creates a delivery record in assigned state', () => { + const record = delivery.createDelivery('pipe-1', 'alice', 'handoff', 'full payload', PROJECT, 1); + expect(record.state).toBe('assigned'); + expect(record.pipeId).toBe('pipe-1'); + expect(record.assignee).toBe('alice'); + expect(record.stage).toBe(1); + expect(record.role).toBe('handoff'); + expect(record.payload).toBe('full payload'); + expect(record.notifyAttempts).toBe(0); + expect(record.notifiedAt).toBeNull(); + expect(record.fetchedAt).toBeNull(); + expect(record.submittedAt).toBeNull(); + }); + + it('transitions assigned → notified on recordNotification', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + const ok = delivery.recordNotification('pipe-1', 'alice', PROJECT); + expect(ok).toBe(true); + + const record = delivery.getDelivery('pipe-1', 'alice', PROJECT); + expect(record?.state).toBe('notified'); + expect(record?.notifyAttempts).toBe(1); + expect(record?.notifiedAt).toBeTruthy(); + expect(record?.lastNotifyAttemptAt).toBeTruthy(); + }); + + it('transitions notified → fetched on recordFetch', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + const ok = delivery.recordFetch('pipe-1', 'alice', PROJECT); + expect(ok).toBe(true); + + const record = delivery.getDelivery('pipe-1', 'alice', PROJECT); + expect(record?.state).toBe('fetched'); + expect(record?.fetchedAt).toBeTruthy(); + }); + + it('transitions fetched → submitted on recordSubmission', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + delivery.recordFetch('pipe-1', 'alice', PROJECT); + const ok = delivery.recordSubmission('pipe-1', 'alice', PROJECT); + expect(ok).toBe(true); + + const record = delivery.getDelivery('pipe-1', 'alice', PROJECT); + expect(record?.state).toBe('submitted'); + expect(record?.submittedAt).toBeTruthy(); + }); + + it('allows direct notified → submitted (skip fetch)', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + const ok = delivery.recordSubmission('pipe-1', 'alice', PROJECT); + expect(ok).toBe(true); + + const record = delivery.getDelivery('pipe-1', 'alice', PROJECT); + expect(record?.state).toBe('submitted'); + }); + + it('allows direct assigned → submitted (fire and forget)', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + const ok = delivery.recordSubmission('pipe-1', 'alice', PROJECT); + expect(ok).toBe(true); + }); + + it('rejects notification after submission', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + delivery.recordSubmission('pipe-1', 'alice', PROJECT); + + const ok = delivery.recordNotification('pipe-1', 'alice', PROJECT); + expect(ok).toBe(false); + }); + + it('rejects fetch after cancellation', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + delivery.cancelDelivery('pipe-1', 'alice', PROJECT); + + const ok = delivery.recordFetch('pipe-1', 'alice', PROJECT); + expect(ok).toBe(false); + }); + + it('rejects submission after expiry', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + delivery.expireDelivery('pipe-1', 'alice', PROJECT); + + const ok = delivery.recordSubmission('pipe-1', 'alice', PROJECT); + expect(ok).toBe(false); + }); + + it('returns false for unknown delivery', () => { + expect(delivery.recordNotification('pipe-999', 'nobody', PROJECT)).toBe(false); + expect(delivery.recordFetch('pipe-999', 'nobody', PROJECT)).toBe(false); + expect(delivery.recordSubmission('pipe-999', 'nobody', PROJECT)).toBe(false); + }); +}); + +// ── Cancellation ──────────────────────────────────────────────────────────── + +describe('delivery cancellation', () => { + it('cancelDelivery marks record as cancelled', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + + const ok = delivery.cancelDelivery('pipe-1', 'alice', PROJECT); + expect(ok).toBe(true); + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('cancelled'); + }); + + it('cancelDelivery does not cancel submitted deliveries', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordSubmission('pipe-1', 'alice', PROJECT); + + const ok = delivery.cancelDelivery('pipe-1', 'alice', PROJECT); + expect(ok).toBe(false); + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('submitted'); + }); + + it('cancelAllDeliveries cancels all non-submitted deliveries for a pipe', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.createDelivery('pipe-1', 'bob', 'handoff', 'payload', PROJECT, 2); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + delivery.recordSubmission('pipe-1', 'alice', PROJECT); + delivery.recordNotification('pipe-1', 'bob', PROJECT); + + delivery.cancelAllDeliveries('pipe-1', PROJECT); + + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('submitted'); // preserved + expect(delivery.getDelivery('pipe-1', 'bob', PROJECT)?.state).toBe('cancelled'); + }); + + it('cancelDeliveriesForAssignee cancels all deliveries for a participant', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.createDelivery('pipe-2', 'alice', 'fan-out-request', 'payload', PROJECT); + delivery.createDelivery('pipe-1', 'bob', 'handoff', 'payload', PROJECT, 2); + + delivery.cancelDeliveriesForAssignee('alice', PROJECT); + + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('cancelled'); + expect(delivery.getDelivery('pipe-2', 'alice', PROJECT)?.state).toBe('cancelled'); + expect(delivery.getDelivery('pipe-1', 'bob', PROJECT)?.state).toBe('assigned'); // untouched + }); +}); + +// ── Re-notify logic ───────────────────────────────────────────────────────── + +describe('re-notify logic', () => { + it('needsRenotify returns false before notification', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(false); + }); + + it('needsRenotify returns false immediately after notification (interval not elapsed)', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(false); + }); + + it('needsRenotify returns true after interval has elapsed', () => { + const clock = createTestClock(); + delivery.setDeliveryClock(clock); + + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, { + renotifyIntervalMs: 10_000, + }); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + + // Advance past the interval + clock.advance(15_000); + expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(true); + }); + + it('needsRenotify returns false after fetch (acked)', () => { + const clock = createTestClock(); + delivery.setDeliveryClock(clock); + + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, { + renotifyIntervalMs: 10_000, + }); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + delivery.recordFetch('pipe-1', 'alice', PROJECT); + + clock.advance(15_000); + expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(false); + }); + + it('needsRenotify returns false when max attempts reached', () => { + const clock = createTestClock(); + delivery.setDeliveryClock(clock); + + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, { + maxNotifyAttempts: 2, + renotifyIntervalMs: 5_000, + }); + + // First notification + delivery.recordNotification('pipe-1', 'alice', PROJECT); + clock.advance(6_000); + + // Second notification (hits max) + delivery.recordNotification('pipe-1', 'alice', PROJECT); + clock.advance(6_000); + + expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(false); + }); + + it('startRenotifyTimer fires callback after interval', async () => { + vi.useFakeTimers(); + + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, { + renotifyIntervalMs: 5_000, + maxNotifyAttempts: 3, + }); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + + const callback = vi.fn(); + delivery.startRenotifyTimer('pipe-1', 'alice', PROJECT, callback); + + vi.advanceTimersByTime(5_000); + expect(callback).toHaveBeenCalledWith('pipe-1', 'alice', PROJECT); + + vi.useRealTimers(); + }); + + it('startRenotifyTimer does not fire after fetch', async () => { + vi.useFakeTimers(); + + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, { + renotifyIntervalMs: 5_000, + }); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + + const callback = vi.fn(); + delivery.startRenotifyTimer('pipe-1', 'alice', PROJECT, callback); + + // Fetch before timer fires — should cancel it + delivery.recordFetch('pipe-1', 'alice', PROJECT); + + vi.advanceTimersByTime(10_000); + expect(callback).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('expireDelivery is called when attempts exhausted in handleRenotifyTick', async () => { + vi.useFakeTimers(); + + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, { + maxNotifyAttempts: 1, + renotifyIntervalMs: 5_000, + }); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + + const callback = vi.fn(); + delivery.startRenotifyTimer('pipe-1', 'alice', PROJECT, callback); + + vi.advanceTimersByTime(5_000); + + // Callback should NOT have been called — attempts exhausted, delivery expired + expect(callback).not.toHaveBeenCalled(); + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('expired'); + + vi.useRealTimers(); + }); +}); + +// ── Payload retrieval ─────────────────────────────────────────────────────── + +describe('payload retrieval', () => { + it('getDeliveryPayload returns the stored payload', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'this is the full payload', PROJECT, 1); + expect(delivery.getDeliveryPayload('pipe-1', 'alice', PROJECT)).toBe('this is the full payload'); + }); + + it('getDeliveryPayload returns undefined for unknown delivery', () => { + expect(delivery.getDeliveryPayload('pipe-999', 'nobody', PROJECT)).toBeUndefined(); + }); +}); + +// ── Active delivery queries ───────────────────────────────────────────────── + +describe('active delivery queries', () => { + it('getActiveDeliveries returns non-terminal records', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'p1', PROJECT, 1); + delivery.createDelivery('pipe-1', 'bob', 'handoff', 'p2', PROJECT, 2); + delivery.createDelivery('pipe-1', 'carol', 'handoff', 'p3', PROJECT, 3); + + // Submit alice, cancel carol + delivery.recordSubmission('pipe-1', 'alice', PROJECT); + delivery.cancelDelivery('pipe-1', 'carol', PROJECT); + + const active = delivery.getActiveDeliveries('pipe-1', PROJECT); + expect(active).toHaveLength(1); + expect(active[0].assignee).toBe('bob'); + }); +}); + +// ── Compact notification formatting ───────────────────────────────────────── + +describe('compact notification formatting', () => { + it('formats a linear handoff notification', () => { + const notification = delivery.formatCompactNotification( + 'abc123', 'linear', 'handoff', 'alice', 3, 2, + ); + expect(notification.body).toContain('#pipe-abc123'); + expect(notification.body).toContain('[linear | stage 2/3 | @alice]'); + expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="abc123")'); + expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="abc123")'); + expect(notification.body).toContain('pipe_submit(pipeId="abc123"'); + expect(notification.body).toContain('Your output passes to the next stage.'); + // Should NOT contain the full prompt text + expect(notification.pipe.pipeId).toBe('abc123'); + expect(notification.pipe.role).toBe('handoff'); + expect(notification.pipe.stage).toBe(2); + }); + + it('formats a final stage notification', () => { + const notification = delivery.formatCompactNotification( + 'abc123', 'linear', 'handoff', 'carol', 3, 3, + ); + expect(notification.body).toContain('Final stage'); + expect(notification.body).toContain('stage 3/3'); + }); + + it('formats a fan-out-request notification', () => { + const notification = delivery.formatCompactNotification( + 'abc123', 'merge-all', 'fan-out-request', 'bob', 3, + ); + expect(notification.body).toContain('[merge-all | fan-out | @bob]'); + expect(notification.body).toContain('independent analysis'); + expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="abc123")'); + expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="abc123")'); + }); + + it('formats a synth-request notification', () => { + const notification = delivery.formatCompactNotification( + 'abc123', 'merge', 'synth-request', 'synth', 3, + ); + expect(notification.body).toContain('[merge | synthesizer | @synth]'); + expect(notification.body).toContain('Synthesize'); + expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="abc123")'); + expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="abc123")'); + }); + +}); + +// ── Clock injection ───────────────────────────────────────────────────────── + +describe('injectable clock', () => { + it('uses injected clock for timestamps', () => { + const clock = createTestClock(1700000000000); // fixed time + delivery.setDeliveryClock(clock); + + const record = delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + expect(record.assignedAt).toBe(new Date(1700000000000).toISOString()); + + clock.advance(5000); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + const updated = delivery.getDelivery('pipe-1', 'alice', PROJECT); + expect(updated?.notifiedAt).toBe(new Date(1700000005000).toISOString()); + }); +}); + +// ── Multiple re-notification increments ───────────────────────────────────── + +describe('re-notification counting', () => { + it('increments notifyAttempts on each recordNotification call', () => { + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, { + maxNotifyAttempts: 5, + }); + + delivery.recordNotification('pipe-1', 'alice', PROJECT); + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifyAttempts).toBe(1); + + delivery.recordNotification('pipe-1', 'alice', PROJECT); + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifyAttempts).toBe(2); + + delivery.recordNotification('pipe-1', 'alice', PROJECT); + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifyAttempts).toBe(3); + }); + + it('preserves first notifiedAt on subsequent notifications', () => { + const clock = createTestClock(); + delivery.setDeliveryClock(clock); + + delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1); + + delivery.recordNotification('pipe-1', 'alice', PROJECT); + const firstNotifiedAt = delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifiedAt; + + clock.advance(10_000); + delivery.recordNotification('pipe-1', 'alice', PROJECT); + const secondNotifiedAt = delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifiedAt; + + // First notifiedAt is preserved — only lastNotifyAttemptAt changes + expect(secondNotifiedAt).toBe(firstNotifiedAt); + expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.lastNotifyAttemptAt).not.toBe(firstNotifiedAt); + }); +}); diff --git a/src/apps/chat/services/pipe-delivery.ts b/src/apps/chat/services/pipe-delivery.ts new file mode 100644 index 0000000..683f879 --- /dev/null +++ b/src/apps/chat/services/pipe-delivery.ts @@ -0,0 +1,439 @@ +import type { Clock } from './clock.js'; +import { systemClock } from './clock.js'; +import type { PipeMode, PipeRole } from '../types.js'; + +// ── Delivery state machine ────────────────────────────────────────────────── + +/** Delivery lifecycle for a single stage assignment. + * assigned → notified → fetched → submitted + * ↓ ↓ + * (re-notify) (submitted) + * ↓ + * expired / cancelled */ +export type DeliveryState = + | 'assigned' // lease granted, notification not yet sent + | 'notified' // compact notification delivered to PTY + | 'fetched' // assignee called pipe_read_output (implicit ack) + | 'submitted' // assignee submitted via pipe_submit + | 'expired' // re-notify attempts exhausted or deadline passed + | 'cancelled'; // pipe cancelled or assignee reassigned + +export interface DeliveryRecord { + pipeId: string; + assignee: string; + stage?: number; + role: string; // 'handoff' | 'fan-out-request' | 'synth-request' + state: DeliveryState; + /** Full payload body generated by the reducer — stored here for fetch retrieval. */ + payload: string; + /** Number of PTY notification attempts so far. */ + notifyAttempts: number; + /** Maximum notification attempts before escalating / expiring. */ + maxNotifyAttempts: number; + /** Milliseconds between re-notify attempts. */ + renotifyIntervalMs: number; + // Timestamps (ISO) + assignedAt: string; + notifiedAt: string | null; + fetchedAt: string | null; + submittedAt: string | null; + lastNotifyAttemptAt: string | null; +} + +// ── Configuration ─────────────────────────────────────────────────────────── + +export const DEFAULT_MAX_NOTIFY_ATTEMPTS = 3; +export const DEFAULT_RENOTIFY_INTERVAL_MS = 30_000; // 30 seconds + +export interface DeliveryConfig { + maxNotifyAttempts?: number; + renotifyIntervalMs?: number; +} + +// ── Storage ───────────────────────────────────────────────────────────────── + +// Key: "projectId:pipeId:assignee" +const deliveryRecords = new Map(); + +// Re-notify timers: same key → NodeJS.Timeout +const renotifyTimers = new Map>(); + +function deliveryKey(pipeId: string, assignee: string, projectId: string | null): string { + return `${projectId ?? '__none__'}:${pipeId}:${assignee}`; +} + +// ── Injectable clock ──────────────────────────────────────────────────────── + +let _clock: Clock = systemClock; + +export function setDeliveryClock(clock: Clock): void { + _clock = clock; +} + +// ── Delivery lifecycle ────────────────────────────────────────────────────── + +/** Create a delivery record when a lease is granted and the reducer action is ready. */ +export function createDelivery( + pipeId: string, + assignee: string, + role: string, + payload: string, + projectId: string | null, + stage?: number, + config?: DeliveryConfig, +): DeliveryRecord { + const key = deliveryKey(pipeId, assignee, projectId); + + const record: DeliveryRecord = { + pipeId, + assignee, + stage, + role, + state: 'assigned', + payload, + notifyAttempts: 0, + maxNotifyAttempts: config?.maxNotifyAttempts ?? DEFAULT_MAX_NOTIFY_ATTEMPTS, + renotifyIntervalMs: config?.renotifyIntervalMs ?? DEFAULT_RENOTIFY_INTERVAL_MS, + assignedAt: _clock.isoNow(), + notifiedAt: null, + fetchedAt: null, + submittedAt: null, + lastNotifyAttemptAt: null, + }; + + deliveryRecords.set(key, record); + return record; +} + +/** Record that the compact notification was delivered to PTY. + * Returns false if the delivery is not in a valid state for notification. */ +export function recordNotification( + pipeId: string, + assignee: string, + projectId: string | null, +): boolean { + const record = getDelivery(pipeId, assignee, projectId); + if (!record) return false; + if (record.state === 'submitted' || record.state === 'expired' || record.state === 'cancelled') { + return false; + } + + const now = _clock.isoNow(); + record.state = 'notified'; + record.notifyAttempts += 1; + record.notifiedAt = record.notifiedAt ?? now; + record.lastNotifyAttemptAt = now; + return true; +} + +/** Record that the assignee fetched the payload (called when pipe_read_output is invoked). + * This serves as an implicit acknowledgment. */ +export function recordFetch( + pipeId: string, + assignee: string, + projectId: string | null, +): boolean { + const record = getDelivery(pipeId, assignee, projectId); + if (!record) return false; + if (record.state === 'submitted' || record.state === 'expired' || record.state === 'cancelled') { + return false; + } + + record.state = 'fetched'; + record.fetchedAt = _clock.isoNow(); + + // Cancel any pending re-notify timer — assignee is alive + cancelRenotifyTimer(pipeId, assignee, projectId); + return true; +} + +/** Record that the assignee submitted their output. */ +export function recordSubmission( + pipeId: string, + assignee: string, + projectId: string | null, +): boolean { + const record = getDelivery(pipeId, assignee, projectId); + if (!record) return false; + if (record.state === 'expired' || record.state === 'cancelled') { + return false; + } + + record.state = 'submitted'; + record.submittedAt = _clock.isoNow(); + + cancelRenotifyTimer(pipeId, assignee, projectId); + return true; +} + +/** Mark a delivery as cancelled (pipe cancelled or assignee reassigned). */ +export function cancelDelivery( + pipeId: string, + assignee: string, + projectId: string | null, +): boolean { + const record = getDelivery(pipeId, assignee, projectId); + if (!record) return false; + if (record.state === 'submitted') return false; // already done + + record.state = 'cancelled'; + cancelRenotifyTimer(pipeId, assignee, projectId); + return true; +} + +/** Mark a delivery as expired (re-notify attempts exhausted). */ +export function expireDelivery( + pipeId: string, + assignee: string, + projectId: string | null, +): boolean { + const record = getDelivery(pipeId, assignee, projectId); + if (!record) return false; + if (record.state === 'submitted' || record.state === 'cancelled') return false; + + record.state = 'expired'; + cancelRenotifyTimer(pipeId, assignee, projectId); + return true; +} + +// ── Re-notify logic ───────────────────────────────────────────────────────── + +export type RenotifyCallback = (pipeId: string, assignee: string, projectId: string | null) => void; + +/** Start the re-notify timer for a delivery. + * If the assignee hasn't fetched the payload within renotifyIntervalMs, + * the callback fires to re-deliver the notification. */ +export function startRenotifyTimer( + pipeId: string, + assignee: string, + projectId: string | null, + callback: RenotifyCallback, +): void { + const record = getDelivery(pipeId, assignee, projectId); + if (!record) return; + if (record.state !== 'notified') return; + + const key = deliveryKey(pipeId, assignee, projectId); + + // Clear any existing timer + cancelRenotifyTimer(pipeId, assignee, projectId); + + const timer = setTimeout(() => { + renotifyTimers.delete(key); + handleRenotifyTick(pipeId, assignee, projectId, callback); + }, record.renotifyIntervalMs); + + // Don't prevent process exit + if (typeof timer === 'object' && 'unref' in timer) timer.unref(); + + renotifyTimers.set(key, timer); +} + +/** Internal: handle a re-notify tick. Checks whether to re-notify or expire. */ +function handleRenotifyTick( + pipeId: string, + assignee: string, + projectId: string | null, + callback: RenotifyCallback, +): void { + const record = getDelivery(pipeId, assignee, projectId); + if (!record) return; + + // Only re-notify if still in 'notified' state (not fetched, submitted, cancelled, etc.) + if (record.state !== 'notified') return; + + if (record.notifyAttempts >= record.maxNotifyAttempts) { + // Exhausted — expire this delivery + expireDelivery(pipeId, assignee, projectId); + return; + } + + // Fire the re-notify callback (caller re-delivers the compact notification) + callback(pipeId, assignee, projectId); +} + +function cancelRenotifyTimer(pipeId: string, assignee: string, projectId: string | null): void { + const key = deliveryKey(pipeId, assignee, projectId); + const timer = renotifyTimers.get(key); + if (timer) { + clearTimeout(timer); + renotifyTimers.delete(key); + } +} + +// ── Queries ───────────────────────────────────────────────────────────────── + +/** Get the delivery record for a specific assignment. */ +export function getDelivery( + pipeId: string, + assignee: string, + projectId: string | null, +): DeliveryRecord | undefined { + return deliveryRecords.get(deliveryKey(pipeId, assignee, projectId)); +} + +/** Get the stored payload for a delivery (used by fetch surface). */ +export function getDeliveryPayload( + pipeId: string, + assignee: string, + projectId: string | null, +): string | undefined { + return getDelivery(pipeId, assignee, projectId)?.payload; +} + +/** Check if a delivery needs re-notification (notified but not fetched, timer-eligible). */ +export function needsRenotify( + pipeId: string, + assignee: string, + projectId: string | null, +): boolean { + const record = getDelivery(pipeId, assignee, projectId); + if (!record || record.state !== 'notified') return false; + if (record.notifyAttempts >= record.maxNotifyAttempts) return false; + + // Check if enough time has passed since last notification attempt + if (!record.lastNotifyAttemptAt) return false; + const elapsed = _clock.now() - new Date(record.lastNotifyAttemptAt).getTime(); + return elapsed >= record.renotifyIntervalMs; +} + +/** Get all active (non-terminal) delivery records for a pipe. */ +export function getActiveDeliveries( + pipeId: string, + projectId: string | null, +): DeliveryRecord[] { + const results: DeliveryRecord[] = []; + const prefix = `${projectId ?? '__none__'}:${pipeId}:`; + for (const [key, record] of deliveryRecords) { + if (key.startsWith(prefix) && record.state !== 'submitted' && record.state !== 'expired' && record.state !== 'cancelled') { + results.push(record); + } + } + return results; +} + +/** Cancel all active deliveries for a pipe (called when pipe is cancelled/failed). */ +export function cancelAllDeliveries(pipeId: string, projectId: string | null): void { + const prefix = `${projectId ?? '__none__'}:${pipeId}:`; + for (const [key, record] of deliveryRecords) { + if (key.startsWith(prefix) && record.state !== 'submitted') { + record.state = 'cancelled'; + // Cancel associated timer + const timer = renotifyTimers.get(key); + if (timer) { + clearTimeout(timer); + renotifyTimers.delete(key); + } + } + } +} + +/** Cancel all deliveries for a specific assignee across all pipes (called when participant leaves). */ +export function cancelDeliveriesForAssignee(assignee: string, projectId: string | null): void { + const suffix = `:${assignee}`; + const prefix = `${projectId ?? '__none__'}:`; + for (const [key, record] of deliveryRecords) { + if (key.startsWith(prefix) && key.endsWith(suffix) && record.state !== 'submitted') { + record.state = 'cancelled'; + const timer = renotifyTimers.get(key); + if (timer) { + clearTimeout(timer); + renotifyTimers.delete(key); + } + } + } +} + +// ── Compact notification formatting ───────────────────────────────────────── + +export interface CompactNotification { + /** The compact text to inject into PTY. */ + body: string; + /** Pipe metadata for the message envelope. */ + pipe: { + pipeId: string; + mode: PipeMode; + role: PipeRole; + stage?: number; + targetAssignee: string; + }; +} + +/** Format a compact assignment notification for PTY delivery. + * This replaces the full payload body — the LLM must fetch the full content + * via pipe_read_output after receiving this notification. */ +export function formatCompactNotification( + pipeId: string, + mode: PipeMode, + actionType: 'handoff' | 'fan-out-request' | 'synth-request', + targetAssignee: string, + totalStages: number, + stage?: number, +): CompactNotification { + const roleMap: Record = { + 'handoff': 'handoff', + 'fan-out-request': 'fan-out-request', + 'synth-request': 'synth-request', + }; + const role = roleMap[actionType]; + + let header: string; + let guidance: string; + + if (actionType === 'handoff') { + const isLast = stage === totalStages; + header = `#pipe-${pipeId} [linear | stage ${stage}/${totalStages} | @${targetAssignee}]`; + guidance = isLast + ? 'Final stage — your response goes to the user.' + : 'Your output passes to the next stage.'; + } else if (actionType === 'fan-out-request') { + header = `#pipe-${pipeId} [${mode} | fan-out | @${targetAssignee}]`; + guidance = 'Provide your independent analysis. Other participants answer in parallel.'; + } else { + header = `#pipe-${pipeId} [${mode} | synthesizer | @${targetAssignee}]`; + guidance = 'Synthesize the fan-out outputs into a unified response.'; + } + + const assignmentLine = `Inspect assignment: pipe_get_assignment(pipeId="${pipeId}")`; + const fetchLine = `Read stage input: pipe_read_output(pipeId="${pipeId}")`; + const submitLine = `Submit: pipe_submit(pipeId="${pipeId}", content="")\nDo not use chat_send. Submit once, then wait.`; + + const body = `${header}\n\n${guidance}\n\n${assignmentLine}\n${fetchLine}\n\n${submitLine}`; + + return { + body, + pipe: { pipeId, mode, role, stage, targetAssignee }, + }; +} + +/** Get delivery records where notification attempts are exhausted or state is expired. + * These represent assignments that failed to reach the assignee. */ +export function getExhaustedDeliveries(projectId: string | null): DeliveryRecord[] { + const results: DeliveryRecord[] = []; + const prefix = `${projectId ?? '__none__'}:`; + for (const [key, record] of deliveryRecords) { + if (!key.startsWith(prefix)) continue; + // Expired deliveries (retries exhausted or explicitly expired) + if (record.state === 'expired') { + results.push(record); + continue; + } + // Still active but retries exhausted — stuck in notified state + if (record.state === 'notified' && record.notifyAttempts >= record.maxNotifyAttempts) { + results.push(record); + } + } + return results; +} + +// ── Test helper ───────────────────────────────────────────────────────────── + +/** Reset all in-memory delivery state. For testing only. */ +export function _resetForTest(): void { + deliveryRecords.clear(); + for (const timer of renotifyTimers.values()) { + clearTimeout(timer); + } + renotifyTimers.clear(); + _clock = systemClock; +} diff --git a/src/apps/chat/services/pipe-fetch-input.test.ts b/src/apps/chat/services/pipe-fetch-input.test.ts new file mode 100644 index 0000000..5836050 --- /dev/null +++ b/src/apps/chat/services/pipe-fetch-input.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createPipe, computeStageInput, submitStage, markPipeStatus, _resetForTest, +} from './pipe-store.js'; + +describe('computeStageInput', () => { + beforeEach(() => _resetForTest()); + + // ── Linear pipes ────────────────────────────────────────────────────── + + describe('linear pipes', () => { + it('stage 1 returns original prompt with hash', () => { + createPipe('p1', 'linear', ['alice', 'bob', 'carol'], 'analyze this', null); + const result = computeStageInput('p1', 1, 'alice', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('prompt'); + expect(result.input.content).toBe('analyze this'); + expect(result.input.contentHash).toMatch(/^[a-f0-9]{64}$/); + expect(result.input.contentVersion).toBe(1); + expect(result.input.stage).toBe(1); + expect(result.input.totalStages).toBe(3); + expect(result.input.assignee).toBe('alice'); + }); + + it('stage 2 returns upstream output after submission', () => { + createPipe('p1', 'linear', ['alice', 'bob'], 'analyze this', null); + submitStage('p1', 'alice', 'stage 1 output', null, false); + const result = computeStageInput('p1', 2, 'bob', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('upstream-output'); + expect(result.input.content).toBe('stage 1 output'); + expect(result.input.sources).toEqual([{ from: 'alice', content: 'stage 1 output' }]); + expect(result.input.contentHash).toMatch(/^[a-f0-9]{64}$/); + expect(result.input.contentVersion).toBe(1); + expect(result.input.prompt).toBe('analyze this'); + }); + + it('stage 2 returns null content before upstream submits', () => { + createPipe('p1', 'linear', ['alice', 'bob'], 'analyze this', null); + const result = computeStageInput('p1', 2, 'bob', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('upstream-output'); + expect(result.input.content).toBeNull(); + expect(result.input.contentHash).toBeNull(); + expect(result.input.contentVersion).toBe(0); + }); + + it('infers stage from assignee position when stage is omitted', () => { + createPipe('p1', 'linear', ['alice', 'bob', 'carol'], 'test', null); + const result = computeStageInput('p1', undefined, 'bob', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.stage).toBe(2); + }); + + it('rejects invalid stage number', () => { + createPipe('p1', 'linear', ['alice', 'bob'], 'test', null); + const result = computeStageInput('p1', 5, 'alice', null); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain('Invalid stage'); + }); + }); + + // ── Merge pipes ────────────────────────────────────────────────────── + + describe('merge pipes', () => { + it('fan-out participant receives prompt', () => { + createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'compare approaches', null); + const result = computeStageInput('p1', undefined, 'alice', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('fan-out-prompt'); + expect(result.input.content).toBe('compare approaches'); + expect(result.input.contentHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('synthesizer receives fan-out outputs after all submit', () => { + createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'compare', null); + submitStage('p1', 'alice', 'alice output', null, false); + submitStage('p1', 'bob', 'bob output', null, false); + const result = computeStageInput('p1', undefined, 'carol', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('fan-out-outputs'); + expect(result.input.sources).toHaveLength(2); + expect(result.input.sources![0].from).toBe('alice'); + expect(result.input.sources![1].from).toBe('bob'); + expect(result.input.contentHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('synthesizer gets empty content before fan-outs submit', () => { + createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'compare', null); + const result = computeStageInput('p1', undefined, 'carol', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('fan-out-outputs'); + expect(result.input.content).toBeNull(); + expect(result.input.contentHash).toBeNull(); + }); + }); + + // ── Merge-all pipes ────────────────────────────────────────────────── + + describe('merge-all pipes', () => { + it('synthesizer in fan-out phase receives prompt', () => { + createPipe('p1', 'merge-all', ['alice', 'bob'], 'explain this', null); + const result = computeStageInput('p1', undefined, 'bob', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('fan-out-prompt'); + }); + + it('synthesizer stays in fan-out phase until their own fan-out is submitted', () => { + createPipe('p1', 'merge-all', ['alice', 'bob'], 'explain this', null); + submitStage('p1', 'alice', 'alice analysis', null, false); + const result = computeStageInput('p1', undefined, 'bob', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('fan-out-prompt'); + expect(result.input.content).toBe('explain this'); + }); + + it('synthesizer in synthesis phase receives fan-out outputs (excluding self)', () => { + createPipe('p1', 'merge-all', ['alice', 'bob'], 'explain this', null); + submitStage('p1', 'alice', 'alice analysis', null, false); + submitStage('p1', 'bob', 'bob analysis', null, false); + // Now bob is in synthesis phase + const result = computeStageInput('p1', undefined, 'bob', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('fan-out-outputs'); + expect(result.input.sources).toHaveLength(1); + expect(result.input.sources![0].from).toBe('alice'); + }); + }); + + describe('merge-all style teaching pipes', () => { + it('explain keeps the synthesizer in prompt mode until their fan-out is submitted', () => { + createPipe('p1', 'explain', ['alice', 'bob'], 'teach me this', null); + submitStage('p1', 'alice', 'alice explanation', null, false); + const result = computeStageInput('p1', undefined, 'bob', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('fan-out-prompt'); + expect(result.input.content).toBe('teach me this'); + }); + + it('summarize keeps the synthesizer in prompt mode until their fan-out is submitted', () => { + createPipe('p1', 'summarize', ['alice', 'bob'], 'summarize this', null); + submitStage('p1', 'alice', 'alice summary', null, false); + const result = computeStageInput('p1', undefined, 'bob', null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.input.role).toBe('fan-out-prompt'); + expect(result.input.content).toBe('summarize this'); + }); + }); + + // ── Error cases ────────────────────────────────────────────────────── + + describe('error cases', () => { + it('returns error for non-existent pipe', () => { + const result = computeStageInput('nonexistent', 1, 'alice', null); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('PIPE_NOT_FOUND'); + }); + + it('returns error for non-assignee', () => { + createPipe('p1', 'linear', ['alice', 'bob'], 'test', null); + const result = computeStageInput('p1', 1, 'eve', null); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('PIPE_NOT_ASSIGNED'); + }); + + it('returns error for closed pipe', () => { + createPipe('p1', 'linear', ['alice', 'bob'], 'test', null); + markPipeStatus('p1', 'completed', null); + const result = computeStageInput('p1', 1, 'alice', null); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('PIPE_CLOSED'); + }); + + it('content hash is deterministic for same content', () => { + createPipe('p1', 'linear', ['alice', 'bob'], 'same prompt', null); + createPipe('p2', 'linear', ['alice', 'bob'], 'same prompt', null); + const r1 = computeStageInput('p1', 1, 'alice', null); + const r2 = computeStageInput('p2', 1, 'alice', null); + expect(r1.ok && r2.ok).toBe(true); + if (!r1.ok || !r2.ok) return; + expect(r1.input.contentHash).toBe(r2.input.contentHash); + }); + }); +}); diff --git a/src/apps/chat/services/pipe-observability.test.ts b/src/apps/chat/services/pipe-observability.test.ts new file mode 100644 index 0000000..b2d55cf --- /dev/null +++ b/src/apps/chat/services/pipe-observability.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as pipeStore from './pipe-store.js'; +import * as provenanceStore from './pipe-provenance.js'; + +describe('Pipe Observability', () => { + const projectId = 'test-project'; + + beforeEach(() => { + pipeStore._resetForTest(); + provenanceStore._resetForTest(); + }); + + // ── Timing Summary ────────────────────────────────────────────────── + + describe('getPipeTimingSummary', () => { + it('returns undefined for non-existent pipe', () => { + expect(pipeStore.getPipeTimingSummary('nope', projectId)).toBeUndefined(); + }); + + it('returns timing for a running linear pipe', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId); + const timing = pipeStore.getPipeTimingSummary('p1', projectId); + expect(timing).toBeDefined(); + expect(timing!.pipeId).toBe('p1'); + expect(timing!.mode).toBe('linear'); + expect(timing!.status).toBe('running'); + expect(timing!.stages).toHaveLength(2); + expect(timing!.completedAt).toBeNull(); + expect(timing!.totalDurationMs).toBeNull(); + }); + + it('tracks submissions in timing stages', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + pipeStore.submitStage('p1', 'alice', 'output1', projectId, true); + pipeStore.grantLease('p1', 'bob', projectId); + pipeStore.submitStage('p1', 'bob', 'output2', projectId, true); + pipeStore.markPipeStatus('p1', 'completed', projectId); + + const timing = pipeStore.getPipeTimingSummary('p1', projectId); + expect(timing!.status).toBe('completed'); + expect(timing!.completedAt).toBeDefined(); + expect(timing!.totalDurationMs).toBeGreaterThanOrEqual(0); + expect(timing!.stages.every(s => s.submittedAt !== null)).toBe(true); + }); + + it('calculates critical path for merge pipe', () => { + pipeStore.createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'test', projectId); + const timing = pipeStore.getPipeTimingSummary('p1', projectId); + expect(timing!.stages.length).toBeGreaterThanOrEqual(3); + }); + }); + + // ── Runtime Lease Statuses ────────────────────────────────────────── + + describe('getRuntimeLeaseStatuses', () => { + it('returns empty array when no leases exist', () => { + expect(pipeStore.getRuntimeLeaseStatuses(projectId)).toEqual([]); + }); + + it('returns active leases with timing fields', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId, { + stageTimeoutMs: 30000, + }); + pipeStore.grantLease('p1', 'alice', projectId); + + const statuses = pipeStore.getRuntimeLeaseStatuses(projectId); + expect(statuses).toHaveLength(1); + expect(statuses[0].assignee).toBe('alice'); + expect(statuses[0].elapsedMs).toBeGreaterThanOrEqual(0); + expect(statuses[0].remainingMs).toBeGreaterThan(0); + expect(statuses[0].isOverdue).toBe(false); + expect(statuses[0].deadline).toBeDefined(); + }); + + it('handles leases without deadlines', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId, { + stageTimeoutMs: 0, + }); + pipeStore.grantLease('p1', 'alice', projectId); + + const statuses = pipeStore.getRuntimeLeaseStatuses(projectId); + expect(statuses[0].deadline).toBeNull(); + expect(statuses[0].remainingMs).toBeNull(); + expect(statuses[0].isOverdue).toBe(false); + }); + }); + + // ── Dead Letter Entries ───────────────────────────────────────────── + + describe('getDeadLetterEntries', () => { + it('returns empty array when no stuck assignments', () => { + expect(pipeStore.getDeadLetterEntries(projectId)).toEqual([]); + }); + + it('does not flag fresh running pipes', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId, { + stageTimeoutMs: 300000, + }); + pipeStore.grantLease('p1', 'alice', projectId); + const entries = pipeStore.getDeadLetterEntries(projectId); + expect(entries).toHaveLength(0); + }); + + it('does not flag submitted slots', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + pipeStore.submitStage('p1', 'alice', 'done', projectId, true); + + const entries = pipeStore.getDeadLetterEntries(projectId); + expect(entries).toHaveLength(0); + }); + + it('returns correct structure for dead-letter entries', () => { + // Just verify the function returns a properly typed array + const entries = pipeStore.getDeadLetterEntries(projectId); + expect(Array.isArray(entries)).toBe(true); + }); + }); + + // ── List All Pipes ────────────────────────────────────────────────── + + describe('listAllPipes', () => { + it('returns empty array when no pipes', () => { + expect(pipeStore.listAllPipes(projectId)).toEqual([]); + }); + + it('includes running and terminal pipes with slot summaries', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId); + pipeStore.createPipe('p2', 'merge', ['alice', 'bob', 'carol'], 'test2', projectId); + pipeStore.markPipeStatus('p2', 'completed', projectId); + + const all = pipeStore.listAllPipes(projectId); + expect(all).toHaveLength(2); + + const p1 = all.find(p => p.pipeId === 'p1')!; + expect(p1.status).toBe('running'); + expect(p1.slotSummary.total).toBe(2); + expect(p1.slotSummary.pending).toBe(2); + + const p2 = all.find(p => p.pipeId === 'p2')!; + expect(p2.status).toBe('completed'); + }); + }); + + // ── Provenance Store ──────────────────────────────────────────────── + + describe('Provenance', () => { + it('records and retrieves provenance for a pipe', () => { + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user', + metadata: { mode: 'linear' }, + }); + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'stage-granted', actor: 'system', actorKind: 'system', + stage: 1, metadata: { assignee: 'alice' }, + }); + + const records = provenanceStore.getProvenanceForPipe('p1', projectId); + expect(records).toHaveLength(2); + expect(records[0].event).toBe('created'); + expect(records[1].event).toBe('stage-granted'); + expect(records[1].stage).toBe(1); + }); + + it('queries by actor', () => { + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user', + }); + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'stage-submitted', actor: 'alice', actorKind: 'llm', + }); + + const userRecords = provenanceStore.queryProvenance(projectId, { actor: 'user' }); + expect(userRecords).toHaveLength(1); + expect(userRecords[0].actor).toBe('user'); + }); + + it('queries by event type', () => { + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user', + }); + provenanceStore.recordProvenance(projectId, { + pipeId: 'p2', event: 'created', actor: 'user', actorKind: 'user', + }); + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'completed', actor: 'system', actorKind: 'system', + }); + + const created = provenanceStore.queryProvenance(projectId, { event: 'created' }); + expect(created).toHaveLength(2); + }); + + it('retrieves provenance for participant across pipes', () => { + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'stage-submitted', actor: 'alice', actorKind: 'llm', + }); + provenanceStore.recordProvenance(projectId, { + pipeId: 'p2', event: 'stage-submitted', actor: 'alice', actorKind: 'llm', + }); + + const aliceRecords = provenanceStore.getProvenanceForParticipant('alice', projectId); + expect(aliceRecords).toHaveLength(2); + }); + + it('cleans up provenance for terminal pipes', () => { + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user', + }); + provenanceStore.recordProvenance(projectId, { + pipeId: 'p2', event: 'created', actor: 'user', actorKind: 'user', + }); + + provenanceStore.cleanupProvenance(['p1'], projectId); + expect(provenanceStore.getProvenanceForPipe('p1', projectId)).toHaveLength(0); + expect(provenanceStore.getProvenanceForPipe('p2', projectId)).toHaveLength(1); + }); + }); +}); diff --git a/src/apps/chat/services/pipe-parser.test.ts b/src/apps/chat/services/pipe-parser.test.ts new file mode 100644 index 0000000..60cf567 --- /dev/null +++ b/src/apps/chat/services/pipe-parser.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { isPipeCommand, parsePipeCommand, validatePipeAssigneeCount, isBrainstormCommand, parseBrainstormCommand } from './pipe-parser.js'; + +describe('pipe-parser', () => { + it('recognizes /explain as a pipe command', () => { + expect(isPipeCommand('/explain teach me this')).toBe(true); + }); + + it('recognizes /summarize as a pipe command', () => { + expect(isPipeCommand('/summarize boil this down')).toBe(true); + }); + + it('parses /explain with explicit assignees without a colon', () => { + expect(parsePipeCommand('/explain @alice @bob explain the bug')).toEqual({ + mode: 'explain', + assignees: ['alice', 'bob'], + prompt: 'explain the bug', + }); + }); + + it('still accepts a legacy colon separator', () => { + expect(parsePipeCommand('/explain @alice @bob: explain the bug')).toEqual({ + mode: 'explain', + assignees: ['alice', 'bob'], + prompt: 'explain the bug', + }); + }); + + it('parses /summarize with explicit assignees', () => { + expect(parsePipeCommand('/summarize @alice @bob summarize this topic')).toEqual({ + mode: 'summarize', + assignees: ['alice', 'bob'], + prompt: 'summarize this topic', + }); + }); + + it('allows prompt-only commands so defaults can be resolved later', () => { + expect(parsePipeCommand('/merge-pipe compare these options')).toEqual({ + mode: 'merge', + assignees: [], + prompt: 'compare these options', + }); + }); + + it('stops assignee parsing when a leading @token is not a known participant', () => { + expect( + parsePipeCommand( + '/merge-all-pipe @alice @bob @user mentioned this bug', + (name) => name === 'alice' || name === 'bob', + ), + ).toEqual({ + mode: 'merge-all', + assignees: ['alice', 'bob'], + prompt: '@user mentioned this bug', + }); + }); + + it('rejects duplicate assignees', () => { + expect(parsePipeCommand('/explain @alice @alice duplicate')).toEqual({ + error: 'Duplicate assignees not allowed.', + }); + }); + + it('validates minimum assignee counts after resolution', () => { + expect(validatePipeAssigneeCount('linear', 1)).toBe('/linear-pipe requires at least 2 assignees.'); + expect(validatePipeAssigneeCount('merge', 2)).toBe('/merge-pipe requires at least 3 assignees (last one synthesizes).'); + expect(validatePipeAssigneeCount('explain', 1)).toBe('/explain requires at least 2 assignees.'); + expect(validatePipeAssigneeCount('explain', 2)).toBeNull(); + expect(validatePipeAssigneeCount('summarize', 1)).toBe('/summarize requires at least 2 assignees.'); + expect(validatePipeAssigneeCount('summarize', 2)).toBeNull(); + }); +}); + +describe('brainstorm-parser', () => { + it('recognizes /brainstorm as a brainstorm command', () => { + expect(isBrainstormCommand('/brainstorm design a cache')).toBe(true); + }); + + it('does not recognize /brainstorming or other variants', () => { + expect(isBrainstormCommand('/brainstorming ideas')).toBe(false); + expect(isBrainstormCommand('/linear-pipe @a @b do work')).toBe(false); + expect(isBrainstormCommand('brainstorm something')).toBe(false); + }); + + it('parses /brainstorm with explicit assignees and colon', () => { + expect(parseBrainstormCommand('/brainstorm @alice @bob : design a cache')).toEqual({ + assignees: ['alice', 'bob'], + prompt: 'design a cache', + }); + }); + + it('parses /brainstorm with explicit assignees without colon', () => { + expect(parseBrainstormCommand('/brainstorm @alice @bob design a cache')).toEqual({ + assignees: ['alice', 'bob'], + prompt: 'design a cache', + }); + }); + + it('parses /brainstorm with no assignees (prompt only)', () => { + expect(parseBrainstormCommand('/brainstorm design a cache')).toEqual({ + assignees: [], + prompt: 'design a cache', + }); + }); + + it('parses /brainstorm with colon and no assignees', () => { + expect(parseBrainstormCommand('/brainstorm : design a cache')).toEqual({ + assignees: [], + prompt: 'design a cache', + }); + }); + + it('returns error for empty prompt', () => { + expect(parseBrainstormCommand('/brainstorm @alice @bob')).toEqual({ + error: 'Brainstorm prompt cannot be empty.', + }); + }); + + it('returns error for duplicate assignees', () => { + expect(parseBrainstormCommand('/brainstorm @alice @alice design a cache')).toEqual({ + error: 'Duplicate assignees not allowed.', + }); + }); + + it('returns error when first leading @name is unknown (no valid assignees)', () => { + const known = new Set(['alice']); + const result = parseBrainstormCommand( + '/brainstorm @ghost design a cache', + (name) => known.has(name), + ); + expect(result).toEqual({ + error: 'Unknown assignee @ghost. All assignees must be connected LLM participants.', + }); + }); + + it('treats unknown @name as prompt text when valid assignees precede it', () => { + const known = new Set(['alice', 'bob']); + const result = parseBrainstormCommand( + '/brainstorm @alice @bob @user wants a cache layer', + (name) => known.has(name), + ); + expect(result).toEqual({ + assignees: ['alice', 'bob'], + prompt: '@user wants a cache layer', + }); + }); + + it('accepts all assignees when validator confirms them', () => { + const known = new Set(['alice', 'bob']); + const result = parseBrainstormCommand( + '/brainstorm @alice @bob : design a cache', + (name) => known.has(name), + ); + expect(result).toEqual({ + assignees: ['alice', 'bob'], + prompt: 'design a cache', + }); + }); +}); diff --git a/src/apps/chat/services/pipe-parser.ts b/src/apps/chat/services/pipe-parser.ts new file mode 100644 index 0000000..83e54f6 --- /dev/null +++ b/src/apps/chat/services/pipe-parser.ts @@ -0,0 +1,215 @@ +import type { PipeMode, PipeTimeoutPolicy } from '../types.js'; + +export interface ParsedPipeCommand { + mode: PipeMode; + assignees: string[]; + prompt: string; + stageTimeoutMs?: number; + timeoutPolicy?: PipeTimeoutPolicy; +} + +export interface PipeParseError { + error: string; +} + +export type PipeParseResult = ParsedPipeCommand | PipeParseError; + +const PIPE_COMMANDS = ['linear-pipe', 'merge-pipe', 'merge-all-pipe', 'explain', 'explain-pipe', 'summarize', 'summarize-pipe'] as const; +const PIPE_CMD_RE = /^\/(linear-pipe|merge-pipe|merge-all-pipe|explain(?:-pipe)?|summarize(?:-pipe)?)\s+/; + +function commandLabel(mode: PipeMode): string { + switch (mode) { + case 'linear': + return '/linear-pipe'; + case 'merge': + return '/merge-pipe'; + case 'merge-all': + return '/merge-all-pipe'; + case 'explain': + return '/explain'; + case 'summarize': + return '/summarize'; + default: + return '/merge-all-pipe'; + } +} + +export function validatePipeAssigneeCount(mode: PipeMode, assigneeCount: number): string | null { + if (mode === 'linear' && assigneeCount < 2) { + return `${commandLabel(mode)} requires at least 2 assignees.`; + } + if (mode === 'merge' && assigneeCount < 3) { + return `${commandLabel(mode)} requires at least 3 assignees (last one synthesizes).`; + } + if ((mode === 'merge-all' || mode === 'explain' || mode === 'summarize') && assigneeCount < 2) { + return `${commandLabel(mode)} requires at least 2 assignees.`; + } + return null; +} + +export function isPipeCommand(body: string): boolean { + return PIPE_CMD_RE.test(body.trim()); +} + +/** Parse a human-friendly duration string (e.g. "5m", "30s", "1h") to milliseconds. */ +export function parseDuration(s: string): number | null { + const match = s.match(/^(\d+)(s|m|h)$/); + if (!match) return null; + const value = parseInt(match[1], 10); + if (value <= 0) return null; + const unit = match[2]; + if (unit === 's') return value * 1000; + if (unit === 'm') return value * 60 * 1000; + if (unit === 'h') return value * 60 * 60 * 1000; + return null; +} + +const VALID_TIMEOUT_POLICIES = ['fail', 'reassign', 'escalate'] as const; + +export function parsePipeCommand( + body: string, + isKnownAssignee?: (name: string) => boolean, +): PipeParseResult { + const trimmed = body.trim(); + const cmdMatch = trimmed.match(/^\/(linear-pipe|merge-pipe|merge-all-pipe|explain(?:-pipe)?|summarize(?:-pipe)?)\s+([\s\S]+)$/); + if (!cmdMatch) { + return { error: `Invalid pipe command. Use ${PIPE_COMMANDS.map(cmd => `/${cmd}`).join(', ')}.` }; + } + + const cmd = cmdMatch[1]; + let mode: PipeMode; + if (cmd === 'linear-pipe') mode = 'linear'; + else if (cmd === 'merge-pipe') mode = 'merge'; + else if (cmd === 'merge-all-pipe') mode = 'merge-all'; + else if (cmd === 'explain' || cmd === 'explain-pipe') mode = 'explain'; + else mode = 'summarize'; + + let remaining = cmdMatch[2].trim(); + + // Parse optional flags (--timeout , --on-timeout ) before @assignees + let stageTimeoutMs: number | undefined; + let timeoutPolicy: PipeTimeoutPolicy | undefined; + + while (true) { + const flagMatch = remaining.match(/^--([\w-]+)\s+(\S+)\s*/); + if (!flagMatch) break; + + const flagName = flagMatch[1]; + const flagValue = flagMatch[2]; + + if (flagName === 'timeout') { + const ms = parseDuration(flagValue); + if (ms === null) return { error: `Invalid timeout duration: ${flagValue}. Use e.g. 5m, 30s, 1h.` }; + stageTimeoutMs = ms; + } else if (flagName === 'on-timeout') { + if (!(VALID_TIMEOUT_POLICIES as readonly string[]).includes(flagValue)) { + return { error: `Invalid timeout policy: ${flagValue}. Use fail, reassign, or escalate.` }; + } + timeoutPolicy = flagValue as PipeTimeoutPolicy; + } else { + break; // Unknown flag — stop flag parsing, rest is @mentions/prompt + } + + remaining = remaining.slice(flagMatch[0].length); + } + + const assignees: string[] = []; + + while (true) { + const match = remaining.match(/^@([\w-]+)(?=\s|:|$)/); + if (!match) break; + + const name = match[1]; + if (isKnownAssignee && !isKnownAssignee(name)) break; + + assignees.push(name); + remaining = remaining.slice(match[0].length).trimStart(); + } + + if (remaining.startsWith(':')) { + remaining = remaining.slice(1).trimStart(); + } + + const prompt = remaining.trim(); + if (!prompt) { + return { error: 'Prompt cannot be empty.' }; + } + + const unique = new Set(assignees); + if (unique.size !== assignees.length) { + return { error: 'Duplicate assignees not allowed.' }; + } + + return { + mode, + assignees, + prompt, + ...(stageTimeoutMs !== undefined ? { stageTimeoutMs } : {}), + ...(timeoutPolicy !== undefined ? { timeoutPolicy } : {}), + }; +} + +export function isPipeParseError(result: PipeParseResult): result is PipeParseError { + return 'error' in result; +} + +// ── Brainstorm command parsing ──────────────────────────────────────────────── + +const BRAINSTORM_CMD_RE = /^\/brainstorm\s+/; + +export interface ParsedBrainstormCommand { + assignees: string[]; + prompt: string; +} + +export function isBrainstormCommand(body: string): boolean { + return BRAINSTORM_CMD_RE.test(body.trim()); +} + +export function parseBrainstormCommand( + body: string, + isKnownAssignee?: (name: string) => boolean, +): ParsedBrainstormCommand | PipeParseError { + const trimmed = body.trim(); + const cmdMatch = trimmed.match(/^\/brainstorm\s+([\s\S]+)$/); + if (!cmdMatch) { + return { error: 'Invalid brainstorm command. Usage: /brainstorm @agent1 @agent2 : topic' }; + } + + let remaining = cmdMatch[1].trim(); + const assignees: string[] = []; + let stoppedAtUnknown: string | null = null; + + while (true) { + const match = remaining.match(/^@([\w-]+)(?=\s|:|$)/); + if (!match) break; + const name = match[1]; + if (isKnownAssignee && !isKnownAssignee(name)) { + stoppedAtUnknown = name; + break; + } + assignees.push(name); + remaining = remaining.slice(match[0].length).trimStart(); + } + + // If no valid assignees were parsed but user wrote @names, the first was unknown + if (stoppedAtUnknown && assignees.length === 0) { + return { error: `Unknown assignee @${stoppedAtUnknown}. All assignees must be connected LLM participants.` }; + } + + if (remaining.startsWith(':')) { + remaining = remaining.slice(1).trimStart(); + } + + const prompt = remaining.trim(); + if (!prompt) { + return { error: 'Brainstorm prompt cannot be empty.' }; + } + + const unique = new Set(assignees); + if (unique.size !== assignees.length) { + return { error: 'Duplicate assignees not allowed.' }; + } + + return { assignees, prompt }; +} diff --git a/src/apps/chat/services/pipe-provenance.ts b/src/apps/chat/services/pipe-provenance.ts new file mode 100644 index 0000000..fbe4c06 --- /dev/null +++ b/src/apps/chat/services/pipe-provenance.ts @@ -0,0 +1,102 @@ +import type { ProvenanceRecord } from '../types.js'; +import { systemClock, type Clock } from './clock.js'; + +// ── Clock ──────────────────────────────────────────────────────────────────── + +let clock: Clock = systemClock; + +/** Override the clock used by provenance store (for deterministic testing). */ +export function _setClockForTest(c: Clock): void { clock = c; } + +// ── Storage ────────────────────────────────────────────────────────────────── + +// projectId -> (pipeId -> ProvenanceRecord[]) +const provenanceStores = new Map>(); + +function getProjectStore(projectId: string | null): Map { + let store = provenanceStores.get(projectId); + if (!store) { + store = new Map(); + provenanceStores.set(projectId, store); + } + return store; +} + +// ── Recording ──────────────────────────────────────────────────────────────── + +/** Record a provenance event for a pipe. */ +export function recordProvenance( + projectId: string | null, + record: Omit, +): ProvenanceRecord { + const store = getProjectStore(projectId); + const full: ProvenanceRecord = { ...record, ts: clock.isoNow() }; + let records = store.get(record.pipeId); + if (!records) { records = []; store.set(record.pipeId, records); } + records.push(full); + return full; +} + +// ── Queries ────────────────────────────────────────────────────────────────── + +/** Get all provenance records for a pipe, ordered chronologically. */ +export function getProvenanceForPipe(pipeId: string, projectId: string | null): ProvenanceRecord[] { + return getProjectStore(projectId).get(pipeId) ?? []; +} + +/** Get all provenance records for a participant across all pipes. */ +export function getProvenanceForParticipant( + actor: string, + projectId: string | null, +): ProvenanceRecord[] { + const store = getProjectStore(projectId); + const result: ProvenanceRecord[] = []; + for (const records of store.values()) { + for (const r of records) { + if (r.actor === actor) result.push(r); + } + } + return result.sort((a, b) => a.ts.localeCompare(b.ts)); +} + +/** Query provenance records with flexible filters. */ +export function queryProvenance( + projectId: string | null, + filters?: { + pipeId?: string; + actor?: string; + event?: ProvenanceRecord['event']; + since?: string; + }, +): ProvenanceRecord[] { + const store = getProjectStore(projectId); + const result: ProvenanceRecord[] = []; + + for (const [pipeId, records] of store) { + if (filters?.pipeId && pipeId !== filters.pipeId) continue; + for (const r of records) { + if (filters?.actor && r.actor !== filters.actor) continue; + if (filters?.event && r.event !== filters.event) continue; + if (filters?.since && r.ts < filters.since) continue; + result.push(r); + } + } + + return result.sort((a, b) => a.ts.localeCompare(b.ts)); +} + +// ── Cleanup ────────────────────────────────────────────────────────────────── + +/** Remove provenance records for terminal pipes. */ +export function cleanupProvenance(pipeIds: string[], projectId: string | null): void { + const store = getProjectStore(projectId); + for (const id of pipeIds) { + store.delete(id); + } +} + +/** Reset all state (for testing). */ +export function _resetForTest(): void { + provenanceStores.clear(); + clock = systemClock; +} diff --git a/src/apps/chat/services/pipe-reconnect-recovery.test.ts b/src/apps/chat/services/pipe-reconnect-recovery.test.ts new file mode 100644 index 0000000..7fa02e8 --- /dev/null +++ b/src/apps/chat/services/pipe-reconnect-recovery.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as pipeStore from './pipe-store.js'; + +describe('Reconnect recovery — assignment queries', () => { + beforeEach(() => { + pipeStore._resetForTest(); + }); + + it('getAssignmentsForParticipant returns pending slots for running pipes', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test prompt', null); + const assignments = pipeStore.getAssignmentsForParticipant('alice', null); + expect(assignments.length).toBe(1); + expect(assignments[0].pipeId).toBe('pipe-1'); + expect(assignments[0].slotStatus).toBe('pending'); + }); + + it('getAssignmentsForParticipant excludes submitted slots', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null); + pipeStore.grantLease('pipe-1', 'alice', null); + pipeStore.submitStage('pipe-1', 'alice', 'output', null); + const assignments = pipeStore.getAssignmentsForParticipant('alice', null); + expect(assignments.length).toBe(0); + }); + + it('getAssignmentsForParticipant shows leased slot with active lease status', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null); + pipeStore.grantLease('pipe-1', 'alice', null); + const assignments = pipeStore.getAssignmentsForParticipant('alice', null); + expect(assignments.length).toBe(1); + expect(assignments[0].slotStatus).toBe('leased'); + expect(assignments[0].leaseStatus).toBe('active'); + }); + + it('getAssignmentsForParticipant returns empty for non-running pipes', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null); + pipeStore.markPipeStatus('pipe-1', 'failed', null); + // markPipeStatus removes from activePipeIndex + const assignments = pipeStore.getAssignmentsForParticipant('alice', null); + expect(assignments.length).toBe(0); + }); + + it('getAssignmentsForParticipant returns assignments across multiple pipes', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'prompt 1', null); + pipeStore.createPipe('pipe-2', 'linear', ['alice', 'carol'], 'prompt 2', null); + const assignments = pipeStore.getAssignmentsForParticipant('alice', null); + expect(assignments.length).toBe(2); + expect(assignments.map(a => a.pipeId)).toContain('pipe-1'); + expect(assignments.map(a => a.pipeId)).toContain('pipe-2'); + }); + + it('expired lease is detected in assignment listing', () => { + vi.useFakeTimers(); + try { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null, { + stageTimeoutMs: 5000, + }); + pipeStore.grantLease('pipe-1', 'alice', null); + vi.advanceTimersByTime(6000); + const assignments = pipeStore.getAssignmentsForParticipant('alice', null); + expect(assignments.length).toBe(1); + expect(assignments[0].leaseStatus).toBe('expired'); + } finally { + vi.useRealTimers(); + } + }); + + it('isLeaseExpired returns false for lease without deadline', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null, { + stageTimeoutMs: 0, + }); + const result = pipeStore.grantLease('pipe-1', 'alice', null); + expect(result.ok).toBe(true); + expect(result.lease!.deadline).toBeNull(); + expect(pipeStore.isLeaseExpired(result.lease!)).toBe(false); + }); + + it('isLeaseExpired returns true for expired lease', () => { + vi.useFakeTimers(); + try { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null, { + stageTimeoutMs: 5000, + }); + const result = pipeStore.grantLease('pipe-1', 'alice', null); + expect(result.ok).toBe(true); + vi.advanceTimersByTime(6000); + expect(pipeStore.isLeaseExpired(result.lease!)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('getAssignmentsForParticipant scopes by projectId', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-a'); + pipeStore.createPipe('pipe-2', 'linear', ['alice', 'carol'], 'test', 'proj-b'); + const assignmentsA = pipeStore.getAssignmentsForParticipant('alice', 'proj-a'); + const assignmentsB = pipeStore.getAssignmentsForParticipant('alice', 'proj-b'); + expect(assignmentsA.length).toBe(1); + expect(assignmentsA[0].pipeId).toBe('pipe-1'); + expect(assignmentsB.length).toBe(1); + expect(assignmentsB[0].pipeId).toBe('pipe-2'); + }); + + it('merge-all pipe shows both fan-out and final slots for last assignee', () => { + pipeStore.createPipe('pipe-1', 'merge-all', ['alice', 'bob'], 'test', null); + // bob (last) should have both fan-out and final slots + const assignments = pipeStore.getAssignmentsForParticipant('bob', null); + expect(assignments.length).toBe(2); + expect(assignments.map(a => a.role)).toContain('fan-out'); + expect(assignments.map(a => a.role)).toContain('final'); + }); + + it('rehydrated pipe preserves assignments for reconnecting participants', () => { + // Simulate: pipe created, stage 1 submitted, then server restart + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'pipe-r', mode: 'linear', assignees: ['alice', 'bob', 'carol'], prompt: 'test' }, + { type: 'stage-output', pipeId: 'pipe-r', from: 'alice', content: 'alice output' }, + ]; + const running = pipeStore.rehydrateFromEvents(events, null); + expect(running).toContain('pipe-r'); + + // bob should have a pending assignment (stage 2) + const bobAssignments = pipeStore.getAssignmentsForParticipant('bob', null); + expect(bobAssignments.length).toBe(1); + expect(bobAssignments[0].pipeId).toBe('pipe-r'); + expect(bobAssignments[0].slotStatus).toBe('pending'); + + // alice should have no pending assignments (already submitted) + const aliceAssignments = pipeStore.getAssignmentsForParticipant('alice', null); + expect(aliceAssignments.length).toBe(0); + + // carol should have a pending assignment (stage 3, not yet reached) + const carolAssignments = pipeStore.getAssignmentsForParticipant('carol', null); + expect(carolAssignments.length).toBe(1); + expect(carolAssignments[0].slotStatus).toBe('pending'); + }); +}); diff --git a/src/apps/chat/services/pipe-reducer.test.ts b/src/apps/chat/services/pipe-reducer.test.ts new file mode 100644 index 0000000..3422a36 --- /dev/null +++ b/src/apps/chat/services/pipe-reducer.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from 'vitest'; +import type { ChatMessage } from '../types.js'; +import { + derivePipeState, + computeNextActions, + matchResponse, +} from './pipe-reducer.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +let seq = 0; +function msg(overrides: Partial & { from: string; body: string }): ChatMessage { + return { + id: `msg-${++seq}`, + ts: new Date(Date.now() + seq * 1000).toISOString(), + to: null, + type: 'message', + ...overrides, + }; +} + +function sysMsg(body: string, pipe: ChatMessage['pipe']): ChatMessage { + return msg({ from: 'system', body, type: 'system', pipe }); +} + +// ── derivePipeState ────────────────────────────────────────────────────────── + +describe('derivePipeState', () => { + it('returns null for unknown pipeId', () => { + expect(derivePipeState([], 'nope')).toBeNull(); + }); + + it('derives running state from start message', () => { + const messages = [ + sysMsg('Pipe started', { + pipeId: 'abc', mode: 'linear', role: 'start', + assignees: ['a', 'b'], prompt: 'solve X', + }), + ]; + const state = derivePipeState(messages, 'abc'); + expect(state).not.toBeNull(); + expect(state!.status).toBe('running'); + expect(state!.prompt).toBe('solve X'); + expect(state!.assignees).toEqual(['a', 'b']); + }); + + it('reads prompt from pipe metadata, not message body', () => { + const messages = [ + sysMsg('#pipe-abc Pipe started (linear): @a → @b', { + pipeId: 'abc', mode: 'linear', role: 'start', + assignees: ['a', 'b'], prompt: 'the real prompt', + }), + ]; + const state = derivePipeState(messages, 'abc'); + expect(state!.prompt).toBe('the real prompt'); + }); + + it('marks completed on final role', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a'], prompt: 'X' }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }), + msg({ from: 'a', body: 'done', pipe: { pipeId: 'abc', mode: 'linear', role: 'final', stage: 1 } }), + ]; + const state = derivePipeState(messages, 'abc'); + expect(state!.status).toBe('completed'); + expect(state!.hasFinal).toBe(true); + }); + + it('marks failed on failed role', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }), + sysMsg('unavail', { pipeId: 'abc', mode: 'merge', role: 'assignee-unavailable', targetAssignee: 'a', reason: 'left' }), + sysMsg('failed', { pipeId: 'abc', mode: 'merge', role: 'failed', reason: 'left' }), + ]; + const state = derivePipeState(messages, 'abc'); + expect(state!.status).toBe('failed'); + }); + + it('marks cancelled on cancelled role', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + sysMsg('cancelled', { pipeId: 'abc', mode: 'linear', role: 'cancelled', reason: 'cancelled-by-user' }), + ]; + const state = derivePipeState(messages, 'abc'); + expect(state!.status).toBe('cancelled'); + }); +}); + +// ── computeNextActions — idempotency ───────────────────────────────────────── + +describe('computeNextActions — linear', () => { + it('emits initial handoff to first assignee', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const actions = computeNextActions(state); + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe('handoff'); + expect(actions[0].targetAssignee).toBe('a'); + expect(actions[0].stage).toBe(1); + }); + + it('does not duplicate handoff if already emitted', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const actions = computeNextActions(state); + expect(actions).toHaveLength(0); // waiting for a's response + }); + + it('emits handoff to next stage after output', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }), + msg({ from: 'a', body: 'output A', pipe: { pipeId: 'abc', mode: 'linear', role: 'stage-output', stage: 1 } }), + ]; + const state = derivePipeState(messages, 'abc')!; + const actions = computeNextActions(state); + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe('handoff'); + expect(actions[0].targetAssignee).toBe('b'); + expect(actions[0].stage).toBe(2); + }); + + it('includes compact prompt with pipe_submit in handoff body', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const actions = computeNextActions(state); + const body = actions[0].body; + expect(body).toContain('#pipe-abc [linear | stage 1/2 | @a]'); + expect(body).toContain('pipe_submit(pipeId="abc"'); + expect(body).toContain('Do not use chat_send'); + expect(body).toContain('next stage'); + expect(body).toContain('Prompt: X'); + }); + + it('emits nothing after terminal state', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a'], prompt: 'X' }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }), + msg({ from: 'a', body: 'done', pipe: { pipeId: 'abc', mode: 'linear', role: 'final', stage: 1 } }), + ]; + const state = derivePipeState(messages, 'abc')!; + expect(computeNextActions(state)).toHaveLength(0); + }); +}); + +describe('computeNextActions — merge', () => { + it('emits fan-out requests to all fan-out assignees', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const actions = computeNextActions(state); + expect(actions).toHaveLength(2); + expect(actions.map(a => a.targetAssignee).sort()).toEqual(['a', 'b']); + }); + + it('emits synth-request when all fan-out replies are in', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }), + sysMsg('fan-out-req a', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }), + sysMsg('fan-out-req b', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'b' }), + msg({ from: 'a', body: 'A output', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }), + msg({ from: 'b', body: 'B output', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }), + ]; + const state = derivePipeState(messages, 'abc')!; + const actions = computeNextActions(state); + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe('synth-request'); + expect(actions[0].targetAssignee).toBe('s'); + }); + + it('merge-all: emits fan-out requests to ALL assignees (including synthesizer)', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge-all', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const actions = computeNextActions(state); + expect(actions).toHaveLength(2); + expect(actions.map(a => a.targetAssignee).sort()).toEqual(['a', 'b']); + + // Synthesizer's fan-out should warn about dual-role + const bFanOut = actions.find(a => a.targetAssignee === 'b')!; + expect(bFanOut.body).toContain('You have 2 stages'); + expect(bFanOut.body).toContain('Synthesis comes next'); + // Non-synthesizer should not have the warning + const aFanOut = actions.find(a => a.targetAssignee === 'a')!; + expect(aFanOut.body).not.toContain('You have 2 stages'); + }); + + it('merge-all: emits synth-request only after ALL fan-outs (including synthesizer) are in', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge-all', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + sysMsg('fo a', { pipeId: 'abc', mode: 'merge-all', role: 'fan-out-request', targetAssignee: 'a' }), + sysMsg('fo b', { pipeId: 'abc', mode: 'merge-all', role: 'fan-out-request', targetAssignee: 'b' }), + msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'merge-all', role: 'fan-out' } }), + ]; + const state = derivePipeState(messages, 'abc')!; + + // b (synthesizer) hasn't sent its fan-out yet + expect(computeNextActions(state)).toHaveLength(0); + + // b sends fan-out + messages.push(msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'merge-all', role: 'fan-out' } })); + const nextState = derivePipeState(messages, 'abc')!; + const actions = computeNextActions(nextState); + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe('synth-request'); + expect(actions[0].targetAssignee).toBe('b'); + expect(actions[0].body).toContain('pipe_read_output(pipeId="abc")'); + expect(actions[0].body).toContain('pipe_submit(pipeId="abc"'); + expect(actions[0].body).toContain('Do not use chat_send'); + }); + + it('explain: emits teaching fan-out requests and a teaching synth request', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'explain', role: 'start', assignees: ['a', 'b'], prompt: 'Teach me X' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const fanOutActions = computeNextActions(state); + expect(fanOutActions).toHaveLength(2); + expect(fanOutActions[0].body).toContain('Explain independently'); + expect(fanOutActions[0].body).toContain('Simplest explanation'); + expect(fanOutActions[0].body).toContain('pipe_submit(pipeId="abc"'); + expect(fanOutActions[0].body).toContain('Do not use chat_send'); + + messages.push( + sysMsg('fo a', { pipeId: 'abc', mode: 'explain', role: 'fan-out-request', targetAssignee: 'a' }), + sysMsg('fo b', { pipeId: 'abc', mode: 'explain', role: 'fan-out-request', targetAssignee: 'b' }), + msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'explain', role: 'fan-out' } }), + msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'explain', role: 'fan-out' } }), + ); + + const nextState = derivePipeState(messages, 'abc')!; + const synthActions = computeNextActions(nextState); + expect(synthActions).toHaveLength(1); + expect(synthActions[0].targetAssignee).toBe('b'); + expect(synthActions[0].body).toContain('Common misunderstandings'); + expect(synthActions[0].body).toContain('pipe_read_output(pipeId="abc")'); + expect(synthActions[0].body).toContain('pipe_submit(pipeId="abc"'); + }); + + it('summarize: emits concise fan-out requests and a compact synth request', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'summarize', role: 'start', assignees: ['a', 'b'], prompt: 'Summarize topic X' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const fanOutActions = computeNextActions(state); + expect(fanOutActions).toHaveLength(2); + expect(fanOutActions[0].body).toContain('Summarize independently'); + expect(fanOutActions[0].body).toContain('TL;DR'); + expect(fanOutActions[0].body).toContain('pipe_submit(pipeId="abc"'); + expect(fanOutActions[0].body).toContain('Do not use chat_send'); + + messages.push( + sysMsg('fo a', { pipeId: 'abc', mode: 'summarize', role: 'fan-out-request', targetAssignee: 'a' }), + sysMsg('fo b', { pipeId: 'abc', mode: 'summarize', role: 'fan-out-request', targetAssignee: 'b' }), + msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'summarize', role: 'fan-out' } }), + msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'summarize', role: 'fan-out' } }), + ); + + const nextState = derivePipeState(messages, 'abc')!; + const synthActions = computeNextActions(nextState); + expect(synthActions).toHaveLength(1); + expect(synthActions[0].targetAssignee).toBe('b'); + expect(synthActions[0].body).toContain('1. TL;DR'); + expect(synthActions[0].body).toContain('Caveat (only if important)'); + expect(synthActions[0].body).toContain('pipe_read_output(pipeId="abc")'); + expect(synthActions[0].body).toContain('pipe_submit(pipeId="abc"'); + }); + + it('does not duplicate synth-request', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }), + sysMsg('fo a', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }), + sysMsg('fo b', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'b' }), + msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }), + msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }), + sysMsg('synth', { pipeId: 'abc', mode: 'merge', role: 'synth-request', targetAssignee: 's' }), + ]; + const state = derivePipeState(messages, 'abc')!; + expect(computeNextActions(state)).toHaveLength(0); + }); + + it('emits nothing after failed state', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }), + sysMsg('fo a', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }), + sysMsg('unavail', { pipeId: 'abc', mode: 'merge', role: 'assignee-unavailable', targetAssignee: 'a', reason: 'left' }), + sysMsg('failed', { pipeId: 'abc', mode: 'merge', role: 'failed', reason: 'left' }), + ]; + const state = derivePipeState(messages, 'abc')!; + expect(computeNextActions(state)).toHaveLength(0); + }); +}); + +// ── matchResponse — reply disambiguation ───────────────────────────────────── + +describe('matchResponse', () => { + it('matches linear stage-output for prompted assignee', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const meta = matchResponse(state, 'a'); + expect(meta).not.toBeNull(); + expect(meta!.role).toBe('stage-output'); + expect(meta!.stage).toBe(1); + }); + + it('does not match unprompted assignee', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + // no handoff emitted yet + ]; + const state = derivePipeState(messages, 'abc')!; + expect(matchResponse(state, 'a')).toBeNull(); + }); + + it('does not match already-responded assignee', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }), + msg({ from: 'a', body: 'done', pipe: { pipeId: 'abc', mode: 'linear', role: 'stage-output', stage: 1 } }), + ]; + const state = derivePipeState(messages, 'abc')!; + expect(matchResponse(state, 'a')).toBeNull(); + }); + + it('matches final for last linear assignee', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }), + msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'linear', role: 'stage-output', stage: 1 } }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 2, targetAssignee: 'b' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const meta = matchResponse(state, 'b'); + expect(meta!.role).toBe('final'); + }); + + it('matches merge fan-out response', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }), + sysMsg('fo', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const meta = matchResponse(state, 'a'); + expect(meta!.role).toBe('fan-out'); + }); + + it('matches merge synthesizer final response', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }), + sysMsg('fo a', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }), + sysMsg('fo b', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'b' }), + msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }), + msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }), + sysMsg('synth', { pipeId: 'abc', mode: 'merge', role: 'synth-request', targetAssignee: 's' }), + ]; + const state = derivePipeState(messages, 'abc')!; + const meta = matchResponse(state, 's'); + expect(meta!.role).toBe('final'); + }); + + it('does not match non-participant', () => { + const messages = [ + sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }), + sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }), + ]; + const state = derivePipeState(messages, 'abc')!; + expect(matchResponse(state, 'z')).toBeNull(); + }); +}); + + diff --git a/src/apps/chat/services/pipe-reducer.ts b/src/apps/chat/services/pipe-reducer.ts new file mode 100644 index 0000000..b34723a --- /dev/null +++ b/src/apps/chat/services/pipe-reducer.ts @@ -0,0 +1,424 @@ +import { randomUUID } from 'crypto'; +import type { ChatMessage, PipeMode, PipeRole, PipeMessageMeta, PipeStatus } from '../types.js'; +import type { ParsedPipeCommand } from './pipe-parser.js'; +import type { StoredPipe } from './pipe-store.js'; + +// ── Reducer state ──────────────────────────────────────────────────────────── + +export interface PipeState { + pipeId: string; + mode: PipeMode; + status: PipeStatus; + assignees: string[]; // ordered list from start message + prompt: string; + /** Linear: which stage-output roles exist (by stage number). */ + stageOutputs: Map; + /** Merge: which fan-out roles exist (by assignee name). */ + fanOutOutputs: Map; + /** Roles already emitted by system (for idempotency). */ + emittedRoles: Set; // keys like "handoff:2", "synth-request", "failed" + hasHandoffs: Set; // stage numbers that have handoff messages + hasSynthRequest: boolean; + hasFinal: boolean; + hasFailed: boolean; + hasCancelled: boolean; +} + +/** Unique key for idempotency checks on system emissions. */ +function emissionKey(role: PipeRole, stage?: number, targetAssignee?: string): string { + if (stage !== undefined) return `${role}:${stage}`; + if (targetAssignee) return `${role}:${targetAssignee}`; + return role; +} + +// ── Scan log to derive pipe state ──────────────────────────────────────────── +// Note: buildStateFromStore (pipe store) is the primary state source in production. +// This function is used as a fallback for legacy/recovery pipes not in the store. + +export function derivePipeState(messages: ChatMessage[], pipeId: string): PipeState | null { + let state: PipeState | null = null; + + for (const msg of messages) { + if (!msg.pipe || msg.pipe.pipeId !== pipeId) continue; + const p = msg.pipe; + + if (p.role === 'start') { + state = { + pipeId, + mode: p.mode, + status: 'running', + assignees: p.assignees ?? [], + prompt: p.prompt ?? msg.body, + stageOutputs: new Map(), + fanOutOutputs: new Map(), + emittedRoles: new Set(), + hasHandoffs: new Set(), + hasSynthRequest: false, + hasFinal: false, + hasFailed: false, + hasCancelled: false, + }; + continue; + } + + if (!state) continue; + + switch (p.role) { + case 'handoff': + if (p.stage !== undefined) state.hasHandoffs.add(p.stage); + state.emittedRoles.add(emissionKey('handoff', p.stage)); + break; + case 'fan-out-request': + state.emittedRoles.add(emissionKey('fan-out-request', undefined, p.targetAssignee)); + break; + case 'stage-output': + if (p.stage !== undefined) { + state.stageOutputs.set(p.stage, { from: msg.from, body: msg.body }); + } + break; + case 'fan-out': + state.fanOutOutputs.set(msg.from, msg.body); + break; + case 'synth-request': + state.hasSynthRequest = true; + state.emittedRoles.add('synth-request'); + break; + case 'final': + state.hasFinal = true; + state.status = 'completed'; + break; + case 'assignee-unavailable': + // Don't set status yet — 'failed' message does that + break; + case 'failed': + state.hasFailed = true; + state.status = 'failed'; + break; + case 'cancelled': + state.hasCancelled = true; + state.status = 'cancelled'; + break; + } + } + + return state; +} + +/** Build PipeState directly from the pipe store — no log scanning needed. + * This is the primary state builder for store-tracked pipes. */ +export function buildStateFromStore(pipe: StoredPipe): PipeState { + const state: PipeState = { + pipeId: pipe.pipeId, + mode: pipe.mode, + status: pipe.status === 'running' ? 'running' : pipe.status, + assignees: pipe.assignees, + prompt: pipe.prompt, + stageOutputs: new Map(), + fanOutOutputs: new Map(), + emittedRoles: new Set(), + hasHandoffs: new Set(pipe.emittedHandoffs), + hasSynthRequest: pipe.emittedSynthRequest, + hasFinal: false, + hasFailed: pipe.status === 'failed', + hasCancelled: pipe.status === 'cancelled', + }; + + // Populate emittedRoles from store tracking + for (const stage of pipe.emittedHandoffs) { + state.emittedRoles.add(`handoff:${stage}`); + } + for (const assignee of pipe.emittedFanOutRequests) { + state.emittedRoles.add(`fan-out-request:${assignee}`); + } + if (pipe.emittedSynthRequest) { + state.emittedRoles.add('synth-request'); + } + + // Populate outputs from store slots + for (const [assignee, slotList] of pipe.slots) { + for (const slot of slotList) { + if (slot.status === 'submitted' && slot.content) { + if (slot.stage !== undefined && slot.role !== 'final') { + state.stageOutputs.set(slot.stage, { from: assignee, body: slot.content }); + } + if (slot.role === 'fan-out') { + state.fanOutOutputs.set(assignee, slot.content); + } + if (slot.role === 'final') { + state.hasFinal = true; + } + } + } + } + + return state; +} + +// ── Reducer: compute next actions ──────────────────────────────────────────── + +export interface PipeAction { + type: 'handoff' | 'fan-out-request' | 'synth-request'; + targetAssignee: string; + stage?: number; + body: string; + pipe: PipeMessageMeta; +} + +function isMergeAllStyleMode(mode: PipeMode): boolean { + return mode === 'merge-all' || mode === 'explain' || mode === 'summarize'; +} + +export function computeNextActions(state: PipeState): PipeAction[] { + // Guard: terminal state → no actions + if (state.hasFinal || state.hasFailed || state.hasCancelled) return []; + + if (state.mode === 'linear') return computeLinearActions(state); + if (state.mode === 'merge' || isMergeAllStyleMode(state.mode)) return computeMergeActions(state); + return []; +} + +function computeLinearActions(state: PipeState): PipeAction[] { + const actions: PipeAction[] = []; + const totalStages = state.assignees.length; + + // Check if stage 1 handoff needs to be emitted (initial delivery) + if (!state.hasHandoffs.has(1)) { + const target = state.assignees[0]; + actions.push({ + type: 'handoff', + targetAssignee: target, + stage: 1, + body: formatLinearHandoff(state, 1), + pipe: { + pipeId: state.pipeId, + mode: 'linear', + role: 'handoff', + stage: 1, + targetAssignee: target, + expectedAssignees: [target], + }, + }); + return actions; // Only emit one action at a time + } + + // Check each stage: if output exists and next handoff missing → emit + for (let stage = 1; stage < totalStages; stage++) { + const output = state.stageOutputs.get(stage); + if (!output) continue; // stage not responded yet + + const nextStage = stage + 1; + const key = emissionKey('handoff', nextStage); + if (state.emittedRoles.has(key)) continue; // already emitted + + const target = state.assignees[nextStage - 1]; + const isLast = nextStage === totalStages; + actions.push({ + type: 'handoff', + targetAssignee: target, + stage: nextStage, + body: formatLinearHandoff(state, nextStage), + pipe: { + pipeId: state.pipeId, + mode: 'linear', + role: 'handoff', + stage: nextStage, + targetAssignee: target, + expectedAssignees: [target], + }, + }); + return actions; // One action at a time for linear + } + + return actions; +} + +function computeMergeActions(state: PipeState): PipeAction[] { + const actions: PipeAction[] = []; + const isMergeAll = isMergeAllStyleMode(state.mode); + const fanOutAssignees = isMergeAll ? state.assignees : state.assignees.slice(0, -1); + const synthesizer = state.assignees[state.assignees.length - 1]; + + // Check if fan-out requests need to be emitted + for (const assignee of fanOutAssignees) { + const key = emissionKey('fan-out-request', undefined, assignee); + if (state.emittedRoles.has(key)) continue; + actions.push({ + type: 'fan-out-request', + targetAssignee: assignee, + body: formatFanOutRequest(state, assignee), + pipe: { + pipeId: state.pipeId, + mode: state.mode, + role: 'fan-out-request', + targetAssignee: assignee, + expectedAssignees: fanOutAssignees, + }, + }); + } + if (actions.length > 0) return actions; // Emit fan-out first + + // Check if all fan-out replies are in and synth-request is missing + const allFanOutDone = fanOutAssignees.every(a => state.fanOutOutputs.has(a)); + if (allFanOutDone && !state.hasSynthRequest) { + actions.push({ + type: 'synth-request', + targetAssignee: synthesizer, + body: formatSynthRequest(state), + pipe: { + pipeId: state.pipeId, + mode: state.mode, + role: 'synth-request', + targetAssignee: synthesizer, + expectedAssignees: [synthesizer], + }, + }); + } + + return actions; +} + +// ── Formatting helpers ─────────────────────────────────────────────────────── + +function submitBlock(pipeId: string): string { + return `Submit: pipe_submit(pipeId="${pipeId}", content="")\nDo not use chat_send. Submit once, then wait.`; +} + +function formatLinearHandoff(state: PipeState, stage: number): string { + const target = state.assignees[stage - 1]; + const total = state.assignees.length; + const isLast = stage === total; + const header = `#pipe-${state.pipeId} [linear | stage ${stage}/${total} | @${target}]`; + + const dest = isLast ? 'Final stage — your response goes to the user.' : 'Your output passes to the next stage.'; + let body = `${dest}\nPrompt: ${state.prompt}`; + if (stage > 1) { + body += `\n\nRead previous stage output: pipe_read_output(pipeId="${state.pipeId}")`; + } + + return `${header}\n\n${body}\n\n${submitBlock(state.pipeId)}`; +} + +function formatFanOutRequest(state: PipeState, assignee: string): string { + const header = `#pipe-${state.pipeId} [${state.mode} | fan-out | @${assignee}]`; + const mergeAll = isMergeAllStyleMode(state.mode); + const synthesizer = state.assignees[state.assignees.length - 1]; + const isSynthesizer = mergeAll && assignee === synthesizer; + + let body: string; + if (state.mode === 'explain') { + body = `Explain independently (parallel). Respond with: +1. Problem 2. Simplest explanation 3. Mental model (≤5 steps) +4. Visual (Mermaid/ASCII/"No visual needed") 5. Key terms (≤5) +6. Common misunderstanding 7. Takeaway +Teach a smart beginner. Clarity over exhaustiveness.`; + } else if (state.mode === 'summarize') { + body = `Summarize independently (parallel). Respond with: +1. Topic 2. Key points (3–5 bullets) 3. Why it matters 4. TL;DR (1–2 sentences) +Compress, cut repetition, minimize jargon.`; + } else { + body = `Provide your independent analysis. Other participants answer in parallel.`; + } + + body += `\nPrompt: ${state.prompt}`; + if (isSynthesizer) { + body += `\n\nYou have 2 stages. This is fan-out — submit your analysis now. Synthesis comes next.`; + } + + return `${header}\n\n${body}\n\n${submitBlock(state.pipeId)}`; +} + +function formatSynthRequest(state: PipeState): string { + const synthesizer = state.assignees[state.assignees.length - 1]; + const header = `#pipe-${state.pipeId} [${state.mode} | synthesizer | @${synthesizer}]`; + + let body: string; + if (state.mode === 'explain') { + body = `Synthesize into one teaching response. Sections: +1. Problem 2. Simplest explanation 3. Mental model 4. Visual +5. Key terms 6. Common misunderstandings 7. Takeaway +Pick the clearest framing. At most one visual (Mermaid preferred).`; + } else if (state.mode === 'summarize') { + body = `Synthesize into one compact summary. Sections: +1. TL;DR 2. Key points 3. Why it matters 4. Caveat (only if important) +Pick the clearest framing. Drop redundant points.`; + } else { + body = `Synthesize the fan-out outputs into a unified response for the user.`; + } + + body += `\nPrompt: ${state.prompt}`; + body += `\n\nRead all fan-out outputs: pipe_read_output(pipeId="${state.pipeId}")`; + + return `${header}\n\n${body}\n\n${submitBlock(state.pipeId)}`; +} + +// ── Pipe start description ─────────────────────────────────────────────────── + +export function generatePipeId(): string { + return randomUUID().substring(0, 8); +} + +export function getStartDescription(cmd: ParsedPipeCommand): string { + if (cmd.mode === 'linear') { + return cmd.assignees.map(a => `@${a}`).join(' \u2192 '); + } + const isMergeAll = isMergeAllStyleMode(cmd.mode); + const fanOutList = isMergeAll ? cmd.assignees : cmd.assignees.slice(0, -1); + const fanOut = fanOutList.map(a => `@${a}`).join(', '); + const synthesizer = `@${cmd.assignees[cmd.assignees.length - 1]}`; + return `[${fanOut}] \u2192 ${synthesizer}`; +} + +/** + * Determine the pipe role for an LLM response based on the current pipe state. + * Returns the appropriate PipeMessageMeta if the sender is an expected assignee, + * or null if the message is not a pipe response. + */ +export function matchResponse( + state: PipeState, + from: string, +): PipeMessageMeta | null { + if (state.status !== 'running') return null; + + if (state.mode === 'linear') { + // Find which stage is expecting a response from this sender + for (let stage = 1; stage <= state.assignees.length; stage++) { + if (state.assignees[stage - 1] !== from) continue; + if (state.stageOutputs.has(stage)) continue; // already responded + if (!state.hasHandoffs.has(stage)) continue; // not yet prompted + + const isLast = stage === state.assignees.length; + return { + pipeId: state.pipeId, + mode: 'linear', + role: isLast ? 'final' : 'stage-output', + stage, + }; + } + } + + if (state.mode === 'merge' || isMergeAllStyleMode(state.mode)) { + const isMergeAll = isMergeAllStyleMode(state.mode); + const fanOutAssignees = isMergeAll ? state.assignees : state.assignees.slice(0, -1); + const synthesizer = state.assignees[state.assignees.length - 1]; + + // Fan-out response + if (fanOutAssignees.includes(from) && !state.fanOutOutputs.has(from)) { + return { + pipeId: state.pipeId, + mode: state.mode, + role: 'fan-out', + }; + } + + // Synthesizer response + if (from === synthesizer && state.hasSynthRequest && !state.hasFinal) { + return { + pipeId: state.pipeId, + mode: state.mode, + role: 'final', + }; + } + } + + return null; +} diff --git a/src/apps/chat/services/pipe-reliability.test.ts b/src/apps/chat/services/pipe-reliability.test.ts new file mode 100644 index 0000000..e5c8a25 --- /dev/null +++ b/src/apps/chat/services/pipe-reliability.test.ts @@ -0,0 +1,464 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as pipeStore from './pipe-store.js'; +import * as pipeReducer from './pipe-reducer.js'; +import { parsePipeCommand, parseDuration } from './pipe-parser.js'; + +beforeEach(() => { + pipeStore._resetForTest(); +}); + +// ── parseDuration ──────────────────────────────────────────────────────────── + +describe('parseDuration', () => { + it('parses seconds', () => { + expect(parseDuration('30s')).toBe(30_000); + }); + + it('parses minutes', () => { + expect(parseDuration('5m')).toBe(300_000); + }); + + it('parses hours', () => { + expect(parseDuration('1h')).toBe(3_600_000); + }); + + it('returns null for invalid input', () => { + expect(parseDuration('abc')).toBeNull(); + expect(parseDuration('5x')).toBeNull(); + expect(parseDuration('')).toBeNull(); + expect(parseDuration('0s')).toBeNull(); + }); + + it('returns null for negative or zero values', () => { + expect(parseDuration('0m')).toBeNull(); + }); +}); + +// ── Pipe command flag parsing ──────────────────────────────────────────────── + +describe('pipe-parser timeout flags', () => { + it('parses --timeout flag', () => { + const result = parsePipeCommand('/linear-pipe --timeout 10m @alice @bob do something'); + expect(result).toMatchObject({ + mode: 'linear', + assignees: ['alice', 'bob'], + prompt: 'do something', + stageTimeoutMs: 600_000, + }); + }); + + it('parses --on-timeout flag', () => { + const result = parsePipeCommand('/linear-pipe --on-timeout escalate @alice @bob do something'); + expect(result).toMatchObject({ + mode: 'linear', + assignees: ['alice', 'bob'], + prompt: 'do something', + timeoutPolicy: 'escalate', + }); + }); + + it('parses both flags together', () => { + const result = parsePipeCommand('/merge-all-pipe --timeout 30s --on-timeout fail @alice @bob analyze this'); + expect(result).toMatchObject({ + mode: 'merge-all', + stageTimeoutMs: 30_000, + timeoutPolicy: 'fail', + assignees: ['alice', 'bob'], + prompt: 'analyze this', + }); + }); + + it('rejects invalid timeout duration', () => { + const result = parsePipeCommand('/linear-pipe --timeout xyz @alice @bob do something'); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('Invalid timeout duration'); + }); + + it('rejects invalid timeout policy', () => { + const result = parsePipeCommand('/linear-pipe --on-timeout destroy @alice @bob do something'); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('Invalid timeout policy'); + }); + + it('accepts commands without flags (backward compat)', () => { + const result = parsePipeCommand('/linear-pipe @alice @bob do something'); + expect(result).toMatchObject({ + mode: 'linear', + assignees: ['alice', 'bob'], + prompt: 'do something', + }); + expect(result).not.toHaveProperty('stageTimeoutMs'); + expect(result).not.toHaveProperty('timeoutPolicy'); + }); + + it('stops flag parsing at unknown flags (treats as prompt)', () => { + const result = parsePipeCommand('/linear-pipe --unknown value do something'); + expect(result).toMatchObject({ + mode: 'linear', + assignees: [], + prompt: '--unknown value do something', + }); + }); +}); + +// ── Pipe creation with timeout config ──────────────────────────────────────── + +describe('pipe-store timeout config', () => { + it('creates a pipe with default timeout', () => { + const pipe = pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1'); + expect(pipe.stageTimeoutMs).toBe(pipeStore.DEFAULT_STAGE_TIMEOUT_MS); + expect(pipe.timeoutPolicy).toBe('fail'); + }); + + it('creates a pipe with custom timeout', () => { + const pipe = pipeStore.createPipe('pipe-2', 'linear', ['alice', 'bob'], 'test', 'proj-1', { + stageTimeoutMs: 30_000, + timeoutPolicy: 'escalate', + }); + expect(pipe.stageTimeoutMs).toBe(30_000); + expect(pipe.timeoutPolicy).toBe('escalate'); + }); + + it('creates a pipe with zero timeout (disabled)', () => { + const pipe = pipeStore.createPipe('pipe-3', 'linear', ['alice', 'bob'], 'test', 'proj-1', { + stageTimeoutMs: 0, + }); + expect(pipe.stageTimeoutMs).toBe(0); + }); +}); + +// ── Lease deadline ─────────────────────────────────────────────────────────── + +describe('pipe-store lease deadline', () => { + it('sets deadline on lease when timeout is configured', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1', { + stageTimeoutMs: 60_000, + }); + const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + expect(result.ok).toBe(true); + expect(result.lease).toBeDefined(); + expect(result.lease!.deadline).toBeDefined(); + expect(result.lease!.deadline).not.toBeNull(); + + // Deadline should be ~60s from now + const deadline = new Date(result.lease!.deadline!).getTime(); + const now = Date.now(); + expect(deadline - now).toBeGreaterThan(55_000); + expect(deadline - now).toBeLessThan(65_000); + }); + + it('sets no deadline when timeout is 0', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1', { + stageTimeoutMs: 0, + }); + const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + expect(result.ok).toBe(true); + expect(result.lease!.deadline).toBeNull(); + }); +}); + +// ── getAllActiveLeases ──────────────────────────────────────────────────────── + +describe('pipe-store getAllActiveLeases', () => { + it('returns empty map when no leases', () => { + expect(pipeStore.getAllActiveLeases().size).toBe(0); + }); + + it('returns active leases after grant', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1'); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + const leases = pipeStore.getAllActiveLeases(); + expect(leases.size).toBe(1); + const lease = [...leases.values()][0]; + expect(lease.pipeId).toBe('pipe-1'); + expect(lease.assignee).toBe('alice'); + }); + + it('removes lease after submit', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1'); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + pipeStore.submitStage('pipe-1', 'alice', 'output', 'proj-1', true); + expect(pipeStore.getAllActiveLeases().size).toBe(0); + }); +}); + +// ── Terminal pipe cleanup ──────────────────────────────────────────────────── + +describe('pipe-store cleanupTerminalPipes', () => { + it('does not remove running pipes', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1'); + const removed = pipeStore.cleanupTerminalPipes('proj-1', 0); // TTL=0 means remove everything + expect(removed).toEqual([]); + }); + + it('removes completed pipes older than TTL', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1'); + pipeStore.markPipeStatus('pipe-1', 'completed', 'proj-1'); + const removed = pipeStore.cleanupTerminalPipes('proj-1', 0); + expect(removed).toEqual(['pipe-1']); + expect(pipeStore.getPipe('pipe-1', 'proj-1')).toBeUndefined(); + }); + + it('removes failed pipes older than TTL', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1'); + pipeStore.markPipeStatus('pipe-1', 'failed', 'proj-1'); + const removed = pipeStore.cleanupTerminalPipes('proj-1', 0); + expect(removed).toEqual(['pipe-1']); + }); + + it('removes cancelled pipes older than TTL', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1'); + pipeStore.markPipeStatus('pipe-1', 'cancelled', 'proj-1'); + const removed = pipeStore.cleanupTerminalPipes('proj-1', 0); + expect(removed).toEqual(['pipe-1']); + }); + + it('does not remove terminal pipes within TTL', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1'); + pipeStore.markPipeStatus('pipe-1', 'completed', 'proj-1'); + const removed = pipeStore.cleanupTerminalPipes('proj-1', 999_999_999); // huge TTL + expect(removed).toEqual([]); + }); +}); + +// ── Rehydration from events ────────────────────────────────────────────────── + +describe('pipe-store rehydrateFromEvents', () => { + it('recreates a pipe from start event', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { + type: 'start', + pipeId: 'pipe-r1', + mode: 'linear', + assignees: ['alice', 'bob'], + prompt: 'test prompt', + stageTimeoutMs: 60_000, + timeoutPolicy: 'escalate', + }, + ]; + const running = pipeStore.rehydrateFromEvents(events, 'proj-1'); + expect(running).toEqual(['pipe-r1']); + + const pipe = pipeStore.getPipe('pipe-r1', 'proj-1'); + expect(pipe).toBeDefined(); + expect(pipe!.mode).toBe('linear'); + expect(pipe!.status).toBe('running'); + expect(pipe!.assignees).toEqual(['alice', 'bob']); + expect(pipe!.stageTimeoutMs).toBe(60_000); + expect(pipe!.timeoutPolicy).toBe('escalate'); + }); + + it('replays submissions', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { + type: 'start', + pipeId: 'pipe-r2', + mode: 'linear', + assignees: ['alice', 'bob'], + prompt: 'test', + }, + { + type: 'stage-output', + pipeId: 'pipe-r2', + from: 'alice', + content: 'alice output', + }, + ]; + const running = pipeStore.rehydrateFromEvents(events, 'proj-1'); + expect(running).toEqual(['pipe-r2']); + + const pipe = pipeStore.getPipe('pipe-r2', 'proj-1'); + expect(pipe).toBeDefined(); + const aliceSlot = pipe!.slots.get('alice')![0]; + expect(aliceSlot.status).toBe('submitted'); + expect(aliceSlot.content).toBe('alice output'); + }); + + it('marks terminal pipes correctly', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { + type: 'start', + pipeId: 'pipe-r3', + mode: 'merge-all', + assignees: ['alice', 'bob'], + prompt: 'test', + }, + { type: 'complete', pipeId: 'pipe-r3' }, + ]; + const running = pipeStore.rehydrateFromEvents(events, 'proj-1'); + expect(running).toEqual([]); + + const pipe = pipeStore.getPipe('pipe-r3', 'proj-1'); + expect(pipe).toBeDefined(); + expect(pipe!.status).toBe('completed'); + }); + + it('marks failed pipes correctly', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { + type: 'start', + pipeId: 'pipe-r4', + mode: 'linear', + assignees: ['alice', 'bob'], + prompt: 'test', + }, + { type: 'failed', pipeId: 'pipe-r4' }, + ]; + const running = pipeStore.rehydrateFromEvents(events, 'proj-1'); + expect(running).toEqual([]); + + const pipe = pipeStore.getPipe('pipe-r4', 'proj-1'); + expect(pipe!.status).toBe('failed'); + }); + + it('marks cancelled pipes correctly', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { + type: 'start', + pipeId: 'pipe-r5', + mode: 'linear', + assignees: ['alice', 'bob'], + prompt: 'test', + }, + { type: 'cancel', pipeId: 'pipe-r5' }, + ]; + const running = pipeStore.rehydrateFromEvents(events, 'proj-1'); + expect(running).toEqual([]); + + const pipe = pipeStore.getPipe('pipe-r5', 'proj-1'); + expect(pipe!.status).toBe('cancelled'); + }); + + it('skips events without a start event', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'stage-output', pipeId: 'pipe-orphan', from: 'alice', content: 'output' }, + ]; + const running = pipeStore.rehydrateFromEvents(events, 'proj-1'); + expect(running).toEqual([]); + expect(pipeStore.getPipe('pipe-orphan', 'proj-1')).toBeUndefined(); + }); + + it('handles multiple pipes in one batch', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'p1', mode: 'linear', assignees: ['alice', 'bob'], prompt: 'first' }, + { type: 'start', pipeId: 'p2', mode: 'merge-all', assignees: ['alice', 'bob'], prompt: 'second' }, + { type: 'stage-output', pipeId: 'p1', from: 'alice', content: 'done' }, + { type: 'complete', pipeId: 'p2' }, + ]; + const running = pipeStore.rehydrateFromEvents(events, 'proj-1'); + expect(running).toEqual(['p1']); + + expect(pipeStore.getPipe('p1', 'proj-1')!.status).toBe('running'); + expect(pipeStore.getPipe('p2', 'proj-1')!.status).toBe('completed'); + }); + + it('does not duplicate pipes already in store', () => { + pipeStore.createPipe('existing', 'linear', ['alice', 'bob'], 'already here', 'proj-1'); + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'existing', mode: 'linear', assignees: ['alice', 'bob'], prompt: 'duplicate' }, + ]; + const running = pipeStore.rehydrateFromEvents(events, 'proj-1'); + expect(running).toEqual([]); + // Original prompt should be preserved + expect(pipeStore.getPipe('existing', 'proj-1')!.prompt).toBe('already here'); + }); +}); + +// ── Emission state rebuild on recovery ─────────────────────────────────────── + +describe('pipe-store emission rebuild on recovery', () => { + it('rebuilds linear emission state: submitted stage-1 marks handoff-1 as emitted', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'lr1', mode: 'linear', assignees: ['alice', 'bob', 'carol'], prompt: 'test' }, + { type: 'stage-output', pipeId: 'lr1', from: 'alice', content: 'alice output' }, + ]; + pipeStore.rehydrateFromEvents(events, 'proj-1'); + const pipe = pipeStore.getPipe('lr1', 'proj-1')!; + // Stage 1 (alice) was submitted, so handoff for stage 1 was emitted + expect(pipe.emittedHandoffs.has(1)).toBe(true); + // Handoff for stage 2 has NOT been emitted yet (bob hasn't started) + expect(pipe.emittedHandoffs.has(2)).toBe(false); + }); + + it('rebuilds linear emission state: two submitted stages mark handoffs 1 and 2 as emitted', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'lr2', mode: 'linear', assignees: ['alice', 'bob', 'carol'], prompt: 'test' }, + { type: 'stage-output', pipeId: 'lr2', from: 'alice', content: 'alice output' }, + { type: 'stage-output', pipeId: 'lr2', from: 'bob', content: 'bob output' }, + ]; + pipeStore.rehydrateFromEvents(events, 'proj-1'); + const pipe = pipeStore.getPipe('lr2', 'proj-1')!; + expect(pipe.emittedHandoffs.has(1)).toBe(true); + expect(pipe.emittedHandoffs.has(2)).toBe(true); + expect(pipe.emittedHandoffs.has(3)).toBe(false); + }); + + it('rebuilds merge-all emission state: submitted fan-out marks fan-out request as emitted', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'mr1', mode: 'merge-all', assignees: ['alice', 'bob'], prompt: 'test' }, + { type: 'stage-output', pipeId: 'mr1', from: 'alice', content: 'alice analysis' }, + ]; + pipeStore.rehydrateFromEvents(events, 'proj-1'); + const pipe = pipeStore.getPipe('mr1', 'proj-1')!; + expect(pipe.emittedFanOutRequests.has('alice')).toBe(true); + // Bob's fan-out was not submitted, but it should still be marked if we're recovering + // (the fan-out request was sent to both participants at pipe start) + // Actually, bob's fan-out slot is still pending, so the request may or may not have been emitted. + // The safest approach: bob's fan-out is pending, so it's NOT marked as emitted. + // The reducer will re-emit it, which is correct — bob didn't respond. + expect(pipe.emittedFanOutRequests.has('bob')).toBe(false); + expect(pipe.emittedSynthRequest).toBe(false); + }); + + it('rebuilds merge-all emission state: all fan-outs submitted but synth not started', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'mr2', mode: 'merge-all', assignees: ['alice', 'bob'], prompt: 'test' }, + { type: 'stage-output', pipeId: 'mr2', from: 'alice', content: 'alice analysis' }, + // bob is the synthesizer in merge-all, his fan-out slot submitted too + { type: 'stage-output', pipeId: 'mr2', from: 'bob', content: 'bob analysis' }, + ]; + pipeStore.rehydrateFromEvents(events, 'proj-1'); + const pipe = pipeStore.getPipe('mr2', 'proj-1')!; + expect(pipe.emittedFanOutRequests.has('alice')).toBe(true); + expect(pipe.emittedFanOutRequests.has('bob')).toBe(true); + // All fan-outs submitted but the final (synth) slot for bob is still pending + // So synth request has NOT been emitted yet + expect(pipe.emittedSynthRequest).toBe(false); + }); + + it('rebuilds no emissions for a pipe with no submissions', () => { + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'empty1', mode: 'linear', assignees: ['alice', 'bob'], prompt: 'test' }, + ]; + pipeStore.rehydrateFromEvents(events, 'proj-1'); + const pipe = pipeStore.getPipe('empty1', 'proj-1')!; + expect(pipe.emittedHandoffs.size).toBe(0); + expect(pipe.emittedFanOutRequests.size).toBe(0); + expect(pipe.emittedSynthRequest).toBe(false); + }); + + it('linear recovery resumes at correct stage (does not re-emit submitted handoffs)', () => { + // Simulate: 3-stage linear pipe where stage 1 (alice) already submitted + // After recovery, the reducer should only emit handoff for stage 2, not stage 1 + const events: pipeStore.PipeRecoveryEvent[] = [ + { type: 'start', pipeId: 'resume1', mode: 'linear', assignees: ['alice', 'bob', 'carol'], prompt: 'test' }, + { type: 'stage-output', pipeId: 'resume1', from: 'alice', content: 'alice done' }, + ]; + pipeStore.rehydrateFromEvents(events, 'proj-1'); + const pipe = pipeStore.getPipe('resume1', 'proj-1')!; + + // Verify emission state prevents duplicate handoffs + expect(pipe.emittedHandoffs.has(1)).toBe(true); // stage 1 already happened + expect(pipe.emittedHandoffs.has(2)).toBe(false); // stage 2 not yet + + // Now simulate what the reducer would do + const state = pipeReducer.buildStateFromStore(pipe); + const actions = pipeReducer.computeNextActions(state); + + // Should emit exactly one action: handoff for stage 2 (bob) + expect(actions).toHaveLength(1); + expect(actions[0].targetAssignee).toBe('bob'); + expect(actions[0].type).toBe('handoff'); + expect(actions[0].stage).toBe(2); + }); +}); diff --git a/src/apps/chat/services/pipe-sidebar-monitor.test.ts b/src/apps/chat/services/pipe-sidebar-monitor.test.ts new file mode 100644 index 0000000..b019abf --- /dev/null +++ b/src/apps/chat/services/pipe-sidebar-monitor.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as pipeStore from './pipe-store.js'; +import * as provenanceStore from './pipe-provenance.js'; + +/** + * Focused verification for the pipe monitoring sidebar. + * + * Tests the data layer patterns that the sidebar UI relies on: + * - Initial load (listAllPipes + getRuntimeLeaseStatuses + getDeadLetterEntries) + * - Drilldown (getPipeStatus + getPipeTimingSummary) + * - Cancel action (cancelPipe + status transition) + * - Representative render states (running, completed, failed, cancelled, dead-lettered) + */ + +const projectId = 'sidebar-test'; + +beforeEach(() => { + pipeStore._resetForTest(); + provenanceStore._resetForTest(); +}); + +// ── Initial Load ───────────────────────────────────────────────────────────── + +describe('Sidebar initial load', () => { + it('returns all pipes with slot summaries for the pipe list', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'prompt1', projectId); + pipeStore.createPipe('p2', 'merge', ['alice', 'bob', 'carol'], 'prompt2', projectId); + + const all = pipeStore.listAllPipes(projectId); + expect(all).toHaveLength(2); + + const p1 = all.find(p => p.pipeId === 'p1')!; + expect(p1.mode).toBe('linear'); + expect(p1.status).toBe('running'); + expect(p1.slotSummary).toBeDefined(); + expect(p1.slotSummary.total).toBe(2); + expect(p1.slotSummary.pending).toBe(2); + expect(p1.slotSummary.submitted).toBe(0); + + const p2 = all.find(p => p.pipeId === 'p2')!; + expect(p2.mode).toBe('merge'); + expect(p2.slotSummary.total).toBe(3); + }); + + it('returns lease statuses for countdown badges', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId, { + stageTimeoutMs: 60000, + }); + pipeStore.grantLease('p1', 'alice', projectId); + + const leases = pipeStore.getRuntimeLeaseStatuses(projectId); + expect(leases).toHaveLength(1); + expect(leases[0]).toMatchObject({ + pipeId: 'p1', + assignee: 'alice', + isOverdue: false, + }); + expect(leases[0].deadline).toBeDefined(); + expect(leases[0].remainingMs).toBeGreaterThan(0); + expect(leases[0].elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it('returns empty dead-letters for healthy pipes', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId, { + stageTimeoutMs: 300000, + }); + pipeStore.grantLease('p1', 'alice', projectId); + expect(pipeStore.getDeadLetterEntries(projectId)).toHaveLength(0); + }); + + it('isolates pipe data by project', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId); + pipeStore.createPipe('p2', 'linear', ['bob'], 'test', 'other-project'); + + expect(pipeStore.listAllPipes(projectId)).toHaveLength(1); + expect(pipeStore.listAllPipes('other-project')).toHaveLength(1); + }); +}); + +// ── Drilldown ──────────────────────────────────────────────────────────────── + +describe('Sidebar drilldown', () => { + it('returns detailed pipe status with slots and prompt', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'my prompt', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + + const status = pipeStore.getPipeStatus('p1', projectId); + expect(status).toBeDefined(); + expect(status!.prompt).toBe('my prompt'); + expect(status!.slots).toHaveLength(2); + expect(status!.assignees).toEqual(['alice', 'bob']); + + const aliceSlot = status!.slots.find(s => s.assignee === 'alice'); + expect(aliceSlot?.role).toBe('stage-output'); + expect(aliceSlot?.status).toBe('leased'); + + const bobSlot = status!.slots.find(s => s.assignee === 'bob'); + expect(bobSlot?.status).toBe('pending'); + }); + + it('returns timing summary for completed pipes', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + pipeStore.submitStage('p1', 'alice', 'output-a', projectId, true); + pipeStore.grantLease('p1', 'bob', projectId); + pipeStore.submitStage('p1', 'bob', 'output-b', projectId, true); + pipeStore.markPipeStatus('p1', 'completed', projectId); + + const timing = pipeStore.getPipeTimingSummary('p1', projectId); + expect(timing).toBeDefined(); + expect(timing!.status).toBe('completed'); + expect(timing!.totalDurationMs).toBeGreaterThanOrEqual(0); + expect(timing!.stages).toHaveLength(2); + expect(timing!.stages.every(s => s.submittedAt !== null)).toBe(true); + expect(timing!.stages.every(s => s.durationMs === null || typeof s.durationMs === 'number')).toBe(true); + }); + + it('returns undefined for non-existent pipe', () => { + expect(pipeStore.getPipeStatus('no-such-pipe', projectId)).toBeUndefined(); + expect(pipeStore.getPipeTimingSummary('no-such-pipe', projectId)).toBeUndefined(); + }); +}); + +// ── Cancel Action ──────────────────────────────────────────────────────────── + +describe('Sidebar cancel action', () => { + it('transitions pipe from running to cancelled', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + + pipeStore.markPipeStatus('p1', 'cancelled', projectId); + + const all = pipeStore.listAllPipes(projectId); + expect(all[0].status).toBe('cancelled'); + + // Leases should be cleared after cancellation + const leases = pipeStore.getRuntimeLeaseStatuses(projectId); + expect(leases).toHaveLength(0); + }); + + it('cancel is idempotent for already-terminal pipes', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId); + pipeStore.markPipeStatus('p1', 'completed', projectId); + + // Re-marking as cancelled on an already-completed pipe should not crash + pipeStore.markPipeStatus('p1', 'cancelled', projectId); + const all = pipeStore.listAllPipes(projectId); + expect(all[0].status).toBe('cancelled'); + }); +}); + +// ── Representative Render States ───────────────────────────────────────────── + +describe('Sidebar render states', () => { + it('running pipe: has leases, pending slots, no timing', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId, { + stageTimeoutMs: 60000, + }); + pipeStore.grantLease('p1', 'alice', projectId); + + const all = pipeStore.listAllPipes(projectId); + expect(all[0].status).toBe('running'); + expect(all[0].slotSummary.leased).toBe(1); + expect(all[0].slotSummary.pending).toBe(1); + + const leases = pipeStore.getRuntimeLeaseStatuses(projectId); + expect(leases.length).toBeGreaterThan(0); + + // Timing not yet available (pipe still running) + const timing = pipeStore.getPipeTimingSummary('p1', projectId); + expect(timing!.completedAt).toBeNull(); + expect(timing!.totalDurationMs).toBeNull(); + }); + + it('completed pipe: all slots submitted, timing available', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + pipeStore.submitStage('p1', 'alice', 'done', projectId, true); + pipeStore.markPipeStatus('p1', 'completed', projectId); + + const all = pipeStore.listAllPipes(projectId); + expect(all[0].status).toBe('completed'); + expect(all[0].slotSummary.submitted).toBe(1); + + const timing = pipeStore.getPipeTimingSummary('p1', projectId); + expect(timing!.completedAt).toBeDefined(); + expect(timing!.totalDurationMs).toBeGreaterThanOrEqual(0); + + // No active leases + expect(pipeStore.getRuntimeLeaseStatuses(projectId)).toHaveLength(0); + }); + + it('failed pipe: status transitions, timing captures partial work', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + pipeStore.submitStage('p1', 'alice', 'partial', projectId, true); + pipeStore.markPipeStatus('p1', 'failed', projectId); + + const all = pipeStore.listAllPipes(projectId); + expect(all[0].status).toBe('failed'); + + const timing = pipeStore.getPipeTimingSummary('p1', projectId); + expect(timing!.status).toBe('failed'); + + // Alice submitted, bob never did + const aliceStage = timing!.stages.find(s => s.assignee === 'alice'); + const bobStage = timing!.stages.find(s => s.assignee === 'bob'); + expect(aliceStage?.submittedAt).not.toBeNull(); + expect(bobStage?.submittedAt).toBeNull(); + }); + + it('cancelled pipe: visible in pipe list with cancelled status', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId); + pipeStore.markPipeStatus('p1', 'cancelled', projectId); + + const all = pipeStore.listAllPipes(projectId); + expect(all[0].status).toBe('cancelled'); + }); + + it('merge pipe with partial fan-out: slot summary reflects progress', () => { + pipeStore.createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + pipeStore.grantLease('p1', 'bob', projectId); + pipeStore.submitStage('p1', 'alice', 'alice-out', projectId, true); + + const all = pipeStore.listAllPipes(projectId); + const p = all[0]; + expect(p.slotSummary.submitted).toBe(1); + expect(p.slotSummary.leased).toBeGreaterThanOrEqual(1); + expect(p.slotSummary.total).toBe(3); // alice(fan-out) + bob(fan-out) + carol(final) + }); +}); + +// ── Event-Driven Refresh ───────────────────────────────────────────────────── + +describe('Sidebar refresh on state changes', () => { + it('slot summary updates after submission', () => { + pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + + let before = pipeStore.listAllPipes(projectId)[0]; + expect(before.slotSummary.submitted).toBe(0); + + pipeStore.submitStage('p1', 'alice', 'output', projectId, true); + + let after = pipeStore.listAllPipes(projectId)[0]; + expect(after.slotSummary.submitted).toBe(1); + }); + + it('pipe status reflects completion in list', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId); + pipeStore.grantLease('p1', 'alice', projectId); + pipeStore.submitStage('p1', 'alice', 'done', projectId, true); + + let all = pipeStore.listAllPipes(projectId); + expect(all[0].status).toBe('running'); + + pipeStore.markPipeStatus('p1', 'completed', projectId); + + all = pipeStore.listAllPipes(projectId); + expect(all[0].status).toBe('completed'); + }); + + it('leases disappear after submission', () => { + pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId, { + stageTimeoutMs: 60000, + }); + pipeStore.grantLease('p1', 'alice', projectId); + expect(pipeStore.getRuntimeLeaseStatuses(projectId)).toHaveLength(1); + + pipeStore.submitStage('p1', 'alice', 'done', projectId, true); + expect(pipeStore.getRuntimeLeaseStatuses(projectId)).toHaveLength(0); + }); +}); + +// ── Provenance (optional for MVP — verify basic availability) ──────────────── + +describe('Sidebar provenance', () => { + it('provenance records are queryable per pipe', () => { + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user', + }); + provenanceStore.recordProvenance(projectId, { + pipeId: 'p1', event: 'stage-granted', actor: 'system', actorKind: 'system', + stage: 1, metadata: { assignee: 'alice' }, + }); + + const records = provenanceStore.getProvenanceForPipe('p1', projectId); + expect(records).toHaveLength(2); + expect(records[0].event).toBe('created'); + expect(records[1].event).toBe('stage-granted'); + }); +}); diff --git a/src/apps/chat/services/pipe-store.test.ts b/src/apps/chat/services/pipe-store.test.ts new file mode 100644 index 0000000..cb3d75a --- /dev/null +++ b/src/apps/chat/services/pipe-store.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as pipeStore from './pipe-store.js'; +import * as assignmentQueries from './pipe-assignment-queries.js'; + +beforeEach(() => { + pipeStore._resetForTest(); +}); + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function createLinearPipe(assignees = ['alice', 'bob', 'carol']) { + return pipeStore.createPipe('pipe-1', 'linear', assignees, 'test prompt', 'proj-1'); +} + +function createMergePipe(assignees = ['alice', 'bob', 'carol']) { + return pipeStore.createPipe('pipe-2', 'merge', assignees, 'test prompt', 'proj-1'); +} + +// ── createPipe ──────────────────────────────────────────────────────────────── + +describe('pipe-store createPipe', () => { + it('creates a linear pipe with correct slots', () => { + const pipe = createLinearPipe(); + expect(pipe.pipeId).toBe('pipe-1'); + expect(pipe.mode).toBe('linear'); + expect(pipe.status).toBe('running'); + expect(pipe.slots.size).toBe(3); + + const alice = pipe.slots.get('alice')![0]; + expect(alice.role).toBe('stage-output'); + expect(alice.stage).toBe(1); + expect(alice.status).toBe('pending'); + + const bob = pipe.slots.get('bob')![0]; + expect(bob.role).toBe('stage-output'); + expect(bob.stage).toBe(2); + + const carol = pipe.slots.get('carol')![0]; + expect(carol.role).toBe('final'); + expect(carol.stage).toBe(3); + }); + + it('creates a merge pipe with fan-out and synthesizer slots', () => { + const pipe = createMergePipe(); + expect(pipe.mode).toBe('merge'); + expect(pipe.slots.size).toBe(3); + + expect(pipe.slots.get('alice')![0].role).toBe('fan-out'); + expect(pipe.slots.get('bob')![0].role).toBe('fan-out'); + expect(pipe.slots.get('carol')![0].role).toBe('final'); + }); + + it('creates a merge-all pipe with fan-out for everyone and synthesizer role for last', () => { + const pipe = pipeStore.createPipe('pipe-3', 'merge-all', ['alice', 'bob'], 'test', 'proj-1'); + expect(pipe.mode).toBe('merge-all'); + expect(pipe.slots.size).toBe(2); + + const alice = pipe.slots.get('alice')!; + expect(alice).toHaveLength(1); + expect(alice[0].role).toBe('fan-out'); + + const bob = pipe.slots.get('bob')!; + expect(bob).toHaveLength(2); + expect(bob[0].role).toBe('fan-out'); + expect(bob[1].role).toBe('final'); + }); + + it('creates an explain pipe with merge-all style slots', () => { + const pipe = pipeStore.createPipe('pipe-4', 'explain', ['alice', 'bob'], 'teach this', 'proj-1'); + expect(pipe.mode).toBe('explain'); + expect(pipe.slots.get('alice')?.map((slot) => slot.role)).toEqual(['fan-out']); + expect(pipe.slots.get('bob')?.map((slot) => slot.role)).toEqual(['fan-out', 'final']); + }); + + it('creates a summarize pipe with merge-all style slots', () => { + const pipe = pipeStore.createPipe('pipe-5', 'summarize', ['alice', 'bob'], 'digest this', 'proj-1'); + expect(pipe.mode).toBe('summarize'); + expect(pipe.slots.get('alice')?.map((slot) => slot.role)).toEqual(['fan-out']); + expect(pipe.slots.get('bob')?.map((slot) => slot.role)).toEqual(['fan-out', 'final']); + }); +}); + +// ── Lease management ────────────────────────────────────────────────────────── + +describe('pipe-store lease management', () => { + it('grants a lease to an assignee', () => { + createLinearPipe(); + const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + expect(result.ok).toBe(true); + expect(result.lease?.assignee).toBe('alice'); + expect(result.lease?.pipeId).toBe('pipe-1'); + + const lease = pipeStore.getActiveLease('alice', 'proj-1'); + expect(lease?.pipeId).toBe('pipe-1'); + }); + + it('rejects lease for a different pipe when one is already held', () => { + createLinearPipe(); + pipeStore.createPipe('pipe-other', 'linear', ['alice', 'bob'], 'other', 'proj-1'); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + + const result = pipeStore.grantLease('pipe-other', 'alice', 'proj-1'); + expect(result.ok).toBe(false); + expect(result.error).toContain('already holds a lease'); + }); + + it('allows re-granting for the same pipe (idempotent)', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + expect(result.ok).toBe(true); + }); + + it('rejects lease for non-assignee', () => { + createLinearPipe(); + const result = pipeStore.grantLease('pipe-1', 'stranger', 'proj-1'); + expect(result.ok).toBe(false); + expect(result.error).toContain('not an assignee'); + }); + + it('rejects lease for already-submitted slot', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + pipeStore.submitStage('pipe-1', 'alice', 'output', 'proj-1', true); + + const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + expect(result.ok).toBe(false); + expect(result.error).toContain('has no pending tasks'); + }); + + it('releases a lease', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + pipeStore.releaseLease('alice', 'proj-1'); + expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeUndefined(); + }); +}); + +// ── Stage submission ────────────────────────────────────────────────────────── + +describe('pipe-store submitStage', () => { + it('accepts submission with valid lease', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + const result = pipeStore.submitStage('pipe-1', 'alice', 'my output', 'proj-1', true); + expect(result.ok).toBe(true); + expect(result.slot?.status).toBe('submitted'); + expect(result.slot?.content).toBe('my output'); + // Lease should be released after submission + expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeUndefined(); + }); + + it('rejects submission without lease when requireLease=true', () => { + createLinearPipe(); + // Don't grant a lease + const result = pipeStore.submitStage('pipe-1', 'alice', 'output', 'proj-1', true); + expect(result.ok).toBe(false); + expect(result.error).toContain('does not hold a lease'); + }); + + it('rejects double submission', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + pipeStore.submitStage('pipe-1', 'alice', 'first', 'proj-1', true); + + const result = pipeStore.submitStage('pipe-1', 'alice', 'second', 'proj-1', false); + expect(result.ok).toBe(false); + expect(result.error).toContain('already submitted'); + }); + + it('rejects submission for non-running pipe', () => { + createLinearPipe(); + pipeStore.markPipeStatus('pipe-1', 'cancelled', 'proj-1'); + const result = pipeStore.submitStage('pipe-1', 'alice', 'output', 'proj-1', false); + expect(result.ok).toBe(false); + expect(result.error).toContain('cancelled'); + }); + + it('rejects submission from non-assignee', () => { + createLinearPipe(); + const result = pipeStore.submitStage('pipe-1', 'stranger', 'output', 'proj-1', false); + expect(result.ok).toBe(false); + expect(result.error).toContain('not an assignee'); + }); + + it('merge-all synthesizer submits fan-out before final', () => { + pipeStore.createPipe('pipe-3', 'merge-all', ['alice', 'bob'], 'test', 'proj-1'); + + const firstLease = pipeStore.grantLease('pipe-3', 'bob', 'proj-1'); + expect(firstLease.ok).toBe(true); + expect(firstLease.lease?.slotRole).toBe('fan-out'); + + const firstSubmit = pipeStore.submitStage('pipe-3', 'bob', 'blind analysis', 'proj-1', true); + expect(firstSubmit.ok).toBe(true); + expect(firstSubmit.slot?.role).toBe('fan-out'); + + const secondLease = pipeStore.grantLease('pipe-3', 'bob', 'proj-1'); + expect(secondLease.ok).toBe(true); + expect(secondLease.lease?.slotRole).toBe('final'); + + const secondSubmit = pipeStore.submitStage('pipe-3', 'bob', 'merged answer', 'proj-1', true); + expect(secondSubmit.ok).toBe(true); + expect(secondSubmit.slot?.role).toBe('final'); + }); +}); + +// ── Queries ─────────────────────────────────────────────────────────────────── + +describe('pipe-store queries', () => { + it('getStageOutput returns submitted content by stage number', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + pipeStore.submitStage('pipe-1', 'alice', 'stage 1 output', 'proj-1', true); + + const output = pipeStore.getStageOutput('pipe-1', 1, 'proj-1'); + expect(output).toEqual({ from: 'alice', body: 'stage 1 output' }); + + // Stage 2 not yet submitted + expect(pipeStore.getStageOutput('pipe-1', 2, 'proj-1')).toBeUndefined(); + }); + + it('getFanOutOutputs returns all submitted fan-out content', () => { + createMergePipe(); + pipeStore.grantLease('pipe-2', 'alice', 'proj-1'); + pipeStore.submitStage('pipe-2', 'alice', 'alice output', 'proj-1', true); + pipeStore.grantLease('pipe-2', 'bob', 'proj-1'); + pipeStore.submitStage('pipe-2', 'bob', 'bob output', 'proj-1', true); + + const outputs = pipeStore.getFanOutOutputs('pipe-2', 'proj-1'); + expect(outputs.size).toBe(2); + expect(outputs.get('alice')).toBe('alice output'); + expect(outputs.get('bob')).toBe('bob output'); + }); + + it('getPipeStatus returns full pipe summary', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + + const status = pipeStore.getPipeStatus('pipe-1', 'proj-1'); + expect(status).toBeDefined(); + expect(status!.pipeId).toBe('pipe-1'); + expect(status!.slots).toHaveLength(3); + expect(status!.leases).toHaveLength(1); + expect(status!.leases[0].assignee).toBe('alice'); + }); +}); + +// ── Pending pipe queue ──────────────────────────────────────────────────────── + +describe('pipe-store pending pipe queue', () => { + it('tracks pending pipes for lease conflicts', () => { + createLinearPipe(); + pipeStore.createPipe('pipe-other', 'linear', ['alice', 'bob'], 'other', 'proj-1'); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + + // pipe-other can't get a lease for alice — add to pending + pipeStore.addPendingPipe('alice', 'proj-1', 'pipe-other'); + + // Pop returns the pending pipe + const pending = pipeStore.popPendingPipes('alice', 'proj-1'); + expect(pending).toEqual(['pipe-other']); + + // Second pop returns empty + expect(pipeStore.popPendingPipes('alice', 'proj-1')).toEqual([]); + }); + + it('returns empty array when no pending pipes', () => { + expect(pipeStore.popPendingPipes('nobody', 'proj-1')).toEqual([]); + }); +}); + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +describe('pipe-store lifecycle', () => { + it('markPipeStatus returns released assignees so callers can drain pending queues', () => { + createLinearPipe(); + pipeStore.createPipe('pipe-blocked', 'linear', ['alice', 'bob'], 'blocked', 'proj-1'); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + + // pipe-blocked is queued behind alice's lease on pipe-1 + pipeStore.addPendingPipe('alice', 'proj-1', 'pipe-blocked'); + + // Cancel pipe-1 — alice's lease is released + const released = pipeStore.markPipeStatus('pipe-1', 'cancelled', 'proj-1'); + expect(released).toContain('alice'); + expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeUndefined(); + + // Caller can now drain pending pipes for the released assignees + const pending = pipeStore.popPendingPipes('alice', 'proj-1'); + expect(pending).toEqual(['pipe-blocked']); + + // pipe-blocked can now get alice's lease + const leaseResult = pipeStore.grantLease('pipe-blocked', 'alice', 'proj-1'); + expect(leaseResult.ok).toBe(true); + }); + + it('markPipeStatus on failure also enables pending drain', () => { + createLinearPipe(); + pipeStore.createPipe('pipe-waiting', 'linear', ['alice', 'bob'], 'waiting', 'proj-1'); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + pipeStore.addPendingPipe('alice', 'proj-1', 'pipe-waiting'); + + const released = pipeStore.markPipeStatus('pipe-1', 'failed', 'proj-1'); + expect(released).toContain('alice'); + + const pending = pipeStore.popPendingPipes('alice', 'proj-1'); + expect(pending).toEqual(['pipe-waiting']); + }); + + it('markPipeStatus releases all leases on terminal status', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeDefined(); + + pipeStore.markPipeStatus('pipe-1', 'failed', 'proj-1'); + expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeUndefined(); + + const pipe = pipeStore.getPipe('pipe-1', 'proj-1'); + expect(pipe?.status).toBe('failed'); + }); + + it('project isolation: pipes in different projects are independent', () => { + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'prompt', 'proj-1'); + pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'prompt', 'proj-2'); + + // Grant lease in proj-1 + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + + // Can still grant in proj-2 (different project, no conflict) + const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-2'); + expect(result.ok).toBe(true); + }); +}); + +// ── Lease-aware authorization (claude-15) ──────────────────────────────────── + +describe('pipe-store lease expiry enforcement', () => { + it('isLeaseExpired returns false when no deadline', () => { + pipeStore.createPipe('pipe-no-timeout', 'linear', ['alice', 'bob'], 'test', 'proj-1', { stageTimeoutMs: 0 }); + const result = pipeStore.grantLease('pipe-no-timeout', 'alice', 'proj-1'); + expect(result.ok).toBe(true); + expect(result.lease!.deadline).toBeNull(); + expect(pipeStore.isLeaseExpired(result.lease!)).toBe(false); + }); + + it('isLeaseExpired returns true when past deadline', () => { + createLinearPipe(); + const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + const farFuture = Date.now() + 10 * 60 * 1000; + expect(pipeStore.isLeaseExpired(result.lease!, farFuture)).toBe(true); + }); + + it('submitStage accepts submission with active lease', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + const result = pipeStore.submitStage('pipe-1', 'alice', 'timely output', 'proj-1', true); + expect(result.ok).toBe(true); + }); +}); + +describe('pipe-store assignment queries', () => { + it('getAssignmentsForParticipant returns slots', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + const a = assignmentQueries.getAssignmentsForParticipant('alice', 'proj-1'); + expect(a).toHaveLength(1); + expect(a[0].leaseStatus).toBe('active'); + }); + + it('getAssignmentForPipe returns details', () => { + createLinearPipe(); + pipeStore.grantLease('pipe-1', 'alice', 'proj-1'); + const a = assignmentQueries.getAssignmentForPipe('pipe-1', 'alice', 'proj-1'); + expect(a).toBeDefined(); + expect(a!.leaseStatus).toBe('active'); + }); + + it('getAssignmentForPipe returns undefined for non-assignee', () => { + createLinearPipe(); + expect(assignmentQueries.getAssignmentForPipe('pipe-1', 'stranger', 'proj-1')).toBeUndefined(); + }); +}); diff --git a/src/apps/chat/services/pipe-store.ts b/src/apps/chat/services/pipe-store.ts new file mode 100644 index 0000000..f9ea3ca --- /dev/null +++ b/src/apps/chat/services/pipe-store.ts @@ -0,0 +1,995 @@ +import { createHash } from 'crypto'; +import type { PipeMode, PipeStatus, PipeTimeoutPolicy, StageTiming, PipeTimingSummary, DeadLetterEntry, RuntimeLeaseStatus } from '../types.js'; +import { systemClock, type Clock } from './clock.js'; +import { getExhaustedDeliveries } from './pipe-delivery.js'; + +let _pipeClock: Clock = systemClock; +export function _setObsClockForTest(c: Clock): void { _pipeClock = c; } + + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface PipeSlot { + assignee: string; + role: 'stage-output' | 'fan-out' | 'final'; + stage?: number; // 1-indexed, for linear pipes + status: 'pending' | 'leased' | 'submitted'; + content: string | null; + submittedAt: string | null; +} + +export interface StoredPipe { + pipeId: string; + mode: PipeMode; + assignees: string[]; + prompt: string; + status: PipeStatus; + slots: Map; // keyed by assignee name + createdAt: string; + // Emission tracking — replaces log scanning for reducer idempotency + emittedHandoffs: Set; // linear stage numbers that have been delivered + emittedFanOutRequests: Set; // assignee names that received fan-out requests + emittedSynthRequest: boolean; // whether synth-request has been sent + // Stage timeout configuration + stageTimeoutMs: number; // per-stage deadline in milliseconds (0 = no timeout) + timeoutPolicy: PipeTimeoutPolicy; // what to do when a stage times out +} + +export interface LeaseInfo { + pipeId: string; + assignee: string; + slotRole: string; + stage?: number; + grantedAt: string; + deadline: string | null; // ISO timestamp when this lease expires (null = no deadline) +} + +export type PipeErrorCode = + | 'PIPE_NOT_FOUND' + | 'PIPE_CLOSED' + | 'PIPE_NOT_ASSIGNED' + | 'PIPE_LEASE_NOT_HELD' + | 'PIPE_LEASE_EXPIRED' + | 'PIPE_ALREADY_SUBMITTED' + | 'PIPE_LEASE_CONFLICT'; + +export interface SubmitResult { + ok: boolean; + error?: string; + code?: PipeErrorCode; + slot?: PipeSlot; + pipe?: { pipeId: string; mode: PipeMode; status: PipeStatus }; +} + +export interface StageInputSource { + from: string; + content: string; +} + +export interface StageInputPayload { + role: 'prompt' | 'upstream-output' | 'fan-out-prompt' | 'fan-out-outputs'; + content: string | null; + contentHash: string | null; + contentVersion: number; + stage?: number; + totalStages?: number; + assignee: string; + prompt: string; + sources?: StageInputSource[]; +} + +export type StageInputResult = + | { ok: true; input: StageInputPayload } + | { ok: false; code: PipeErrorCode; error: string }; + +// ── Storage ─────────────────────────────────────────────────────────────────── + +// projectId -> (pipeId -> StoredPipe) +const stores = new Map>(); + +// "projectId:assigneeName" -> LeaseInfo (one active lease per participant) +const activeLeases = new Map(); + +// "projectId:assigneeName" -> Set (pipes waiting for this participant's lease to release) +const pendingPipes = new Map>(); + +// "projectId:assigneeName" -> Set (index of active/running pipes per participant) +const activePipeIndex = new Map>(); + +function addToActivePipeIndex(assignee: string, projectId: string | null, pipeId: string): void { + const key = leaseKey(assignee, projectId); + let pipeIds = activePipeIndex.get(key); + if (!pipeIds) { pipeIds = new Set(); activePipeIndex.set(key, pipeIds); } + pipeIds.add(pipeId); +} + +function removeFromActivePipeIndex(assignee: string, projectId: string | null, pipeId: string): void { + const key = leaseKey(assignee, projectId); + const pipeIds = activePipeIndex.get(key); + if (pipeIds) { + pipeIds.delete(pipeId); + if (pipeIds.size === 0) activePipeIndex.delete(key); + } +} + +/** Get all running pipe IDs for a participant (O(1) lookup). */ +export function getActivePipesForParticipant(assignee: string, projectId: string | null): string[] { + const key = leaseKey(assignee, projectId); + const pipeIds = activePipeIndex.get(key); + return pipeIds ? [...pipeIds] : []; +} + +function leaseKey(assignee: string, projectId: string | null): string { + return `${projectId ?? '__none__'}:${assignee}`; +} + +/** Get all projectIds that have pipe data in the store. */ +export function getTrackedProjectIds(): Array { + return [...stores.keys()]; +} + +function getProjectStore(projectId: string | null): Map { + let store = stores.get(projectId); + if (!store) { + store = new Map(); + stores.set(projectId, store); + } + return store; +} + +function computeContentHash(content: string): string { + return createHash('sha256').update(content, 'utf8').digest('hex'); +} + +// ── Pipe lifecycle ──────────────────────────────────────────────────────────── + +export const DEFAULT_STAGE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes + +/** Create a new pipe in the store with slots for each assignee. */ +export function createPipe( + pipeId: string, + mode: PipeMode, + assignees: string[], + prompt: string, + projectId: string | null, + opts?: { stageTimeoutMs?: number; timeoutPolicy?: PipeTimeoutPolicy }, +): StoredPipe { + const store = getProjectStore(projectId); + const slots = new Map(); + + if (mode === 'linear') { + for (let i = 0; i < assignees.length; i++) { + const isLast = i === assignees.length - 1; + slots.set(assignees[i], [{ + assignee: assignees[i], + role: isLast ? 'final' : 'stage-output', + stage: i + 1, + status: 'pending', + content: null, + submittedAt: null, + }]); + } + } else if (mode === 'merge') { + // merge: fan-out assignees + last one is synthesizer + const fanOutAssignees = assignees.slice(0, -1); + const synthesizer = assignees[assignees.length - 1]; + for (const a of fanOutAssignees) { + slots.set(a, [{ + assignee: a, + role: 'fan-out', + status: 'pending', + content: null, + submittedAt: null, + }]); + } + slots.set(synthesizer, [{ + assignee: synthesizer, + role: 'final', + status: 'pending', + content: null, + submittedAt: null, + }]); + } else { + // merge-all style: ALL assignees get fan-out + last one gets final + for (let i = 0; i < assignees.length; i++) { + const a = assignees[i]; + const isLast = i === assignees.length - 1; + const assigneeSlots: PipeSlot[] = [{ + assignee: a, + role: 'fan-out', + status: 'pending', + content: null, + submittedAt: null, + }]; + if (isLast) { + assigneeSlots.push({ + assignee: a, + role: 'final', + status: 'pending', + content: null, + submittedAt: null, + }); + } + slots.set(a, assigneeSlots); + } + } + + const pipe: StoredPipe = { + pipeId, + mode, + assignees, + prompt, + status: 'running', + slots, + createdAt: new Date().toISOString(), + emittedHandoffs: new Set(), + emittedFanOutRequests: new Set(), + emittedSynthRequest: false, + stageTimeoutMs: opts?.stageTimeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS, + timeoutPolicy: opts?.timeoutPolicy ?? 'fail', + }; + store.set(pipeId, pipe); + + // Populate active pipe index for all assignees + for (const a of assignees) { + addToActivePipeIndex(a, projectId, pipeId); + } + + return pipe; +} + +/** Get a pipe from the store. */ +export function getPipe(pipeId: string, projectId: string | null): StoredPipe | undefined { + return getProjectStore(projectId).get(pipeId); +} + +/** Track that a reducer action has been emitted (for idempotency without log scanning). */ +export function markEmitted( + pipeId: string, + type: 'handoff' | 'fan-out-request' | 'synth-request', + key: string | number | undefined, + projectId: string | null, +): void { + const pipe = getPipe(pipeId, projectId); + if (!pipe) return; + if (type === 'handoff' && typeof key === 'number') pipe.emittedHandoffs.add(key); + else if (type === 'fan-out-request' && typeof key === 'string') pipe.emittedFanOutRequests.add(key); + else if (type === 'synth-request') pipe.emittedSynthRequest = true; +} + +/** Mark a pipe as completed, failed, or cancelled. Releases all leases for its assignees. + * Returns assignee names whose leases were released (callers should drain their pending queues). */ +export function markPipeStatus(pipeId: string, status: PipeStatus, projectId: string | null): string[] { + const pipe = getPipe(pipeId, projectId); + if (!pipe) return []; + pipe.status = status; + if (status !== 'running') { + // Remove from active pipe index for all assignees + for (const a of pipe.assignees) { + removeFromActivePipeIndex(a, projectId, pipeId); + } + return releaseAllLeases(pipe, projectId); + } + return []; +} + +// ── Lease management ────────────────────────────────────────────────────────── + +/** Grant a lease to a participant for a specific pipe. + * A participant can only hold one active lease at a time. + * Returns error if the participant already holds a lease for a *different* pipe. */ +export function grantLease( + pipeId: string, + assignee: string, + projectId: string | null, +): { ok: boolean; error?: string; code?: PipeErrorCode; lease?: LeaseInfo } { + const key = leaseKey(assignee, projectId); + const existing = activeLeases.get(key); + + // Allow re-granting for the same pipe (idempotent) + if (existing && existing.pipeId !== pipeId) { + return { + ok: false, + code: 'PIPE_LEASE_CONFLICT', + error: `${assignee} already holds a lease for pipe #${existing.pipeId}. ` + + `Complete or release that pipe before starting pipe #${pipeId}.`, + }; + } + + const pipe = getPipe(pipeId, projectId); + if (!pipe) return { ok: false, code: 'PIPE_NOT_FOUND', error: `Pipe #${pipeId} not found` }; + if (pipe.status !== 'running') return { ok: false, code: 'PIPE_CLOSED', error: `Pipe #${pipeId} is ${pipe.status}` }; + + const assigneeSlots = pipe.slots.get(assignee); + if (!assigneeSlots || assigneeSlots.length === 0) { + return { ok: false, error: `${assignee} is not an assignee of pipe #${pipeId}` }; + } + + // Find the first pending or leased task for this assignee. + // In merge-all, this will be fan-out first, then final. + const slot = assigneeSlots.find(s => s.status === 'pending' || s.status === 'leased'); + if (!slot) return { ok: false, error: `${assignee} has no pending tasks for pipe #${pipeId}` }; + + slot.status = 'leased'; + const now = new Date(); + const deadline = pipe.stageTimeoutMs > 0 + ? new Date(now.getTime() + pipe.stageTimeoutMs).toISOString() + : null; + const lease: LeaseInfo = { + pipeId, + assignee, + slotRole: slot.role, + stage: slot.stage, + grantedAt: now.toISOString(), + deadline, + }; + activeLeases.set(key, lease); + return { ok: true, lease }; +} + +/** Release a participant's active lease. */ +export function releaseLease(assignee: string, projectId: string | null): void { + activeLeases.delete(leaseKey(assignee, projectId)); +} + +/** Get the active lease for a participant (if any). */ +export function getActiveLease(assignee: string, projectId: string | null): LeaseInfo | undefined { + return activeLeases.get(leaseKey(assignee, projectId)); +} + +/** Check whether a lease has passed its deadline. */ +export function isLeaseExpired(lease: LeaseInfo, now: number = Date.now()): boolean { + if (!lease.deadline) return false; + return now >= new Date(lease.deadline).getTime(); +} + +/** Release all leases for a pipe's assignees. Returns names of assignees whose leases were released. */ +function releaseAllLeases(pipe: StoredPipe, projectId: string | null): string[] { + const released: string[] = []; + for (const assignee of pipe.assignees) { + const lease = getActiveLease(assignee, projectId); + if (lease?.pipeId === pipe.pipeId) { + releaseLease(assignee, projectId); + released.push(assignee); + } + } + return released; +} + +/** Get all active leases (for watchdog / deadline checks). */ +export function getAllActiveLeases(): ReadonlyMap { + return activeLeases; +} + +// ── Pending pipe queue (for lease conflicts) ────────────────────────────────── + +/** Record that a pipe is waiting for a participant's lease to be released. + * Pipes are drained in creation-time order (oldest first). */ +export function addPendingPipe(assignee: string, projectId: string | null, pipeId: string): void { + const key = leaseKey(assignee, projectId); + let pending = pendingPipes.get(key); + if (!pending) { pending = new Set(); pendingPipes.set(key, pending); } + pending.add(pipeId); +} + +/** Pop all pending pipe IDs for a participant (called after lease release). + * Returns pipe IDs sorted by pipe creation time (oldest first). */ +export function popPendingPipes(assignee: string, projectId: string | null): string[] { + const key = leaseKey(assignee, projectId); + const pending = pendingPipes.get(key); + if (!pending || pending.size === 0) return []; + const result = [...pending]; + pendingPipes.delete(key); + + // Sort by pipe creation time so older pipes are drained first + const store = getProjectStore(projectId); + result.sort((a, b) => { + const pipeA = store.get(a); + const pipeB = store.get(b); + if (!pipeA || !pipeB) return 0; + return pipeA.createdAt.localeCompare(pipeB.createdAt); + }); + + return result; +} + +// ── Stage submission ────────────────────────────────────────────────────────── + +/** Submit stage output for a pipe. + * @param requireLease If true (default), the assignee must hold the lease. Set false for backward compat via chat_send. */ +export function submitStage( + pipeId: string, + assignee: string, + content: string, + projectId: string | null, + requireLease = true, +): SubmitResult { + const pipe = getPipe(pipeId, projectId); + if (!pipe) return { ok: false, code: 'PIPE_NOT_FOUND', error: `Pipe #${pipeId} not found` }; + if (pipe.status !== 'running') return { ok: false, code: 'PIPE_CLOSED', error: `Pipe #${pipeId} is ${pipe.status}` }; + + const assigneeSlots = pipe.slots.get(assignee); + if (!assigneeSlots || assigneeSlots.length === 0) { + return { ok: false, code: 'PIPE_NOT_ASSIGNED', error: `${assignee} is not an assignee of pipe #${pipeId}` }; + } + + let slot: PipeSlot | undefined; + if (requireLease) { + const lease = getActiveLease(assignee, projectId); + if (!lease || lease.pipeId !== pipeId) { + return { + ok: false, + code: 'PIPE_LEASE_NOT_HELD', + error: `${assignee} does not hold a lease for pipe #${pipeId}. ` + + `Stage submission requires an active lease granted by the system.`, + }; + } + // Reject submits after the lease deadline has passed. + if (isLeaseExpired(lease)) { + return { + ok: false, + code: 'PIPE_LEASE_EXPIRED', + error: `Lease for ${assignee} on pipe #${pipeId} expired at ${lease.deadline}. ` + + `The stage deadline has passed — submission rejected.`, + }; + } + slot = assigneeSlots.find(s => s.role === lease.slotRole && (s.stage === lease.stage || (s.stage === undefined && lease.stage === undefined)) && s.status === 'leased'); + } else { + // Non-leased submission (backward compat) — take the first non-submitted task + slot = assigneeSlots.find(s => s.status !== 'submitted'); + } + + if (!slot) return { ok: false, code: 'PIPE_ALREADY_SUBMITTED', error: `${assignee} already submitted all tasks for pipe #${pipeId}` }; + + slot.content = content; + slot.status = 'submitted'; + slot.submittedAt = new Date().toISOString(); + releaseLease(assignee, projectId); + + return { + ok: true, + slot: { ...slot }, + pipe: { pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status }, + }; +} + +// ── Assignment queries (reconnect / recovery) ─────────────────────────────── + +export interface ParticipantAssignment { + pipeId: string; + mode: PipeMode; + role: PipeSlot['role']; + stage?: number; + slotStatus: PipeSlot['status']; + leaseStatus: 'active' | 'expired' | 'none'; + deadline: string | null; + grantedAt: string | null; + pipeStatus: PipeStatus; +} + +/** List all non-submitted slots for a participant across running pipes. + * Used by reconnect recovery and the pipe_list_assignments tool. */ +export function getAssignmentsForParticipant( + assignee: string, + projectId: string | null, +): ParticipantAssignment[] { + const activePipeIds = getActivePipesForParticipant(assignee, projectId); + const assignments: ParticipantAssignment[] = []; + const lease = getActiveLease(assignee, projectId); + + for (const pipeId of activePipeIds) { + const pipe = getPipe(pipeId, projectId); + if (!pipe) continue; + + const slots = pipe.slots.get(assignee); + if (!slots) continue; + + for (const slot of slots) { + if (slot.status === 'submitted') continue; + + const isLeasedSlot = lease?.pipeId === pipeId + && lease.slotRole === slot.role + && (lease.stage === slot.stage || (lease.stage === undefined && slot.stage === undefined)); + + let leaseStatus: ParticipantAssignment['leaseStatus'] = 'none'; + let deadline: string | null = null; + let grantedAt: string | null = null; + + if (isLeasedSlot && lease) { + leaseStatus = isLeaseExpired(lease) ? 'expired' : 'active'; + deadline = lease.deadline; + grantedAt = lease.grantedAt; + } + + assignments.push({ + pipeId, + mode: pipe.mode, + role: slot.role, + stage: slot.stage, + slotStatus: slot.status, + leaseStatus, + deadline, + grantedAt, + pipeStatus: pipe.status, + }); + } + } + + return assignments; +} + +// ── Queries ─────────────────────────────────────────────────────────────────── + +/** Get the stored output for a linear stage. */ +export function getStageOutput( + pipeId: string, + stage: number, + projectId: string | null, +): { from: string; body: string } | undefined { + const pipe = getPipe(pipeId, projectId); + if (!pipe) return undefined; + for (const slotList of pipe.slots.values()) { + for (const slot of slotList) { + if (slot.stage === stage && slot.status === 'submitted' && slot.content) { + return { from: slot.assignee, body: slot.content }; + } + } + } + return undefined; +} + +/** Get all submitted fan-out outputs for a merge pipe. */ +export function getFanOutOutputs( + pipeId: string, + projectId: string | null, +): Map { + const pipe = getPipe(pipeId, projectId); + const outputs = new Map(); + if (!pipe) return outputs; + for (const slotList of pipe.slots.values()) { + for (const slot of slotList) { + if (slot.role === 'fan-out' && slot.status === 'submitted' && slot.content) { + outputs.set(slot.assignee, slot.content); + } + } + } + return outputs; +} + +export function computeStageInput( + pipeId: string, + stage: number | undefined, + assignee: string, + projectId: string | null, +): StageInputResult { + const pipe = getPipe(pipeId, projectId); + if (!pipe) { + return { ok: false, code: 'PIPE_NOT_FOUND', error: `Pipe #${pipeId} not found` }; + } + if (pipe.status !== 'running') { + return { ok: false, code: 'PIPE_CLOSED', error: `Pipe #${pipeId} is ${pipe.status}` }; + } + + const assigneeIndex = pipe.assignees.indexOf(assignee); + if (assigneeIndex === -1) { + return { ok: false, code: 'PIPE_NOT_ASSIGNED', error: `${assignee} is not an assignee of pipe #${pipeId}` }; + } + + const resolvedStage = stage ?? (pipe.mode === 'linear' ? assigneeIndex + 1 : undefined); + + if (pipe.mode === 'linear') { + if (!resolvedStage || resolvedStage < 1 || resolvedStage > pipe.assignees.length) { + return { ok: false, code: 'PIPE_NOT_ASSIGNED', error: `Invalid stage ${String(stage)} for pipe #${pipeId}` }; + } + if (pipe.assignees[resolvedStage - 1] !== assignee) { + return { ok: false, code: 'PIPE_NOT_ASSIGNED', error: `${assignee} is not assigned to stage ${resolvedStage} of pipe #${pipeId}` }; + } + if (resolvedStage === 1) { + return { + ok: true, + input: { + role: 'prompt', + content: pipe.prompt, + contentHash: computeContentHash(pipe.prompt), + contentVersion: 1, + stage: 1, + totalStages: pipe.assignees.length, + assignee, + prompt: pipe.prompt, + }, + }; + } + + const previous = getStageOutput(pipeId, resolvedStage - 1, projectId); + return { + ok: true, + input: { + role: 'upstream-output', + content: previous?.body ?? null, + contentHash: previous ? computeContentHash(previous.body) : null, + contentVersion: previous ? 1 : 0, + stage: resolvedStage, + totalStages: pipe.assignees.length, + assignee, + prompt: pipe.prompt, + sources: previous ? [{ from: previous.from, content: previous.body }] : [], + }, + }; + } + + const isMergeAll = pipe.mode === 'merge-all' || pipe.mode === 'explain' || pipe.mode === 'summarize'; + const synthesizer = pipe.assignees[pipe.assignees.length - 1]; + const callerSlots = pipe.slots.get(assignee) ?? []; + const callerFanOutSlot = callerSlots.find(slot => slot.role === 'fan-out') ?? null; + const callerFinalSlot = callerSlots.find(slot => slot.role === 'final') ?? null; + const fanOutOutputs = [...getFanOutOutputs(pipeId, projectId).entries()] + .filter(([from]) => !(isMergeAll && from === synthesizer)) + .map(([from, content]) => ({ from, content })); + + // Dual-role synthesizers stay in fan-out mode until their own fan-out slot is submitted. + const isSynthPhase = assignee === synthesizer + && callerFinalSlot != null + && (!callerFanOutSlot || callerFanOutSlot.status === 'submitted'); + if (!isSynthPhase) { + return { + ok: true, + input: { + role: 'fan-out-prompt', + content: pipe.prompt, + contentHash: computeContentHash(pipe.prompt), + contentVersion: 1, + assignee, + prompt: pipe.prompt, + sources: [], + }, + }; + } + + const mergedContent = fanOutOutputs.map(({ from, content }) => `${from}: ${content}`).join('\n\n'); + return { + ok: true, + input: { + role: 'fan-out-outputs', + content: fanOutOutputs.length > 0 ? mergedContent : null, + contentHash: fanOutOutputs.length > 0 ? computeContentHash(mergedContent) : null, + contentVersion: fanOutOutputs.length > 0 ? 1 : 0, + assignee, + prompt: pipe.prompt, + sources: fanOutOutputs, + }, + }; +} + +/** Get pipe status summary for the pipe_status tool. */ +export function getPipeStatus(pipeId: string, projectId: string | null): { + pipeId: string; + mode: PipeMode; + status: PipeStatus; + assignees: string[]; + prompt: string; + slots: Array<{ + assignee: string; + role: string; + stage?: number; + status: string; + hasContent: boolean; + submittedAt: string | null; + }>; + leases: Array; +} | undefined { + const pipe = getPipe(pipeId, projectId); + if (!pipe) return undefined; + + const slots: Array<{ + assignee: string; + role: string; + stage?: number; + status: string; + hasContent: boolean; + submittedAt: string | null; + }> = []; + const leases: LeaseInfo[] = []; + + for (const [, slotList] of pipe.slots) { + for (const slot of slotList) { + slots.push({ + assignee: slot.assignee, + role: slot.role, + stage: slot.stage, + status: slot.status, + hasContent: slot.content !== null, + submittedAt: slot.submittedAt, + }); + } + const lease = getActiveLease(slotList[0].assignee, projectId); + if (lease?.pipeId === pipeId) leases.push(lease); + } + + return { + pipeId: pipe.pipeId, + mode: pipe.mode, + status: pipe.status, + assignees: pipe.assignees, + prompt: pipe.prompt, + slots, + leases, + }; +} + +/** List all active (running) pipes for a project. */ +export function listActivePipes(projectId: string | null): Array<{ + pipeId: string; + mode: PipeMode; + status: PipeStatus; + assignees: string[]; +}> { + const store = getProjectStore(projectId); + const result: Array<{ pipeId: string; mode: PipeMode; status: PipeStatus; assignees: string[] }> = []; + for (const pipe of store.values()) { + if (pipe.status === 'running') { + result.push({ pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status, assignees: pipe.assignees }); + } + } + return result; +} + +// ── Terminal pipe cleanup ──────────────────────────────────────────────────── + +export const DEFAULT_PIPE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** Remove terminal pipes (completed/failed/cancelled) that exceed the given TTL. + * Returns the pipeIds that were removed. */ +export function cleanupTerminalPipes( + projectId: string | null, + ttlMs: number = DEFAULT_PIPE_TTL_MS, +): string[] { + const store = getProjectStore(projectId); + const now = Date.now(); + const removed: string[] = []; + + for (const [pipeId, pipe] of store) { + if (pipe.status === 'running') continue; + // Use the last slot submission time or createdAt as the reference + let latestTs = new Date(pipe.createdAt).getTime(); + for (const [, slotList] of pipe.slots) { + for (const slot of slotList) { + if (slot.submittedAt) { + const t = new Date(slot.submittedAt).getTime(); + if (t > latestTs) latestTs = t; + } + } + } + if (now - latestTs >= ttlMs) { + store.delete(pipeId); + removed.push(pipeId); + } + } + + return removed; +} + +// ── Recovery from persisted events ─────────────────────────────────────────── + +export interface PipeRecoveryEvent { + type: string; + pipeId: string; + mode?: PipeMode; + assignees?: string[]; + prompt?: string; + stageTimeoutMs?: number; + timeoutPolicy?: PipeTimeoutPolicy; + from?: string; + role?: string; + stage?: number; + content?: string; +} + +/** Rehydrate pipe state from persisted events. + * Called on server restart to rebuild in-memory pipes from event logs. + * Returns the list of pipeIds that are still in 'running' state. */ +export function rehydrateFromEvents( + events: PipeRecoveryEvent[], + projectId: string | null, +): string[] { + // Group events by pipeId, preserving order + const grouped = new Map(); + for (const event of events) { + let list = grouped.get(event.pipeId); + if (!list) { list = []; grouped.set(event.pipeId, list); } + list.push(event); + } + + const runningPipes: string[] = []; + + for (const [pipeId, pipeEvents] of grouped) { + // Find the start event + const startEvent = pipeEvents.find(e => e.type === 'start'); + if (!startEvent || !startEvent.assignees || !startEvent.prompt || !startEvent.mode) continue; + + // Skip if already in store (shouldn't happen, but defensive) + if (getPipe(pipeId, projectId)) continue; + + // Recreate the pipe + createPipe(pipeId, startEvent.mode, startEvent.assignees, startEvent.prompt, projectId, { + stageTimeoutMs: startEvent.stageTimeoutMs, + timeoutPolicy: startEvent.timeoutPolicy, + }); + + // Replay submissions + for (const event of pipeEvents) { + if (event.type === 'stage-output' && event.from && event.content) { + submitStage(pipeId, event.from, event.content, projectId, false); + } + } + + // Apply terminal status if pipe ended + const terminalEvent = pipeEvents.find(e => + e.type === 'complete' || e.type === 'failed' || e.type === 'cancel' + ); + if (terminalEvent) { + const status: PipeStatus = + terminalEvent.type === 'complete' ? 'completed' : + terminalEvent.type === 'cancel' ? 'cancelled' : 'failed'; + markPipeStatus(pipeId, status, projectId); + } else { + // Pipe was running when server stopped — it's recoverable. + // Rebuild emission tracking from submitted slot state so the reducer + // doesn't re-emit handoffs/fan-outs that were already delivered. + rebuildEmissionState(pipeId, projectId); + runningPipes.push(pipeId); + } + } + + return runningPipes; +} + +/** Rebuild emission tracking from submitted slot state. + * After rehydration, the emission sets are empty. This function infers which + * emissions have already occurred by examining slot statuses: + * - Linear: if stage N is submitted or leased, handoffs 1..N were emitted. + * - Merge/merge-all: if assignee X's fan-out slot is submitted/leased, their fan-out request was emitted. + * - If all fan-out slots are submitted, the synth request was emitted. + * This prevents the reducer from re-emitting stale prompts after restart. */ +function rebuildEmissionState(pipeId: string, projectId: string | null): void { + const pipe = getPipe(pipeId, projectId); + if (!pipe) return; + + if (pipe.mode === 'linear') { + // For linear pipes: any stage that is submitted or leased implies its handoff was emitted. + // Also, the stage AFTER the last submitted stage was emitted (it's the next handoff target). + let maxSubmittedStage = 0; + for (const [, slotList] of pipe.slots) { + for (const slot of slotList) { + if (slot.stage && (slot.status === 'submitted' || slot.status === 'leased')) { + if (slot.stage > maxSubmittedStage) maxSubmittedStage = slot.stage; + } + } + } + // Handoffs 1..maxSubmittedStage were emitted (each stage received its handoff and acted on it) + for (let i = 1; i <= maxSubmittedStage; i++) { + pipe.emittedHandoffs.add(i); + } + } else { + // Merge / merge-all / explain / summarize + const synthesizer = pipe.assignees[pipe.assignees.length - 1]; + let allFanOutsSubmitted = true; + + for (const [assignee, slotList] of pipe.slots) { + for (const slot of slotList) { + if (slot.role === 'fan-out' && (slot.status === 'submitted' || slot.status === 'leased')) { + pipe.emittedFanOutRequests.add(assignee); + } + if (slot.role === 'fan-out' && slot.status !== 'submitted') { + allFanOutsSubmitted = false; + } + } + } + + // If all fan-out slots are submitted AND the synthesizer has a leased/submitted final slot, + // then the synth request was emitted + if (allFanOutsSubmitted) { + const synthSlots = pipe.slots.get(synthesizer); + if (synthSlots) { + const finalSlot = synthSlots.find(s => s.role === 'final'); + if (finalSlot && (finalSlot.status === 'leased' || finalSlot.status === 'submitted')) { + pipe.emittedSynthRequest = true; + } + } + } + } +} + +// ── Observability ──────────────────────────────────────────────────────────── + +export function getPipeTimingSummary(pipeId: string, projectId: string | null): PipeTimingSummary | undefined { + const pipe = getPipe(pipeId, projectId); + if (!pipe) return undefined; + const stages: StageTiming[] = []; + let latestSubmission: string | null = null; + for (const [assignee, slotList] of pipe.slots) { + const lease = getActiveLease(assignee, projectId); + for (const slot of slotList) { + const isActive = lease?.pipeId === pipeId && lease.slotRole === slot.role && (lease.stage === slot.stage || (lease.stage === undefined && slot.stage === undefined)); + const grantedAt = isActive ? lease!.grantedAt : null; + const deadline = isActive ? lease!.deadline : null; + let durationMs: number | null = null; + if (grantedAt && slot.submittedAt) durationMs = new Date(slot.submittedAt).getTime() - new Date(grantedAt).getTime(); + stages.push({ stage: slot.stage, assignee: slot.assignee, role: slot.role, grantedAt, submittedAt: slot.submittedAt, deadline, durationMs }); + if (slot.submittedAt && (!latestSubmission || slot.submittedAt > latestSubmission)) latestSubmission = slot.submittedAt; + } + } + const completedAt = pipe.status !== 'running' ? latestSubmission : null; + const totalDurationMs = completedAt ? new Date(completedAt).getTime() - new Date(pipe.createdAt).getTime() : null; + let criticalPathMs: number | null = null; + const durations = stages.filter(s => s.durationMs !== null).map(s => ({ role: s.role, ms: s.durationMs! })); + if (durations.length > 0) { + if (pipe.mode === 'linear') criticalPathMs = durations.reduce((sum, d) => sum + d.ms, 0); + else { const fm = Math.max(...durations.filter(d => d.role === 'fan-out').map(d => d.ms), 0); criticalPathMs = fm + (durations.find(d => d.role === 'final')?.ms ?? 0); } + } + return { pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status, createdAt: pipe.createdAt, completedAt, totalDurationMs, stages, criticalPathMs, stageTimeoutMs: pipe.stageTimeoutMs, timeoutPolicy: pipe.timeoutPolicy }; +} + +export function getRuntimeLeaseStatuses(projectId: string | null): RuntimeLeaseStatus[] { + const nowMs = _pipeClock.now(); + const result: RuntimeLeaseStatus[] = []; + const prefix = (projectId ?? '__none__') + ':'; + for (const [key, lease] of activeLeases) { + if (!key.startsWith(prefix)) continue; + const elapsedMs = nowMs - new Date(lease.grantedAt).getTime(); + const deadlineMs = lease.deadline ? new Date(lease.deadline).getTime() : null; + result.push({ pipeId: lease.pipeId, assignee: lease.assignee, slotRole: lease.slotRole, stage: lease.stage, grantedAt: lease.grantedAt, deadline: lease.deadline, elapsedMs, remainingMs: deadlineMs !== null ? deadlineMs - nowMs : null, isOverdue: deadlineMs !== null && nowMs > deadlineMs }); + } + return result; +} + +export function getDeadLetterEntries(projectId: string | null): DeadLetterEntry[] { + const nowMs = _pipeClock.now(); + const entries: DeadLetterEntry[] = []; + for (const pipe of getProjectStore(projectId).values()) { + if (pipe.status !== 'running') continue; + for (const [assignee, slotList] of pipe.slots) { + for (const slot of slotList) { + if (slot.status === 'submitted') continue; + const lease = getActiveLease(assignee, projectId); + const isLeased = lease?.pipeId === pipe.pipeId && lease.slotRole === slot.role; + if (isLeased && lease!.deadline) { + const deadlineMs = new Date(lease!.deadline!).getTime(); + if (nowMs > deadlineMs) entries.push({ pipeId: pipe.pipeId, assignee, stage: slot.stage, role: slot.role, status: 'timeout-expired', reason: 'Lease expired ' + String(Math.round((nowMs - deadlineMs) / 1000)) + 's ago', grantedAt: lease!.grantedAt, deadline: lease!.deadline, elapsedMs: nowMs - new Date(lease!.grantedAt).getTime(), pipeMode: pipe.mode, pipeStatus: pipe.status }); + } + if (slot.status === 'pending' && !isLeased) { + const pipeAge = nowMs - new Date(pipe.createdAt).getTime(); + const threshold = pipe.stageTimeoutMs > 0 ? pipe.stageTimeoutMs * 2 : 10 * 60 * 1000; + if (pipeAge > threshold) entries.push({ pipeId: pipe.pipeId, assignee, stage: slot.stage, role: slot.role, status: 'stuck', reason: 'Pending for ' + String(Math.round(pipeAge / 1000)) + 's', grantedAt: null, deadline: null, elapsedMs: pipeAge, pipeMode: pipe.mode, pipeStatus: pipe.status }); + } + } + } + } + // Delivery-failed: notification retries exhausted + for (const delivery of getExhaustedDeliveries(projectId)) { + const reason = `Notification exhausted after ${delivery.notifyAttempts} attempts (state: ${delivery.state})`; + entries.push({ pipeId: delivery.pipeId, assignee: delivery.assignee, stage: delivery.stage, role: delivery.role, status: 'delivery-failed', reason, grantedAt: delivery.assignedAt, deadline: null, elapsedMs: _pipeClock.now() - new Date(delivery.assignedAt).getTime(), pipeMode: getPipe(delivery.pipeId, projectId)?.mode ?? 'linear', pipeStatus: getPipe(delivery.pipeId, projectId)?.status ?? 'running' }); + } + + return entries; +} + +export function listAllPipes(projectId: string | null): Array<{ pipeId: string; mode: PipeMode; status: PipeStatus; assignees: string[]; createdAt: string; stageTimeoutMs: number; timeoutPolicy: PipeTimeoutPolicy; slotSummary: { total: number; submitted: number; leased: number; pending: number } }> { + const result: Array<{ pipeId: string; mode: PipeMode; status: PipeStatus; assignees: string[]; createdAt: string; stageTimeoutMs: number; timeoutPolicy: PipeTimeoutPolicy; slotSummary: { total: number; submitted: number; leased: number; pending: number } }> = []; + for (const pipe of getProjectStore(projectId).values()) { + let total = 0, submitted = 0, leased = 0, pending = 0; + for (const slotList of pipe.slots.values()) { for (const slot of slotList) { total++; if (slot.status === 'submitted') submitted++; else if (slot.status === 'leased') leased++; else pending++; } } + result.push({ pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status, assignees: pipe.assignees, createdAt: pipe.createdAt, stageTimeoutMs: pipe.stageTimeoutMs, timeoutPolicy: pipe.timeoutPolicy, slotSummary: { total, submitted, leased, pending } }); + } + return result; +} + +// ── Test helper ─────────────────────────────────────────────────────────────── + +/** Reset all in-memory state. For testing only. */ +export function _resetForTest(): void { + _pipeClock = systemClock; + stores.clear(); + activeLeases.clear(); + pendingPipes.clear(); + activePipeIndex.clear(); +} diff --git a/src/apps/chat/services/pipe-tool-usage-regression.test.ts b/src/apps/chat/services/pipe-tool-usage-regression.test.ts new file mode 100644 index 0000000..a4b3120 --- /dev/null +++ b/src/apps/chat/services/pipe-tool-usage-regression.test.ts @@ -0,0 +1,576 @@ +/** + * Regression tests for observed LLM pipe tool usage patterns. + * + * These tests document and verify the cross-cutting behaviors observed when + * LLMs interact with the pipe system: + * + * 1. Delivery state machine when pipe_read_output is skipped vs used + * 2. Assignment lifecycle for submit-without-fetch (the legacy/workaround path) + * 3. Fan-out payload readability after the auth guard fix + * 4. Compact notification wording (assignment vs stage input separation) + * 5. Re-notify behavior when fetch is skipped + * + * These tests operate at the delivery, assignment, and materializer layers + * (not the registry integration layer, which is covered by chat-registry.pipe-submit.test.ts). + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as delivery from './pipe-delivery.js'; +import * as materializer from './pipe-assignment-materializer.js'; +import * as assignmentStore from './assignment-store.js'; +import * as payloadStore from './payload-store.js'; +import { createTestClock } from './clock.js'; + +const PROJECT = 'regression-test'; + +beforeEach(() => { + delivery._resetForTest(); + assignmentStore._resetForTest(); + payloadStore._resetForTest(); +}); + +// ── Observed LLM Pattern: submit without fetch ──────────────────────────────── + +describe('LLM workaround: submit without pipe_read_output', () => { + it('delivery allows direct notified → submitted (skipping fetch)', () => { + delivery.createDelivery('pipe-r1', 'alice', 'fan-out-request', 'prompt text', PROJECT); + delivery.recordNotification('pipe-r1', 'alice', PROJECT); + + // LLM skips pipe_read_output, submits directly + const ok = delivery.recordSubmission('pipe-r1', 'alice', PROJECT); + expect(ok).toBe(true); + + const record = delivery.getDelivery('pipe-r1', 'alice', PROJECT); + expect(record?.state).toBe('submitted'); + // fetchedAt should remain null — LLM never called pipe_read_output + expect(record?.fetchedAt).toBeNull(); + expect(record?.submittedAt).toBeTruthy(); + }); + + it('delivery allows direct assigned → submitted (fire-and-forget)', () => { + delivery.createDelivery('pipe-r2', 'bob', 'fan-out-request', 'prompt', PROJECT); + + // LLM submits without even being notified (race condition / fast agent) + const ok = delivery.recordSubmission('pipe-r2', 'bob', PROJECT); + expect(ok).toBe(true); + + const record = delivery.getDelivery('pipe-r2', 'bob', PROJECT); + expect(record?.state).toBe('submitted'); + expect(record?.notifiedAt).toBeNull(); + expect(record?.fetchedAt).toBeNull(); + }); + + it('assignment completeAssignment handles submit-without-fetch via fast-forward', () => { + const result = materializer.materializeAssignment('pipe-r3', 'merge', { + type: 'fan-out-request', + targetAssignee: 'alice', + body: 'Fan-out prompt', + }, PROJECT); + expect(result).not.toBeNull(); + + // Simulate: notification was sent but LLM skipped pipe_read_output + materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT); + + // LLM submits — completeAssignment should walk through intermediate states + const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT); + expect(assignment?.status).toBe('notified'); + + // Walk manually: notified → acknowledged → payload_fetched → submitted + materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT); + const ok = materializer.completeAssignment(result!.assignmentId, PROJECT); + expect(ok).toBe(true); + + const final = assignmentStore.getAssignment(result!.assignmentId, PROJECT); + expect(final?.status).toBe('submitted'); + }); +}); + +// ── Observed LLM Pattern: pipe_get_assignment → chat_read → pipe_submit ─────── + +describe('LLM workaround: assignment metadata is sufficient for fan-out work', () => { + it('fan-out assignment has correct metadata without needing pipe_read_output', () => { + const results = materializer.materializePipeAssignments( + 'pipe-r4', 'explain', ['alice', 'bob', 'charlie'], 'How does X work?', PROJECT, + ); + + // All participants get fan-out assignments with metadata + expect(results).toHaveLength(3); + for (const r of results) { + expect(r.role).toBe('fan-out'); + expect(r.assignmentId).toBeTruthy(); + expect(r.payloadId).toBeTruthy(); + expect(r.stageId).toMatch(/^fan-out:/); + } + + // Assignment store has the metadata an LLM would get from pipe_get_assignment + for (const r of results) { + const assignment = assignmentStore.getAssignment(r.assignmentId, PROJECT); + expect(assignment).toBeDefined(); + expect(assignment!.role).toBe('fan-out'); + expect(assignment!.status).toBe('assigned'); + expect(assignment!.pipeId).toBe('pipe-r4'); + } + }); + + it('fan-out payload content matches the original prompt', () => { + const prompt = 'Explain how the pipe system works'; + const results = materializer.materializePipeAssignments( + 'pipe-r5', 'explain', ['alice', 'bob'], prompt, PROJECT, + ); + + // Each fan-out assignee's payload contains the prompt + for (const r of results) { + const payload = payloadStore.getPayload(r.payloadId, PROJECT); + expect(payload).toBeDefined(); + expect(payload!.content).toBe(prompt); + expect(payload!.status).toBe('active'); + } + }); + + it('fan-out payload is fetchable via payload store after assignment creation', () => { + const prompt = 'Analyze this code'; + const results = materializer.materializePipeAssignments( + 'pipe-r6', 'merge', ['alice', 'bob', 'carol'], prompt, PROJECT, + ); + + // merge: alice and bob are fan-out, carol is synthesizer + expect(results).toHaveLength(2); + + for (const r of results) { + const fetchResult = payloadStore.fetchPayloadContent(r.payloadId, PROJECT); + expect(fetchResult.ok).toBe(true); + if (fetchResult.ok) { + expect(fetchResult.content).toBe(prompt); + expect(fetchResult.contentHash).toBeTruthy(); + } + } + }); +}); + +// ── Delivery state integrity when pipe_read_output IS called ────────────────── + +describe('correct path: pipe_read_output advances delivery state', () => { + it('fetch transitions delivery from notified → fetched', () => { + delivery.createDelivery('pipe-r7', 'alice', 'fan-out-request', 'payload', PROJECT); + delivery.recordNotification('pipe-r7', 'alice', PROJECT); + + const ok = delivery.recordFetch('pipe-r7', 'alice', PROJECT); + expect(ok).toBe(true); + + const record = delivery.getDelivery('pipe-r7', 'alice', PROJECT); + expect(record?.state).toBe('fetched'); + expect(record?.fetchedAt).toBeTruthy(); + }); + + it('fetch after notification advances assignment to payload_fetched', () => { + const result = materializer.materializeAssignment('pipe-r8', 'explain', { + type: 'fan-out-request', + targetAssignee: 'alice', + body: 'Explain this', + }, PROJECT); + + // Simulate notification + fetch (the correct LLM path) + materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT); + + const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT); + expect(assignment?.status).toBe('payload_fetched'); + }); + + it('full correct lifecycle: assigned → notified → acknowledged → fetched → submitted', () => { + const result = materializer.materializeAssignment('pipe-r9', 'linear', { + type: 'handoff', + targetAssignee: 'bob', + stage: 2, + body: 'Stage 2 content', + }, PROJECT); + + // Delivery lifecycle + delivery.createDelivery('pipe-r9', 'bob', 'handoff', 'Stage 2 content', PROJECT, 2); + delivery.recordNotification('pipe-r9', 'bob', PROJECT); + delivery.recordFetch('pipe-r9', 'bob', PROJECT); + delivery.recordSubmission('pipe-r9', 'bob', PROJECT); + + const deliveryRecord = delivery.getDelivery('pipe-r9', 'bob', PROJECT); + expect(deliveryRecord?.state).toBe('submitted'); + expect(deliveryRecord?.fetchedAt).toBeTruthy(); + expect(deliveryRecord?.submittedAt).toBeTruthy(); + + // Assignment lifecycle + materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT); + const ok = materializer.completeAssignment(result!.assignmentId, PROJECT); + expect(ok).toBe(true); + + const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT); + expect(assignment?.status).toBe('submitted'); + }); +}); + +// ── Re-notify behavior when fetch is skipped ────────────────────────────────── + +describe('re-notify fires when LLM skips pipe_read_output', () => { + it('re-notify timer fires when agent is notified but does not fetch', async () => { + vi.useFakeTimers(); + + delivery.createDelivery('pipe-r10', 'alice', 'fan-out-request', 'payload', PROJECT, undefined, { + renotifyIntervalMs: 5_000, + maxNotifyAttempts: 3, + }); + delivery.recordNotification('pipe-r10', 'alice', PROJECT); + + const callback = vi.fn(); + delivery.startRenotifyTimer('pipe-r10', 'alice', PROJECT, callback); + + // Agent skips pipe_read_output — timer fires after interval + vi.advanceTimersByTime(5_000); + expect(callback).toHaveBeenCalledWith('pipe-r10', 'alice', PROJECT); + + vi.useRealTimers(); + }); + + it('re-notify timer is cancelled when agent calls pipe_read_output (fetch)', async () => { + vi.useFakeTimers(); + + delivery.createDelivery('pipe-r11', 'bob', 'fan-out-request', 'payload', PROJECT, undefined, { + renotifyIntervalMs: 5_000, + }); + delivery.recordNotification('pipe-r11', 'bob', PROJECT); + + const callback = vi.fn(); + delivery.startRenotifyTimer('pipe-r11', 'bob', PROJECT, callback); + + // Agent calls pipe_read_output → fetch cancels the timer + delivery.recordFetch('pipe-r11', 'bob', PROJECT); + + vi.advanceTimersByTime(10_000); + expect(callback).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('re-notify does not fire when agent submits directly (skipping fetch)', async () => { + vi.useFakeTimers(); + + delivery.createDelivery('pipe-r12', 'carol', 'fan-out-request', 'payload', PROJECT, undefined, { + renotifyIntervalMs: 5_000, + }); + delivery.recordNotification('pipe-r12', 'carol', PROJECT); + + const callback = vi.fn(); + delivery.startRenotifyTimer('pipe-r12', 'carol', PROJECT, callback); + + // Agent submits without fetch — recordSubmission cancels the re-notify timer + delivery.recordSubmission('pipe-r12', 'carol', PROJECT); + + vi.advanceTimersByTime(10_000); + // Timer is cancelled by recordSubmission (via cancelRenotifyTimer), not just suppressed + expect(callback).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); +}); + +// ── Compact notification wording regression ─────────────────────────────────── + +describe('compact notification separates assignment metadata from stage input', () => { + it('fan-out notification has separate assignment and input instructions', () => { + const notification = delivery.formatCompactNotification( + 'abc123', 'explain', 'fan-out-request', 'alice', 3, + ); + + // Should have separate lines for assignment metadata and stage input + expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="abc123")'); + expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="abc123")'); + // Should NOT have the old combined wording + expect(notification.body).not.toContain('Read your assignment'); + }); + + it('linear handoff notification has separate assignment and input instructions', () => { + const notification = delivery.formatCompactNotification( + 'def456', 'linear', 'handoff', 'bob', 3, 2, + ); + + expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="def456")'); + expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="def456")'); + expect(notification.body).not.toContain('Read your assignment'); + }); + + it('synth-request notification has separate assignment and input instructions', () => { + const notification = delivery.formatCompactNotification( + 'ghi789', 'merge', 'synth-request', 'synth', 3, + ); + + expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="ghi789")'); + expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="ghi789")'); + expect(notification.body).not.toContain('Read your assignment'); + }); + + it('all notification types include pipe_submit instruction', () => { + const modes: Array<{ mode: 'explain' | 'linear' | 'merge'; type: 'fan-out-request' | 'handoff' | 'synth-request'; stage?: number }> = [ + { mode: 'explain', type: 'fan-out-request' }, + { mode: 'linear', type: 'handoff', stage: 1 }, + { mode: 'merge', type: 'synth-request' }, + ]; + + for (const { mode, type, stage } of modes) { + const notification = delivery.formatCompactNotification( + 'test-pipe', mode, type, 'agent', 3, stage, + ); + expect(notification.body).toContain('pipe_submit(pipeId="test-pipe"'); + expect(notification.body).toContain('Do not use chat_send'); + } + }); +}); + +// ── Fan-out payload lifecycle ───────────────────────────────────────────────── + +describe('fan-out payload lifecycle across pipe modes', () => { + it('explain mode: all participants (including synthesizer) get fan-out payloads', () => { + const prompt = 'Explain closures in JS'; + const results = materializer.materializePipeAssignments( + 'pipe-exp', 'explain', ['alice', 'bob', 'charlie'], prompt, PROJECT, + ); + + expect(results).toHaveLength(3); + for (const r of results) { + const payload = payloadStore.getPayload(r.payloadId, PROJECT); + expect(payload!.content).toBe(prompt); + expect(payload!.status).toBe('active'); + } + }); + + it('merge mode: only non-synthesizer participants get fan-out payloads', () => { + const prompt = 'Compare approaches'; + const results = materializer.materializePipeAssignments( + 'pipe-mrg', 'merge', ['alice', 'bob', 'carol'], prompt, PROJECT, + ); + + // merge: alice and bob are fan-out, carol is synthesizer (no initial payload) + expect(results).toHaveLength(2); + const assignees = results.map(r => r.assignee); + expect(assignees).toContain('alice'); + expect(assignees).toContain('bob'); + expect(assignees).not.toContain('carol'); + }); + + it('payloads are archived when pipe assignments are cancelled', () => { + const results = materializer.materializePipeAssignments( + 'pipe-cancel', 'explain', ['alice', 'bob'], 'prompt', PROJECT, + ); + + materializer.cancelPipeAssignments('pipe-cancel', PROJECT); + + // Payloads should be archived + for (const r of results) { + const payload = payloadStore.getPayload(r.payloadId, PROJECT); + expect(payload!.status).toBe('archived'); + } + }); + + it('payload is archived after assignment completion', () => { + const result = materializer.materializeAssignment('pipe-complete', 'linear', { + type: 'handoff', + targetAssignee: 'alice', + stage: 1, + body: 'Do work', + }, PROJECT); + + // Walk through lifecycle to submitted + materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT); + materializer.completeAssignment(result!.assignmentId, PROJECT); + + const payload = payloadStore.getPayload(result!.payloadId, PROJECT); + expect(payload!.status).toBe('archived'); + }); +}); + +// ── Cross-layer consistency: delivery + assignment + payload ─────────────────── + +describe('cross-layer consistency for fan-out pipe', () => { + it('all three layers stay consistent through the correct path', () => { + const pipeId = 'pipe-consistent'; + const assignee = 'alice'; + const prompt = 'Explain the bug'; + + // 1. Materialize assignment + payload + const materialized = materializer.materializeAssignment(pipeId, 'explain', { + type: 'fan-out-request', + targetAssignee: assignee, + body: prompt, + }, PROJECT); + expect(materialized).not.toBeNull(); + + // 2. Create delivery record + delivery.createDelivery(pipeId, assignee, 'fan-out-request', prompt, PROJECT); + + // 3. Notification sent + delivery.recordNotification(pipeId, assignee, PROJECT); + materializer.transitionAssignmentStatus(materialized!.assignmentId, 'notified', PROJECT); + + // Verify: all layers in 'notified' state + expect(delivery.getDelivery(pipeId, assignee, PROJECT)?.state).toBe('notified'); + expect(assignmentStore.getAssignment(materialized!.assignmentId, PROJECT)?.status).toBe('notified'); + expect(payloadStore.getPayload(materialized!.payloadId, PROJECT)?.status).toBe('active'); + + // 4. Agent fetches (pipe_read_output) + delivery.recordFetch(pipeId, assignee, PROJECT); + materializer.transitionAssignmentStatus(materialized!.assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(materialized!.assignmentId, 'payload_fetched', PROJECT); + + // Verify: delivery fetched, assignment payload_fetched, payload active + expect(delivery.getDelivery(pipeId, assignee, PROJECT)?.state).toBe('fetched'); + expect(assignmentStore.getAssignment(materialized!.assignmentId, PROJECT)?.status).toBe('payload_fetched'); + expect(payloadStore.getPayload(materialized!.payloadId, PROJECT)?.status).toBe('active'); + + // 5. Agent submits + delivery.recordSubmission(pipeId, assignee, PROJECT); + materializer.completeAssignment(materialized!.assignmentId, PROJECT); + + // Verify: all layers in terminal state + expect(delivery.getDelivery(pipeId, assignee, PROJECT)?.state).toBe('submitted'); + expect(assignmentStore.getAssignment(materialized!.assignmentId, PROJECT)?.status).toBe('submitted'); + expect(payloadStore.getPayload(materialized!.payloadId, PROJECT)?.status).toBe('archived'); + }); + + it('all three layers stay consistent through the workaround path (skip fetch)', () => { + const pipeId = 'pipe-workaround'; + const assignee = 'bob'; + const prompt = 'What is happening?'; + + // 1. Materialize assignment + payload + const materialized = materializer.materializeAssignment(pipeId, 'explain', { + type: 'fan-out-request', + targetAssignee: assignee, + body: prompt, + }, PROJECT); + + // 2. Create delivery record + delivery.createDelivery(pipeId, assignee, 'fan-out-request', prompt, PROJECT); + + // 3. Notification sent + delivery.recordNotification(pipeId, assignee, PROJECT); + materializer.transitionAssignmentStatus(materialized!.assignmentId, 'notified', PROJECT); + + // 4. Agent SKIPS pipe_read_output — goes directly to submit + delivery.recordSubmission(pipeId, assignee, PROJECT); + + // Assignment needs manual walk-through since completeAssignment from 'notified' + // requires walking through intermediate states + materializer.transitionAssignmentStatus(materialized!.assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(materialized!.assignmentId, 'payload_fetched', PROJECT); + materializer.completeAssignment(materialized!.assignmentId, PROJECT); + + // Verify: delivery submitted (no fetch), assignment submitted, payload archived + const deliveryRecord = delivery.getDelivery(pipeId, assignee, PROJECT); + expect(deliveryRecord?.state).toBe('submitted'); + expect(deliveryRecord?.fetchedAt).toBeNull(); // never fetched + expect(deliveryRecord?.submittedAt).toBeTruthy(); + + expect(assignmentStore.getAssignment(materialized!.assignmentId, PROJECT)?.status).toBe('submitted'); + expect(payloadStore.getPayload(materialized!.payloadId, PROJECT)?.status).toBe('archived'); + }); +}); + +// ── Synthesizer payload lifecycle ───────────────────────────────────────────── + +describe('synthesizer: correct pipe_read_output usage', () => { + it('synth assignment materializes with collected outputs as payload', () => { + const synthBody = '--- @alice ---\nAlice analysis\n\n--- @bob ---\nBob analysis'; + const result = materializer.materializeSynthAssignment( + 'pipe-synth', 'explain', 'charlie', synthBody, PROJECT, + ); + + expect(result).not.toBeNull(); + expect(result!.role).toBe('final'); + expect(result!.stageId).toBe('synth'); + + const payload = payloadStore.getPayload(result!.payloadId, PROJECT); + expect(payload!.content).toBe(synthBody); + }); + + it('synth delivery lifecycle tracks fetch correctly', () => { + const synthBody = 'Synthesize these outputs'; + + delivery.createDelivery('pipe-synth2', 'synth', 'synth-request', synthBody, PROJECT); + delivery.recordNotification('pipe-synth2', 'synth', PROJECT); + + // Synthesizer MUST call pipe_read_output to get fan-out outputs + delivery.recordFetch('pipe-synth2', 'synth', PROJECT); + + const record = delivery.getDelivery('pipe-synth2', 'synth', PROJECT); + expect(record?.state).toBe('fetched'); + expect(record?.fetchedAt).toBeTruthy(); + + delivery.recordSubmission('pipe-synth2', 'synth', PROJECT); + expect(delivery.getDelivery('pipe-synth2', 'synth', PROJECT)?.state).toBe('submitted'); + }); +}); + +// ── Linear pipe: pipe_read_output is essential for stage 2+ ─────────────────── + +describe('linear pipe: pipe_read_output is essential for downstream stages', () => { + it('stage 2 assignment payload contains the compact notification (not upstream output)', () => { + // The materializer stores the compact notification as the payload body + // The actual upstream output is served by readPipeOutput in chat-registry + const result = materializer.materializeNextLinearAssignment( + 'pipe-lin', 1, ['alice', 'bob', 'carol'], 'Stage 1 output from alice', PROJECT, + ); + + expect(result).not.toBeNull(); + expect(result!.assignee).toBe('bob'); + expect(result!.stage).toBe(2); + + // Payload contains what was passed as the body (stage 1 output) + const payload = payloadStore.getPayload(result!.payloadId, PROJECT); + expect(payload!.content).toBe('Stage 1 output from alice'); + }); + + it('linear assignment + delivery + payload all track through full lifecycle', () => { + // Materialize stage 1 + const s1 = materializer.materializePipeAssignments( + 'pipe-lin-full', 'linear', ['alice', 'bob'], 'Original prompt', PROJECT, + ); + expect(s1).toHaveLength(1); + + // Complete stage 1 through full lifecycle + delivery.createDelivery('pipe-lin-full', 'alice', 'handoff', 'Original prompt', PROJECT, 1); + delivery.recordNotification('pipe-lin-full', 'alice', PROJECT); + delivery.recordFetch('pipe-lin-full', 'alice', PROJECT); + delivery.recordSubmission('pipe-lin-full', 'alice', PROJECT); + + materializer.transitionAssignmentStatus(s1[0].assignmentId, 'notified', PROJECT); + materializer.transitionAssignmentStatus(s1[0].assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(s1[0].assignmentId, 'payload_fetched', PROJECT); + materializer.completeAssignment(s1[0].assignmentId, PROJECT); + + // Materialize stage 2 + const s2 = materializer.materializeNextLinearAssignment( + 'pipe-lin-full', 1, ['alice', 'bob'], 'Alice stage 1 output', PROJECT, + ); + expect(s2).not.toBeNull(); + expect(s2!.assignee).toBe('bob'); + expect(s2!.stage).toBe(2); + + // Stage 2 delivery lifecycle (bob calls pipe_read_output for upstream content) + delivery.createDelivery('pipe-lin-full', 'bob', 'handoff', 'Alice stage 1 output', PROJECT, 2); + delivery.recordNotification('pipe-lin-full', 'bob', PROJECT); + delivery.recordFetch('pipe-lin-full', 'bob', PROJECT); + delivery.recordSubmission('pipe-lin-full', 'bob', PROJECT); + + materializer.transitionAssignmentStatus(s2!.assignmentId, 'notified', PROJECT); + materializer.transitionAssignmentStatus(s2!.assignmentId, 'acknowledged', PROJECT); + materializer.transitionAssignmentStatus(s2!.assignmentId, 'payload_fetched', PROJECT); + materializer.completeAssignment(s2!.assignmentId, PROJECT); + + // Verify both stages completed + expect(assignmentStore.getAssignment(s1[0].assignmentId, PROJECT)?.status).toBe('submitted'); + expect(assignmentStore.getAssignment(s2!.assignmentId, PROJECT)?.status).toBe('submitted'); + expect(payloadStore.getPayload(s1[0].payloadId, PROJECT)?.status).toBe('archived'); + expect(payloadStore.getPayload(s2!.payloadId, PROJECT)?.status).toBe('archived'); + }); +}); diff --git a/src/apps/chat/services/terminal-utils.ts b/src/apps/chat/services/terminal-utils.ts new file mode 100644 index 0000000..ff8ce6e --- /dev/null +++ b/src/apps/chat/services/terminal-utils.ts @@ -0,0 +1,21 @@ +/** + * Shared terminal text helpers for PTY output analysis. + * Used by both chat-registry (prompt watcher / status tracking) and + * the chat router (shell readiness detection for invite). + */ + +/** Regex to strip ANSI escape sequences and carriage returns from terminal output. */ +export const STRIP_ANSI_RE = /[\x1b\x9b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r/g; + +/** Strip ANSI escape sequences and carriage returns from text. */ +export function stripAnsi(str: string): string { + return str.replace(STRIP_ANSI_RE, ''); +} + +/** Matches common shell prompt endings: $, #, %, >, ⚡ */ +export const SHELL_PROMPT_RE = /[>$#%⚡]\s*$/m; + +/** Returns true if text contains a shell prompt after ANSI stripping. */ +export function hasShellPrompt(text: string): boolean { + return SHELL_PROMPT_RE.test(stripAnsi(text)); +} diff --git a/src/apps/chat/src/index.ts b/src/apps/chat/src/index.ts new file mode 100644 index 0000000..cd4e549 --- /dev/null +++ b/src/apps/chat/src/index.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { createChatMcpServer } from "./mcp.js"; +import { runStdio } from "../../../packages/mcp-utils/src/index.js"; + +// ── Stdio MCP mode ────────────────────────────────────────────────────────── +if (process.argv.includes("--stdio")) { + const server = createChatMcpServer(); + await runStdio(server); + console.error("Devglide Chat MCP server running on stdio"); +} diff --git a/src/apps/chat/src/mcp.test.ts b/src/apps/chat/src/mcp.test.ts new file mode 100644 index 0000000..605f90e --- /dev/null +++ b/src/apps/chat/src/mcp.test.ts @@ -0,0 +1,343 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const registeredTools = vi.hoisted(() => new Map Promise>()); + +const createDevglideMcpServerMock = vi.hoisted(() => vi.fn(() => { + const server = { + tool: vi.fn((name: string, _description: string, _schema: unknown, handler: (args: any) => Promise) => { + registeredTools.set(name, handler); + }), + }; + return server; +})); + +vi.mock('../../../packages/mcp-utils/src/index.js', () => ({ + createDevglideMcpServer: createDevglideMcpServerMock, + jsonResult: (data: unknown) => ({ + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }), + errorResult: (message: string) => ({ + content: [{ type: 'text', text: message }], + isError: true, + }), +})); + +vi.mock('../services/chat-store.js', () => ({ + readMessages: vi.fn(() => []), +})); + +vi.mock('../services/chat-rules.js', () => ({ + getEffectiveRules: vi.fn(() => '## Rules'), +})); + +function mockJsonResponse(ok: boolean, status: number, data: unknown) { + return { + ok, + status, + json: vi.fn(async () => data), + }; +} + +function parseJsonResult(result: { content: Array<{ text: string }> }) { + return JSON.parse(result.content[0]!.text); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('chat MCP session ownership', () => { + beforeEach(() => { + vi.clearAllMocks(); + registeredTools.clear(); + }); + + it('rejects a second join on the same live MCP session', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' })) + .mockResolvedValueOnce(mockJsonResponse(true, 200, { + joined: true, + name: 'alpha-1', + paneId: 'pane-1', + detached: false, + projectId: 'project-1', + })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer, chatServerSessions } = await import('./mcp.js'); + const server = createChatMcpServer(); + const chatJoin = registeredTools.get('chat_join'); + expect(chatJoin).toBeTypeOf('function'); + + const first = await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' }); + const second = await chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' }); + + expect(parseJsonResult(first)).toMatchObject({ name: 'alpha-1', projectId: 'project-1' }); + expect(second.isError).toBe(true); + expect(second.content[0]!.text).toContain('already joined as "alpha-1"'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]![0])).toContain('/api/chat/join'); + expect(String(fetchMock.mock.calls[1]![0])).toContain('/api/chat/status?name=alpha-1&projectId=project-1'); + expect(chatServerSessions.get(server as never)).toEqual([{ name: 'alpha-1', projectId: 'project-1', paneId: 'pane-1' }]); + }); + + it('allows a new join when the tracked participant is already gone', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' })) + .mockResolvedValueOnce(mockJsonResponse(false, 404, { error: 'Participant "alpha-1" not found', joined: false })) + .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'beta-2', projectId: 'project-1' })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer, chatServerSessions } = await import('./mcp.js'); + const server = createChatMcpServer(); + const chatJoin = registeredTools.get('chat_join'); + expect(chatJoin).toBeTypeOf('function'); + + await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' }); + const second = await chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' }); + + expect(second.isError).not.toBe(true); + expect(parseJsonResult(second)).toMatchObject({ name: 'beta-2', projectId: 'project-1' }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(chatServerSessions.get(server as never)).toEqual([{ name: 'beta-2', projectId: 'project-1', paneId: 'pane-2' }]); + }); + + it('allows a new join when the tracked participant is detached', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' })) + .mockResolvedValueOnce(mockJsonResponse(true, 200, { + joined: true, + name: 'alpha-1', + paneId: 'pane-1', + detached: true, + projectId: 'project-1', + })) + .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'beta-2', projectId: 'project-1' })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer, chatServerSessions } = await import('./mcp.js'); + const server = createChatMcpServer(); + const chatJoin = registeredTools.get('chat_join'); + expect(chatJoin).toBeTypeOf('function'); + + await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' }); + const second = await chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' }); + + expect(second.isError).not.toBe(true); + expect(parseJsonResult(second)).toMatchObject({ name: 'beta-2', projectId: 'project-1' }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(chatServerSessions.get(server as never)).toEqual([{ name: 'beta-2', projectId: 'project-1', paneId: 'pane-2' }]); + }); + + it('rejects overlapping joins on the same MCP session before a second participant is created', async () => { + const joinResponse = deferred>(); + const fetchMock = vi.fn(() => joinResponse.promise); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer, chatServerSessions } = await import('./mcp.js'); + const server = createChatMcpServer(); + const chatJoin = registeredTools.get('chat_join'); + expect(chatJoin).toBeTypeOf('function'); + + const firstJoinPromise = chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' }); + const second = await chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' }); + + expect(second.isError).toBe(true); + expect(second.content[0]!.text).toContain('already in progress'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + joinResponse.resolve(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' })); + const first = await firstJoinPromise; + + expect(parseJsonResult(first)).toMatchObject({ name: 'alpha-1', projectId: 'project-1' }); + expect(chatServerSessions.get(server as never)).toEqual([{ name: 'alpha-1', projectId: 'project-1', paneId: 'pane-1' }]); + }); + + it('rejects overlapping joins while stale-session recovery is still in progress', async () => { + const statusResponse = deferred>(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' })) + .mockImplementationOnce(() => statusResponse.promise) + .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'beta-2', projectId: 'project-1' })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer, chatServerSessions } = await import('./mcp.js'); + const server = createChatMcpServer(); + const chatJoin = registeredTools.get('chat_join'); + expect(chatJoin).toBeTypeOf('function'); + + await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' }); + + const firstRecoveryJoin = chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' }); + const secondRecoveryJoin = await chatJoin!({ name: 'gamma', paneId: 'pane-3', submitKey: 'cr' }); + + expect(secondRecoveryJoin.isError).toBe(true); + expect(secondRecoveryJoin.content[0]!.text).toContain('already in progress'); + expect(fetchMock).toHaveBeenCalledTimes(2); + + statusResponse.resolve(mockJsonResponse(false, 404, { error: 'Participant "alpha-1" not found', joined: false })); + const recovered = await firstRecoveryJoin; + + expect(parseJsonResult(recovered)).toMatchObject({ name: 'beta-2', projectId: 'project-1' }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(chatServerSessions.get(server as never)).toEqual([{ name: 'beta-2', projectId: 'project-1', paneId: 'pane-2' }]); + }); + + it('adopts an existing REST-joined participant by paneId before sending', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 200, { + joined: true, + name: 'alpha-1', + paneId: 'pane-1', + detached: false, + projectId: 'project-1', + })) + .mockResolvedValueOnce(mockJsonResponse(true, 201, { id: 'msg-1' })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer, chatServerSessions } = await import('./mcp.js'); + const server = createChatMcpServer(); + const chatSend = registeredTools.get('chat_send'); + expect(chatSend).toBeTypeOf('function'); + + const result = await chatSend!({ message: 'hello', paneId: 'pane-1' }); + + expect(result.isError).not.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]![0])).toContain('/api/chat/status?paneId=pane-1'); + expect(String(fetchMock.mock.calls[1]![0])).toContain('/api/chat/send'); + expect(JSON.parse(String(fetchMock.mock.calls[1]![1]?.body))).toMatchObject({ + from: 'alpha-1', + projectId: 'project-1', + message: 'hello', + }); + expect(chatServerSessions.get(server as never)).toEqual([{ name: 'alpha-1', projectId: 'project-1', paneId: 'pane-1' }]); + }); + + it('pipe_read_output adopts session by paneId and sends X-Pane-Id header', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 200, { + joined: true, + name: 'alpha-1', + paneId: 'pane-1', + detached: false, + projectId: 'project-1', + })) + .mockResolvedValueOnce(mockJsonResponse(true, 200, { + pipeId: 'abc123', + mode: 'linear', + previousOutput: { stage: 1, from: 'other', content: 'stage 1 work' }, + })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer } = await import('./mcp.js'); + createChatMcpServer(); + const pipeReadOutput = registeredTools.get('pipe_read_output'); + expect(pipeReadOutput).toBeTypeOf('function'); + + const result = await pipeReadOutput!({ pipeId: '#pipe-abc123', paneId: 'pane-1' }); + + expect(result.isError).not.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + // First call: adopt session via /status?paneId= + expect(String(fetchMock.mock.calls[0]![0])).toContain('/api/chat/status?paneId=pane-1'); + // Second call: GET /pipes/:id/output with X-Pane-Id header + const outputUrl = String(fetchMock.mock.calls[1]![0]); + expect(outputUrl).toContain('/api/chat/pipes/abc123/output'); + expect(outputUrl).not.toContain('from='); + const headers = fetchMock.mock.calls[1]![1]?.headers as Record; + expect(headers['x-pane-id']).toBe('pane-1'); + }); + + it('pipe_read_output returns error when not joined', async () => { + vi.stubGlobal('fetch', vi.fn()); + + const { createChatMcpServer } = await import('./mcp.js'); + createChatMcpServer(); + const pipeReadOutput = registeredTools.get('pipe_read_output'); + expect(pipeReadOutput).toBeTypeOf('function'); + + const result = await pipeReadOutput!({ pipeId: 'abc123' }); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain('Not joined'); + }); + + it('pipe_read_output returns error when no pane ID available', async () => { + // Join first (no paneId in response, paneId arg not passed to join) + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer } = await import('./mcp.js'); + createChatMcpServer(); + const chatJoin = registeredTools.get('chat_join'); + await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' }); + + // Now call pipe_read_output — session has paneId from join arg fallback + fetchMock.mockResolvedValueOnce(mockJsonResponse(true, 200, { + pipeId: 'abc123', mode: 'linear', + previousOutput: { stage: 1, from: 'other', content: 'output' }, + })); + const pipeReadOutput = registeredTools.get('pipe_read_output'); + const result = await pipeReadOutput!({ pipeId: 'abc123' }); + + expect(result.isError).not.toBe(true); + const headers = fetchMock.mock.calls[1]![1]?.headers as Record; + expect(headers['x-pane-id']).toBe('pane-1'); + }); + + it('pipe_read_output forwards REST errors', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 200, { + joined: true, name: 'alpha-1', paneId: 'pane-1', detached: false, projectId: 'project-1', + })) + .mockResolvedValueOnce(mockJsonResponse(false, 403, { error: 'Not an assignee' })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer } = await import('./mcp.js'); + createChatMcpServer(); + const pipeReadOutput = registeredTools.get('pipe_read_output'); + expect(pipeReadOutput).toBeTypeOf('function'); + + const result = await pipeReadOutput!({ pipeId: 'abc123', paneId: 'pane-1' }); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain('Not an assignee'); + }); + + it('adopts an existing REST-joined participant by paneId before pipe submit', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse(true, 200, { + joined: true, + name: 'alpha-1', + paneId: 'pane-1', + detached: false, + projectId: 'project-1', + })) + .mockResolvedValueOnce(mockJsonResponse(true, 201, { ok: true })); + vi.stubGlobal('fetch', fetchMock); + + const { createChatMcpServer } = await import('./mcp.js'); + createChatMcpServer(); + const pipeSubmit = registeredTools.get('pipe_submit'); + expect(pipeSubmit).toBeTypeOf('function'); + + const result = await pipeSubmit!({ pipeId: '#pipe-abc123', content: 'artifact', paneId: 'pane-1' }); + + expect(result.isError).not.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1]![0])).toContain('/api/chat/pipes/abc123/submit'); + expect(JSON.parse(String(fetchMock.mock.calls[1]![1]?.body))).toMatchObject({ + from: 'alpha-1', + projectId: 'project-1', + content: 'artifact', + }); + }); +}); diff --git a/src/apps/chat/src/mcp.ts b/src/apps/chat/src/mcp.ts new file mode 100644 index 0000000..e586cf5 --- /dev/null +++ b/src/apps/chat/src/mcp.ts @@ -0,0 +1,530 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { jsonResult, errorResult, createDevglideMcpServer } from '../../../packages/mcp-utils/src/index.js'; +import * as store from '../services/chat-store.js'; +import { getEffectiveRules } from '../services/chat-rules.js'; + +const UNIFIED_BASE = `http://localhost:${process.env.PORT ?? 7000}`; + +export interface ChatSessionEntry { name: string; projectId: string | null; paneId?: string | null } +interface ChatMcpServerState { + sessionEntry: ChatSessionEntry | null; + joinInFlight: boolean; +} + +/** Maps each per-session McpServer instance to its tracked chat participant(s). + * New code keeps this to a single entry per MCP session, but the array shape is retained + * so onSessionClose can safely clean up stale sessions from older builds. */ +export const chatServerSessions = new WeakMap(); +const chatMcpServerStates = new WeakMap(); +const chatMcpServersBySessionId = new Map(); + +interface ChatStatusPayload { + joined?: boolean; + detached?: boolean; + paneId?: string | null; + error?: string; +} + +/** POST/GET helper for the unified server's chat REST API. */ +async function chatApi(path: string, body?: unknown, extraHeaders?: Record): Promise<{ ok: boolean; status: number; data: unknown }> { + const opts: RequestInit = { + headers: { 'Content-Type': 'application/json', ...extraHeaders }, + }; + if (body !== undefined) { + opts.method = 'POST'; + opts.body = JSON.stringify(body); + } + const res = await fetch(`${UNIFIED_BASE}/api/chat${path}`, opts); + const data = await res.json(); + return { ok: res.ok, status: res.status, data }; +} + +/** Reverse-lookup: find the MCP session ID for a given server instance. */ +function getMcpSessionId(server: McpServer): string | undefined { + for (const [id, s] of chatMcpServersBySessionId) { + if (s === server) return id; + } + return undefined; +} + +function getServerState(server: McpServer): ChatMcpServerState { + let state = chatMcpServerStates.get(server); + if (!state) { + state = { sessionEntry: null, joinInFlight: false }; + chatMcpServerStates.set(server, state); + } + return state; +} + +function setTrackedSessionEntry(server: McpServer, entry: ChatSessionEntry | null): void { + const state = getServerState(server); + state.sessionEntry = entry; + if (entry) { + chatServerSessions.set(server, [{ ...entry }]); + return; + } + chatServerSessions.delete(server); +} + +export function registerChatMcpHttpSession(sessionId: string, server: McpServer): void { + chatMcpServersBySessionId.set(sessionId, server); + getServerState(server); +} + +export function unregisterChatMcpHttpSession(server: McpServer, sessionId?: string): void { + if (sessionId) { + if (chatMcpServersBySessionId.get(sessionId) === server) chatMcpServersBySessionId.delete(sessionId); + return; + } + for (const [id, trackedServer] of chatMcpServersBySessionId) { + if (trackedServer === server) chatMcpServersBySessionId.delete(id); + } +} + +export function bindChatSessionToMcpHttpSession(sessionId: string, entry: ChatSessionEntry | null): boolean { + const server = chatMcpServersBySessionId.get(sessionId); + if (!server) return false; + setTrackedSessionEntry(server, entry); + return true; +} + +export function hasChatMcpHttpSession(sessionId: string): boolean { + return chatMcpServersBySessionId.has(sessionId); +} + +export function createChatMcpServer(): McpServer { + const server = createDevglideMcpServer( + 'devglide-chat', + '0.1.0', + 'Multi-LLM chat room for cross-agent communication', + { + instructions: [ + '## Chat — Usage Conventions', + '', + '### Purpose', + '- Chat provides a shared room where the user and multiple LLM instances communicate.', + '- Messages are **broadcast within the active project** so every participant stays current.', + '- LLMs receive messages via PTY injection when linked to a shell pane.', + '', + '### Joining', + '- Use `chat_join` to register as a participant. Provide your `name` (e.g. "claude", "codex") and optionally `model` (e.g. "claude-sonnet-4-6", "gpt-5").', + '- **Name assignment:** The server derives your chat alias from `name` + pane number (e.g. "claude-1" for name "claude" on pane 1). The `name` param is the identity base — use a stable agent label, not the backend model. **Always use the `name` returned by `chat_join`** — that is your identity for the session.', + '- `"user"` and `"system"` are **reserved names** — do not use them.', + '- `chat_join` requires an explicit `paneId`. Read `DEVGLIDE_PANE_ID` from your shell session and pass it as `paneId` every time. Do not use `"auto"` and do not rely on MCP process env inheritance.', + '- If your paneId collides with another participant, the **existing session is preserved** and the newcomer receives a 409 error with `code: "PANE_ALREADY_BOUND"`. The newcomer must use a different pane or wait for the existing participant to leave.', + '- **`submitKey` parameter:** Controls the character sent after PTY-injected messages to trigger input submission. Use `"cr"` (carriage return, default) for all known clients including Claude Code and Codex. The submit key is sent after a short delay to avoid paste-burst detection in TUI frameworks like crossterm.', + '- Each MCP session may own only one chat participant. Use `chat_leave()` first, or create a separate MCP session for another agent.', + '', + '### Rules of Engagement', + '- On `chat_join`, you receive a `rules` field containing the project\'s **Rules of Engagement** (markdown).', + '- **Follow these rules exactly** — they define when you should respond and when to stay silent.', + '- Default rule: reply if @mentioned, or if the user makes a global request only after your claim has been explicitly confirmed by the other active LLM participants. Do not let multiple LLMs answer the same global request uncoordinated.', + '- Rules can be customized per project. Always follow the rules returned by `chat_join`.', + '', + '### Sending messages', + '- Use `chat_send` to send a message. Use **@mentions in the message body** to address specific participants (e.g. `@user check this`).', + '- **Targeted PTY delivery:** Delivery recipients are resolved from the `to` param plus any `@mentions` in the message body. Use `@all` as an explicit broadcast token to reach all participants. LLM messages with no recipients in either `to` or body @mentions are persisted in history but not PTY-delivered to any agent terminal.', + '- Never @mention yourself — messages are never delivered back to the sender.', + '- Markdown is supported in message bodies.', + '', + '### Reading history', + '- Use `chat_read` to read recent message history. Supports `limit` and `since` filters.', + '- Use `chat_members` to list active participants and check their pane link status (`paneId: null` means disconnected).', + '', + '### Pane linking', + '- A valid `paneId` is required to receive messages via PTY injection.', + '- `chat_join` now fails if the supplied pane is missing or not routable by the shell backend.', + '- If your pane closes, you are automatically removed from the chat.', + '', + '### Limitations', + '- You cannot send messages to yourself (self-mentions are ignored).', + '- Only participants in the same project see each other and can exchange messages.', + '- The `to` param and body @mentions are both merged to build the delivery target set for all senders including LLMs. Leaving both empty means no PTY delivery for LLM senders.', + '- Participants are in-memory only — if the server restarts, everyone must rejoin.', + '', + '### Quick reference — commonly confused parameters', + '- `chat_join(name, model?, paneId, submitKey?)` — register. `paneId` is required and must come from `DEVGLIDE_PANE_ID` in your shell (never `"auto"`). Check returned `name` (server assigns it). `"user"`/`"system"` reserved. `submitKey`: `"cr"` (default, correct for all known clients including Claude Code and Codex).', + '- `chat_leave(paneId?)` — unregister from the chat room. Pass `paneId` if this MCP session has no tracked state (e.g. after a REST-only join).', + '- `chat_send(message, to?, paneId?)` — send a message. Delivery goes to recipients resolved from `to` plus body @mentions; use `@all` to broadcast to all participants. LLM messages with no recipients in either field are persisted but not PTY-delivered. Messages that start with `#pipe-` or reference a currently running `#pipe-*` are rejected — use `pipe_submit` instead. Pass `paneId` to adopt a REST-joined session.', + '- `pipe_submit(pipeId, content, paneId?)` — submit your output for a pipe stage. Use this instead of `chat_send` when responding to a `#pipe-` prompt. Pass `paneId` to adopt a REST-joined session.', + '- `pipe_get_assignment(pipeId, paneId?)` — inspect assignment metadata (role, stage, lease status, deadline). Use this to confirm what you are assigned to do. Does not return stage content.', + '- `pipe_read_output(pipeId, paneId?)` — read the stage input content you are entitled to (previous stage output for linear, original prompt for fan-out, aggregated fan-out outputs for synthesizer). Caller identity resolved from session.', + '- `chat_read(limit?, since?)` — read message history.', + '- `chat_members()` — list active participants with pane link status.', + ], + }, + ); + + function setSessionEntry(entry: ChatSessionEntry | null): void { + setTrackedSessionEntry(server, entry); + } + + function getSessionEntry(): ChatSessionEntry | null { + return getServerState(server).sessionEntry; + } + + function setJoinInFlight(value: boolean): void { + getServerState(server).joinInFlight = value; + } + + function getJoinInFlight(): boolean { + return getServerState(server).joinInFlight; + } + + function getSessionProjectId(): string | null { + return getSessionEntry()?.projectId ?? null; + } + + function getSessionName(): string | null { + return getSessionEntry()?.name ?? null; + } + + async function readTrackedParticipantStatus(entry: ChatSessionEntry): Promise<{ ok: boolean; status: number; data: ChatStatusPayload } | null> { + const query = `?name=${encodeURIComponent(entry.name)}${entry.projectId ? `&projectId=${encodeURIComponent(entry.projectId)}` : ''}`; + try { + const res = await chatApi(`/status${query}`); + return { ok: res.ok, status: res.status, data: (res.data as ChatStatusPayload) ?? {} }; + } catch { + return null; + } + } + + async function ensureSessionCanJoin(): Promise<{ ok: true } | { ok: false; result: ReturnType }> { + const sessionEntry = getSessionEntry(); + if (!sessionEntry) return { ok: true }; + + const status = await readTrackedParticipantStatus(sessionEntry); + if (!status) { + return { + ok: false, + result: errorResult( + `This MCP session is already joined as "${sessionEntry.name}", and its current state could not be verified. Use chat_leave first or create a separate MCP session for another participant.`, + ), + }; + } + if (!status.ok) { + if (status.status === 404) { + setSessionEntry(null); + return { ok: true }; + } + return { + ok: false, + result: errorResult( + `This MCP session is already joined as "${sessionEntry.name}". Status check failed: ${status.data.error ?? 'unknown error'}. Use chat_leave first or create a separate MCP session for another participant.`, + ), + }; + } + + if (status.data.joined === false || status.data.detached || !status.data.paneId) { + setSessionEntry(null); + return { ok: true }; + } + + return { + ok: false, + result: errorResult( + `This MCP session is already joined as "${sessionEntry.name}". Use chat_leave first or create a separate MCP session for another participant.`, + ), + }; + } + + async function tryAdoptSessionByPaneId(paneId?: string): Promise { + const existing = getSessionEntry(); + if (existing || !paneId) return existing; + + const res = await chatApi(`/status?paneId=${encodeURIComponent(paneId)}`).catch(() => null); + if (!res?.ok) return null; + + const data = (res.data as ChatStatusPayload & { name?: string; projectId?: string | null }) ?? {}; + if (!data.joined || !data.name || data.detached || !data.paneId) return null; + + const adopted = { name: data.name, projectId: data.projectId ?? null, paneId }; + setSessionEntry(adopted); + return adopted; + } + + // ── 1. chat_join ────────────────────────────────────────────────────── + + server.tool( + 'chat_join', + 'Join the chat room as a participant. Requires explicit paneId — read $DEVGLIDE_PANE_ID from your shell session and pass it directly. Do not use "auto" or omit paneId.', + { + name: z.string().describe('Stable agent identity label used as the base for your chat alias (e.g. "claude", "codex", "cursor"). Do not pass the backend model here — use a consistent short name.'), + model: z.string().optional().describe('Backend model identifier for display (e.g. "claude-sonnet-4-6", "gpt-5"). Not used for name derivation — use `name` for identity.'), + paneId: z.string().describe('Shell pane ID for PTY delivery. Read DEVGLIDE_PANE_ID from your shell session and pass it directly. Do not use "auto" — the server will not guess your pane.'), + submitKey: z.enum(['cr', 'lf']).optional().describe('Character to trigger submit after PTY injection: "cr" (default, correct for all known clients including Claude Code and Codex). Only use "lf" if you have verified a specific client requires it'), + }, + async ({ name, model, paneId, submitKey }) => { + if (name === 'user') return errorResult('"user" is reserved for the dashboard user'); + if (name === 'system') return errorResult('"system" is reserved'); + + // Reject "auto" — LLMs must pass their actual pane ID + if (paneId === 'auto') { + return errorResult( + 'chat_join requires an explicit paneId for LLM participants. ' + + 'Run "echo $DEVGLIDE_PANE_ID" in your shell and pass the result as paneId. ' + + 'Do not use "auto" — the server cannot reliably guess your pane.', + ); + } + + if (getJoinInFlight()) { + return errorResult( + 'chat_join is already in progress for this MCP session. Wait for it to finish, or use a separate MCP session for another agent.', + ); + } + setJoinInFlight(true); + try { + const sessionCheck = await ensureSessionCanJoin(); + if (sessionCheck.ok === false) return sessionCheck.result; + const mcpSid = getMcpSessionId(server); + const joinHeaders = mcpSid ? { 'mcp-session-id': mcpSid } : undefined; + const res = await chatApi('/join', { name, model: model ?? null, paneId, submitKey: submitKey ?? undefined }, joinHeaders); + if (!res.ok) { + const data = res.data as { error?: string; diagnostics?: unknown }; + const errMsg = data?.error ?? 'Join failed'; + const diag = data?.diagnostics; + if (diag) { + return errorResult(`${errMsg}\n\nDiagnostics: ${JSON.stringify(diag, null, 2)}`); + } + return errorResult(errMsg); + } + // Use the resolved name from the server (may be a generated unique name) + const participant = res.data as { name: string; projectId?: string | null; paneId?: string | null }; + setSessionEntry({ name: participant.name, projectId: participant.projectId ?? null, paneId: participant.paneId ?? paneId }); + // Attach rules of engagement so the joining LLM knows how to behave + const rules = getEffectiveRules(participant.projectId); + return jsonResult({ ...participant, rules }); + } finally { + setJoinInFlight(false); + } + }, + ); + + // ── 2. chat_leave ───────────────────────────────────────────────────── + + server.tool( + 'chat_leave', + 'Leave the chat room. Uses the name from the current session.', + { + paneId: z.string().optional().describe('Optional pane ID to adopt an existing REST-joined participant into this MCP session before leaving. Only needed when this MCP session has no tracked chat state.'), + }, + async ({ paneId }) => { + if (getJoinInFlight()) { + return errorResult('chat_join is still in progress for this MCP session. Wait for it to finish before leaving.'); + } + await tryAdoptSessionByPaneId(paneId); + const sessionEntry = getSessionEntry(); + if (!sessionEntry) return errorResult('Not joined — call chat_join first'); + const current = sessionEntry; + const res = await chatApi('/leave', { name: current.name, projectId: current.projectId }); + if (!res.ok) { + if (res.status === 404) { + setSessionEntry(null); + return jsonResult({ ok: true, left: current.name, stale: true }); + } + return errorResult((res.data as { error?: string })?.error ?? 'Leave failed'); + } + setSessionEntry(null); + return jsonResult(res.data); + }, + ); + + // ── 3. chat_send ────────────────────────────────────────────────────── + + server.tool( + 'chat_send', + 'Send a message to the chat room. Delivery goes to recipients resolved from the `to` param plus body @mentions; use @all to broadcast to all participants. LLM messages with no recipients in either field are persisted but not PTY-delivered. Messages that start with #pipe- or reference a currently running #pipe-* are rejected — use pipe_submit for pipe stage output.', + { + message: z.string().describe('Message text (markdown supported)'), + to: z.string().optional().describe('Recipient name for direct delivery. Merged with body @mentions to build the delivery target set. For LLM senders, leaving both empty means no PTY delivery.'), + paneId: z.string().optional().describe('Optional pane ID to adopt an existing REST-joined participant into this MCP session before sending. Only needed when this MCP session has no tracked chat state.'), + }, + async ({ message, to, paneId }) => { + const adopted = await tryAdoptSessionByPaneId(paneId); + const sessionName = adopted?.name ?? getSessionName(); + const sessionProjectId = adopted?.projectId ?? getSessionProjectId(); + if (!sessionName) return errorResult('Not joined — call chat_join first'); + const res = await chatApi('/send', { from: sessionName, message, to, projectId: sessionProjectId }); + if (!res.ok) return errorResult((res.data as { error?: string })?.error ?? 'Send failed'); + return jsonResult(res.data); + }, + ); + + // ── 3b. pipe_submit ───────────────────────────────────────────────── + + server.tool( + 'pipe_submit', + 'Submit your output for a pipe stage. Use this instead of chat_send when responding to a #pipe- prompt. Optionally pass assignmentId for forward-compatible assignment binding.', + { + pipeId: z.string().describe('The pipe ID — accepts "#pipe-abc123", "pipe-abc123", or just "abc123"'), + content: z.string().describe('Your stage output content (markdown supported)'), + assignmentId: z.string().optional().describe('Optional assignment ID for forward-compatible assignment binding.'), + paneId: z.string().optional().describe('Optional pane ID to adopt an existing REST-joined participant into this MCP session before submitting. Only needed when this MCP session has no tracked chat state.'), + }, + async ({ pipeId, content, assignmentId, paneId }) => { + const adopted = await tryAdoptSessionByPaneId(paneId); + const sessionName = adopted?.name ?? getSessionName(); + const sessionProjectId = adopted?.projectId ?? getSessionProjectId(); + if (!sessionName) return errorResult('Not joined — call chat_join first'); + // Normalize pipeId: strip leading "#pipe-" or "pipe-" prefix to get the bare ID + const normalizedPipeId = pipeId.replace(/^#?pipe-/i, ''); + const res = await chatApi(`/pipes/${encodeURIComponent(normalizedPipeId)}/submit`, { + from: sessionName, + content, + projectId: sessionProjectId, + }); + if (!res.ok) { + const data = res.data as { error?: string; code?: string }; + const msg = data.code + ? `[${data.code}] ${data.error ?? 'Pipe submit failed'}` + : (data.error ?? 'Pipe submit failed'); + return errorResult(msg); + } + return jsonResult(res.data); + }, + ); + + // ── 3c. pipe_read_output ─────────────────────────────────────────── + + server.tool( + 'pipe_read_output', + 'Read the stage input content you are entitled to for the current stage. Returns previous stage output (linear), original prompt (fan-out), or aggregated fan-out outputs (synthesizer). This is the content tool — use pipe_get_assignment for assignment metadata. Caller identity resolved from your chat session.', + { + pipeId: z.string().describe('The pipe ID — accepts "#pipe-abc123", "pipe-abc123", or just "abc123"'), + paneId: z.string().optional().describe('Optional pane ID to adopt an existing REST-joined participant into this MCP session before reading. Only needed when this MCP session has no tracked chat state.'), + }, + async ({ pipeId, paneId }) => { + const adopted = await tryAdoptSessionByPaneId(paneId); + const sessionEntry = adopted ?? getSessionEntry(); + if (!sessionEntry?.name) return errorResult('Not joined — call chat_join first'); + const effectivePaneId = paneId ?? sessionEntry.paneId; + if (!effectivePaneId) return errorResult('No pane ID available — pass paneId or rejoin'); + const normalizedPipeId = pipeId.replace(/^#?pipe-/i, ''); + const query = sessionEntry.projectId ? `?projectId=${encodeURIComponent(sessionEntry.projectId)}` : ''; + const res = await chatApi( + `/pipes/${encodeURIComponent(normalizedPipeId)}/output${query}`, + undefined, + { 'x-pane-id': effectivePaneId }, + ); + if (!res.ok) { + const data = res.data as { error?: string }; + return errorResult(data?.error ?? 'Pipe read failed'); + } + return jsonResult(res.data); + }, + ); + + + // ── 3d. pipe_list_assignments ────────────────────────────────────── + + server.tool( + 'pipe_list_assignments', + 'List your active and pending pipe assignments with lease status and deadlines.', + { paneId: z.string().optional().describe('Optional pane ID to adopt session.') }, + async ({ paneId }) => { + const adopted = await tryAdoptSessionByPaneId(paneId); + const sessionName = adopted?.name ?? getSessionName(); + const sessionProjectId = adopted?.projectId ?? getSessionProjectId(); + if (!sessionName) return errorResult('Not joined — call chat_join first'); + const res = await chatApi(`/pipes/assignments?assignee=${encodeURIComponent(sessionName)}${sessionProjectId ? `&projectId=${encodeURIComponent(sessionProjectId)}` : ''}`); + if (!res.ok) return errorResult((res.data as { error?: string })?.error ?? 'Failed to list assignments'); + return jsonResult(res.data); + }, + ); + + // ── 3e. pipe_get_assignment ─────────────────────────────────────── + + server.tool( + 'pipe_get_assignment', + 'Inspect assignment metadata for a specific pipe (role, stage, lease status, deadline). This is the metadata tool — use pipe_read_output for stage input content.', + { + pipeId: z.string().describe('The pipe ID'), + paneId: z.string().optional().describe('Optional pane ID to adopt session.'), + }, + async ({ pipeId, paneId }) => { + const adopted = await tryAdoptSessionByPaneId(paneId); + const sessionName = adopted?.name ?? getSessionName(); + const sessionProjectId = adopted?.projectId ?? getSessionProjectId(); + if (!sessionName) return errorResult('Not joined — call chat_join first'); + const normalizedPipeId = pipeId.replace(/^#?pipe-/i, ''); + const query = sessionProjectId ? `?projectId=${encodeURIComponent(sessionProjectId)}` : ''; + const res = await chatApi(`/pipes/${encodeURIComponent(normalizedPipeId)}/assignment${query}`, undefined, { 'x-pane-id': paneId ?? getSessionEntry()?.paneId ?? '' }); + if (!res.ok) return errorResult((res.data as { error?: string })?.error ?? 'Failed to get assignment'); + return jsonResult(res.data); + }, + ); + + // ── 4. chat_read ────────────────────────────────────────────────────── + + server.tool( + 'chat_read', + 'Read recent chat message history. Returns all persisted messages regardless of PTY delivery — some messages may not have been injected into your terminal pane.', + { + limit: z.number().optional().describe('Max messages to return (default 50)'), + since: z.string().optional().describe('ISO timestamp — only return messages after this time'), + }, + async ({ limit, since }) => { + const messages = store.readMessages({ limit, since }, getSessionProjectId()); + return jsonResult(messages); + }, + ); + + // ── 5. chat_members ─────────────────────────────────────────────────── + + server.tool( + 'chat_members', + 'List active chat participants with their pane link status.', + {}, + async () => { + // Use REST API for consistent behavior — direct registry calls can miss + // participants when sessionProjectId is null (before join or after restart). + const res = await chatApi('/members'); + if (!res.ok) return errorResult('Failed to fetch members'); + return jsonResult(res.data); + }, + ); + +// ── pipe_status ────────────────────────────────────────────────────── server.tool( 'pipe_status', 'Get detailed status of a pipe: slot states, active leases, timing breakdown, and dead-letter entries.', { pipeId: z.string().describe('The pipe ID'), paneId: z.string().optional().describe('Optional pane ID to adopt session'), }, async ({ pipeId, paneId }) => { await tryAdoptSessionByPaneId(paneId); const sessionEntry = getSessionEntry(); const pid = sessionEntry?.projectId ?? null; const normalizedPipeId = pipeId.replace(/^#?pipe-/i, ''); const query = pid ? `?projectId=${encodeURIComponent(pid)}` : ''; const [statusRes, timingRes] = await Promise.all([ chatApi(`/pipes/${encodeURIComponent(normalizedPipeId)}/status${query}`).catch(() => null), chatApi(`/pipes/${encodeURIComponent(normalizedPipeId)}/timing${query}`).catch(() => null), ]); if (!statusRes?.ok) { const data = statusRes?.data as { error?: string } | undefined; return errorResult(data?.error ?? `Pipe #${normalizedPipeId} not found`); } const result: Record = { ...(statusRes.data as Record) }; if (timingRes?.ok) { const td = timingRes.data as Record; result.timing = { totalDurationMs: td.totalDurationMs, criticalPathMs: td.criticalPathMs, completedAt: td.completedAt, stages: td.stages }; } return jsonResult(result); }, ); + // ── 6. chat_status ──────────────────────────────────────────────────── + + server.tool( + 'chat_status', + 'Check your current chat connection status and diagnostics. Use this to debug delivery issues or verify your session is healthy.', + {}, + async () => { + const sessionEntry = getSessionEntry(); + const pid = sessionEntry?.projectId ?? null; + const sessionName = sessionEntry?.name ?? null; + const joined = !!sessionName; + + // Use REST API for consistent behavior — avoids project-scoping issues + // when sessionProjectId is null (before join or after restart). + const statusQuery = sessionName ? `?name=${encodeURIComponent(sessionName)}${pid ? `&projectId=${encodeURIComponent(pid)}` : ''}` : ''; + const statusRes = await chatApi(`/status${statusQuery}`).catch(() => null); + if (statusRes?.ok) { + const data = statusRes.data as Record; + return jsonResult({ joined, name: sessionName, ...data }); + } + if (statusRes?.status === 404 && sessionEntry) { + setSessionEntry(null); + return jsonResult({ + joined: false, + name: null, + projectId: pid, + error: `Tracked participant "${sessionName}" is no longer registered.`, + }); + } + + // Fallback to basic info if REST fails + return jsonResult({ + joined, + name: sessionName, + projectId: pid, + error: 'Could not fetch status from REST API', + }); + }, + ); + + return server; +} diff --git a/src/apps/chat/tsconfig.json b/src/apps/chat/tsconfig.json new file mode 100644 index 0000000..ef860bf --- /dev/null +++ b/src/apps/chat/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../packages/tsconfig/node.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "mcp.ts", "types.ts", "services"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/apps/chat/types.ts b/src/apps/chat/types.ts new file mode 100644 index 0000000..33bff3c --- /dev/null +++ b/src/apps/chat/types.ts @@ -0,0 +1,226 @@ +export interface ChatMessage { + id: string; + ts: string; // ISO timestamp + from: string; // participant name + to: string | null; // null = no target, "name" = direct, "all" = broadcast, comma-separated for multi + body: string; // markdown text + type: 'message' | 'join' | 'leave' | 'system'; + pipe?: PipeMessageMeta; // present when message is part of a pipe run + deliveredTo?: number; // count of participants who received PTY delivery + unresolvedTargets?: string[]; // @mention tokens that didn't match any known participant +} + +/** Result of target resolution for PTY delivery. */ +export interface DeliveryPlan { + /** Raw @mention tokens as written (e.g. "all", "claude-7", "team-ui") — for msg.to storage */ + targetLabels: string[]; + /** Concrete participant names for PTY delivery (expanded from labels) */ + recipients: string[]; + /** Direct @mentions only (no group expansions) — for status side-effects */ + concreteAssignees: string[]; + /** Whether to fall back to broadcast when recipients is empty */ + fallbackBroadcast: boolean; + /** Individual @mention tokens that didn't resolve to any known participant */ + unresolvedTargets: string[]; +} + +// ── Pipe types ─────────────────────────────────────────────────────── + +export type PipeMode = 'linear' | 'merge' | 'merge-all' | 'explain' | 'summarize'; + +export type PipeTimeoutPolicy = 'fail' | 'reassign' | 'escalate'; + +export type PipeRole = + | 'start' + | 'handoff' + | 'fan-out-request' + | 'stage-output' + | 'fan-out' + | 'synth-request' + | 'final' + | 'assignee-unavailable' + | 'failed' + | 'cancelled'; + +export interface PipeMessageMeta { + pipeId: string; + mode: PipeMode; + role: PipeRole; + assignees?: string[]; // ordered list on 'start'; defines sequence (linear) or fan-out + synth (merge, last = synth) + prompt?: string; // original user prompt, carried on 'start' so reducer can reconstruct it + stage?: number; // 1-indexed, for linear handoff/stage-output + expectedAssignees?: string[]; // who the reducer expects responses from at current step + targetAssignee?: string; // who this system message is directed at (handoff, fan-out-request, synth-request) + reason?: 'left' | 'detached' | 'pane-closed' | 'cancelled-by-user' | 'timeout'; +} + +/** Derived pipe status — computed from log, not stored. */ +export type PipeStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export type PipeUiEventType = + | 'start' + | 'complete' + | 'failed' + | 'cancel' + | 'queued' + | 'instruction' + | 'stage-output'; + +export interface PipeUiEvent { + id: string; + ts: string; + type: PipeUiEventType; + pipeId: string; + mode?: PipeMode | null; + actionType?: 'handoff' | 'fan-out-request' | 'synth-request'; + assignee?: string; + from?: string; + role?: Extract; + stage?: number; + content?: string; + reason?: string; + // Recovery fields — present on 'start' events for state reconstruction + assignees?: string[]; + prompt?: string; + stageTimeoutMs?: number; + timeoutPolicy?: PipeTimeoutPolicy; +} + +export interface ChatParticipant { + name: string; + kind: 'user' | 'llm'; + model: string | null; // e.g. "claude", "cursor", "codex" + status?: 'idle' | 'working'; + paneId: string | null; // linked shell pane for PTY delivery + paneNum: number | null; // per-project display number — used by frontend for color assignment + projectId: string | null; // project this participant belongs to + submitKey: string; // character sent after delayed PTY injection to trigger submit (default \r, correct for all known clients) + joinedAt: string; + lastSeen: string; + detached: boolean; // true when MCP session closed but pane is still alive — awaiting reclaim + joinedVia?: 'rest' | 'mcp' | null; // how the participant joined — 'rest' for direct REST call, 'mcp' for MCP tool + clientId?: string; // optional stable identity for future strong-reclaim support + permissionMode?: 'supervised' | 'auto-accept' | 'unrestricted' | null; // permission mode the LLM was launched with +} + +export interface ChatJoinResponse extends ChatParticipant { + rules: string; // effective rules of engagement (markdown) +} + +// ── Assignment types ──────────────────────────────────────────────── + +/** Lifecycle states for a durable assignment. */ +export type AssignmentStatus = + | 'assigned' // created, notification not yet sent + | 'notified' // compact notification delivered via PTY + | 'acknowledged' // assignee acknowledged receipt + | 'payload_fetched' // assignee fetched the authoritative payload + | 'submitted' // assignee submitted stage output + | 'expired' // deadline passed without submission + | 'reassigned' // replaced by a new assignment to a different agent + | 'superseded' // replaced by a retry of the same agent + | 'cancelled'; // pipe was cancelled, assignment voided + +/** Terminal statuses — an assignment in one of these states cannot transition further. */ +export const TERMINAL_ASSIGNMENT_STATUSES: ReadonlySet = new Set([ + 'submitted', 'expired', 'reassigned', 'superseded', 'cancelled', +]); + +/** Valid status transitions for the assignment state machine. */ +export const ASSIGNMENT_TRANSITIONS: Readonly> = { + assigned: ['notified', 'expired', 'reassigned', 'superseded', 'cancelled'], + notified: ['acknowledged', 'expired', 'reassigned', 'superseded', 'cancelled'], + acknowledged: ['payload_fetched', 'expired', 'reassigned', 'superseded', 'cancelled'], + payload_fetched: ['submitted', 'expired', 'reassigned', 'superseded', 'cancelled'], + submitted: [], + expired: [], + reassigned: [], + superseded: [], + cancelled: [], +}; + +// ── Payload types ─────────────────────────────────────────────────── + +/** Lifecycle states for stored payloads. */ +export type PayloadStatus = 'active' | 'archived' | 'deleted'; + +// —— Pipe observability types ——————————————————————————————————————————————— + +export interface StageTiming { + stage?: number; + assignee: string; + role: Extract; + grantedAt: string | null; + submittedAt: string | null; + deadline: string | null; + durationMs: number | null; +} + +export interface PipeTimingSummary { + pipeId: string; + mode: PipeMode; + status: PipeStatus; + createdAt: string; + completedAt: string | null; + totalDurationMs: number | null; + stages: StageTiming[]; + criticalPathMs: number | null; + stageTimeoutMs: number; + timeoutPolicy: PipeTimeoutPolicy; +} + +export interface RuntimeLeaseStatus { + pipeId: string; + assignee: string; + slotRole: string; + stage?: number; + grantedAt: string; + deadline: string | null; + elapsedMs: number; + remainingMs: number | null; + isOverdue: boolean; +} + +export interface DeadLetterEntry { + pipeId: string; + assignee: string; + stage?: number; + role: string; + status: 'timeout-expired' | 'stuck' | 'delivery-failed'; + reason: string; + grantedAt: string | null; + deadline: string | null; + elapsedMs: number; + pipeMode: PipeMode; + pipeStatus: PipeStatus; +} + +// —— Pipe provenance types ———————————————————————————————————————————————— + +export type ProvenanceEvent = + | 'created' + | 'stage-granted' + | 'stage-submitted' + | 'completed' + | 'failed' + | 'cancelled' + | 'payload-created' + | 'payload-fetched' + | 'assignment-created' + | 'assignment-transitioned' + | 'delivery-created' + | 'delivery-fetched' + | 'delivery-exhausted'; + +export interface ProvenanceRecord { + ts: string; + pipeId: string; + event: ProvenanceEvent; + actor: string; + actorKind: 'user' | 'llm' | 'system'; + stage?: number; + role?: PipeRole | Extract; + assignmentId?: string; + payloadId?: string; + metadata?: Record; +} diff --git a/src/apps/coder/package.json b/src/apps/coder/package.json index 15e1e9b..16abaae 100644 --- a/src/apps/coder/package.json +++ b/src/apps/coder/package.json @@ -3,14 +3,5 @@ "version": "0.1.0", "type": "module", "description": "In-browser IDE for viewing and editing monorepo files", - "main": "server.js", - "scripts": { - "dev": "node --watch server.js", - "start": "node server.js", - "lint": "eslint ." - }, - "private": true, - "dependencies": { - "express": "^5.2.1" - } + "private": true } diff --git a/src/apps/coder/public/favicon.svg b/src/apps/coder/public/favicon.svg deleted file mode 100644 index 85b3c77..0000000 --- a/src/apps/coder/public/favicon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/apps/coder/public/page.css b/src/apps/coder/public/page.css index bfcbdb2..02736eb 100644 --- a/src/apps/coder/public/page.css +++ b/src/apps/coder/public/page.css @@ -1,34 +1,5 @@ -.page-coder { - display: flex; - flex-direction: column; - height: 100%; - font-family: var(--df-font-mono); - background: var(--df-color-bg-base); - color: var(--df-color-text-primary); - -webkit-font-smoothing: antialiased; - overflow: hidden; -} - -/* ── Toolbar ────────────────────────────────────────────────────────── */ -.page-coder .coder-toolbar { - display: flex; - align-items: center; - gap: var(--df-space-2); - padding: 0 var(--df-space-4); - height: 38px; - background: var(--df-color-bg-surface); - border-bottom: 1px solid var(--df-color-border-default); - flex-shrink: 0; -} - -.page-coder .app-name { - font-size: var(--df-font-size-md); - font-weight: normal; - color: var(--df-color-accent-default); - font-family: var(--df-font-mono); - letter-spacing: var(--df-letter-spacing-wider); - text-transform: uppercase; -} +/* ── Coder — App-specific styles ─────────────────────────────────────────────── */ +/* Common primitives (page root, header, scrollbar) are in shared-ui.css. */ .page-coder .save-status { margin-left: auto; diff --git a/src/apps/coder/public/page.js b/src/apps/coder/public/page.js index ccd4260..0536e30 100644 --- a/src/apps/coder/public/page.js +++ b/src/apps/coder/public/page.js @@ -5,6 +5,11 @@ // in the SPA shell (no iframe). import { escapeHtml } from '/shared-assets/ui-utils.js'; +import { createApi } from '/shared-ui/app-page.js'; +import { createHeader } from '/shared-ui/components/header.js'; +import { confirmModal } from '/shared-ui/components/modal.js'; + +const api = createApi('coder'); const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2'; @@ -33,10 +38,7 @@ let _monacoReady = false; // ── HTML ───────────────────────────────────────────────────────────── const BODY_HTML = ` -
- Coder - -
+ ${createHeader({ brand: 'Coder', meta: '' })}
@@ -56,19 +58,6 @@ const BODY_HTML = `
- - `; // ── Monaco loader ─────────────────────────────────────────────────── @@ -192,38 +181,6 @@ function updateTreeHeader() { if (header) header.textContent = _currentRoot ? _currentRoot.split('/').pop() : 'Explorer'; } -function coderConfirm(title, message) { - return new Promise(resolve => { - const overlay = _container?.querySelector('.modal-overlay'); - if (!overlay) { resolve(false); return; } - - const titleEl = overlay.querySelector('#coder-confirm-title'); - const msgEl = overlay.querySelector('#coder-confirm-msg'); - if (titleEl) titleEl.textContent = title; - if (msgEl) msgEl.textContent = message; - - overlay.classList.remove('hidden'); - - const ac = new AbortController(); - const close = (value) => { - overlay.classList.add('hidden'); - ac.abort(); - resolve(value); - }; - - overlay.addEventListener('click', (e) => { - if (e.target === overlay) close(false); - }, { signal: ac.signal }); - - overlay.querySelector('[data-action="confirm-cancel"]')?.addEventListener('click', () => close(false), { signal: ac.signal }); - overlay.querySelector('[data-action="confirm-ok"]')?.addEventListener('click', () => close(true), { signal: ac.signal }); - - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') close(false); - }, { signal: ac.signal }); - }); -} - // ── File tree ─────────────────────────────────────────────────────── async function fetchTree() { @@ -233,9 +190,9 @@ async function fetchTree() { if (!tree) return; try { const url = _currentRoot - ? `/api/coder/tree?root=${encodeURIComponent(_currentRoot)}` - : '/api/coder/tree'; - const res = await fetch(url); + ? `/tree?root=${encodeURIComponent(_currentRoot)}` + : '/tree'; + const res = await api(url); const nodes = await res.json(); if (gen !== _treeGen) return; tree.innerHTML = ''; @@ -295,7 +252,7 @@ async function openFile(path) { if (!_monacoReady || !_editor) return; try { const rootParam = _currentRoot ? `&root=${encodeURIComponent(_currentRoot)}` : ''; - const res = await fetch(`/api/coder/file?path=${encodeURIComponent(path)}${rootParam}`); + const res = await api(`/file?path=${encodeURIComponent(path)}${rootParam}`); if (!res.ok) { setStatus((await res.json()).error, 'err'); return; } const { content } = await res.json(); const model = monaco.editor.createModel(content, langFromPath(path)); @@ -341,7 +298,7 @@ async function closeTab(path) { if (!_container) return; const tab = _tabs.get(path); if (tab?.dirty) { - const ok = await coderConfirm('Unsaved Changes', `Unsaved changes in ${path.split('/').pop()}. Close anyway?`); + const ok = await confirmModal(_container, { title: 'Unsaved Changes', message: `Unsaved changes in ${path.split('/').pop()}. Close anyway?`, confirmLabel: 'Close Anyway', confirmCls: 'btn-danger' }); if (!ok) return; } tab?.model?.dispose(); @@ -379,7 +336,7 @@ async function saveActive() { const content = tab.model.getValue(); setStatus('Saving\u2026', ''); try { - const res = await fetch('/api/coder/file', { + const res = await api('/file', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: _activeFile, content, root: _currentRoot }), @@ -419,7 +376,7 @@ export async function mount(container, ctx) { _treeGen = 0; // 1. Scope the container - container.classList.add('page-coder'); + container.classList.add('page-coder', 'app-page'); // 2. Build HTML container.innerHTML = BODY_HTML; @@ -506,7 +463,7 @@ export function unmount(container) { } // 5. Remove scope class & clear HTML - container.classList.remove('page-coder'); + container.classList.remove('page-coder', 'app-page'); container.innerHTML = ''; // 6. Clear module references diff --git a/src/apps/coder/server.js b/src/apps/coder/server.js deleted file mode 100644 index ccfda53..0000000 --- a/src/apps/coder/server.js +++ /dev/null @@ -1,3 +0,0 @@ -// ── Coder — standalone server (superseded by unified server) ───────────────── -// This file is retained for reference. All coder routes are now served by -// src/server.ts via src/routers/coder.ts. Use `devglide dev` to run. diff --git a/src/apps/documentation/package.json b/src/apps/documentation/package.json new file mode 100644 index 0000000..c4eb431 --- /dev/null +++ b/src/apps/documentation/package.json @@ -0,0 +1,23 @@ +{ + "name": "@devglide/documentation", + "version": "0.1.0", + "description": "Operational guidance for DevGlide tools — workflows, troubleshooting, examples", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "lint": "eslint ." + }, + "private": true, + "dependencies": { + "@devglide/mcp-utils": "workspace:*", + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.25.49" + }, + "devDependencies": { + "tsx": "^4.19.4", + "typescript": "^5.8.0" + } +} diff --git a/src/apps/documentation/seed/example-flaky-selector.json b/src/apps/documentation/seed/example-flaky-selector.json new file mode 100644 index 0000000..7bfbe3f --- /dev/null +++ b/src/apps/documentation/seed/example-flaky-selector.json @@ -0,0 +1,37 @@ +{ + "id": "ex-flaky-selector", + "type": "example", + "toolName": "devglide-test", + "scenario": "Flaky selector in React controlled form", + "startingAssumptions": [ + "A test scenario intermittently fails on a click or type step targeting a form element.", + "The selector works when tested manually in the browser DevTools.", + "The app uses React with controlled form inputs." + ], + "toolSequence": [ + "test_get_result — read the failure details. Note which step failed and the selector used.", + "Inspect the app source code to check if the element has stable attributes (id, data-testid, role, aria-label).", + "If the selector uses dynamic class names (CSS modules, styled-components): it will break between builds.", + "If the element is rendered conditionally or inside a transition: add a wait-for-element step before the interaction.", + "Add a data-testid attribute to the element in the app source code if no stable selector exists.", + "Update the scenario to use the data-testid selector and add a wait step.", + "Re-run the scenario multiple times to verify the fix is stable." + ], + "whatGoodLooksLike": [ + "The scenario passes consistently across multiple runs.", + "The selector uses a stable attribute that will not change between builds.", + "The wait step ensures the element is rendered before interaction." + ], + "whatBadLooksLike": [ + "The scenario still fails intermittently — the timing issue is not fully resolved.", + "Adding data-testid changes app behavior (unlikely but possible if the attribute conflicts)." + ], + "whatToDoNext": [ + "If still flaky: increase the wait timeout or add an assertion that the element is visible before interacting.", + "If the form field is a complex component (date picker, autocomplete): interact via its UI controls rather than targeting the underlying input directly.", + "Consider whether the React component needs a fix for accessibility — stable selectors often align with proper ARIA attributes." + ], + "tags": ["example", "test", "selector", "react", "form", "flaky"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/example-no-result-recovery.json b/src/apps/documentation/seed/example-no-result-recovery.json new file mode 100644 index 0000000..870e0ee --- /dev/null +++ b/src/apps/documentation/seed/example-no-result-recovery.json @@ -0,0 +1,35 @@ +{ + "id": "ex-no-result-recovery", + "type": "example", + "toolName": "devglide-test", + "scenario": "Scenario returns no result found — recovery steps", + "startingAssumptions": [ + "You submitted a scenario via test_run_scenario.", + "test_get_result returns 'no result found' after waiting." + ], + "toolSequence": [ + "test_get_result — confirm the 'no result found' response.", + "log_read — check if devtools.js is posting any log entries. If no recent entries, devtools.js is not active.", + "shell_run_command to check if the dev server process is running.", + "If the dev server is running: the issue is the browser. Ask the user to verify the browser tab is open on the app.", + "If devtools.js is not in the page source: instruct the user to add the devtools.js script tag.", + "After the browser is confirmed ready: re-submit the scenario via test_run_scenario.", + "test_get_result — should now return a real result." + ], + "whatGoodLooksLike": [ + "After recovery, test_get_result returns 'passed' or 'failed' (not 'no result found').", + "log_read shows fresh entries from the current browser session." + ], + "whatBadLooksLike": [ + "test_get_result still returns 'no result found' after recovery steps.", + "log_read shows no entries — devtools.js is still not connected." + ], + "whatToDoNext": [ + "If still no result: check that the DevGlide server port matches what devtools.js is configured for.", + "Check the browser console directly for devtools.js initialization errors.", + "As a last resort, fully close and reopen the browser, navigate to the app, and retry." + ], + "tags": ["example", "test", "no-result", "recovery"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/example-pass-with-errors.json b/src/apps/documentation/seed/example-pass-with-errors.json new file mode 100644 index 0000000..c22d58f --- /dev/null +++ b/src/apps/documentation/seed/example-pass-with-errors.json @@ -0,0 +1,36 @@ +{ + "id": "ex-pass-with-errors", + "type": "example", + "toolName": "devglide-test", + "scenario": "Scenario passes but logs show runtime exception", + "startingAssumptions": [ + "A test scenario just completed with status 'passed'.", + "You are about to check logs as part of the verification workflow." + ], + "toolSequence": [ + "test_get_result — confirms 'passed'.", + "log_read with level filter 'error' — read error-level log entries.", + "Identify the error: read the message, stack trace, and timestamp.", + "Correlate the error timestamp with the scenario steps to find the trigger.", + "Determine if the error is in app code (fixable) or third-party (document as noise).", + "If fixable: fix the app code, re-run the scenario, and re-check logs.", + "If third-party noise: document it in a project override via docs_add." + ], + "whatGoodLooksLike": [ + "After the fix, the scenario still passes AND logs are clean of unexpected errors.", + "If the error was third-party noise: a project override documents it for future runs." + ], + "whatBadLooksLike": [ + "The fix breaks the scenario — it now fails.", + "New errors appear after the fix.", + "The error is intermittent and hard to reproduce." + ], + "whatToDoNext": [ + "If the fix broke the scenario: the fix may be incorrect. Revert and investigate further.", + "If new errors appeared: the fix had side effects. Review the change carefully.", + "If intermittent: run the scenario multiple times and check logs each time to gather more data." + ], + "tags": ["example", "test", "log", "runtime-error", "false-positive"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/example-test-ui-flow.json b/src/apps/documentation/seed/example-test-ui-flow.json new file mode 100644 index 0000000..179bbff --- /dev/null +++ b/src/apps/documentation/seed/example-test-ui-flow.json @@ -0,0 +1,40 @@ +{ + "id": "ex-test-ui-flow", + "type": "example", + "toolName": "devglide-test", + "scenario": "Test a UI flow and inspect logs after run", + "startingAssumptions": [ + "The app dev server is running.", + "A browser page is open on the app with devtools.js loaded.", + "The DevGlide server is running on port 7000.", + "You know which UI flow to test (e.g. 'create a new item', 'submit a form')." + ], + "toolSequence": [ + "test_list_saved — check if a relevant saved scenario exists.", + "test_run_scenario with a natural language description of the flow (e.g. 'Navigate to the form page, fill in the name field with \"Test Club\", select a category, and click Submit. Verify a success message appears.')", + "Wait 3-5 seconds for the browser to consume and execute the scenario.", + "test_get_result — read the scenario outcome.", + "log_read with targetPath for the project log — read the browser console output after the run.", + "Assess: scenario passed + no unexpected errors in logs = verification success." + ], + "whatGoodLooksLike": [ + "test_get_result returns status 'passed' with all steps completed.", + "log_read shows normal app output — no errors, only expected info/debug messages.", + "The UI reflects the expected state after the flow (e.g. the new item appears in a list)." + ], + "whatBadLooksLike": [ + "test_get_result returns 'no result found' — the browser did not consume the scenario.", + "test_get_result returns status 'failed' with a step failure.", + "log_read shows error-level entries (unhandled exceptions, assertion failures).", + "test_get_result returns 'passed' but logs contain runtime errors." + ], + "whatToDoNext": [ + "If 'no result found': verify browser is open with devtools.js, then retry.", + "If a step failed: read the failure details, check the selector and timing, and fix the scenario or app code.", + "If logs show errors: diagnose the root cause from the error message and stack trace, fix the app, and re-run.", + "If the scenario passed but logs have errors: treat as failure — add assertions or fix the underlying error." + ], + "tags": ["example", "test", "log", "verification", "ui"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/tool-guide-devglide-log.json b/src/apps/documentation/seed/tool-guide-devglide-log.json new file mode 100644 index 0000000..ad1a6a1 --- /dev/null +++ b/src/apps/documentation/seed/tool-guide-devglide-log.json @@ -0,0 +1,48 @@ +{ + "id": "guide-devglide-log", + "type": "tool-guide", + "toolName": "devglide-log", + "summary": "Read and manage structured log files captured from browser console output and server-side processes. Used alongside devglide-test to verify that apps run without runtime errors.", + "executionModel": "File-based log capture. The browser-side sniffer (devtools.js) intercepts console.log/warn/error calls and POSTs them to the DevGlide server, which appends them to a log file. Server-side logs are captured separately. log_read returns recent entries from a target log file.", + "prerequisites": [ + "devtools.js must be loaded in the browser page for browser-side log capture.", + "The DevGlide server must be running to receive log POST requests.", + "A log session must be active — devtools.js creates a session on page load." + ], + "inputsExplained": { + "targetPath": "Path to the log file to read. For project-scoped logs, this is typically ~/.devglide/projects/{projectId}/logs/{project-name}-console.log. If omitted, reads the default DevGlide console log.", + "lines": "Number of recent lines to return. Defaults to 50.", + "level": "Optional filter by log level (log, warn, error, info, debug)." + }, + "resultSemantics": { + "entries_returned": "Log entries matching the query. Each entry includes timestamp, level, source, and message.", + "empty_result": "No log entries found. This may mean: no logs captured yet, wrong targetPath, or devtools.js not loaded.", + "error_entries": "Entries with level 'error' indicate runtime exceptions or failed assertions in the app." + }, + "preferredPatterns": [ + "Always read logs after every devglide-test scenario run — log review is mandatory for verification, not optional.", + "To find the correct log path for a project: the pattern is ~/.devglide/projects/{projectId}/logs/{project-name}-console.log where project-name is the name registered in DevGlide.", + "Filter by level 'error' first to quickly identify runtime failures.", + "Distinguish expected noise from real failures. Common expected noise includes: map tile 404s, font loading warnings, style sheet 404s from optional dependencies, and development-mode React warnings.", + "When a scenario passes but logs show errors, treat the errors as failures — a passing scenario does not mean the app is healthy." + ], + "antiPatterns": [ + "Do not skip log review after test runs. A scenario can pass while the app throws unhandled exceptions.", + "Do not assume an empty log means success — it may mean devtools.js is not capturing.", + "Do not treat all warnings as failures. Many frameworks emit development-mode warnings that are not bugs.", + "Do not read logs from a stale session. Check the session timestamp to ensure you are reading current output." + ], + "followUpChecks": [ + "After identifying errors in logs, correlate them with the scenario step that was executing at that timestamp.", + "If logs show errors but the scenario passed, the error may be in a background process or async operation not covered by the scenario steps." + ], + "commonFailures": [ + "No log entries despite running the app — devtools.js not loaded or posting to wrong server URL.", + "Log file path does not exist — project not registered in DevGlide or using wrong project name.", + "Logs show hundreds of entries — filter by level or use lines parameter to limit output." + ], + "seeAlso": ["devglide-test"], + "tags": ["log", "debugging", "verification", "console"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/tool-guide-devglide-test.json b/src/apps/documentation/seed/tool-guide-devglide-test.json new file mode 100644 index 0000000..5ee50f8 --- /dev/null +++ b/src/apps/documentation/seed/tool-guide-devglide-test.json @@ -0,0 +1,54 @@ +{ + "id": "guide-devglide-test", + "type": "tool-guide", + "toolName": "devglide-test", + "summary": "Run browser automation scenarios against an app instrumented with devtools.js. Scenarios are described in natural language, translated into browser commands, and executed inside a real browser page.", + "executionModel": "Browser-driven, not server-driven. Submitting a scenario via test_run_scenario or test_run_saved only queues work on the server. Actual execution happens inside the browser page where devtools.js is loaded — it polls the server for pending scenarios and runs them in-page. The server never drives the browser directly.", + "prerequisites": [ + "The app's dev server must be running and reachable at its expected URL.", + "A real browser page must be open on the app (not just a server process).", + "devtools.js must be loaded in the page — add to the app's HTML in development.", + "The browser session must be active — devtools.js polls the DevGlide server; if the tab is closed or navigated away, polling stops.", + "The DevGlide server must be running (default port 7000)." + ], + "inputsExplained": { + "scenario": "A natural-language description of what to test. DevGlide translates this into a sequence of browser commands (navigate, click, type, assert, wait, etc.).", + "target": "The base URL of the app under test. Resolved from the active project if not specified.", + "steps": "When using test_run_scenario with explicit steps, each step is a command object (e.g. { command: 'click', selector: '#submit' })." + }, + "resultSemantics": { + "passed": "All steps completed successfully. The scenario ran to completion without assertion failures.", + "failed": "One or more steps failed. Inspect the failed step details and check devglide-log for runtime errors.", + "no_result_found": "The browser has NOT consumed the scenario yet. This is NOT a test failure — it means devtools.js has not polled or the page is not open. Wait briefly and retry test_get_result, or verify the browser is open with devtools.js loaded." + }, + "preferredPatterns": [ + "Use click-based navigation after the initial page load. Simulate what a real user would do — click links, buttons, and menu items rather than navigating via URL.", + "Wait for state changes, not fixed timeouts. Use assertions or wait-for-element steps rather than arbitrary sleep durations.", + "Keep scenarios focused on one user flow. A scenario that tests club creation should not also test user settings.", + "Inspect devglide-log after every run — even if the scenario passes, the logs may contain runtime errors or unexpected warnings.", + "For stateful React forms with controlled inputs, add targeted data-testid attributes only where semantic selectors (role, label, placeholder) are unstable.", + "Prefer realistic data in scenarios — use plausible names, emails, and values rather than 'test123'." + ], + "antiPatterns": [ + "Do not treat 'no result found' as a normal test failure. It means the scenario was not consumed by the browser.", + "Do not use excessive navigate steps. Each full navigation can reset React state, unmount components, and break stateful flows.", + "Do not rely only on scenario pass/fail without reviewing logs. A passing scenario can mask runtime errors visible in the console.", + "Do not use fragile CSS selectors like nth-child or deeply nested class chains. Prefer data-testid, role, or label-based selectors.", + "Do not run scenarios against a page that has not finished loading. Wait for the app to be interactive before submitting." + ], + "followUpChecks": [ + "Read the browser log via devglide-log after every scenario run.", + "Distinguish expected noise (e.g. map tile 404s, style loading warnings) from real failures (unhandled exceptions, assertion errors).", + "If the scenario fails, check both the step failure details AND the logs — the root cause is often visible in the console before the step that failed." + ], + "commonFailures": [ + "Scenario queued but never runs — browser tab closed, devtools.js not loaded, or wrong target URL.", + "Selector works in DevTools but automation fails — element not yet rendered, inside shadow DOM, or hidden behind an overlay.", + "React controlled input does not update — automation types text but React state does not reflect it. Use dispatchEvent with InputEvent or interact via the React fiber.", + "Navigate causes full reload and context loss — stateful app loses form data mid-flow. Use click navigation instead." + ], + "seeAlso": ["devglide-log"], + "tags": ["test", "browser", "automation", "devtools"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/troubleshoot-controlled-input.json b/src/apps/documentation/seed/troubleshoot-controlled-input.json new file mode 100644 index 0000000..2b08fdd --- /dev/null +++ b/src/apps/documentation/seed/troubleshoot-controlled-input.json @@ -0,0 +1,27 @@ +{ + "id": "ts-controlled-input", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "React controlled input does not update as expected", + "likelyCauses": [ + "The automation sets the input value directly (e.g. element.value = 'text') but React does not recognize the change because no synthetic event was fired.", + "React controlled inputs require an InputEvent or Change event dispatched through the React event system to trigger state updates.", + "The input has a debounce or validation handler that delays or rejects the change." + ], + "howToDiagnose": [ + "Check if the input is controlled (has a value prop bound to React state).", + "After the type step, check whether the displayed value matches what was typed.", + "Look at React DevTools to see if the component state updated.", + "Check the browser console for React warnings about uncontrolled-to-controlled transitions." + ], + "howToFix": [ + "Use the type command which simulates individual key presses — this usually fires the correct events for React.", + "If type does not work, the scenario may need to dispatch a native InputEvent: new InputEvent('input', { bubbles: true, data: 'text' }).", + "For complex form fields (date pickers, rich text editors), interact through their UI controls rather than setting values directly.", + "Add a data-testid to the input and verify the value via assertion after typing." + ], + "whenToRetry": "After switching to the type command or implementing proper event dispatch.", + "tags": ["test", "react", "input", "controlled", "form"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/troubleshoot-expected-warnings.json b/src/apps/documentation/seed/troubleshoot-expected-warnings.json new file mode 100644 index 0000000..d2b8c64 --- /dev/null +++ b/src/apps/documentation/seed/troubleshoot-expected-warnings.json @@ -0,0 +1,27 @@ +{ + "id": "ts-expected-warnings", + "type": "troubleshooting", + "toolName": "devglide-log", + "symptom": "logs contain expected app-specific warnings", + "likelyCauses": [ + "The app has known non-critical warnings that appear during normal operation.", + "Common examples: map tile 404s when no local tileserver is running, font loading failures from CDN, stylesheet 404s from optional dependencies, React development-mode warnings.", + "These are not bugs — they are expected noise for the current development environment." + ], + "howToDiagnose": [ + "Check the log level — warnings (level: warn) are typically non-critical.", + "Check if the warning message matches known patterns for the app (e.g. 'Failed to load tile', '404 for /fonts/', 'React does not recognize the X prop').", + "Check project documentation or overrides for a list of known expected warnings.", + "If unsure, ask the user whether the warning is expected for their app." + ], + "howToFix": [ + "Do not treat expected warnings as failures in verification.", + "Document known expected warnings in a project override so other agents can distinguish them from real failures.", + "If the warning is unexpected: investigate whether a dependency is missing or misconfigured.", + "Filter log output by level 'error' to focus on real failures." + ], + "whenToRetry": "Not applicable — this is about correct interpretation, not a fixable failure.", + "tags": ["log", "warnings", "noise", "expected"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/troubleshoot-navigate-reload.json b/src/apps/documentation/seed/troubleshoot-navigate-reload.json new file mode 100644 index 0000000..c6839d3 --- /dev/null +++ b/src/apps/documentation/seed/troubleshoot-navigate-reload.json @@ -0,0 +1,27 @@ +{ + "id": "ts-navigate-reload", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "navigate causes reload and context loss", + "likelyCauses": [ + "The scenario uses a navigate step that triggers a full page reload instead of client-side routing.", + "The app is a single-page app (SPA) but the navigate step goes to a different origin or uses a full URL that bypasses the client router.", + "React or framework state (form data, auth tokens, component state) is lost on full reload.", + "devtools.js must re-initialize after a full page reload, causing a gap in the polling loop." + ], + "howToDiagnose": [ + "Check the scenario steps — is there a navigate command mid-flow?", + "Check if the app uses client-side routing (React Router, Next.js, etc.).", + "Look at the browser network tab to confirm whether a full page load occurred." + ], + "howToFix": [ + "Replace navigate steps with click-based navigation. Click the link, button, or menu item that the user would click to reach that page.", + "If a navigate is necessary (e.g. initial page load), keep it as the first step only.", + "For SPAs, ensure the navigate URL uses the same origin and lets the client router handle routing.", + "If form state is lost: restructure the scenario to complete one form before navigating away." + ], + "whenToRetry": "After rewriting the scenario to use click-based navigation instead of navigate steps.", + "tags": ["test", "navigate", "reload", "spa", "state-loss"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/troubleshoot-no-result-found.json b/src/apps/documentation/seed/troubleshoot-no-result-found.json new file mode 100644 index 0000000..7b35c64 --- /dev/null +++ b/src/apps/documentation/seed/troubleshoot-no-result-found.json @@ -0,0 +1,29 @@ +{ + "id": "ts-no-result-found", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "test_get_result returns no result found", + "likelyCauses": [ + "The browser page is not open or the tab is inactive.", + "devtools.js is not loaded in the page.", + "The browser is open on a different URL than the target app.", + "devtools.js polling was interrupted (page navigated away, tab crashed, or JavaScript error blocked polling).", + "The DevGlide server restarted after the scenario was submitted but before the browser consumed it." + ], + "howToDiagnose": [ + "Check that a browser page is open on the target app URL.", + "Open the browser DevTools console and look for 'devglide' messages confirming devtools.js is active.", + "Check devglide-log for recent session entries — if there are none, devtools.js is not connected.", + "Verify the DevGlide server is running on the expected port (default 7000)." + ], + "howToFix": [ + "Open the app in a browser if no page is open.", + "Add to the app's development HTML if devtools.js is missing.", + "Refresh the browser page to restart the devtools.js polling loop.", + "Re-submit the scenario after confirming the browser is ready." + ], + "whenToRetry": "After confirming the browser page is open and devtools.js is loaded. Wait 2-5 seconds after the fix, then call test_get_result again.", + "tags": ["test", "no-result", "browser", "devtools"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/troubleshoot-pass-but-errors.json b/src/apps/documentation/seed/troubleshoot-pass-but-errors.json new file mode 100644 index 0000000..44b8396 --- /dev/null +++ b/src/apps/documentation/seed/troubleshoot-pass-but-errors.json @@ -0,0 +1,28 @@ +{ + "id": "ts-pass-but-errors", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "scenario passes but console logs show runtime error", + "likelyCauses": [ + "The runtime error occurs in async code (setTimeout, Promise, event handler) that is not part of the synchronous scenario execution flow.", + "The error occurs in a background component or service worker, not in the component being tested.", + "The scenario assertions do not check for error states — they only verify the happy path.", + "A race condition causes the error only sometimes, depending on timing." + ], + "howToDiagnose": [ + "Read the full log output after the scenario run. Filter by level 'error'.", + "Correlate the error timestamp with the scenario steps to identify which action triggered it.", + "Check if the error is in app code or a third-party library.", + "Run the scenario multiple times to check if the error is consistent or intermittent." + ], + "howToFix": [ + "Treat the runtime error as a real failure even though the scenario passed — the scenario assertions were incomplete.", + "Fix the runtime error in the app code.", + "Add assertion steps to the scenario that verify no error state is visible (e.g. check that no error toast or banner appeared).", + "If the error is in a third-party library and cannot be fixed: document it as expected noise in a project override." + ], + "whenToRetry": "After fixing the runtime error. Re-run the scenario and verify that both the scenario passes AND logs are clean.", + "tags": ["test", "log", "runtime-error", "false-positive"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/troubleshoot-scenario-never-runs.json b/src/apps/documentation/seed/troubleshoot-scenario-never-runs.json new file mode 100644 index 0000000..9b130f8 --- /dev/null +++ b/src/apps/documentation/seed/troubleshoot-scenario-never-runs.json @@ -0,0 +1,29 @@ +{ + "id": "ts-scenario-never-runs", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "scenario accepted but never runs", + "likelyCauses": [ + "devtools.js is loaded but polling is blocked by a JavaScript error in the app.", + "The app page is open but on a route that does not load devtools.js (e.g. a separate login page or error page).", + "A previous scenario is still running or stuck, blocking the queue.", + "The target URL in the scenario does not match the page currently open in the browser.", + "Browser DevTools is paused on a breakpoint, blocking script execution." + ], + "howToDiagnose": [ + "Check the browser console for JavaScript errors that may have halted devtools.js.", + "Verify the page URL matches the expected target for the scenario.", + "Check if a previous scenario result is pending via test_get_result.", + "Look for 'devglide runner' messages in the browser console confirming the polling loop is active." + ], + "howToFix": [ + "If a JS error is blocking devtools.js: fix the error or refresh the page.", + "If on the wrong page: navigate to the correct app URL.", + "If a previous scenario is stuck: refresh the page to clear the queue.", + "If DevTools is paused: resume execution." + ], + "whenToRetry": "After clearing the blocking condition. Re-submit the scenario — do not assume the old submission will eventually run.", + "tags": ["test", "scenario", "stuck", "polling"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/troubleshoot-selector-fails.json b/src/apps/documentation/seed/troubleshoot-selector-fails.json new file mode 100644 index 0000000..4a326a2 --- /dev/null +++ b/src/apps/documentation/seed/troubleshoot-selector-fails.json @@ -0,0 +1,30 @@ +{ + "id": "ts-selector-fails", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "selector works manually but automation fails", + "likelyCauses": [ + "The element is not yet rendered when the automation tries to find it (timing issue).", + "The element is inside a shadow DOM and the selector does not pierce it.", + "The element is hidden behind a modal, overlay, or loading spinner.", + "The selector relies on dynamically generated class names (e.g. CSS modules, styled-components) that change between builds.", + "The element is in an iframe that the automation does not target." + ], + "howToDiagnose": [ + "Add a wait-for-element step before the interaction step.", + "Check if the element is visible in the DOM at the time of the step (not hidden by CSS or not yet mounted).", + "Inspect whether the element is inside a shadow DOM boundary.", + "Check if the class names in the selector are stable across builds." + ], + "howToFix": [ + "Add a wait step before interacting with the element.", + "Use stable selectors: data-testid, role attributes, aria-label, or visible text content.", + "If the element is behind a modal: add a step to dismiss the modal first.", + "If class names are dynamic: add a data-testid attribute to the element in the app source code.", + "If inside shadow DOM: use the appropriate shadow DOM piercing selector or restructure the test to avoid it." + ], + "whenToRetry": "After updating the selector or adding appropriate wait steps.", + "tags": ["test", "selector", "timing", "dom"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/seed/workflow-verify-ui-flow.json b/src/apps/documentation/seed/workflow-verify-ui-flow.json new file mode 100644 index 0000000..914ea10 --- /dev/null +++ b/src/apps/documentation/seed/workflow-verify-ui-flow.json @@ -0,0 +1,56 @@ +{ + "id": "workflow-verify-ui-flow", + "type": "workflow", + "name": "verify-ui-flow-with-devglide-test-and-devglide-log", + "goal": "Verify a UI flow end-to-end using browser test scenarios and log review. This is the standard verification loop for confirming that an app feature works correctly.", + "toolsInvolved": ["devglide-test", "devglide-log"], + "preflight": [ + "Confirm the target app and its expected URL (check project config or ask the user).", + "Confirm the app's dev server is running. If not, start it via devglide-shell.", + "Confirm a browser page is open on the app with devtools.js loaded.", + "Confirm the browser session is active by checking devglide-log for recent session entries." + ], + "stepSequence": [ + "Identify the UI flow to verify (e.g. 'create a club', 'submit a form', 'navigate to settings').", + "Check if a saved test scenario already exists via test_list_saved. Reuse if available.", + "If no saved scenario exists, create a realistic scenario describing the user flow in natural language. Use click-based navigation, realistic data, and targeted assertions.", + "Run the scenario via test_run_saved or test_run_scenario.", + "Wait briefly (2-5 seconds) then check the result via test_get_result.", + "If test_get_result returns 'no result found', the browser has not consumed the scenario yet. Verify the browser is open and devtools.js is loaded. Retry test_get_result after a few seconds.", + "Read browser logs via log_read immediately after the run — filter for errors first.", + "Separate expected noise from true failures. Expected noise includes: map tile 404s, font/style loading warnings, React development warnings.", + "If the scenario failed: examine the failed step, correlate with log entries at that timestamp, and diagnose the root cause.", + "If the scenario passed but logs show runtime errors: treat as a failure. The error may be in async code not covered by scenario assertions.", + "Fix the identified issue in the application code.", + "Re-run the scenario and re-check logs. Repeat until the scenario passes AND logs are clean of unexpected errors.", + "Report the verification result: pass/fail, what was tested, any fixes applied, and any known non-blocking warnings." + ], + "successCriteria": [ + "The test scenario passes — all steps complete without assertion failures.", + "Browser logs contain no unexpected errors after the run.", + "Any app-specific expected noise is identified and excluded from failure assessment.", + "The verification result is reported clearly." + ], + "failureBranches": [ + "If devtools.js is not loaded: guide the user to add the script tag to their app's development HTML.", + "If the dev server is not running: start it via shell_run_command.", + "If 'no result found' persists after multiple retries: check that the browser tab is active and not on a different page.", + "If a selector fails: inspect the page structure, consider adding data-testid attributes for unstable elements.", + "If a controlled input does not update: use the appropriate input simulation technique for the framework (React needs InputEvent dispatch).", + "If logs show errors unrelated to the test flow: note them as pre-existing issues and focus on the target flow." + ], + "expectedOutputs": [ + "test_get_result with status 'passed' or 'failed' and step details.", + "log_read output showing browser console entries during and after the test run.", + "A clear verification report." + ], + "expectedNoise": [ + "Map tile 404s when no local tileserver is running.", + "Font or stylesheet loading warnings from CDN dependencies.", + "React development-mode warnings (e.g. key props, deprecated lifecycle methods).", + "Service worker registration messages." + ], + "tags": ["verification", "testing", "ui", "workflow", "devglide-test", "devglide-log"], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" +} diff --git a/src/apps/documentation/services/documentation-store.ts b/src/apps/documentation/services/documentation-store.ts new file mode 100644 index 0000000..826c3a5 --- /dev/null +++ b/src/apps/documentation/services/documentation-store.ts @@ -0,0 +1,460 @@ +import fs from 'fs/promises'; +import path from 'path'; +import type { DocEntry, DocSummary, DocType } from '../types.js'; +import { getActiveProject } from '../../../project-context.js'; +import { DOCUMENTATION_DIR, PROJECTS_DIR } from '../../../packages/paths.js'; +import { JsonFileStore } from '../../../packages/json-file-store.js'; +import { SEED_ENTRIES } from './seed-data.js'; + +/** + * Per-project and global documentation storage. + * One JSON file per entry for git-friendly diffs. + * Global: ~/.devglide/documentation/ + * Per-project: ~/.devglide/projects/{projectId}/documentation/ + */ +export class DocumentationStore extends JsonFileStore { + private static instance: DocumentationStore; + protected readonly baseDir = DOCUMENTATION_DIR; + + private seedDone = false; + + static getInstance(): DocumentationStore { + if (!DocumentationStore.instance) { + DocumentationStore.instance = new DocumentationStore(); + } + return DocumentationStore.instance; + } + + /** + * Write embedded seed entries into the global documentation directory + * if they do not already exist. Uses in-memory seed data so it works + * in both source mode (tsx) and bundled mode (dist/mcp/*.mjs). + */ + private async ensureSeeded(): Promise { + if (this.seedDone) return; + this.seedDone = true; + + const globalDir = this.getGlobalDir(); + await this.ensureDir(globalDir); + + for (const entry of SEED_ENTRIES) { + const targetPath = path.join(globalDir, `${entry.id}.json`); + try { + await fs.access(targetPath); + // Already exists — skip + } catch { + await fs.writeFile(targetPath, JSON.stringify(entry, null, 2)); + } + } + } + + // ── List ────────────────────────────────────────────────────────────────── + + async list(filter?: { type?: DocType; toolName?: string; tag?: string }): Promise { + await this.ensureSeeded(); + const seen = new Map(); + + const projectDir = this.getProjectDir(); + if (projectDir) { + for (const s of await this.scanDir(projectDir, 'project')) { + seen.set(s.id, s); + } + for (const s of await this.scanDir(this.getGlobalDir(), 'global')) { + if (!seen.has(s.id)) seen.set(s.id, s); + } + } else { + for (const s of await this.scanDir(this.getGlobalDir(), 'global')) { + if (!seen.has(s.id)) seen.set(s.id, s); + } + } + + let results = [...seen.values()]; + + if (filter?.type) { + results = results.filter((e) => e.type === filter.type); + } + if (filter?.toolName) { + const name = filter.toolName.toLowerCase(); + results = results.filter((e) => e.title.toLowerCase().includes(name) || e.summary.toLowerCase().includes(name)); + } + if (filter?.tag) { + results = results.filter((e) => e.tags.includes(filter.tag!)); + } + + return results; + } + + // ── Match (keyword search) ──────────────────────────────────────────────── + + async match(query: string): Promise { + const all = await this.list(); + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + if (terms.length === 0) return all; + + const scored: Array<{ entry: DocSummary; score: number }> = []; + + for (const entry of all) { + const haystack = [entry.title, entry.summary, ...entry.tags, entry.type].join(' ').toLowerCase(); + let score = 0; + for (const term of terms) { + if (haystack.includes(term)) score++; + } + if (score > 0) scored.push({ entry, score }); + } + + scored.sort((a, b) => b.score - a.score); + return scored.map((s) => s.entry); + } + + // ── Save (with validation) ──────────────────────────────────────────────── + + async save( + input: Omit & { id?: string; scope?: 'project' | 'global' }, + ): Promise { + // Validate required fields per type + this.validateEntry(input); + + const lockKey = input.id ?? this.generateId(); + return this.withLock(lockKey, async () => { + const now = new Date().toISOString(); + const isUpdate = !!input.id; + + let existing: DocEntry | null = null; + if (isUpdate) { + existing = await this.get(input.id!); + } + + const entry = { + ...input, + id: input.id ?? lockKey, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + } as DocEntry; + + let scope = input.scope; + if (!scope && isUpdate) { + scope = await this.resolveExistingScope(input.id!); + } + scope = scope ?? (getActiveProject() ? 'project' : 'global'); + + await this.writeEntity(entry, scope, getActiveProject()?.id); + return entry; + }); + } + + // ── Compiled context ────────────────────────────────────────────────────── + + async getCompiledContext(query?: string, projectId?: string): Promise { + await this.ensureSeeded(); + let entries: DocEntry[]; + + if (query) { + // match() calls list() which uses active project context. + // If projectId is specified, also scan that project dir explicitly. + const summaries = await this.match(query); + const fullEntries: DocEntry[] = []; + for (const s of summaries.slice(0, 10)) { + const full = await this.get(s.id); + if (full) fullEntries.push(full); + } + + // Merge in entries from the specified project dir if it differs from active + if (projectId) { + const projectOverrides = await this.listFullForProject(projectId); + for (const e of projectOverrides) { + if (!fullEntries.some((f) => f.id === e.id)) { + fullEntries.push(e); + } + } + } + + entries = fullEntries; + } else { + entries = await this.listFull(); + // Merge in entries from the specified project dir if it differs from active + if (projectId) { + const projectOverrides = await this.listFullForProject(projectId); + for (const e of projectOverrides) { + if (!entries.some((f) => f.id === e.id)) { + entries.push(e); + } + } + } + } + + if (projectId) { + // Filter out entries from other projects, keep global + target project + entries = entries.filter((e) => !e.projectId || e.projectId === projectId); + } + + if (entries.length === 0) return ''; + + const lines: string[] = ['# DevGlide Documentation Context', '']; + + const byType = new Map(); + for (const entry of entries) { + if (!byType.has(entry.type)) byType.set(entry.type, []); + byType.get(entry.type)!.push(entry); + } + + const typeLabels: Record = { + 'tool-guide': 'Tool Guides', + 'workflow': 'Workflows', + 'example': 'Examples', + 'troubleshooting': 'Troubleshooting', + 'project-override': 'Project Overrides', + }; + + for (const [type, typeEntries] of byType) { + lines.push(`## ${typeLabels[type] ?? type}`, ''); + for (const entry of typeEntries) { + lines.push(this.renderEntry(entry), ''); + } + } + + return lines.join('\n'); + } + + // ── Specific getters ────────────────────────────────────────────────────── + + async getToolGuide(toolName: string): Promise { + await this.ensureSeeded(); + const all = await this.listFull(); + const normalized = toolName.toLowerCase(); + return all.find((e) => e.type === 'tool-guide' && (e as any).toolName.toLowerCase() === normalized) ?? null; + } + + async getWorkflow(name: string): Promise { + await this.ensureSeeded(); + const all = await this.listFull(); + const normalized = name.toLowerCase(); + return all.find((e) => e.type === 'workflow' && (e as any).name.toLowerCase() === normalized) ?? null; + } + + async getTroubleshooting(toolName: string, symptom: string): Promise { + await this.ensureSeeded(); + const all = await this.listFull(); + const normalizedTool = toolName.toLowerCase(); + const normalizedSymptom = symptom.toLowerCase(); + + return all.filter((e) => { + if (e.type !== 'troubleshooting') return false; + const ts = e as any; + const toolMatch = ts.toolName.toLowerCase() === normalizedTool; + const symptomMatch = ts.symptom.toLowerCase().includes(normalizedSymptom); + return toolMatch && symptomMatch; + }); + } + + // ── Validation ──────────────────────────────────────────────────────────── + + private validateEntry(input: Record): void { + const type = input.type as string; + if (!type) throw new Error('type is required'); + + // Ensure tags is an array + if (!Array.isArray(input.tags)) input.tags = []; + + switch (type) { + case 'tool-guide': + this.requireStrings(input, ['toolName', 'summary', 'executionModel']); + this.requireArrays(input, ['prerequisites', 'preferredPatterns', 'antiPatterns', 'followUpChecks', 'commonFailures', 'seeAlso']); + if (!input.resultSemantics || typeof input.resultSemantics !== 'object') input.resultSemantics = {}; + if (!input.inputsExplained || typeof input.inputsExplained !== 'object') input.inputsExplained = {}; + break; + case 'workflow': + this.requireStrings(input, ['name', 'goal']); + this.requireArrays(input, ['toolsInvolved', 'preflight', 'stepSequence', 'successCriteria', 'failureBranches', 'expectedOutputs', 'expectedNoise']); + break; + case 'example': + this.requireStrings(input, ['toolName', 'scenario']); + this.requireArrays(input, ['startingAssumptions', 'toolSequence', 'whatGoodLooksLike', 'whatBadLooksLike', 'whatToDoNext']); + break; + case 'troubleshooting': + this.requireStrings(input, ['toolName', 'symptom']); + this.requireArrays(input, ['likelyCauses', 'howToDiagnose', 'howToFix']); + if (typeof input.whenToRetry !== 'string') input.whenToRetry = ''; + break; + case 'project-override': + this.requireStrings(input, ['targetToolName', 'notes']); + if (!input.overrides || typeof input.overrides !== 'object') input.overrides = {}; + break; + default: + throw new Error(`Unknown document type: ${type}`); + } + } + + private requireStrings(input: Record, fields: string[]): void { + for (const field of fields) { + if (typeof input[field] !== 'string') { + throw new Error(`${field} is required and must be a string`); + } + } + } + + private requireArrays(input: Record, fields: string[]): void { + for (const field of fields) { + if (!Array.isArray(input[field])) input[field] = []; + } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private async listFull(): Promise { + const seen = new Map(); + + const projectDir = this.getProjectDir(); + if (projectDir) { + for (const e of await this.scanDirFull(projectDir)) { + seen.set(e.id, e); + } + for (const e of await this.scanDirFull(this.getGlobalDir())) { + if (!seen.has(e.id)) seen.set(e.id, e); + } + } else { + for (const e of await this.scanDirFull(this.getGlobalDir())) { + if (!seen.has(e.id)) seen.set(e.id, e); + } + } + + return [...seen.values()]; + } + + /** + * Scan a specific project's documentation directory by projectId, + * regardless of which project is currently active. + */ + private async listFullForProject(projectId: string): Promise { + const featureName = path.basename(this.baseDir); + const projectDir = path.join(PROJECTS_DIR, projectId, featureName); + return this.scanDirFull(projectDir); + } + + private toSummary(entry: DocEntry, scope: 'project' | 'global'): DocSummary { + return { + id: entry.id, + type: entry.type, + title: this.getTitle(entry), + summary: this.getSummary(entry), + tags: entry.tags ?? [], + scope, + updatedAt: entry.updatedAt, + }; + } + + private getTitle(entry: DocEntry): string { + switch (entry.type) { + case 'tool-guide': return entry.toolName; + case 'workflow': return entry.name; + case 'example': return `${entry.toolName}: ${entry.scenario}`; + case 'troubleshooting': return `${entry.toolName}: ${entry.symptom}`; + case 'project-override': return `Override: ${entry.targetToolName}`; + } + } + + private getSummary(entry: DocEntry): string { + switch (entry.type) { + case 'tool-guide': return entry.summary; + case 'workflow': return entry.goal; + case 'example': return entry.scenario; + case 'troubleshooting': return entry.symptom; + case 'project-override': return entry.notes; + } + } + + private renderEntry(entry: DocEntry): string { + switch (entry.type) { + case 'tool-guide': + return [ + `### ${entry.toolName}`, + entry.summary, + '', + `**Execution model:** ${entry.executionModel}`, + '', + '**Prerequisites:**', + ...(entry.prerequisites ?? []).map((p) => `- ${p}`), + '', + '**Result semantics:**', + ...Object.entries(entry.resultSemantics ?? {}).map(([k, v]) => `- \`${k}\`: ${v}`), + '', + '**Preferred patterns:**', + ...(entry.preferredPatterns ?? []).map((p) => `- ${p}`), + '', + '**Anti-patterns:**', + ...(entry.antiPatterns ?? []).map((p) => `- ${p}`), + '', + '**Follow-up checks:**', + ...(entry.followUpChecks ?? []).map((c) => `- ${c}`), + ].join('\n'); + + case 'workflow': + return [ + `### ${entry.name}`, + entry.goal, + '', + `**Tools:** ${(entry.toolsInvolved ?? []).join(', ')}`, + '', + '**Steps:**', + ...(entry.stepSequence ?? []).map((s, i) => `${i + 1}. ${s}`), + '', + '**Success criteria:**', + ...(entry.successCriteria ?? []).map((c) => `- ${c}`), + '', + '**Failure branches:**', + ...(entry.failureBranches ?? []).map((f) => `- ${f}`), + ].join('\n'); + + case 'example': + return [ + `### ${entry.toolName}: ${entry.scenario}`, + '', + '**Starting assumptions:**', + ...(entry.startingAssumptions ?? []).map((a) => `- ${a}`), + '', + '**Tool sequence:**', + ...(entry.toolSequence ?? []).map((s, i) => `${i + 1}. ${s}`), + '', + '**Good outcome:**', + ...(entry.whatGoodLooksLike ?? []).map((g) => `- ${g}`), + '', + '**Bad outcome:**', + ...(entry.whatBadLooksLike ?? []).map((b) => `- ${b}`), + '', + '**Next steps if bad:**', + ...(entry.whatToDoNext ?? []).map((n) => `- ${n}`), + ].join('\n'); + + case 'troubleshooting': + return [ + `### ${entry.toolName}: ${entry.symptom}`, + '', + '**Likely causes:**', + ...(entry.likelyCauses ?? []).map((c) => `- ${c}`), + '', + '**How to diagnose:**', + ...(entry.howToDiagnose ?? []).map((d) => `- ${d}`), + '', + '**How to fix:**', + ...(entry.howToFix ?? []).map((f) => `- ${f}`), + '', + `**When to retry:** ${entry.whenToRetry ?? ''}`, + ].join('\n'); + + case 'project-override': + return [ + `### Override: ${entry.targetToolName}`, + entry.notes, + '', + '**Overrides:**', + '```json', + JSON.stringify(entry.overrides ?? {}, null, 2), + '```', + ].join('\n'); + } + } + + private async scanDir(dir: string, scope: 'project' | 'global'): Promise { + const entries = await this.scanDirFull(dir); + return entries.map((e) => this.toSummary(e, scope)); + } +} diff --git a/src/apps/documentation/services/seed-data.ts b/src/apps/documentation/services/seed-data.ts new file mode 100644 index 0000000..726a19d --- /dev/null +++ b/src/apps/documentation/services/seed-data.ts @@ -0,0 +1,598 @@ +import type { DocEntry } from '../types.js'; + +/** + * Embedded seed documentation entries. + * These are written to ~/.devglide/documentation/ on first use if not already present. + * Embedded in code so the bundled MCP server (dist/mcp/documentation.mjs) does not + * need to locate seed files on disk. + */ +export const SEED_ENTRIES: DocEntry[] = [ + { + "id": "ex-flaky-selector", + "type": "example", + "toolName": "devglide-test", + "scenario": "Flaky selector in React controlled form", + "startingAssumptions": [ + "A test scenario intermittently fails on a click or type step targeting a form element.", + "The selector works when tested manually in the browser DevTools.", + "The app uses React with controlled form inputs." + ], + "toolSequence": [ + "test_get_result — read the failure details. Note which step failed and the selector used.", + "Inspect the app source code to check if the element has stable attributes (id, data-testid, role, aria-label).", + "If the selector uses dynamic class names (CSS modules, styled-components): it will break between builds.", + "If the element is rendered conditionally or inside a transition: add a wait-for-element step before the interaction.", + "Add a data-testid attribute to the element in the app source code if no stable selector exists.", + "Update the scenario to use the data-testid selector and add a wait step.", + "Re-run the scenario multiple times to verify the fix is stable." + ], + "whatGoodLooksLike": [ + "The scenario passes consistently across multiple runs.", + "The selector uses a stable attribute that will not change between builds.", + "The wait step ensures the element is rendered before interaction." + ], + "whatBadLooksLike": [ + "The scenario still fails intermittently — the timing issue is not fully resolved.", + "Adding data-testid changes app behavior (unlikely but possible if the attribute conflicts)." + ], + "whatToDoNext": [ + "If still flaky: increase the wait timeout or add an assertion that the element is visible before interacting.", + "If the form field is a complex component (date picker, autocomplete): interact via its UI controls rather than targeting the underlying input directly.", + "Consider whether the React component needs a fix for accessibility — stable selectors often align with proper ARIA attributes." + ], + "tags": [ + "example", + "test", + "selector", + "react", + "form", + "flaky" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ex-no-result-recovery", + "type": "example", + "toolName": "devglide-test", + "scenario": "Scenario returns no result found — recovery steps", + "startingAssumptions": [ + "You submitted a scenario via test_run_scenario.", + "test_get_result returns 'no result found' after waiting." + ], + "toolSequence": [ + "test_get_result — confirm the 'no result found' response.", + "log_read — check if devtools.js is posting any log entries. If no recent entries, devtools.js is not active.", + "shell_run_command to check if the dev server process is running.", + "If the dev server is running: the issue is the browser. Ask the user to verify the browser tab is open on the app.", + "If devtools.js is not in the page source: instruct the user to add the devtools.js script tag.", + "After the browser is confirmed ready: re-submit the scenario via test_run_scenario.", + "test_get_result — should now return a real result." + ], + "whatGoodLooksLike": [ + "After recovery, test_get_result returns 'passed' or 'failed' (not 'no result found').", + "log_read shows fresh entries from the current browser session." + ], + "whatBadLooksLike": [ + "test_get_result still returns 'no result found' after recovery steps.", + "log_read shows no entries — devtools.js is still not connected." + ], + "whatToDoNext": [ + "If still no result: check that the DevGlide server port matches what devtools.js is configured for.", + "Check the browser console directly for devtools.js initialization errors.", + "As a last resort, fully close and reopen the browser, navigate to the app, and retry." + ], + "tags": [ + "example", + "test", + "no-result", + "recovery" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ex-pass-with-errors", + "type": "example", + "toolName": "devglide-test", + "scenario": "Scenario passes but logs show runtime exception", + "startingAssumptions": [ + "A test scenario just completed with status 'passed'.", + "You are about to check logs as part of the verification workflow." + ], + "toolSequence": [ + "test_get_result — confirms 'passed'.", + "log_read with level filter 'error' — read error-level log entries.", + "Identify the error: read the message, stack trace, and timestamp.", + "Correlate the error timestamp with the scenario steps to find the trigger.", + "Determine if the error is in app code (fixable) or third-party (document as noise).", + "If fixable: fix the app code, re-run the scenario, and re-check logs.", + "If third-party noise: document it in a project override via docs_add." + ], + "whatGoodLooksLike": [ + "After the fix, the scenario still passes AND logs are clean of unexpected errors.", + "If the error was third-party noise: a project override documents it for future runs." + ], + "whatBadLooksLike": [ + "The fix breaks the scenario — it now fails.", + "New errors appear after the fix.", + "The error is intermittent and hard to reproduce." + ], + "whatToDoNext": [ + "If the fix broke the scenario: the fix may be incorrect. Revert and investigate further.", + "If new errors appeared: the fix had side effects. Review the change carefully.", + "If intermittent: run the scenario multiple times and check logs each time to gather more data." + ], + "tags": [ + "example", + "test", + "log", + "runtime-error", + "false-positive" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ex-test-ui-flow", + "type": "example", + "toolName": "devglide-test", + "scenario": "Test a UI flow and inspect logs after run", + "startingAssumptions": [ + "The app dev server is running.", + "A browser page is open on the app with devtools.js loaded.", + "The DevGlide server is running on port 7000.", + "You know which UI flow to test (e.g. 'create a new item', 'submit a form')." + ], + "toolSequence": [ + "test_list_saved — check if a relevant saved scenario exists.", + "test_run_scenario with a natural language description of the flow (e.g. 'Navigate to the form page, fill in the name field with \"Test Club\", select a category, and click Submit. Verify a success message appears.')", + "Wait 3-5 seconds for the browser to consume and execute the scenario.", + "test_get_result — read the scenario outcome.", + "log_read with targetPath for the project log — read the browser console output after the run.", + "Assess: scenario passed + no unexpected errors in logs = verification success." + ], + "whatGoodLooksLike": [ + "test_get_result returns status 'passed' with all steps completed.", + "log_read shows normal app output — no errors, only expected info/debug messages.", + "The UI reflects the expected state after the flow (e.g. the new item appears in a list)." + ], + "whatBadLooksLike": [ + "test_get_result returns 'no result found' — the browser did not consume the scenario.", + "test_get_result returns status 'failed' with a step failure.", + "log_read shows error-level entries (unhandled exceptions, assertion failures).", + "test_get_result returns 'passed' but logs contain runtime errors." + ], + "whatToDoNext": [ + "If 'no result found': verify browser is open with devtools.js, then retry.", + "If a step failed: read the failure details, check the selector and timing, and fix the scenario or app code.", + "If logs show errors: diagnose the root cause from the error message and stack trace, fix the app, and re-run.", + "If the scenario passed but logs have errors: treat as failure — add assertions or fix the underlying error." + ], + "tags": [ + "example", + "test", + "log", + "verification", + "ui" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "guide-devglide-log", + "type": "tool-guide", + "toolName": "devglide-log", + "summary": "Read and manage structured log files captured from browser console output and server-side processes. Used alongside devglide-test to verify that apps run without runtime errors.", + "executionModel": "File-based log capture. The browser-side sniffer (devtools.js) intercepts console.log/warn/error calls and POSTs them to the DevGlide server, which appends them to a log file. Server-side logs are captured separately. log_read returns recent entries from a target log file.", + "prerequisites": [ + "devtools.js must be loaded in the browser page for browser-side log capture.", + "The DevGlide server must be running to receive log POST requests.", + "A log session must be active — devtools.js creates a session on page load." + ], + "inputsExplained": { + "targetPath": "Path to the log file to read. For project-scoped logs, this is typically ~/.devglide/projects/{projectId}/logs/{project-name}-console.log. If omitted, reads the default DevGlide console log.", + "lines": "Number of recent lines to return. Defaults to 50.", + "level": "Optional filter by log level (log, warn, error, info, debug)." + }, + "resultSemantics": { + "entries_returned": "Log entries matching the query. Each entry includes timestamp, level, source, and message.", + "empty_result": "No log entries found. This may mean: no logs captured yet, wrong targetPath, or devtools.js not loaded.", + "error_entries": "Entries with level 'error' indicate runtime exceptions or failed assertions in the app." + }, + "preferredPatterns": [ + "Always read logs after every devglide-test scenario run — log review is mandatory for verification, not optional.", + "To find the correct log path for a project: the pattern is ~/.devglide/projects/{projectId}/logs/{project-name}-console.log where project-name is the name registered in DevGlide.", + "Filter by level 'error' first to quickly identify runtime failures.", + "Distinguish expected noise from real failures. Common expected noise includes: map tile 404s, font loading warnings, style sheet 404s from optional dependencies, and development-mode React warnings.", + "When a scenario passes but logs show errors, treat the errors as failures — a passing scenario does not mean the app is healthy." + ], + "antiPatterns": [ + "Do not skip log review after test runs. A scenario can pass while the app throws unhandled exceptions.", + "Do not assume an empty log means success — it may mean devtools.js is not capturing.", + "Do not treat all warnings as failures. Many frameworks emit development-mode warnings that are not bugs.", + "Do not read logs from a stale session. Check the session timestamp to ensure you are reading current output." + ], + "followUpChecks": [ + "After identifying errors in logs, correlate them with the scenario step that was executing at that timestamp.", + "If logs show errors but the scenario passed, the error may be in a background process or async operation not covered by the scenario steps." + ], + "commonFailures": [ + "No log entries despite running the app — devtools.js not loaded or posting to wrong server URL.", + "Log file path does not exist — project not registered in DevGlide or using wrong project name.", + "Logs show hundreds of entries — filter by level or use lines parameter to limit output." + ], + "seeAlso": [ + "devglide-test" + ], + "tags": [ + "log", + "debugging", + "verification", + "console" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "guide-devglide-test", + "type": "tool-guide", + "toolName": "devglide-test", + "summary": "Run browser automation scenarios against an app instrumented with devtools.js. Scenarios are described in natural language, translated into browser commands, and executed inside a real browser page.", + "executionModel": "Browser-driven, not server-driven. Submitting a scenario via test_run_scenario or test_run_saved only queues work on the server. Actual execution happens inside the browser page where devtools.js is loaded — it polls the server for pending scenarios and runs them in-page. The server never drives the browser directly.", + "prerequisites": [ + "The app's dev server must be running and reachable at its expected URL.", + "A real browser page must be open on the app (not just a server process).", + "devtools.js must be loaded in the page — add to the app's HTML in development.", + "The browser session must be active — devtools.js polls the DevGlide server; if the tab is closed or navigated away, polling stops.", + "The DevGlide server must be running (default port 7000)." + ], + "inputsExplained": { + "scenario": "A natural-language description of what to test. DevGlide translates this into a sequence of browser commands (navigate, click, type, assert, wait, etc.).", + "target": "The base URL of the app under test. Resolved from the active project if not specified.", + "steps": "When using test_run_scenario with explicit steps, each step is a command object (e.g. { command: 'click', selector: '#submit' })." + }, + "resultSemantics": { + "passed": "All steps completed successfully. The scenario ran to completion without assertion failures.", + "failed": "One or more steps failed. Inspect the failed step details and check devglide-log for runtime errors.", + "no_result_found": "The browser has NOT consumed the scenario yet. This is NOT a test failure — it means devtools.js has not polled or the page is not open. Wait briefly and retry test_get_result, or verify the browser is open with devtools.js loaded." + }, + "preferredPatterns": [ + "Use click-based navigation after the initial page load. Simulate what a real user would do — click links, buttons, and menu items rather than navigating via URL.", + "Wait for state changes, not fixed timeouts. Use assertions or wait-for-element steps rather than arbitrary sleep durations.", + "Keep scenarios focused on one user flow. A scenario that tests club creation should not also test user settings.", + "Inspect devglide-log after every run — even if the scenario passes, the logs may contain runtime errors or unexpected warnings.", + "For stateful React forms with controlled inputs, add targeted data-testid attributes only where semantic selectors (role, label, placeholder) are unstable.", + "Prefer realistic data in scenarios — use plausible names, emails, and values rather than 'test123'." + ], + "antiPatterns": [ + "Do not treat 'no result found' as a normal test failure. It means the scenario was not consumed by the browser.", + "Do not use excessive navigate steps. Each full navigation can reset React state, unmount components, and break stateful flows.", + "Do not rely only on scenario pass/fail without reviewing logs. A passing scenario can mask runtime errors visible in the console.", + "Do not use fragile CSS selectors like nth-child or deeply nested class chains. Prefer data-testid, role, or label-based selectors.", + "Do not run scenarios against a page that has not finished loading. Wait for the app to be interactive before submitting." + ], + "followUpChecks": [ + "Read the browser log via devglide-log after every scenario run.", + "Distinguish expected noise (e.g. map tile 404s, style loading warnings) from real failures (unhandled exceptions, assertion errors).", + "If the scenario fails, check both the step failure details AND the logs — the root cause is often visible in the console before the step that failed." + ], + "commonFailures": [ + "Scenario queued but never runs — browser tab closed, devtools.js not loaded, or wrong target URL.", + "Selector works in DevTools but automation fails — element not yet rendered, inside shadow DOM, or hidden behind an overlay.", + "React controlled input does not update — automation types text but React state does not reflect it. Use dispatchEvent with InputEvent or interact via the React fiber.", + "Navigate causes full reload and context loss — stateful app loses form data mid-flow. Use click navigation instead." + ], + "seeAlso": [ + "devglide-log" + ], + "tags": [ + "test", + "browser", + "automation", + "devtools" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ts-controlled-input", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "React controlled input does not update as expected", + "likelyCauses": [ + "The automation sets the input value directly (e.g. element.value = 'text') but React does not recognize the change because no synthetic event was fired.", + "React controlled inputs require an InputEvent or Change event dispatched through the React event system to trigger state updates.", + "The input has a debounce or validation handler that delays or rejects the change." + ], + "howToDiagnose": [ + "Check if the input is controlled (has a value prop bound to React state).", + "After the type step, check whether the displayed value matches what was typed.", + "Look at React DevTools to see if the component state updated.", + "Check the browser console for React warnings about uncontrolled-to-controlled transitions." + ], + "howToFix": [ + "Use the type command which simulates individual key presses — this usually fires the correct events for React.", + "If type does not work, the scenario may need to dispatch a native InputEvent: new InputEvent('input', { bubbles: true, data: 'text' }).", + "For complex form fields (date pickers, rich text editors), interact through their UI controls rather than setting values directly.", + "Add a data-testid to the input and verify the value via assertion after typing." + ], + "whenToRetry": "After switching to the type command or implementing proper event dispatch.", + "tags": [ + "test", + "react", + "input", + "controlled", + "form" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ts-expected-warnings", + "type": "troubleshooting", + "toolName": "devglide-log", + "symptom": "logs contain expected app-specific warnings", + "likelyCauses": [ + "The app has known non-critical warnings that appear during normal operation.", + "Common examples: map tile 404s when no local tileserver is running, font loading failures from CDN, stylesheet 404s from optional dependencies, React development-mode warnings.", + "These are not bugs — they are expected noise for the current development environment." + ], + "howToDiagnose": [ + "Check the log level — warnings (level: warn) are typically non-critical.", + "Check if the warning message matches known patterns for the app (e.g. 'Failed to load tile', '404 for /fonts/', 'React does not recognize the X prop').", + "Check project documentation or overrides for a list of known expected warnings.", + "If unsure, ask the user whether the warning is expected for their app." + ], + "howToFix": [ + "Do not treat expected warnings as failures in verification.", + "Document known expected warnings in a project override so other agents can distinguish them from real failures.", + "If the warning is unexpected: investigate whether a dependency is missing or misconfigured.", + "Filter log output by level 'error' to focus on real failures." + ], + "whenToRetry": "Not applicable — this is about correct interpretation, not a fixable failure.", + "tags": [ + "log", + "warnings", + "noise", + "expected" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ts-navigate-reload", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "navigate causes reload and context loss", + "likelyCauses": [ + "The scenario uses a navigate step that triggers a full page reload instead of client-side routing.", + "The app is a single-page app (SPA) but the navigate step goes to a different origin or uses a full URL that bypasses the client router.", + "React or framework state (form data, auth tokens, component state) is lost on full reload.", + "devtools.js must re-initialize after a full page reload, causing a gap in the polling loop." + ], + "howToDiagnose": [ + "Check the scenario steps — is there a navigate command mid-flow?", + "Check if the app uses client-side routing (React Router, Next.js, etc.).", + "Look at the browser network tab to confirm whether a full page load occurred." + ], + "howToFix": [ + "Replace navigate steps with click-based navigation. Click the link, button, or menu item that the user would click to reach that page.", + "If a navigate is necessary (e.g. initial page load), keep it as the first step only.", + "For SPAs, ensure the navigate URL uses the same origin and lets the client router handle routing.", + "If form state is lost: restructure the scenario to complete one form before navigating away." + ], + "whenToRetry": "After rewriting the scenario to use click-based navigation instead of navigate steps.", + "tags": [ + "test", + "navigate", + "reload", + "spa", + "state-loss" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ts-no-result-found", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "test_get_result returns no result found", + "likelyCauses": [ + "The browser page is not open or the tab is inactive.", + "devtools.js is not loaded in the page.", + "The browser is open on a different URL than the target app.", + "devtools.js polling was interrupted (page navigated away, tab crashed, or JavaScript error blocked polling).", + "The DevGlide server restarted after the scenario was submitted but before the browser consumed it." + ], + "howToDiagnose": [ + "Check that a browser page is open on the target app URL.", + "Open the browser DevTools console and look for 'devglide' messages confirming devtools.js is active.", + "Check devglide-log for recent session entries — if there are none, devtools.js is not connected.", + "Verify the DevGlide server is running on the expected port (default 7000)." + ], + "howToFix": [ + "Open the app in a browser if no page is open.", + "Add to the app's development HTML if devtools.js is missing.", + "Refresh the browser page to restart the devtools.js polling loop.", + "Re-submit the scenario after confirming the browser is ready." + ], + "whenToRetry": "After confirming the browser page is open and devtools.js is loaded. Wait 2-5 seconds after the fix, then call test_get_result again.", + "tags": [ + "test", + "no-result", + "browser", + "devtools" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ts-pass-but-errors", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "scenario passes but console logs show runtime error", + "likelyCauses": [ + "The runtime error occurs in async code (setTimeout, Promise, event handler) that is not part of the synchronous scenario execution flow.", + "The error occurs in a background component or service worker, not in the component being tested.", + "The scenario assertions do not check for error states — they only verify the happy path.", + "A race condition causes the error only sometimes, depending on timing." + ], + "howToDiagnose": [ + "Read the full log output after the scenario run. Filter by level 'error'.", + "Correlate the error timestamp with the scenario steps to identify which action triggered it.", + "Check if the error is in app code or a third-party library.", + "Run the scenario multiple times to check if the error is consistent or intermittent." + ], + "howToFix": [ + "Treat the runtime error as a real failure even though the scenario passed — the scenario assertions were incomplete.", + "Fix the runtime error in the app code.", + "Add assertion steps to the scenario that verify no error state is visible (e.g. check that no error toast or banner appeared).", + "If the error is in a third-party library and cannot be fixed: document it as expected noise in a project override." + ], + "whenToRetry": "After fixing the runtime error. Re-run the scenario and verify that both the scenario passes AND logs are clean.", + "tags": [ + "test", + "log", + "runtime-error", + "false-positive" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ts-scenario-never-runs", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "scenario accepted but never runs", + "likelyCauses": [ + "devtools.js is loaded but polling is blocked by a JavaScript error in the app.", + "The app page is open but on a route that does not load devtools.js (e.g. a separate login page or error page).", + "A previous scenario is still running or stuck, blocking the queue.", + "The target URL in the scenario does not match the page currently open in the browser.", + "Browser DevTools is paused on a breakpoint, blocking script execution." + ], + "howToDiagnose": [ + "Check the browser console for JavaScript errors that may have halted devtools.js.", + "Verify the page URL matches the expected target for the scenario.", + "Check if a previous scenario result is pending via test_get_result.", + "Look for 'devglide runner' messages in the browser console confirming the polling loop is active." + ], + "howToFix": [ + "If a JS error is blocking devtools.js: fix the error or refresh the page.", + "If on the wrong page: navigate to the correct app URL.", + "If a previous scenario is stuck: refresh the page to clear the queue.", + "If DevTools is paused: resume execution." + ], + "whenToRetry": "After clearing the blocking condition. Re-submit the scenario — do not assume the old submission will eventually run.", + "tags": [ + "test", + "scenario", + "stuck", + "polling" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "ts-selector-fails", + "type": "troubleshooting", + "toolName": "devglide-test", + "symptom": "selector works manually but automation fails", + "likelyCauses": [ + "The element is not yet rendered when the automation tries to find it (timing issue).", + "The element is inside a shadow DOM and the selector does not pierce it.", + "The element is hidden behind a modal, overlay, or loading spinner.", + "The selector relies on dynamically generated class names (e.g. CSS modules, styled-components) that change between builds.", + "The element is in an iframe that the automation does not target." + ], + "howToDiagnose": [ + "Add a wait-for-element step before the interaction step.", + "Check if the element is visible in the DOM at the time of the step (not hidden by CSS or not yet mounted).", + "Inspect whether the element is inside a shadow DOM boundary.", + "Check if the class names in the selector are stable across builds." + ], + "howToFix": [ + "Add a wait step before interacting with the element.", + "Use stable selectors: data-testid, role attributes, aria-label, or visible text content.", + "If the element is behind a modal: add a step to dismiss the modal first.", + "If class names are dynamic: add a data-testid attribute to the element in the app source code.", + "If inside shadow DOM: use the appropriate shadow DOM piercing selector or restructure the test to avoid it." + ], + "whenToRetry": "After updating the selector or adding appropriate wait steps.", + "tags": [ + "test", + "selector", + "timing", + "dom" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + }, + { + "id": "workflow-verify-ui-flow", + "type": "workflow", + "name": "verify-ui-flow-with-devglide-test-and-devglide-log", + "goal": "Verify a UI flow end-to-end using browser test scenarios and log review. This is the standard verification loop for confirming that an app feature works correctly.", + "toolsInvolved": [ + "devglide-test", + "devglide-log" + ], + "preflight": [ + "Confirm the target app and its expected URL (check project config or ask the user).", + "Confirm the app's dev server is running. If not, start it via devglide-shell.", + "Confirm a browser page is open on the app with devtools.js loaded.", + "Confirm the browser session is active by checking devglide-log for recent session entries." + ], + "stepSequence": [ + "Identify the UI flow to verify (e.g. 'create a club', 'submit a form', 'navigate to settings').", + "Check if a saved test scenario already exists via test_list_saved. Reuse if available.", + "If no saved scenario exists, create a realistic scenario describing the user flow in natural language. Use click-based navigation, realistic data, and targeted assertions.", + "Run the scenario via test_run_saved or test_run_scenario.", + "Wait briefly (2-5 seconds) then check the result via test_get_result.", + "If test_get_result returns 'no result found', the browser has not consumed the scenario yet. Verify the browser is open and devtools.js is loaded. Retry test_get_result after a few seconds.", + "Read browser logs via log_read immediately after the run — filter for errors first.", + "Separate expected noise from true failures. Expected noise includes: map tile 404s, font/style loading warnings, React development warnings.", + "If the scenario failed: examine the failed step, correlate with log entries at that timestamp, and diagnose the root cause.", + "If the scenario passed but logs show runtime errors: treat as a failure. The error may be in async code not covered by scenario assertions.", + "Fix the identified issue in the application code.", + "Re-run the scenario and re-check logs. Repeat until the scenario passes AND logs are clean of unexpected errors.", + "Report the verification result: pass/fail, what was tested, any fixes applied, and any known non-blocking warnings." + ], + "successCriteria": [ + "The test scenario passes — all steps complete without assertion failures.", + "Browser logs contain no unexpected errors after the run.", + "Any app-specific expected noise is identified and excluded from failure assessment.", + "The verification result is reported clearly." + ], + "failureBranches": [ + "If devtools.js is not loaded: guide the user to add the script tag to their app's development HTML.", + "If the dev server is not running: start it via shell_run_command.", + "If 'no result found' persists after multiple retries: check that the browser tab is active and not on a different page.", + "If a selector fails: inspect the page structure, consider adding data-testid attributes for unstable elements.", + "If a controlled input does not update: use the appropriate input simulation technique for the framework (React needs InputEvent dispatch).", + "If logs show errors unrelated to the test flow: note them as pre-existing issues and focus on the target flow." + ], + "expectedOutputs": [ + "test_get_result with status 'passed' or 'failed' and step details.", + "log_read output showing browser console entries during and after the test run.", + "A clear verification report." + ], + "expectedNoise": [ + "Map tile 404s when no local tileserver is running.", + "Font or stylesheet loading warnings from CDN dependencies.", + "React development-mode warnings (e.g. key props, deprecated lifecycle methods).", + "Service worker registration messages." + ], + "tags": [ + "verification", + "testing", + "ui", + "workflow", + "devglide-test", + "devglide-log" + ], + "createdAt": "2026-03-24T00:00:00.000Z", + "updatedAt": "2026-03-24T00:00:00.000Z" + } +]; diff --git a/src/apps/documentation/src/index.ts b/src/apps/documentation/src/index.ts new file mode 100644 index 0000000..2ea9833 --- /dev/null +++ b/src/apps/documentation/src/index.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { createDocumentationMcpServer } from "./mcp.js"; +import { runStdio } from "../../../packages/mcp-utils/src/index.js"; + +// ── Stdio MCP mode ────────────────────────────────────────────────────────── +if (process.argv.includes("--stdio")) { + const server = createDocumentationMcpServer(); + await runStdio(server); + console.error("Devglide Documentation MCP server running on stdio"); +} diff --git a/src/apps/documentation/src/mcp.ts b/src/apps/documentation/src/mcp.ts new file mode 100644 index 0000000..d2873b8 --- /dev/null +++ b/src/apps/documentation/src/mcp.ts @@ -0,0 +1,216 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { DocumentationStore } from '../services/documentation-store.js'; +import { jsonResult, errorResult, createDevglideMcpServer } from '../../../packages/mcp-utils/src/index.js'; +import type { DocType } from '../types.js'; + +export function createDocumentationMcpServer(): McpServer { + const server = createDevglideMcpServer( + 'devglide-documentation', + '0.1.0', + 'Operational guidance for DevGlide tools — workflows, troubleshooting, examples', + { + instructions: [ + '## Documentation — Usage Conventions', + '', + '### Purpose', + '- The documentation server provides operational guidance that tool schemas alone cannot carry.', + '- It answers: how does this tool execute, what must be running, what does a failure mean, what to do next.', + '- Content types: tool guides, workflows, examples, troubleshooting, project overrides.', + '', + '### When to use', + '- **Before using devglide-test or devglide-log** for a verification task, call `docs_context` with your task description to get the full operational loop.', + '- **When a tool run fails with a known symptom**, call `docs_get_troubleshooting` or `docs_match` to find diagnosis and fix guidance.', + '- **To discover available documentation**, call `docs_list` or `docs_match` with a keyword query.', + '', + '### Reading documentation', + '- Use `docs_list` to browse all available documentation entries, optionally filtered by type or tool name.', + '- Use `docs_match` to search documentation by keyword query — returns ranked results.', + '- Use `docs_get_tool_guide` to get the full operational guide for a specific tool.', + '- Use `docs_get_workflow` to get a step-by-step workflow by name.', + '- Use `docs_get_troubleshooting` to find troubleshooting entries by tool name and symptom.', + '- Use `docs_context` to get a compiled markdown bundle relevant to a task query — best for injection into your working context.', + '', + '### Writing documentation', + '- Use `docs_add` to create a new documentation entry (tool guide, workflow, example, troubleshooting, or project override).', + '- Use `docs_update` to modify an existing entry by ID.', + '- Use `docs_remove` to delete an entry by ID.', + ], + }, + ); + + const store = DocumentationStore.getInstance(); + + // ── 1. docs_list ────────────────────────────────────────────────────────── + + server.tool( + 'docs_list', + 'List all documentation entries. Optionally filter by type, tool name, or tag.', + { + type: z.string().optional().describe('Filter by content type: tool-guide, workflow, example, troubleshooting, project-override'), + toolName: z.string().optional().describe('Filter by tool name (e.g. "devglide-test")'), + tag: z.string().optional().describe('Filter by tag'), + }, + async ({ type, toolName, tag }) => { + const entries = await store.list({ + type: type as DocType | undefined, + toolName, + tag, + }); + return jsonResult(entries); + }, + ); + + // ── 2. docs_match ───────────────────────────────────────────────────────── + + server.tool( + 'docs_match', + 'Search documentation by keyword query. Returns ranked summaries with IDs for discovery.', + { + query: z.string().describe('Search query — keywords to match against titles, summaries, tags, and types'), + }, + async ({ query }) => { + const results = await store.match(query); + return jsonResult(results); + }, + ); + + // ── 3. docs_get_tool_guide ──────────────────────────────────────────────── + + server.tool( + 'docs_get_tool_guide', + 'Get the full operational guide for a specific tool. Returns execution model, prerequisites, result semantics, patterns, and anti-patterns.', + { + toolName: z.string().describe('Tool name (e.g. "devglide-test", "devglide-log")'), + }, + async ({ toolName }) => { + const guide = await store.getToolGuide(toolName); + if (!guide) return errorResult(`No tool guide found for "${toolName}"`); + return jsonResult(guide); + }, + ); + + // ── 4. docs_get_workflow ────────────────────────────────────────────────── + + server.tool( + 'docs_get_workflow', + 'Get a step-by-step workflow by name. Returns the full sequence with preflight, steps, success criteria, and failure branches.', + { + name: z.string().describe('Workflow name (e.g. "verify-ui-flow-with-devglide-test-and-devglide-log")'), + }, + async ({ name }) => { + const workflow = await store.getWorkflow(name); + if (!workflow) return errorResult(`No workflow found for "${name}"`); + return jsonResult(workflow); + }, + ); + + // ── 5. docs_get_troubleshooting ─────────────────────────────────────────── + + server.tool( + 'docs_get_troubleshooting', + 'Find troubleshooting entries by tool name and symptom. Returns likely causes, diagnosis steps, and fix instructions.', + { + toolName: z.string().describe('Tool name (e.g. "devglide-test")'), + symptom: z.string().describe('Symptom description (e.g. "no result found", "scenario never runs")'), + }, + async ({ toolName, symptom }) => { + const entries = await store.getTroubleshooting(toolName, symptom); + if (entries.length === 0) return errorResult(`No troubleshooting entry found for "${toolName}" with symptom "${symptom}"`); + return jsonResult(entries); + }, + ); + + // ── 6. docs_context ─────────────────────────────────────────────────────── + + server.tool( + 'docs_context', + 'Get compiled documentation as markdown for a task query. Returns the most relevant tool guides, workflows, examples, and troubleshooting bundled for LLM context injection.', + { + query: z.string().optional().describe('Task description to match relevant docs (e.g. "test club creation and verify logs"). Omit to get all docs.'), + projectId: z.string().optional().describe('Optional project ID to include project-specific overrides'), + }, + async ({ query, projectId }) => { + const markdown = await store.getCompiledContext(query, projectId); + return { + content: [{ type: 'text' as const, text: markdown || 'No documentation entries found.' }], + }; + }, + ); + + // ── 7. docs_add ─────────────────────────────────────────────────────────── + + server.tool( + 'docs_add', + 'Create a new documentation entry. Provide the full content as a JSON string matching the content type schema.', + { + type: z.string().describe('Content type: tool-guide, workflow, example, troubleshooting, project-override'), + content: z.string().describe('JSON string with the full entry content (all fields for the chosen type)'), + }, + async ({ type, content }) => { + let parsed: Record; + try { + parsed = JSON.parse(content); + } catch { + return errorResult('Invalid JSON in content field'); + } + + parsed.type = type; + if (!parsed.tags) parsed.tags = []; + + try { + const entry = await store.save(parsed as any); + return jsonResult(entry); + } catch (err) { + return errorResult(`Validation failed: ${(err as Error).message}`); + } + }, + ); + + // ── 8. docs_update ──────────────────────────────────────────────────────── + + server.tool( + 'docs_update', + 'Update an existing documentation entry by ID. Provide only the fields to change as a JSON string.', + { + id: z.string().describe('Entry ID'), + content: z.string().describe('JSON string with the fields to update'), + }, + async ({ id, content }) => { + const existing = await store.get(id); + if (!existing) return errorResult('Entry not found'); + + let updates: Record; + try { + updates = JSON.parse(content); + } catch { + return errorResult('Invalid JSON in content field'); + } + + const merged = { ...existing, ...updates, id, type: existing.type }; + try { + const entry = await store.save(merged as any); + return jsonResult(entry); + } catch (err) { + return errorResult(`Validation failed: ${(err as Error).message}`); + } + }, + ); + + // ── 9. docs_remove ──────────────────────────────────────────────────────── + + server.tool( + 'docs_remove', + 'Remove a documentation entry by ID.', + { + id: z.string().describe('Entry ID'), + }, + async ({ id }) => { + const deleted = await store.delete(id); + if (!deleted) return errorResult('Entry not found'); + return jsonResult({ ok: true }); + }, + ); + + return server; +} diff --git a/src/apps/documentation/tsconfig.json b/src/apps/documentation/tsconfig.json new file mode 100644 index 0000000..ef860bf --- /dev/null +++ b/src/apps/documentation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../packages/tsconfig/node.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "mcp.ts", "types.ts", "services"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/apps/documentation/types.ts b/src/apps/documentation/types.ts new file mode 100644 index 0000000..55ea812 --- /dev/null +++ b/src/apps/documentation/types.ts @@ -0,0 +1,99 @@ +// ── Content types ───────────────────────────────────────────────────────────── + +export interface ToolGuide { + id: string; + type: 'tool-guide'; + toolName: string; + summary: string; + executionModel: string; + prerequisites: string[]; + inputsExplained: Record; + resultSemantics: Record; + preferredPatterns: string[]; + antiPatterns: string[]; + followUpChecks: string[]; + commonFailures: string[]; + seeAlso: string[]; + tags: string[]; + projectId?: string; + createdAt: string; + updatedAt: string; +} + +export interface DocWorkflow { + id: string; + type: 'workflow'; + name: string; + goal: string; + toolsInvolved: string[]; + preflight: string[]; + stepSequence: string[]; + successCriteria: string[]; + failureBranches: string[]; + expectedOutputs: string[]; + expectedNoise: string[]; + tags: string[]; + projectId?: string; + createdAt: string; + updatedAt: string; +} + +export interface DocExample { + id: string; + type: 'example'; + toolName: string; + scenario: string; + startingAssumptions: string[]; + toolSequence: string[]; + whatGoodLooksLike: string[]; + whatBadLooksLike: string[]; + whatToDoNext: string[]; + tags: string[]; + projectId?: string; + createdAt: string; + updatedAt: string; +} + +export interface Troubleshooting { + id: string; + type: 'troubleshooting'; + toolName: string; + symptom: string; + likelyCauses: string[]; + howToDiagnose: string[]; + howToFix: string[]; + whenToRetry: string; + tags: string[]; + projectId?: string; + createdAt: string; + updatedAt: string; +} + +export interface ProjectOverride { + id: string; + type: 'project-override'; + targetToolName: string; + overrides: Record; + notes: string; + tags: string[]; + projectId?: string; + createdAt: string; + updatedAt: string; +} + +// ── Union and summary types ────────────────────────────────────────────────── + +export type DocEntry = ToolGuide | DocWorkflow | DocExample | Troubleshooting | ProjectOverride; + +export type DocType = DocEntry['type']; + +export interface DocSummary { + id: string; + type: DocType; + /** Primary label: toolName, name, or scenario depending on type */ + title: string; + summary: string; + tags: string[]; + scope: 'project' | 'global'; + updatedAt: string; +} diff --git a/src/apps/kanban/public/favicon.svg b/src/apps/kanban/public/favicon.svg deleted file mode 100644 index 85b3c77..0000000 --- a/src/apps/kanban/public/favicon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/apps/kanban/public/page.css b/src/apps/kanban/public/page.css index 1c6cbe9..fca9df2 100644 --- a/src/apps/kanban/public/page.css +++ b/src/apps/kanban/public/page.css @@ -1,10 +1,9 @@ +/* ── Kanban — App-specific styles ────────────────────────────────────────────── */ +/* Common primitives (page root, header, scrollbar) are in shared-ui.css. */ + .page-kanban { - font-family: var(--df-font-mono); - background: var(--df-color-bg-base); - color: var(--df-color-text-primary); - -webkit-font-smoothing: antialiased; - min-height: 100vh; font-size: var(--df-font-size-sm); + min-height: 100vh; overflow: hidden; position: relative; } @@ -14,28 +13,6 @@ text-decoration: none; } -/* ── App Header ──────────────────────────────────────────────────────────── */ - -.page-kanban .app-header { - display: flex; - align-items: center; - gap: var(--df-space-2); - height: 38px; - padding: 0 var(--df-space-4); - background: var(--df-color-bg-surface); - border-bottom: 1px solid var(--df-color-border-default); - flex-shrink: 0; -} - -.page-kanban .app-name { - font-size: var(--df-font-size-md); - font-weight: normal; - color: var(--df-color-accent-default); - font-family: var(--df-font-mono); - letter-spacing: var(--df-letter-spacing-wider); - text-transform: uppercase; -} - .page-kanban .board-edit-btn { background: none; border: none; @@ -54,7 +31,7 @@ background: var(--df-bg-hover); } -.page-kanban .header-actions { +.page-kanban .toolbar-actions { margin-left: auto; display: flex; align-items: center; diff --git a/src/apps/kanban/public/page.js b/src/apps/kanban/public/page.js index 18103f5..cad0652 100644 --- a/src/apps/kanban/public/page.js +++ b/src/apps/kanban/public/page.js @@ -4,7 +4,10 @@ // This replaces the iframe-based page module with a fully native implementation. // All DOM queries are scoped to `_root` (the container). -import { escapeHtml, escapeAttr, normalizeEscapes } from '/shared-assets/ui-utils.js'; +import { escapeHtml, escapeAttr, normalizeEscapes, sanitizeHtml } from '/shared-assets/ui-utils.js'; +import { createHeader } from '/shared-ui/components/header.js'; +import { showToast as suiToast, clearToasts } from '/shared-ui/components/toast.js'; +import { confirmModal } from '/shared-ui/components/modal.js'; let _root = null; let _projectId = null; @@ -56,20 +59,9 @@ function apiFetch(url, options = {}) { return fetch(url, { ...options, headers }); } -let _toastTimer = null; function showToast(msg, type = 'error') { if (!_root) return; - let toast = _root.querySelector('.toast'); - if (!toast) { - toast = document.createElement('div'); - toast.className = 'toast'; - _root.appendChild(toast); - } - toast.textContent = msg; - toast.dataset.type = type; - toast.classList.add('visible'); - clearTimeout(_toastTimer); - _toastTimer = setTimeout(() => toast.classList.remove('visible'), 4000); + suiToast(_root, msg, type); } // ── Scoped query helpers ───────────────────────────────────────────────────── @@ -84,6 +76,7 @@ const FEATURE_COLORS = [ '#f59e0b', '#22c55e', '#06b6d4', '#3b82f6', ]; +// Canonical values: src/packages/shared-types/src/index.ts (KANBAN_PRIORITIES) const PRIORITY_LABELS = { LOW: 'Low', MEDIUM: 'Medium', HIGH: 'High', URGENT: 'Urgent' }; // ── State ──────────────────────────────────────────────────────────────────── @@ -95,6 +88,7 @@ let boardPollTimer = null; let isDragging = false; let isDialogOpen = false; let searchQuery = ''; +let featureSearchQuery = ''; let sortableInstances = []; let selectedColor = FEATURE_COLORS[0]; let deleteTargetFeature = null; @@ -128,24 +122,59 @@ function renderFeatureList() { Board updated
-
- Kanban -
- - + ${createHeader({ + brand: 'Kanban', + meta: '', + actions: '', + })} +
+
${_getDialogHTML()} `; $('[data-action="new-feature"]')?.addEventListener('click', openNewFeatureDialog); + initFeatureSearch(); _bindModalOverlays(); renderFeatures(); } +function initFeatureSearch() { + const input = $('[data-field="feature-search-input"]'); + const clear = $('[data-action="feature-search-clear"]'); + if (!input) return; + + input.addEventListener('input', () => { + featureSearchQuery = input.value; + clear?.classList.toggle('hidden', !featureSearchQuery); + renderFeatures(); + }); + + clear?.addEventListener('click', () => { + featureSearchQuery = ''; + input.value = ''; + clear.classList.add('hidden'); + input.focus(); + renderFeatures(); + }); +} + +function getFilteredFeatures() { + let filtered = features; + const q = featureSearchQuery.toLowerCase().trim(); + if (q) { + filtered = filtered.filter(f => + f.name.toLowerCase().includes(q) || + (f.description || '').toLowerCase().includes(q) + ); + } + return filtered; +} + function renderFeatures() { const main = $('.features-container'); if (!main) return; @@ -164,10 +193,26 @@ function renderFeatures() { return; } + const filtered = getFilteredFeatures(); + const countLabel = featureSearchQuery + ? `${filtered.length} of ${features.length} feature${features.length !== 1 ? 's' : ''}` + : `${features.length} feature${features.length !== 1 ? 's' : ''}`; + + if (filtered.length === 0) { + main.innerHTML = ` +

${escapeHtml(countLabel)}

+
+
\u{1F50D}
+

No matching features

+
+ `; + return; + } + main.innerHTML = ` -

Features

+

${escapeHtml(countLabel)}

- ${features.map(f => ` + ${filtered.map(f => `
@@ -315,63 +360,19 @@ function _bindNewFeatureDialog() { // ── Delete Feature Dialog ──────────────────────────────────────────────────── -function openDeleteFeatureDialog(feature) { - deleteTargetFeature = feature; - const dialog = $('.modal-overlay[data-dialog="delete-feature"]'); - if (!dialog) return; - const msg = dialog.querySelector('[data-field="delete-feature-msg"]'); - let text = `Are you sure you want to delete ${escapeHtml(feature.name)}?`; +async function openDeleteFeatureDialog(feature) { + let message = `Are you sure you want to delete ${escapeHtml(feature.name)}?`; if (feature._count.issues > 0) { - text += ` This will permanently remove ${feature._count.issues} item${feature._count.issues !== 1 ? 's' : ''}.`; + message += ` This will permanently remove ${feature._count.issues} item${feature._count.issues !== 1 ? 's' : ''}.`; } - msg.innerHTML = text; - dialog.classList.remove('hidden'); -} - -function _bindDeleteFeatureDialog() { - const dialog = $('.modal-overlay[data-dialog="delete-feature"]'); - if (!dialog) return; - - dialog.querySelector('[data-action="df-cancel"]')?.addEventListener('click', () => { - dialog.classList.add('hidden'); - deleteTargetFeature = null; - }); - - dialog.querySelector('[data-action="df-confirm"]')?.addEventListener('click', async () => { - if (!deleteTargetFeature) return; - await apiFetch(`/api/kanban/features/${deleteTargetFeature.id}`, { method: 'DELETE' }); - features = features.filter(f => f.id !== deleteTargetFeature.id); - renderFeatures(); - dialog.classList.add('hidden'); - deleteTargetFeature = null; - }); + const ok = await confirmModal(_root, { title: 'Delete Feature', message, confirmLabel: 'Delete', confirmCls: 'btn-danger' }); + if (!ok) return; + await apiFetch(`/api/kanban/features/${feature.id}`, { method: 'DELETE' }); + features = features.filter(f => f.id !== feature.id); + renderFeatures(); } -function _bindDeleteIssueDialog() { - const dialog = $('.modal-overlay[data-dialog="delete-issue"]'); - if (!dialog) return; - - dialog.querySelector('[data-action="di-cancel"]')?.addEventListener('click', () => { - dialog.classList.add('hidden'); - $('.modal-overlay[data-dialog="issue"]')?.classList.remove('hidden'); - }); - - dialog.querySelector('[data-action="di-confirm"]')?.addEventListener('click', async () => { - const s = dialogState; - if (!s.issue) return; - dialog.classList.add('hidden'); - await apiFetch(`/api/kanban/issues/${s.issue.id}`, { method: 'DELETE' }); - s.onDelete(s.issue.id); - closeDialog(); - }); - - dialog.addEventListener('click', (e) => { - if (e.target === dialog) { - dialog.classList.add('hidden'); - $('.modal-overlay[data-dialog="issue"]')?.classList.remove('hidden'); - } - }); -} +// Delete issue dialog replaced by confirmModal() — no binding needed // ── Edit Feature Dialog ────────────────────────────────────────────────────── @@ -472,8 +473,6 @@ function _bindModalOverlays() { }); _bindNewFeatureDialog(); - _bindDeleteFeatureDialog(); - _bindDeleteIssueDialog(); _bindEditFeatureDialog(); } @@ -508,15 +507,11 @@ function renderBoardUI() { Board updated
-
+
- ${escapeHtml(f.name)} - -
- -
+
${escapeHtml(f.name)}
+
+
@@ -1101,7 +1104,7 @@ function bindDialogEvents() { writeDiv?.classList.add('hidden'); previewDiv?.classList.remove('hidden'); if (previewDiv) { - previewDiv.innerHTML = s.description ? marked.parse(normalizeEscapes(s.description)) : 'Nothing to preview'; + previewDiv.innerHTML = s.description ? sanitizeHtml(marked.parse(normalizeEscapes(s.description))) : 'Nothing to preview'; } } else { writeDiv?.classList.remove('hidden'); @@ -1214,15 +1217,15 @@ function bindDialogEvents() { // Cancel $('[data-action="dlg-cancel"]')?.addEventListener('click', closeDialog); - // Delete — show styled confirmation dialog - $('[data-action="dlg-delete"]')?.addEventListener('click', () => { + // Delete — show shared confirmation modal + $('[data-action="dlg-delete"]')?.addEventListener('click', async () => { if (!s.issue) return; - const dialog = $('.modal-overlay[data-dialog="delete-issue"]'); - if (!dialog) return; - const msg = dialog.querySelector('[data-field="delete-issue-msg"]'); - if (msg) msg.innerHTML = `Are you sure you want to delete ${escapeHtml(s.issue.title)}? This action cannot be undone.`; $('.modal-overlay[data-dialog="issue"]')?.classList.add('hidden'); - dialog.classList.remove('hidden'); + const ok = await confirmModal(_root, { title: 'Delete Item', message: `Are you sure you want to delete ${escapeHtml(s.issue.title)}? This action cannot be undone.`, confirmLabel: 'Delete', confirmCls: 'btn-danger' }); + if (!ok) { $('.modal-overlay[data-dialog="issue"]')?.classList.remove('hidden'); return; } + await apiFetch(`/api/kanban/issues/${s.issue.id}`, { method: 'DELETE' }); + s.onDelete(s.issue.id); + closeDialog(); }); // Close on overlay click @@ -1396,7 +1399,7 @@ function renderVersionedEntries() { v${e.version}
-
${marked.parse(normalizeEscapes(e.content))}
+
${sanitizeHtml(marked.parse(normalizeEscapes(e.content)))}
`).join(''); }; @@ -1437,33 +1440,7 @@ function _getDialogHTML() { - - - - - +