From d8fd7e8a612459e75d9df69b5a935a563e917367 Mon Sep 17 00:00:00 2001 From: tomdps <60640908+tomdps@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:01:46 +0100 Subject: [PATCH 1/4] Release (#171) Release dev to main --------- Co-authored-by: Eivind Meyer Co-authored-by: Claude Opus 4.5 Co-authored-by: Eivind Meyer Co-authored-by: Michael Eichelbeck <141341133+mkceichelbeck@users.noreply.github.com> Co-authored-by: Michael Eichelbeck --- CONTRIBUTING.md | 22 +- README.md | 3 +- cli/index.js | 14 +- .../base-templates/debug-workflow.json | 46 +- .../base-templates/full-workflow.json | 184 +++- .../base-templates/heavy-validation.json | 253 ++++++ .../base-templates/quick-validation.json | 285 ++++++ .../base-templates/single-worker.json | 10 +- .../base-templates/worker-validator.json | 32 +- cluster-templates/conductor-bootstrap.json | 14 +- docs/context-management.md | 182 ++++ docs/tui-v2/IMPLEMENTATION_PLAN.md | 583 +++++++++++++ docs/tui-v2/PRD.md | 302 +++++++ package-lock.json | 823 +++++++++++++++--- package.json | 6 +- src/agent-wrapper.js | 8 +- src/agent/agent-context-builder.js | 303 ++++--- src/agent/agent-lifecycle.js | 146 +++- src/agent/agent-task-executor.js | 140 ++- src/agent/context-metrics.js | 160 ++++ src/agent/context-pack-builder.js | 367 ++++++++ src/agent/output-extraction.js | 47 +- src/agent/validation-platform.js | 35 + src/agents/git-pusher-template.js | 11 + src/config-router.js | 4 +- src/config-validator.js | 57 +- src/issue-providers/azure-devops-provider.js | 38 +- src/issue-providers/github-provider.js | 24 +- src/issue-providers/gitlab-provider.js | 23 +- src/issue-providers/jira-provider.js | 23 +- src/message-bus-bridge.js | 9 +- src/orchestrator.js | 64 ++ src/preflight.js | 18 + src/providers/openai/models.js | 6 +- src/schemas/sub-cluster.js | 31 +- src/state-snapshot.js | 398 +++++++++ src/state-snapshotter.js | 142 +++ src/sub-cluster-wrapper.js | 50 +- src/tui/layout.js | 7 +- task-lib/attachable-watcher.js | 40 +- task-lib/claude-recovery.js | 22 + task-lib/tui/layout.js | 7 +- task-lib/watcher.js | 51 +- tests/cannot-validate-status.test.js | 23 + tests/config-validator.test.js | 75 +- tests/context-packs.test.js | 144 +++ tests/context-source-selection.test.js | 171 ++++ tests/integration/context-metrics.test.js | 133 +++ tests/nested-cluster.test.js | 55 ++ tests/state-snapshot.test.js | 381 ++++++++ tests/template-resolver.test.js | 16 +- tests/two-stage-validation.test.js | 195 +++++ .../unit/claude-fatal-error-detection.test.js | 48 + tests/unit/cli-provider-override.test.js | 74 ++ tests/unit/cli-resume-loads-clusters.test.js | 24 + tests/unit/context-metrics.test.js | 107 +++ .../output-extraction-fatal-strings.test.js | 30 + tests/validation-platform.test.js | 49 ++ 58 files changed, 6112 insertions(+), 403 deletions(-) create mode 100644 cluster-templates/base-templates/heavy-validation.json create mode 100644 cluster-templates/base-templates/quick-validation.json create mode 100644 docs/context-management.md create mode 100644 docs/tui-v2/IMPLEMENTATION_PLAN.md create mode 100644 docs/tui-v2/PRD.md create mode 100644 src/agent/context-metrics.js create mode 100644 src/agent/context-pack-builder.js create mode 100644 src/agent/validation-platform.js create mode 100644 src/state-snapshot.js create mode 100644 src/state-snapshotter.js create mode 100644 tests/context-packs.test.js create mode 100644 tests/context-source-selection.test.js create mode 100644 tests/integration/context-metrics.test.js create mode 100644 tests/state-snapshot.test.js create mode 100644 tests/two-stage-validation.test.js create mode 100644 tests/unit/claude-fatal-error-detection.test.js create mode 100644 tests/unit/cli-provider-override.test.js create mode 100644 tests/unit/cli-resume-loads-clusters.test.js create mode 100644 tests/unit/context-metrics.test.js create mode 100644 tests/unit/output-extraction-fatal-strings.test.js create mode 100644 tests/validation-platform.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1b81ec7..fe29475e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -574,21 +574,33 @@ See `src/logic-engine.js` for sandbox implementation. ### Context Building -Agents build context from ledger messages before executing: +Agents build context from ledger messages before executing. Use explicit selection semantics: -```javascript +```json { "contextStrategy": { "sources": [ - { "topic": "ISSUE_OPENED", "limit": 1 }, - { "topic": "VALIDATION_RESULT", "since": "last_task_end", "limit": 10 } + { "topic": "ISSUE_OPENED", "priority": "required", "strategy": "latest", "amount": 1 }, + { "topic": "STATE_SNAPSHOT", "priority": "required", "strategy": "latest", "amount": 1 }, + { + "topic": "VALIDATION_RESULT", + "priority": "high", + "since": "last_task_end", + "strategy": "latest", + "amount": 10, + "compactAmount": 3 + } ], "maxTokens": 100000 } } ``` -See `src/agent/agent-context-builder.js` for implementation. +Notes: `limit` is a deprecated alias for `amount`. If `amount` is set and `strategy` is omitted, +the default is `latest`; otherwise defaults to `all`. + +See `docs/context-management.md` for the full reference and diagrams. Implementation lives in +`src/agent/agent-context-builder.js`. ### Template Resolution diff --git a/README.md b/README.md index 04a46141..f6f29b8b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ and surface conflicts with details. Handle the ABA problem where version goes A- ## Install and Requirements -**Platforms**: Linux, macOS (Windows WSL not yet supported) +**Platforms**: Linux, macOS. Windows (native/WSL) is deferred while we harden reliability and multi-provider correctness. ```bash npm install -g @covibes/zeroshot @@ -439,6 +439,7 @@ zeroshot settings set dockerEnvPassthrough '["MY_API_KEY", "TF_VAR_*"]' - [CLAUDE.md](./CLAUDE.md) - Architecture, cluster config schema, agent primitives - `docs/providers.md` - Provider setup, model levels, and Docker mounts +- `docs/context-management.md` - Context selection, context packs, and state snapshots - [Discord](https://discord.gg/PdZ3UEXB) - Support and community - `zeroshot export ` - Export conversation to markdown - `sqlite3 ~/.zeroshot/*.db` - Direct ledger access for debugging diff --git a/cli/index.js b/cli/index.js index 821976a3..46dee9ea 100755 --- a/cli/index.js +++ b/cli/index.js @@ -237,10 +237,12 @@ function detectRunInput(inputArg) { return input; } -function resolveProviderOverride(options, settings) { - return normalizeProviderName( - options.provider || process.env.ZEROSHOT_PROVIDER || settings.defaultProvider - ); +function resolveProviderOverride(options) { + const override = options.provider || process.env.ZEROSHOT_PROVIDER; + if (!override || (typeof override === 'string' && !override.trim())) { + return null; + } + return normalizeProviderName(override); } function runClusterPreflight({ input, options, providerOverride, settings, forceProvider }) { @@ -2440,7 +2442,7 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps) // Auto-detect input type const input = detectRunInput(inputArg); const settings = loadSettings(); - const providerOverride = resolveProviderOverride(options, settings); + const providerOverride = resolveProviderOverride(options); // Preflight checks runClusterPreflight({ input, options, providerOverride, settings, forceProvider }); @@ -3006,7 +3008,7 @@ program try { // Try cluster first, then task (both use same ID format: "adjective-noun-number") const OrchestratorModule = require('../src/orchestrator'); - const orchestrator = new OrchestratorModule(); + const orchestrator = await OrchestratorModule.create(); // Check if cluster exists const cluster = orchestrator.getCluster(id); diff --git a/cluster-templates/base-templates/debug-workflow.json b/cluster-templates/base-templates/debug-workflow.json index c95130d3..7925fb51 100644 --- a/cluster-templates/base-templates/debug-workflow.json +++ b/cluster-templates/base-templates/debug-workflow.json @@ -120,7 +120,15 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 } ], "format": "chronological", @@ -164,16 +172,28 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "INVESTIGATION_COMPLETE", - "limit": 1 + "priority": "high", + "strategy": "latest", + "amount": 1 }, { "topic": "VALIDATION_RESULT", + "priority": "high", "since": "last_task_end", - "limit": 5 + "strategy": "latest", + "amount": 5 } ], "format": "chronological", @@ -333,16 +353,28 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "INVESTIGATION_COMPLETE", - "limit": 1 + "priority": "high", + "strategy": "latest", + "amount": 1 }, { "topic": "FIX_APPLIED", + "priority": "medium", "since": "last_agent_start", - "limit": 1 + "strategy": "latest", + "amount": 1 } ], "format": "chronological", diff --git a/cluster-templates/base-templates/full-workflow.json b/cluster-templates/base-templates/full-workflow.json index 80b25602..72ae777a 100644 --- a/cluster-templates/base-templates/full-workflow.json +++ b/cluster-templates/base-templates/full-workflow.json @@ -114,7 +114,15 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "medium", + "strategy": "latest", + "amount": 1 } ], "format": "chronological", @@ -193,21 +201,35 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "PLAN_READY", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "WORKER_PROGRESS", + "priority": "medium", "since": "last_task_end", - "limit": 3 + "strategy": "latest", + "amount": 3 }, { "topic": "VALIDATION_RESULT", + "priority": "high", "since": "last_task_end", - "limit": 10 + "strategy": "latest", + "amount": 10 } ], "format": "chronological", @@ -255,12 +277,84 @@ }, "maxIterations": "{{max_iterations}}" }, + { + "id": "meta-coordinator", + "role": "coordinator", + "modelLevel": "level1", + "timeout": "{{timeout}}", + "condition": "{{complexity}} == 'CRITICAL' && {{validator_count}} == 0", + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["load_quick", "load_heavy", "skip"] + } + }, + "required": ["action"] + }, + "prompt": { + "system": "Coordinate two-stage validation. On IMPLEMENTATION_READY: output {\"action\":\"load_quick\"}. On QUICK_VALIDATION_PASSED: output {\"action\":\"load_heavy\"}. On VALIDATION_RESULT from Stage 1 (rejection): output {\"action\":\"skip\"}" + }, + "contextStrategy": { + "sources": [ + { + "topic": "IMPLEMENTATION_READY", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "QUICK_VALIDATION_PASSED", + "priority": "medium", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "VALIDATION_RESULT", + "priority": "medium", + "strategy": "latest", + "amount": 1 + } + ], + "format": "chronological", + "maxTokens": "{{max_tokens}}" + }, + "triggers": [ + { + "topic": "IMPLEMENTATION_READY", + "action": "execute_task" + }, + { + "topic": "QUICK_VALIDATION_PASSED", + "action": "execute_task" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "CLUSTER_OPERATIONS" + }, + "transform": { + "engine": "javascript", + "script": "const template = result.action === 'load_quick' ? 'quick-validation' : 'heavy-validation'; return { topic: 'CLUSTER_OPERATIONS', content: { text: result.action === 'load_quick' ? 'Stage 1 (quick)' : 'Stage 2 (heavy)', data: { operations: [{ operation: 'load_config', config: { base: template, params: { validator_level: '{{validator_level}}', max_tokens: {{max_tokens}}, timeout: {{timeout}} } } }] } } };" + }, + "logic": { + "engine": "javascript", + "script": "return result.action !== 'skip';" + } + } + } + }, { "id": "validator-requirements", "role": "validator", "modelLevel": "{{validator_level}}", "timeout": "{{timeout}}", "maxRetries": 3, + "condition": "{{validator_count}} >= 1 && {{validator_count}} < 4", "outputFormat": "json", "jsonSchema": { "type": "object", @@ -316,7 +410,7 @@ } } }, - "required": ["approved", "summary", "criteriaResults"] + "required": ["approved", "summary"] }, "prompt": { "system": "# REQUIREMENTS VALIDATOR\n\nVerify implementation meets ALL requirements from issue. Hold a HIGH BAR.\n\n## WORKFLOW\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific validation\n2. Parse acceptanceCriteria from PLAN_READY\n3. For EACH criterion: run verification, record evidence\n4. If repo has validation script (e.g. `./scripts/check-all.sh`), RUN IT\n\n## VERIFICATION\n- SEARCH before claiming 'missing' (Glob, Grep, Read)\n- RUN commands, capture output as evidence\n- CANNOT_VALIDATE only for: tool not installed, no network, permission denied\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- 'Phase 2 deferred' = REJECT\n- 'Will add tests later' = REJECT\n- ANY priority=MUST criterion fails = REJECT\n\n## APPROVAL\n- approved:true = ALL MUST criteria pass + no blocking issues\n- approved:false = any MUST fails OR incomplete implementation\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON with these REQUIRED fields:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"blocking issue 1\", \"blocking issue 2\"],\n \"criteriaResults\": [{\"id\": \"AC1\", \"status\": \"PASS|FAIL|CANNOT_VALIDATE\", \"evidence\": {\"command\": \"...\", \"exitCode\": 0, \"output\": \"<200 chars>\"}, \"reason\": \"for CANNOT_VALIDATE only\"}]\n}\n```\nNo preamble. JSON only." @@ -325,16 +419,28 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "PLAN_READY", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "IMPLEMENTATION_READY", + "priority": "high", "since": "last_agent_start", - "limit": 1 + "strategy": "latest", + "amount": 1 } ], "format": "chronological", @@ -369,7 +475,7 @@ "modelLevel": "{{validator_level}}", "timeout": "{{timeout}}", "maxRetries": 3, - "condition": "{{validator_count}} >= 2", + "condition": "{{validator_count}} >= 2 && {{validator_count}} < 4", "outputFormat": "json", "jsonSchema": { "type": "object", @@ -396,16 +502,28 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "PLAN_READY", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "IMPLEMENTATION_READY", + "priority": "high", "since": "last_agent_start", - "limit": 1 + "strategy": "latest", + "amount": 1 } ], "format": "chronological", @@ -439,7 +557,7 @@ "modelLevel": "{{validator_level}}", "timeout": "{{timeout}}", "maxRetries": 3, - "condition": "{{validator_count}} >= 3", + "condition": "{{validator_count}} == 3", "outputFormat": "json", "jsonSchema": { "type": "object", @@ -466,16 +584,28 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "PLAN_READY", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "IMPLEMENTATION_READY", + "priority": "high", "since": "last_agent_start", - "limit": 1 + "strategy": "latest", + "amount": 1 } ], "format": "chronological", @@ -509,7 +639,7 @@ "modelLevel": "{{validator_level}}", "timeout": "{{timeout}}", "maxRetries": 3, - "condition": "{{validator_count}} >= 4", + "condition": "{{validator_count}} == 3", "outputFormat": "json", "jsonSchema": { "type": "object", @@ -539,16 +669,28 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "PLAN_READY", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "IMPLEMENTATION_READY", + "priority": "high", "since": "last_agent_start", - "limit": 1 + "strategy": "latest", + "amount": 1 } ], "format": "chronological", diff --git a/cluster-templates/base-templates/heavy-validation.json b/cluster-templates/base-templates/heavy-validation.json new file mode 100644 index 00000000..960f8518 --- /dev/null +++ b/cluster-templates/base-templates/heavy-validation.json @@ -0,0 +1,253 @@ +{ + "name": "Heavy Validation", + "description": "Stage 2: Security + Adversarial testing. Expensive (120-180s). Only runs after Stage 1 passes.", + "params": { + "validator_level": { + "type": "string", + "enum": ["level1", "level2", "level3"], + "default": "level2" + }, + "max_tokens": { + "type": "number", + "default": 100000 + }, + "timeout": { + "type": "number", + "default": 0, + "description": "Task timeout in milliseconds (0 = no timeout)" + } + }, + "agents": [ + { + "id": "validator-security", + "role": "validator", + "modelLevel": "{{validator_level}}", + "timeout": "{{timeout}}", + "maxRetries": 3, + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "approved": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["approved", "summary"] + }, + "prompt": { + "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\n## šŸ”“ READ CONTEXT FILES FOR REPO-SPECIFIC VALIDATION\n\n**BEFORE approving any implementation:**\n1. Read the repo's context files (CLAUDE.md, AGENTS.md, README if they exist)\n2. Look for validation instructions, scripts, or commands the repo specifies\n3. If context files say to run a validation script (e.g., `./scripts/check-all.sh`), RUN IT\n4. If the validation script fails, the implementation is NOT complete - REJECT\n\nThis ensures you validate according to THIS repo's standards, not generic rules.\n\n## šŸ”“ VERIFICATION PROTOCOL (REQUIRED - PREVENTS FALSE CLAIMS)\n\nBefore making ANY claim about security vulnerabilities or missing protections:\n\n1. **SEARCH FIRST** - Use Glob to find ALL relevant files\n2. **READ THE CODE** - Use Read to inspect actual implementation\n3. **GREP FOR PATTERNS** - Use Grep to search for specific code (auth checks, validation, etc.)\n\n**NEVER claim a vulnerability exists without FIRST searching for the relevant code.**\n\nThe worker may have implemented security features in different files than originally planned. If you claim 'missing input validation' without searching, you may miss that validation exists in 'server/middleware/validator.ts' instead of the controller.\n\n### Example Verification Flow:\n```\n1. Claim: 'Missing SQL injection protection'\n2. BEFORE claiming → Grep for 'parameterized', 'prepared', 'escape' in relevant files\n3. BEFORE claiming → Read the actual database query code\n4. ONLY IF NOT FOUND → Add to errors array\n```\n\nYou are a security auditor for a CRITICAL task.\n\n## Security Review Checklist\n1. Input validation (injection attacks)\n2. Authentication/authorization checks\n3. Sensitive data handling\n4. OWASP Top 10 vulnerabilities\n5. Secrets management\n6. Error messages don't leak info\n\n## Output\n- approved: true if no security issues\n- summary: Security assessment\n- errors: Security vulnerabilities found\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" + }, + "contextStrategy": { + "sources": [ + { + "topic": "ISSUE_OPENED", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "PLAN_READY", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "IMPLEMENTATION_READY", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "QUICK_VALIDATION_PASSED", + "priority": "required", + "strategy": "latest", + "amount": 1 + } + ], + "format": "chronological", + "maxTokens": "{{max_tokens}}" + }, + "triggers": [ + { + "topic": "QUICK_VALIDATION_PASSED", + "action": "execute_task" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "HEAVY_VALIDATION_RESULT", + "content": { + "text": "{{result.summary}}", + "data": { + "approved": "{{result.approved}}", + "errors": "{{result.errors}}", + "validatorId": "validator-security" + } + } + } + } + } + }, + { + "id": "validator-tester", + "role": "validator", + "modelLevel": "{{validator_level}}", + "timeout": "{{timeout}}", + "maxRetries": 3, + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "approved": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "testResults": { + "type": "string" + } + }, + "required": ["approved", "summary"] + }, + "prompt": { + "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n- testResults field: ONLY include pass/fail counts and key errors, NOT full test output\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a TEST EXECUTOR. Your job is to RUN TESTS, not read them.\n\n## šŸ”“ CORE PRINCIPLE: RUN THE TESTS, DON'T JUST READ THEM\n\n**Reading test code is NOT verification. You must EXECUTE tests.**\n\n- 'Tests look correct' = NOT ACCEPTABLE\n- 'Test output shows 15/15 passing' = ACTUAL VERIFICATION\n\n## šŸ”“ STEP 1: FIND AND RUN THE TEST SUITE (MANDATORY)\n\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific test commands\n2. Find the test runner: `npm test`, `pytest`, `go test`, `cargo test`, etc.\n3. **RUN THE TESTS** using Bash tool\n4. Record FULL output in testResults field\n5. If ANY tests fail → REJECT immediately\n\n**This is not optional. You MUST run tests, not just search for them.**\n\n## šŸ”“ STEP 2: RUN REPO-SPECIFIC VALIDATION\n\nIf context files specify validation commands (e.g., `./scripts/check-all.sh`):\n1. RUN THEM\n2. Record output\n3. If they fail → REJECT\n\n## šŸ”“ STEP 3: VERIFY TEST QUALITY BY RUNNING\n\n**DO NOT assess quality by reading code. Assess by execution:**\n\n1. Run tests with verbose output: `npm test -- --verbose`\n2. Check coverage: `npm test -- --coverage`\n3. Record actual numbers in testResults\n\n**Quality indicators from EXECUTION:**\n- Coverage percentage (from actual run)\n- Number of test cases (from actual output)\n- Test duration (from actual output)\n\n## FORBIDDEN PATTERNS\n\n- āŒ 'Tests appear to have good coverage' without running them\n- āŒ 'Test assertions look correct' without executing them\n- āŒ 'The test file exists' as evidence of testing\n- āŒ Approving without testResults containing actual test output\n\n## APPROVAL CRITERIA\n\nONLY approve if:\n1. You RAN the test suite (actual output in testResults)\n2. All tests pass (verified by execution)\n3. Repo-specific validation commands pass (if specified)\n4. Coverage is acceptable for the repo (from actual coverage report)\n\n## Output\n- **approved**: true if tests RAN and PASSED\n- **summary**: Assessment based on ACTUAL test execution results\n- **errors**: Issues found (from running tests, not reading code)\n- **testResults**: ACTUAL OUTPUT from running test commands (REQUIRED)\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" + }, + "contextStrategy": { + "sources": [ + { + "topic": "ISSUE_OPENED", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "PLAN_READY", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "IMPLEMENTATION_READY", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "QUICK_VALIDATION_PASSED", + "priority": "required", + "strategy": "latest", + "amount": 1 + } + ], + "format": "chronological", + "maxTokens": "{{max_tokens}}" + }, + "triggers": [ + { + "topic": "QUICK_VALIDATION_PASSED", + "action": "execute_task" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "HEAVY_VALIDATION_RESULT", + "content": { + "text": "{{result.summary}}", + "data": { + "approved": "{{result.approved}}", + "errors": "{{result.errors}}", + "testResults": "{{result.testResults}}", + "validatorId": "validator-tester" + } + } + } + } + } + }, + { + "id": "consensus-coordinator", + "role": "coordinator", + "modelLevel": "level1", + "timeout": "{{timeout}}", + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "allApproved": { + "type": "boolean" + }, + "summary": { + "type": "string" + } + }, + "required": ["allApproved", "summary"] + }, + "prompt": { + "system": "Check if both validators approved. Output: {\"allApproved\": boolean, \"summary\": \"<50 chars>\"}" + }, + "contextStrategy": { + "sources": [ + { + "topic": "HEAVY_VALIDATION_RESULT", + "priority": "required", + "strategy": "latest", + "amount": 2 + } + ], + "format": "chronological", + "maxTokens": "{{max_tokens}}" + }, + "triggers": [ + { + "topic": "HEAVY_VALIDATION_RESULT", + "logic": { + "engine": "javascript", + "script": "const results = ledger.query({ topic: 'HEAVY_VALIDATION_RESULT', since: ledger.findLast({ topic: 'QUICK_VALIDATION_PASSED' })?.timestamp || 0 }); return results.length === 2;" + }, + "action": "execute_task" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "transform": { + "engine": "javascript", + "script": "return { topic: 'VALIDATION_RESULT', content: { text: result.allApproved ? 'All validations passed' : 'Stage 2 rejected', data: { approved: result.allApproved, stage: 'heavy', summary: result.summary, errors: ledger.query({ topic: 'HEAVY_VALIDATION_RESULT' }).flatMap(r => r.content?.data?.errors || []) } } };" + } + } + } + } + ] +} diff --git a/cluster-templates/base-templates/quick-validation.json b/cluster-templates/base-templates/quick-validation.json new file mode 100644 index 00000000..bffabd89 --- /dev/null +++ b/cluster-templates/base-templates/quick-validation.json @@ -0,0 +1,285 @@ +{ + "name": "Quick Validation", + "description": "Stage 1: Requirements + Code validation. Fast feedback (30-60s).", + "params": { + "validator_level": { + "type": "string", + "enum": ["level1", "level2", "level3"], + "default": "level2" + }, + "max_tokens": { + "type": "number", + "default": 100000 + }, + "timeout": { + "type": "number", + "default": 0, + "description": "Task timeout in milliseconds (0 = no timeout)" + } + }, + "agents": [ + { + "id": "validator-requirements", + "role": "validator", + "modelLevel": "{{validator_level}}", + "timeout": "{{timeout}}", + "maxRetries": 3, + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "approved": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "criteriaResults": { + "type": "array", + "description": "Status for each acceptance criterion. PASS/FAIL require evidence. CANNOT_VALIDATE requires reason.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "AC1, AC2, etc. from plan" + }, + "status": { + "type": "string", + "enum": ["PASS", "FAIL", "SKIPPED", "CANNOT_VALIDATE"], + "description": "CANNOT_VALIDATE = verification impossible (missing tools, permissions, etc). Treated as PASS with warning." + }, + "evidence": { + "type": "object", + "description": "REQUIRED for PASS/FAIL. Proof of verification - actual command output.", + "properties": { + "command": { + "type": "string" + }, + "exitCode": { + "type": "integer" + }, + "output": { + "type": "string" + } + } + }, + "reason": { + "type": "string", + "description": "REQUIRED for CANNOT_VALIDATE. WHY verification is impossible (e.g., 'kubectl not installed', 'no SSH access')." + } + }, + "required": ["id", "status"] + } + } + }, + "required": ["approved", "summary"] + }, + "prompt": { + "system": "# REQUIREMENTS VALIDATOR\n\nVerify implementation meets ALL requirements from issue. Hold a HIGH BAR.\n\n## WORKFLOW\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific validation\n2. Parse acceptanceCriteria from PLAN_READY\n3. For EACH criterion: run verification, record evidence\n4. If repo has validation script (e.g. `./scripts/check-all.sh`), RUN IT\n\n## VERIFICATION\n- SEARCH before claiming 'missing' (Glob, Grep, Read)\n- RUN commands, capture output as evidence\n- CANNOT_VALIDATE only for: tool not installed, no network, permission denied\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- 'Phase 2 deferred' = REJECT\n- 'Will add tests later' = REJECT\n- ANY priority=MUST criterion fails = REJECT\n\n## APPROVAL\n- approved:true = ALL MUST criteria pass + no blocking issues\n- approved:false = any MUST fails OR incomplete implementation\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON with these REQUIRED fields:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"blocking issue 1\", \"blocking issue 2\"],\n \"criteriaResults\": [{\"id\": \"AC1\", \"status\": \"PASS|FAIL|CANNOT_VALIDATE\", \"evidence\": {\"command\": \"...\", \"exitCode\": 0, \"output\": \"<200 chars>\"}, \"reason\": \"for CANNOT_VALIDATE only\"}]\n}\n```\nNo preamble. JSON only." + }, + "contextStrategy": { + "sources": [ + { + "topic": "ISSUE_OPENED", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "PLAN_READY", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "IMPLEMENTATION_READY", + "priority": "high", + "since": "last_agent_start", + "strategy": "latest", + "amount": 1 + } + ], + "format": "chronological", + "maxTokens": "{{max_tokens}}" + }, + "triggers": [ + { + "topic": "IMPLEMENTATION_READY", + "action": "execute_task" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "QUICK_VALIDATION_RESULT", + "content": { + "text": "{{result.summary}}", + "data": { + "approved": "{{result.approved}}", + "errors": "{{result.errors}}", + "criteriaResults": "{{result.criteriaResults}}", + "validatorId": "validator-requirements" + } + } + } + } + } + }, + { + "id": "validator-code", + "role": "validator", + "modelLevel": "{{validator_level}}", + "timeout": "{{timeout}}", + "maxRetries": 3, + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "approved": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["approved", "summary"] + }, + "prompt": { + "system": "# CODE VALIDATOR\n\nSenior engineer code review. Catch REAL bugs, not style preferences.\n\n## WORKFLOW\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific validation\n2. SEARCH before claiming 'missing' (Glob, Grep, Read)\n3. RUN validation scripts if specified\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- Dangerous fallbacks hiding failures = REJECT\n\n## šŸ”“ GENERALIZATION CHECK (CRITICAL)\nWorker fixed a bug? Verify they fixed ALL instances:\n1. Identify the PATTERN (not just the line)\n2. `grep -rn \"pattern\" .` - search codebase\n3. If N > 1 exists → Did worker fix ALL? If NO → REJECT\n\nExamples: null check in one handler? Check ALL. SQL injection in one query? Check ALL. A fix that leaves identical bugs elsewhere is NOT a fix.\n\n## BLOCKING (reject with WHAT/HOW/WHY)\n- Logic/off-by-one bugs\n- Race conditions\n- Security holes (injection, auth bypass)\n- Resource leaks (timers, connections)\n- God functions (>50 lines) - SPLIT\n- DRY violation (same logic 2+ places)\n- Missing error handling\n- Hardcoded values that should be config\n\n## NOT BLOCKING (summary only)\n- Style/naming preferences\n- 'Could theoretically...' without proof\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"WHAT: X. HOW: Y. WHY: Z\"]\n}\n```\nNo preamble. JSON only." + }, + "contextStrategy": { + "sources": [ + { + "topic": "ISSUE_OPENED", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "PLAN_READY", + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "IMPLEMENTATION_READY", + "priority": "high", + "since": "last_agent_start", + "strategy": "latest", + "amount": 1 + } + ], + "format": "chronological", + "maxTokens": "{{max_tokens}}" + }, + "triggers": [ + { + "topic": "IMPLEMENTATION_READY", + "action": "execute_task" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "QUICK_VALIDATION_RESULT", + "content": { + "text": "{{result.summary}}", + "data": { + "approved": "{{result.approved}}", + "errors": "{{result.errors}}", + "validatorId": "validator-code" + } + } + } + } + } + }, + { + "id": "consensus-coordinator", + "role": "coordinator", + "modelLevel": "level1", + "timeout": "{{timeout}}", + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "allApproved": { + "type": "boolean" + }, + "summary": { + "type": "string" + } + }, + "required": ["allApproved", "summary"] + }, + "prompt": { + "system": "Check if both validators approved. Output: {\"allApproved\": boolean, \"summary\": \"<50 chars>\"}" + }, + "contextStrategy": { + "sources": [ + { + "topic": "QUICK_VALIDATION_RESULT", + "priority": "required", + "strategy": "latest", + "amount": 2 + } + ], + "format": "chronological", + "maxTokens": "{{max_tokens}}" + }, + "triggers": [ + { + "topic": "QUICK_VALIDATION_RESULT", + "logic": { + "engine": "javascript", + "script": "const results = ledger.query({ topic: 'QUICK_VALIDATION_RESULT', since: ledger.findLast({ topic: 'IMPLEMENTATION_READY' })?.timestamp || 0 }); return results.length === 2;" + }, + "action": "execute_task" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "QUICK_VALIDATION_PASSED", + "content": { + "text": "Stage 1 passed", + "data": {} + } + }, + "logic": { + "engine": "javascript", + "script": "if (!result.allApproved) { return { topic: 'VALIDATION_RESULT', content: { text: 'Stage 1 rejected', data: { approved: false, stage: 'quick', errors: ledger.query({ topic: 'QUICK_VALIDATION_RESULT' }).flatMap(r => r.content?.data?.errors || []) } } }; }" + } + } + } + } + ] +} diff --git a/cluster-templates/base-templates/single-worker.json b/cluster-templates/base-templates/single-worker.json index 93446801..6378b208 100644 --- a/cluster-templates/base-templates/single-worker.json +++ b/cluster-templates/base-templates/single-worker.json @@ -35,7 +35,15 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 } ], "format": "chronological", diff --git a/cluster-templates/base-templates/worker-validator.json b/cluster-templates/base-templates/worker-validator.json index f7a3aa19..9bc74d3b 100644 --- a/cluster-templates/base-templates/worker-validator.json +++ b/cluster-templates/base-templates/worker-validator.json @@ -80,17 +80,29 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "WORKER_PROGRESS", + "priority": "medium", "since": "last_task_end", - "limit": 3 + "strategy": "latest", + "amount": 3 }, { "topic": "VALIDATION_RESULT", + "priority": "high", "since": "last_task_end", - "limit": 3 + "strategy": "latest", + "amount": 3 } ], "format": "chronological", @@ -172,11 +184,21 @@ "sources": [ { "topic": "ISSUE_OPENED", - "limit": 1 + "priority": "required", + "strategy": "latest", + "amount": 1 + }, + { + "topic": "STATE_SNAPSHOT", + "priority": "required", + "strategy": "latest", + "amount": 1 }, { "topic": "IMPLEMENTATION_READY", - "limit": 1 + "priority": "high", + "strategy": "latest", + "amount": 1 } ], "format": "chronological", diff --git a/cluster-templates/conductor-bootstrap.json b/cluster-templates/conductor-bootstrap.json index f08106bf..cb927c22 100644 --- a/cluster-templates/conductor-bootstrap.json +++ b/cluster-templates/conductor-bootstrap.json @@ -32,7 +32,9 @@ "system": "You are the JUNIOR CONDUCTOR - fast task classification.\n\n## Your Job\nClassify the task on TWO dimensions.\n\n## šŸ”“ COST REMINDER\n- CRITICAL uses Opus ($15/M tokens) + 4 validators = EXPENSIVE\n- STANDARD uses Sonnet ($3/M tokens) + 2 validators = NORMAL\n- Don't waste money on false positives. CRITICAL is rare.\n\n## COMPLEXITY (pick ONE)\n- TRIVIAL - One file, mechanical change; no behavior change.\n- SIMPLE - Small change, 1-2 files, low risk.\n- STANDARD - Multi-file work or user-visible behavior. **DEFAULT CHOICE.**\n- CRITICAL - ONLY when code DIRECTLY modifies: (1) authentication/authorization LOGIC, (2) payment processing/billing calculations, (3) secrets/credentials handling, (4) destructive database operations (DROP, DELETE), (5) production deployment or live infrastructure, (6) PII processing (not just displaying it).\n- UNCERTAIN - Escalate to senior conductor.\n\n**šŸ”“ BIAS: If unsure between STANDARD and CRITICAL, choose STANDARD.** CRITICAL is expensive. Reserve it for actual risk.\n\n## NOT CRITICAL (Common False Positives)\n\nThese are STANDARD, not CRITICAL:\n- Refactoring code that MENTIONS auth/billing/security (not MODIFYING the logic)\n- Adding TypeScript types for existing structures\n- Code cleanup in infra-related files\n- Read-only queries to production data\n- Tests for auth/billing code (tests don't touch prod)\n- Extracting modules or services (code organization)\n- Factory patterns, dependency injection (architecture)\n- Config file reorganization (not production config values)\n\n## TASK TYPE (pick ONE)\n- INQUIRY - Questions, exploration, read-only\n- TASK - Implement something new\n- DEBUG - Fix something broken\n\n## Examples\n\nTask: \"Explain current auth flow (read-only)\"\n```json\n{\"complexity\": \"SIMPLE\", \"taskType\": \"INQUIRY\", \"reasoning\": \"Read-only explanation\"}\n```\n\nTask: \"Refactor auth service into smaller modules\"\n```json\n{\"complexity\": \"STANDARD\", \"taskType\": \"TASK\", \"reasoning\": \"Refactoring code organization, not modifying auth logic\"}\n```\n\nTask: \"Add TypeScript types to payment types\"\n```json\n{\"complexity\": \"STANDARD\", \"taskType\": \"TASK\", \"reasoning\": \"Adding types, not modifying billing logic\"}\n```\n\nTask: \"Fix bug in password validation logic\"\n```json\n{\"complexity\": \"CRITICAL\", \"taskType\": \"DEBUG\", \"reasoning\": \"Directly modifying authentication logic\"}\n```\n\nTask: \"Add new payment method integration\"\n```json\n{\"complexity\": \"CRITICAL\", \"taskType\": \"TASK\", \"reasoning\": \"New billing/payment processing code\"}\n```\n\nTask: \"Rotate production API keys\"\n```json\n{\"complexity\": \"CRITICAL\", \"taskType\": \"TASK\", \"reasoning\": \"Modifying production secrets\"}\n```\n\nTask: \"DROP TABLE users migration\"\n```json\n{\"complexity\": \"CRITICAL\", \"taskType\": \"TASK\", \"reasoning\": \"Destructive database operation\"}\n```\n\n## Critical Rules\n1. Output ONLY valid JSON - no other text\n2. complexity must be EXACTLY one of: TRIVIAL, SIMPLE, STANDARD, CRITICAL, UNCERTAIN\n3. taskType must be EXACTLY one of: INQUIRY, TASK, DEBUG\n\nTask: {{ISSUE_OPENED.content.text}}" }, "contextStrategy": { - "sources": [{ "topic": "ISSUE_OPENED", "limit": 1 }], + "sources": [ + { "topic": "ISSUE_OPENED", "priority": "required", "strategy": "latest", "amount": 1 } + ], "format": "chronological", "maxTokens": 100000 }, @@ -87,16 +89,20 @@ }, "contextStrategy": { "sources": [ - { "topic": "ISSUE_OPENED", "limit": 1 }, + { "topic": "ISSUE_OPENED", "priority": "required", "strategy": "latest", "amount": 1 }, { "topic": "CONDUCTOR_ESCALATE", + "priority": "high", "since": "last_agent_start", - "limit": 1 + "strategy": "latest", + "amount": 1 }, { "topic": "CLUSTER_OPERATIONS_VALIDATION_FAILED", + "priority": "high", "since": "cluster_start", - "limit": 3 + "strategy": "latest", + "amount": 3 } ], "format": "chronological", diff --git a/docs/context-management.md b/docs/context-management.md new file mode 100644 index 00000000..4f33c7fb --- /dev/null +++ b/docs/context-management.md @@ -0,0 +1,182 @@ +# Context Management + +Zeroshot builds agent prompts from ledger history with explicit selection rules, a token budget, +and a durable state snapshot. This document explains the technology and how to configure it in +templates. + +## Technology stack + +- Ledger: SQLite message log per cluster (`~/.zeroshot/.db`). +- MessageBus: publish/subscribe wrapper over the ledger. +- AgentContextBuilder: assembles prompt sections and context sources. +- ContextPackBuilder: priority and budget based selection with compact variants. +- StateSnapshotter: derives `STATE_SNAPSHOT` from structured outputs. +- Context metrics: optional logs or ledger entries for observability. + +## Data flow + +```mermaid +flowchart LR + Issue[ISSUE_OPENED] -->|publish| Ledger[(SQLite ledger)] + Ledger --> Bus[MessageBus] + Bus --> Snapshotter[StateSnapshotter] + Snapshotter -->|STATE_SNAPSHOT| Ledger + Bus --> Builder[AgentContextBuilder] + Builder --> Packs[ContextPackBuilder] + Packs --> Prompt[Final agent context] + Prompt --> Agent[Agent execution] + Agent -->|publish topics| Ledger +``` + +## Prompt assembly sections + +The builder assembles the prompt as a set of packs: + +- Header and non-interactive rules +- Agent instructions and output schema +- Source sections from `contextStrategy.sources` +- Validator skip hints (validator role only) +- Triggering message (always included at the end) + +## Context strategy sources + +`contextStrategy.sources` controls which ledger messages are pulled into an agent prompt. + +| Field | Type | Purpose | Default | +| --------------- | --------------------------------- | --------------------------- | ----------------------------------------- | +| topic | string | Ledger topic name | required | +| sender | string | Filter by sender | none | +| since | string or timestamp | Lower bound for timestamps | none | +| strategy | latest \| oldest \| all | Selection semantics | latest if amount set, else all | +| amount | number | Max messages to include | none | +| limit | number | Deprecated alias for amount | none | +| priority | required \| high \| medium \| low | Pack priority | derived if missing | +| compactAmount | number | Amount for compact mode | 1 | +| compactStrategy | latest \| oldest \| all | Compact selection | latest if base strategy is all, else base | + +`since` accepts: `cluster_start`, `last_task_end`, `last_agent_start`, or an ISO timestamp string. + +### Selection semantics + +- `latest`: query DESC with limit, then reverse to render chronologically. +- `oldest`: query ASC with limit. +- `all`: query ASC with no limit (or a hard cap if amount is set). + +### Priority defaults + +If `priority` is missing, the builder assigns: + +- required: `STATE_SNAPSHOT`, `ISSUE_OPENED`, `PLAN_READY` +- high: `VALIDATION_RESULT`, `IMPLEMENTATION_READY` +- medium: everything else + +Templates should set priority explicitly for clarity. + +## Context packs and budgeting + +Context is built as a set of packs that are selected under a token budget. Each pack can provide +full and compact text. Required packs are always included first. + +```mermaid +flowchart TD + Start --> Required[Include required packs] + Required --> Optional[Evaluate optional packs by priority] + Optional --> Fits{Fits budget?} + Fits -- yes --> Include[Include full pack] + Fits -- no --> Compact{Compact fits?} + Compact -- yes --> IncludeCompact[Include compact pack] + Compact -- no --> Skip[Skip pack] + Include --> Next[Next pack] + IncludeCompact --> Next + Skip --> Next + Next --> MaxGuard[Apply max chars guard] + MaxGuard --> Done[Final context] +``` + +Budgeting details: + +- Token estimates use `estimateTokensFromChars` (chars / 4, rounded up). +- `maxTokens` controls selection; default is 100000 if unset. +- A defensive max chars guard caps the final context to 500000 chars. +- Required packs are preserved; if the max chars guard triggers, optional packs are compacted or + dropped first, then required packs are truncated as a last resort. + +## STATE_SNAPSHOT (durable working memory) + +`STATE_SNAPSHOT` is a structured summary of current cluster state. It is derived from ledger +messages and republished whenever relevant updates occur. + +### Update triggers + +State is updated from these topics: + +- `ISSUE_OPENED` +- `PLAN_READY` +- `WORKER_PROGRESS` +- `IMPLEMENTATION_READY` +- `VALIDATION_RESULT` +- `INVESTIGATION_COMPLETE` + +```mermaid +sequenceDiagram + participant Agent + participant Bus as MessageBus + participant Snapshotter + participant Ledger + Agent->>Bus: publish PLAN_READY + Bus->>Snapshotter: deliver PLAN_READY + Snapshotter->>Bus: publish STATE_SNAPSHOT + Bus->>Ledger: persist STATE_SNAPSHOT +``` + +Include `STATE_SNAPSHOT` as a required context source for workers, planners, and validators. + +## Context metrics and observability + +Enable context metrics: + +- `ZEROSHOT_CONTEXT_METRICS=1` prints metrics to stdout. +- `ZEROSHOT_CONTEXT_METRICS_LEDGER=1` publishes a `CONTEXT_METRICS` message. + +Metrics include: + +- total chars and estimated tokens +- per section and pack breakdown +- budget and truncation details + +## Example context strategy + +```json +{ + "contextStrategy": { + "sources": [ + { "topic": "ISSUE_OPENED", "priority": "required", "strategy": "latest", "amount": 1 }, + { "topic": "STATE_SNAPSHOT", "priority": "required", "strategy": "latest", "amount": 1 }, + { "topic": "PLAN_READY", "priority": "required", "strategy": "latest", "amount": 1 }, + { + "topic": "VALIDATION_RESULT", + "priority": "high", + "since": "last_task_end", + "strategy": "latest", + "amount": 5, + "compactAmount": 1 + }, + { + "topic": "WORKER_PROGRESS", + "priority": "medium", + "since": "last_task_end", + "strategy": "latest", + "amount": 3, + "compactAmount": 1 + } + ], + "maxTokens": 100000 + } +} +``` + +## Troubleshooting + +- Missing anchors: ensure required sources are present and priority is set to required. +- Stale messages: verify `strategy: \"latest\"` and that `amount` is set. +- Over budget: lower `maxTokens` or add `compactAmount` to optional sources. diff --git a/docs/tui-v2/IMPLEMENTATION_PLAN.md b/docs/tui-v2/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..e3bc66d3 --- /dev/null +++ b/docs/tui-v2/IMPLEMENTATION_PLAN.md @@ -0,0 +1,583 @@ +# Zeroshot TUI v2 (Ink) - Multi-Stage Implementation Plan + +Date: 2026-01-25 +Status: Draft plan (intended to become a chain of GitHub issues) + +## Guiding Constraints + +- Ink + TypeScript for all new TUI code. +- Do not refactor unrelated JS during the TUI project. + - Small, targeted extraction is allowed only when it reduces duplication for TUI integration (e.g. shared `run` helpers). +- Replace the current dashboard feature (`zeroshot watch` / `src/tui/*`) with a new TUI experience launched by `zeroshot` (no args). +- Design/visual polish is explicitly deferred; prioritize UX flows and correctness. + +## Proposed Technical Approach (Replace Existing `src/tui`) + +### Code organization (proposed) + +- `src/tui/` (TypeScript source, Ink components) - replaces the existing blessed dashboard implementation + - `src/tui/app.tsx` - top-level Ink app, router, global keybindings + - `src/tui/views/*` - launcher/monitor/cluster/agent views + - `src/tui/commands/*` - slash command parser + dispatch + - `src/tui/services/*` - adapters around existing JS orchestrator/ledger + - `src/tui/domain/*` - typed models (ClusterRow, AgentRow, TimelineEvent, etc) +- `lib/tui/` (compiled JS output shipped in npm package) + +Rationale: + +- Keeps the existing JS runtime intact. +- Allows incremental TS adoption with minimal surface area. +- Avoids a repo-wide build migration. + +### Build strategy (minimal TS emission) + +Add a dedicated `tsconfig.tui.json` that: + +- includes only `src/tui/**/*.ts(x)` +- emits CJS output to `lib/tui/` +- keeps the existing `tsconfig.json` as "check JS, no emit" + +Wire `npm run build:tui` into `prepublishOnly` so `npm pack`/publish contains the compiled Ink app. + +### CLI integration strategy + +Update `cli/index.js` to: + +- open the TUI when `zeroshot` is run with no args in an interactive TTY +- add explicit commands: + - `zeroshot tui` (always open TUI) + - `zeroshot codex|claude|gemini|opencode` (open TUI with session provider override) +- keep `zeroshot watch` as an alias (expected to start in Monitor view once implemented). + +Important: + +- We do not keep the old blessed dashboard in parallel. As soon as the Ink entrypoint exists, + the old `src/tui/*` implementation is replaced/removed. + +## Workstreams + +This plan is split into chainable milestones, with parallelizable work inside each milestone. + +- Workstream A: CLI + packaging + TypeScript build +- Workstream B: Ink app shell + navigation + command model +- Workstream C: Data adapters (orchestrator, ledger logs, metrics, topology) +- Workstream D: UI views (launcher, monitor, cluster, agent) +- Workstream E: Guidance messaging backend (new capability) + UI wiring +- Workstream F: Tests + reliability hardening + +## Milestones and Issues + +Below, each "Issue" is intended to become a standalone PR with tight boundaries. + +### Milestone 1: Foundations (TS + Ink skeleton) + +#### Issue 1.1 - Add Ink + React dependencies (no runtime behavior change) + +Scope: + +- Add runtime deps needed for Ink TUI (e.g. `ink`, `react`). +- Add dev deps for TS typing/testing (e.g. `@types/react`, `ink-testing-library`), if needed. + +Non-scope: + +- No CLI behavior changes. +- No new commands. + +Acceptance: + +- `npm test` still passes. +- Existing CLI commands behave identically. + +#### Issue 1.2 - Replace `src/tui/` with "Hello Ink TUI" + build pipeline + +Scope: + +- Replace the existing blessed dashboard implementation under `src/tui/` with a minimal Ink app + (start with a single screen that renders and exits cleanly). +- Create `src/tui/index.tsx` (or equivalent entry) that renders a minimal Ink screen. +- Add `tsconfig.tui.json` emitting to `lib/tui/`. +- Add `npm run build:tui` and ensure it runs in `prepublishOnly`. +- Repoint the `zeroshot watch` implementation to the Ink entrypoint (old dashboard is removed immediately). +- Remove/replace any legacy dashboard tests that depend on blessed-contrib layout behavior. + +Acceptance: + +- `npm run build:tui` produces `lib/tui/index.js`. +- Running a temporary dev entry (e.g. `node -e "require('./lib/tui').start()"`) renders in terminal. +- `npm pack` includes `lib/tui/*`. +- `zeroshot watch` opens the Ink app (even if it is still a minimal stub). + +Parallelizable with: + +- Issue 1.1 + +#### Issue 1.3 - Add `zeroshot tui` command (explicit entrypoint) + provider session override + +Scope: + +- Add `zeroshot tui` command to `cli/index.js` that invokes the compiled Ink app. +- Ensure `--provider ` is accepted for `zeroshot tui` (session override only). +- Keep `zeroshot watch` as an alias (expected to start in Monitor view once implemented). + +Acceptance: + +- `zeroshot tui` opens the Ink app. +- `zeroshot tui --provider codex` passes provider override into the app (visible in UI state). + +Depends on: + +- Issue 1.2 + +### Milestone 2: Navigation + Command Model (no cluster execution yet) + +#### Issue 2.1 - Implement view router + "Esc back" navigation + +Scope: + +- Implement 4 view states (even if stubbed): + - Launcher + - Monitor + - Cluster + - Agent +- Global navigation rule: Esc pops view stack until Launcher. + +Acceptance: + +- Esc navigation is consistent from every view. +- Ctrl+C exits cleanly (no terminal corruption). + +Depends on: + +- Issue 1.3 + +#### Issue 2.2 - Implement command box + slash-command parser (MVP set) + +Scope: + +- Global input component. +- Parse rules: + - `/...` => command + - otherwise => plain-text task description (stubbed for now) +- Implement MVP commands: + - `/help`, `/monitor`, `/issue `, `/provider `, `/quit` +- Display command output (toast/status area). + +Acceptance: + +- Commands work from any view. +- Provider switches update a session state indicator. +- Commands that require orchestration (e.g. `/issue`) can be stubbed here and fully implemented in Milestone 3. + +Parallelizable with: + +- Issue 2.1 + +#### Issue 2.3 - Command dispatch scaffolding for "full CLI parity" over time + +Scope: + +- Introduce a typed command registry that maps `/...` commands to handlers. +- Start with a small compatibility layer so new commands can reuse existing CLI helper functions + (without requiring users to type `zeroshot ...`). +- Implement 1-2 additional commands as proof (e.g. `/status `, `/list`). + +Non-scope: + +- Do not implement every command in one PR. +- Avoid large refactors of `cli/index.js`; prefer extracting tiny shared helpers. + +Acceptance: + +- Adding a new slash command is a small, isolated change (new handler + tests). +- `/status ` works end-to-end and renders output in the TUI. + +### Milestone 3: Launch Cluster From Text (end-to-end MVP loop) + +#### Issue 3.1 - Extract minimal reusable "start cluster" helper for TUI (avoid copying CLI) + +Scope: + +- Create a small adapter module (prefer `lib/` or `src/` JS) that encapsulates: + - explicit input construction: + - plain text (default launcher behavior) + - issue refs for `/issue ...` (use existing parsing logic, but only for the `/issue` path) + - provider override resolution + - loading config (`resolveConfigPath`, `loadClusterConfig`) + - calling `orchestrator.start(...)` +- TUI calls this helper rather than duplicating CLI logic. + +Constraints: + +- Keep refactor minimal; do not restructure the CLI wholesale. + +Acceptance: + +- Existing CLI `zeroshot run` remains unchanged in behavior. +- New helper can be unit-tested independently. + +#### Issue 3.2 - Launcher view: Enter launches cluster and transitions to Cluster view + +Scope: + +- In Launcher view, non-`/` input starts a cluster from plain text with current provider override. +- Ambiguity rule: numeric input like `123` is treated as plain text (never an issue). +- Show cluster id immediately (optimistic UI). +- Transition to Cluster view after start begins. + +Acceptance: + +- Typing `Implement X` and pressing Enter starts a cluster and switches to Cluster view. +- Failures are shown as a clear error message and user remains in Launcher view. + +Depends on: + +- Issue 3.1 + +#### Issue 3.3 - `/issue ` command: run an issue and transition to Cluster view + +Scope: + +- Implement `/issue `: + - `ref` can be: `123`, `org/repo#123`, full issue URL, Jira key, etc (same accepted formats as CLI `zeroshot run `). + - Start a cluster using that issue ref and current provider override. + - Transition to Cluster view for that cluster id. + +Acceptance: + +- `/issue 123` starts a cluster from the issue (no ambiguity with plain text `123`). +- Errors are clearly rendered in the TUI (e.g. missing `gh`, auth failures, invalid ref). + +Depends on: + +- Issue 3.1 + +#### Issue 3.4 - Live log streaming in Cluster view (baseline) + +Scope: + +- Subscribe to the cluster ledger via `messageBus.subscribe(...)` or `ledger.since(...)` polling. +- Render a scrolling log viewport (Ink list/text). +- Provide basic filtering by agent id (optional toggle; can be later). + +Acceptance: + +- Cluster view shows new log lines within 0.5s of being written. +- No unbounded memory growth (keep only last N lines in view state). + +Parallelizable with: + +- Issue 3.2 (once cluster id is known) + +### Milestone 4: Monitor View (replacement for old dashboard) + +#### Issue 4.1 - Monitor view: list clusters from orchestrator registry + +Scope: + +- Implement `/monitor` view: + - fetch cluster list from orchestrator + - display a selectable list (arrow keys + enter) +- Enter opens Cluster view for selected id. + +Acceptance: + +- Monitor list matches `zeroshot list` cluster table order/contents (within reason). +- Can open any existing cluster (including completed) into Cluster view. + +Depends on: + +- Milestone 3 (Cluster view exists) + +#### Issue 4.2 - Add resource metrics to Monitor view (best effort) + +Scope: + +- Gather per-agent pid/CPU/memory using existing `pidusage` patterns. +- Aggregate per cluster and show in the list. +- Degrade gracefully on unsupported platforms. + +Acceptance: + +- On macOS/Linux, CPU/mem fields are populated for running clusters when possible. +- UI remains responsive with 10+ clusters; metrics refresh is throttled (e.g. every 2s). + +Parallelizable with: + +- Issue 4.1 + +#### Issue 4.3 - `zeroshot watch` starts directly in Monitor view + +Scope: + +- Ensure `zeroshot watch` starts the Ink TUI directly in Monitor view (not Launcher). +- Keep `zeroshot tui` defaulting to Launcher view. + +Acceptance: + +- `zeroshot watch` opens Monitor view as the initial screen. +- No user-facing regression for "monitor clusters" workflow. + +Depends on: + +- Issue 4.1 + +### Milestone 5: Cluster Focused View Enhancements (topology, steps) + +#### Issue 5.1 - Topology rendering from cluster config + +Scope: + +- Build a topology model from the running cluster config: + - agents (id, role) + - triggers/edges (topic wiring) +- Render as: + - MVP: adjacency list / ASCII tree / simple box diagram + - keep layout engine out-of-scope for now + +Acceptance: + +- For built-in templates, topology view shows all agents and their relationships. +- Works for dynamically added agents (best effort; show as appended nodes). + +#### Issue 5.2 - Step timeline derived from workflow triggers (MVP) + +Scope: + +- Define a minimal "timeline event" schema for the TUI. +- Populate it from `WORKFLOW_TRIGGERS` messages (PLAN_READY, IMPLEMENTATION_READY, VALIDATION_RESULT, etc). +- Render a compact history list in Cluster view. + +Acceptance: + +- Timeline shows the major phases for a typical run. +- Timeline persists on resume (derived from ledger, not in-memory only). + +Parallelizable with: + +- Issue 5.1 + +Pin: + +- Later we can enrich the timeline with additional lifecycle events/state transitions for better fidelity. + +### Milestone 6: Agent View (drill-down + messaging UI) + +#### Issue 6.1 - Agent selection + Agent view log tail + +Scope: + +- In Cluster view, allow selecting an agent (arrow keys) and opening Agent view (Enter). +- Agent view tails logs for that agent only. + +Acceptance: + +- Agent view shows live agent logs and updates in near real time. +- Esc returns to Cluster view preserving selection. + +Depends on: + +- Issue 3.4 + +#### Issue 6.2 - Agent messaging UI (stubbed backend) + +Scope: + +- Add an input box in Agent view for sending messages. +- For now, messages can be recorded as "pending" without delivery (backend not yet wired). + +Acceptance: + +- User can type and submit a message; UI shows it as queued. +- No crashes if backend is missing. + +Parallelizable with: + +- Milestone 7 backend work + +### Milestone 7: Guidance Messaging (new backend capability) + +This milestone is required to meet the PRD's "steer agents/cluster live" vision. It is intentionally separated because it touches core orchestration behavior. + +#### Issue 7.1 - Ledger topic + mailbox schema for guidance + +Scope: + +- Define new message topics (example): + - `USER_GUIDANCE_CLUSTER` + - `USER_GUIDANCE_AGENT` +- Each message includes: + - `cluster_id`, `sender: "user"`, `topic`, `content.text` + - optional `target_agent_id` + - delivery state fields (optional, can be derived) +- Implement a mailbox query helper for "guidance since last delivered". + +Acceptance: + +- Guidance messages are persisted in the ledger. +- Queries for "undelivered guidance" are deterministic and testable. + +#### Issue 7.2 - Live injection plumbing (provider stdin/PTY) with graceful detection + +Scope: + +- Implement a provider-agnostic "send input" path that writes directly to the underlying provider CLI session stdin/PTY when available. + - This should reuse the same mechanism the provider uses for interactive input (e.g. node-pty stdin write). +- Persist the guidance message in the ledger regardless (for audit/history), but mark whether it was injected live. +- If a given agent/provider session does not expose an interactive stdin handle, return "unsupported" so callers can fallback to queue semantics (Issue 7.3). + +Acceptance: + +- For at least one provider with interactive sessions, sending guidance while the agent is working injects into the live session. +- If injection is not possible, the API signals that it was not injected (so UI can show "queued"). + +Depends on: + +- Issue 7.1 + +#### Issue 7.3 - Queue fallback: apply guidance at safe points + +Scope: + +- Implement a safe-point mailbox consumer in the agent execution loop: + - fetch queued guidance for (cluster, agent) + - append to the next provider prompt in a controlled way (clearly delimited) +- Ensure guidance does not break JSON-schema modes or structured output. + +Acceptance: + +- In a test cluster for a non-injectable mode/provider, guidance sent mid-run appears in the next agent prompt. +- No regressions for existing runs (no guidance => behavior unchanged). + +Depends on: + +- Issue 7.2 + +#### Issue 7.4 - Cluster-wide guidance broadcast (injection-first, fallback-queue) + +Scope: + +- Implement cluster-level guidance delivery: + - write a single cluster-scoped message + - each agent attempts live injection when possible (Issue 7.2) + - otherwise the message is applied at that agent's next safe point (Issue 7.3) + +Acceptance: + +- Cluster-level guidance reaches all agents: + - injected live when supported + - queued otherwise + +Depends on: + +- Issue 7.3 + +#### Issue 7.5 - Wire TUI messaging UI to backend guidance mechanism + +Scope: + +- Cluster view: guidance box sends `USER_GUIDANCE_CLUSTER`. +- Agent view: guidance box sends `USER_GUIDANCE_AGENT`. +- Display delivery feedback (injected/queued/applied if detectable). + +Acceptance: + +- Messages typed in the TUI are persisted and delivered to agents (per Issues 7.1-7.4). + +Depends on: + +- Issues 6.2, 7.4 + +### Milestone 8: Default Entry + Cleanup + Hardening + +#### Issue 8.1 - `zeroshot` (no args) launches TUI (TTY only) + +Scope: + +- Modify `cli/index.js` default behavior: + - if interactive TTY and no subcommand: open TUI launcher + - else: keep existing help output behavior + +Acceptance: + +- `zeroshot` opens TUI in a normal terminal. +- `echo foo | zeroshot` does not hang (prints help and exits). + +Depends on: + +- Milestone 3 (minimum viable launcher exists) + +#### Issue 8.2 - Add `zeroshot codex|claude|gemini|opencode` convenience entrypoints + +Scope: + +- Add CLI commands that invoke `zeroshot tui --provider `. + +Acceptance: + +- `zeroshot codex` opens TUI with codex selected for the session. + +Depends on: + +- Issue 8.1 (or earlier if `zeroshot tui --provider` is already done) + +#### Issue 8.3 - Cleanup: remove legacy dashboard docs/tests and stale references + +Scope: + +- Remove any remaining blessed-dashboard-specific docs, demos, and tests (after the Ink TUI replacement). +- Ensure CLI help text and docs point to the Ink TUI entrypoints (`zeroshot`, `zeroshot tui`, `zeroshot watch`). + +Acceptance: + +- No references remain to the old blessed dashboard behavior. + +Depends on: + +- Issue 1.2 + +#### Issue 8.4 - Reliability/Perf pass + tests + +Scope: + +- Add tests: + - slash command parsing + - router "Esc back" behavior + - monitor list rendering + - guidance mailbox behavior (if Milestone 7 done) +- Manual checklist: + - resize behavior + - exit behavior (terminal reset) + - running multiple clusters + - resume existing cluster into Cluster view + +Acceptance: + +- CI is green. +- No known terminal corruption issues on exit. + +## Parallelization Map (Suggested) + +- Team 1: + - Milestone 1 (deps/build) + Milestone 2 (router/commands) +- Team 2: + - Milestone 3 (cluster start + logs) once helper exists +- Team 3: + - Milestone 4 (monitor view + metrics) +- Team 4: + - Milestone 7 (guidance backend) can start as soon as requirements are agreed +- Team 5: + - Milestone 5 (topology + timeline) can start once cluster config/state access is finalized + +## Definition of Done (Project-level) + +The project is considered "v2 shipped" when: + +- `zeroshot` launches the Ink TUI (TTY only) and can start a cluster from text. +- `/monitor` provides a stable cluster dashboard and drill-down. +- Cluster view supports logs + topology + timeline. +- The blessed-based dashboard is removed; `zeroshot watch` is an alias that opens the Ink TUI Monitor view. + +## Deferred / Post-v2 Candidates + +- AI summary panel (`/summary` or periodic): provider/model choice and cost controls TBD. +- Enrich the step timeline with additional lifecycle events beyond `WORKFLOW_TRIGGERS`. diff --git a/docs/tui-v2/PRD.md b/docs/tui-v2/PRD.md new file mode 100644 index 00000000..90940a57 --- /dev/null +++ b/docs/tui-v2/PRD.md @@ -0,0 +1,302 @@ +# Zeroshot TUI v2 (Ink) - PRD + +Date: 2026-01-25 +Owner: Zeroshot CLI +Status: Draft + +## Summary + +Build a new terminal UI (TUI) for Zeroshot using Ink + TypeScript. This fully replaces the current `zeroshot watch` dashboard (blessed-based) with a single interactive experience launched by running `zeroshot` (no args). + +Core workflow: + +- Open TUI +- Type a task description into a central input box +- Press Enter to launch a cluster +- Immediately switch to the focused cluster view (topology + logs + progress) +- Use `/monitor` to see a high-level dashboard of all clusters, then drill down via Enter +- Navigate back with Esc (Esc always steps back until the launcher view) + +Provider selection is session-scoped and can be chosen at launch (`zeroshot codex|claude|gemini|opencode`) or switched in-TUI. + +## Goals + +1. Replace the current dashboard feature with an Ink-based TUI. +2. Make `zeroshot` (no args) a first-class interactive mode for: + - launching clusters from free-form text + - monitoring all running clusters + - drilling into clusters and individual agents +3. Add a command palette/input model: + - plain text launches a cluster + - `/`-prefixed commands run zeroshot operations without re-typing `zeroshot` +4. Establish the first production TypeScript surface in the Zeroshot codebase (TUI + required adapters), without refactoring unrelated JS. + +## Non-Goals (for v2 MVP) + +- No pixel-perfect or final visual design (layout/theme/typography can evolve). +- No full JavaScript -> TypeScript migration. +- No remote multi-user UI; TUI is local-only. +- No promise of message delivery for providers/modes that do not support interactive stdin injection. + - In those cases, guidance is queued and applied at the next safe point. +- No replacement of non-dashboard CLI commands; existing subcommands remain supported. + +## Users / Personas + +- Power users running many clusters and needing quick navigation (htop/k9s style). +- Developers launching a cluster from an idea ("just type it and go"). +- Operators who need to see whether a run is stuck, progressing, or consuming resources. + +## Glossary + +- Cluster: a multi-agent run managed by `src/orchestrator.js`, persisted in `~/.zeroshot/*.db`. +- Agent / Worker: an `AgentWrapper` instance inside a cluster (worker, planner, validators, etc). +- Provider: external CLI used for agent reasoning (claude, codex, gemini, opencode). + +## Entry Points + +### `zeroshot` (no args) + +Expected behavior: + +- If running in an interactive TTY: start TUI in Launcher view. +- If not a TTY (piped/CI): print help and exit non-zero only on misuse (avoid breaking scripts). + +### `zeroshot tui` (explicit) + +Always opens the TUI (even if additional flags are provided). + +### `zeroshot codex|claude|gemini|opencode` + +Opens the TUI with a session-scoped provider override. While the TUI is open: + +- any cluster launch uses the chosen provider (equivalent to passing `--provider ` behind the scenes) +- switching provider in-TUI updates this override for subsequent operations + +Notes: + +- This must not permanently change the default provider setting. Persisted defaults remain managed by `zeroshot providers set-default ...`. + +### `zeroshot watch` + +`zeroshot watch` remains as a convenience alias that opens the new Ink TUI directly in Monitor view. + +The existing blessed-based TUI implementation is removed (no parallel legacy dashboard). + +## Core Navigation / Views + +### 1) Launcher View (Main) + +Primary UI shown on start. + +Content: + +- A central input box for text. +- Short instructions/hints (e.g. `/monitor`, `/help`, provider indicator). + +Input semantics: + +- If input starts with `/`: treat as a command (see Commands). +- Else: treat as plain-text task description and start a cluster. + - Ambiguity rule: numeric input like `123` is treated as plain text. + - To run an issue, use `/issue ...` (e.g. `/issue 123`). + +On Enter with non-command input: + +- Start cluster +- Transition to Cluster Focused View for that cluster + +### 2) Monitor View (Dashboard) + +Opened by `/monitor` from anywhere. + +Displays a high-level list of clusters, including: + +- cluster id +- input/task name (derived from input: issue title, file name, or first line of text) +- provider used +- status (running/completed/failed/stopped/corrupted) +- running time (duration) +- agents/workers count +- resource usage (CPU%, memory) aggregated across agents (best effort; degrade gracefully) +- token usage (if available via ledger aggregation) +- last activity timestamp + +Interaction: + +- Up/Down (or j/k) moves selection +- Enter opens Cluster Focused View for selected cluster +- Esc goes back to previous view + +Optional (later, but supported by design): + +- filtering (running/stopped/all) +- search by cluster id / task substring +- actions: stop/kill/export via `/stop `, `/kill `, `/export ` + +### 3) Cluster Focused View + +Opened automatically after launching a cluster, or by selecting a cluster from Monitor view. + +Must display: + +1. Cluster topology graph: + - Rendered from the cluster config template (agents + triggers). + - MVP: tree/adjacency list/ASCII graph; fidelity can improve over time. +2. Live logs: + - Streamed from the ledger message bus (append-only). + - Includes per-agent attribution (role/agent id). +3. Step timeline / phase log: + - High-level step list derived from workflow triggers (`WORKFLOW_TRIGGERS`) for MVP. + - Examples: plan -> implement -> validate -> iterate -> complete. + - Exact naming/format is defined by the TUI domain model and can evolve. + +Interaction: + +- Left/Right (or Tab) cycles focus between panes (topology/logs/steps/agents list). +- Up/Down moves selection when in a selectable pane (e.g. agents list). +- Enter on a selected agent opens Agent View. +- Esc goes back (Monitor if you came from Monitor, or Launcher if you came from Launcher). + +Cluster guidance input: + +- A command/text box is available in this view to send guidance to the whole cluster. +- This requires backend support (see "Guidance Messaging"). + +### 4) Agent View (Focused Worker/Agent) + +Opened by selecting an agent from Cluster Focused View. + +Must display: + +- Live logs for that agent (tailing relevant ledger output) +- Agent identity (role, provider, model level/model) +- Agent status (idle/executing/stuck/failed/completed) + +Agent messaging input: + +- A command/text box that lets the user send guidance to that agent while it is working. +- Provider capability may vary. If true "live injection" is not available, guidance is queued and applied at the next safe point. + +Esc returns to Cluster Focused View. + +## Commands (Slash Commands) + +General: + +- Any view can accept `/...` commands. +- Commands should be parsed similarly to CLI subcommands, but do not require the `zeroshot` prefix. +- Commands produce feedback in a lightweight output/toast area (success/failure). + +Parity target: + +- Long-term: every existing `zeroshot ` operation should be invocable as `/` inside the TUI. +- Short-term: ship an MVP subset first (below), then expand to full parity incrementally. + +Required commands for MVP: + +- `/help` - show available commands and keybindings +- `/monitor` - open Monitor view +- `/issue ` - start a cluster from an issue reference (e.g. `123`, `org/repo#123`, URL, Jira key, etc) +- `/provider ` - switch provider for the session (claude|codex|gemini|opencode) +- `/quit` (and `/exit`) - exit the TUI (with confirmation if clusters running) + +Strongly recommended commands (v2 follow-up): + +- `/run ` - start a cluster using CLI-style input parsing (issue/file/text auto-detection) +- `/list` - list clusters/tasks (or open Monitor view) +- `/status ` - show detailed cluster/task status +- `/logs ` - open Cluster Focused View (or open logs modal) +- `/stop ` and `/kill ` - control cluster/task + +## Guidance Messaging (Backend Requirement) + +Two scopes: + +1. Cluster guidance: message broadcast to all agents in the cluster. +2. Agent guidance: message targeted to a specific agent. + +Delivery semantics (MVP): + +- Attempt live stdin injection into the underlying provider CLI session when supported. + - Implementation detail: write to the provider process/PTY stdin (same mechanism the provider uses for interactive chat). +- If live injection is not supported (provider limitation or non-interactive mode), queue the guidance: + - store in the ledger (or a small mailbox store) + - agents apply it at the next safe point: + - before starting a new iteration + - before generating a new provider prompt + - after finishing a tool execution step + +TUI requirements: + +- Show whether a guidance message was injected live or queued. +- Indicate delivery status (pending/applied/expired) when possible. + +## Data Sources / Domain Model + +The TUI reads from: + +- Orchestrator registry: cluster list and state (`src/orchestrator.js`) +- Ledger message bus: logs, events, tokens (`src/message-bus.js`, `src/ledger.js`) +- Process metrics: pid/CPU/mem per agent (existing `pidusage` usage) +- Cluster config: topology information (from resolved config JSON) + +The TUI writes: + +- Start/stop/kill commands via orchestrator +- Guidance messages via message bus / mailbox + +## Non-Functional Requirements + +Performance: + +- TUI launch time (from `zeroshot` to first paint): < 500ms on a typical dev machine. +- UI update cadence: + - logs: event-driven, or up to 200-500ms batching + - cluster list refresh: 1s (configurable) + - resource metrics refresh: 2s (configurable, because pidusage can be expensive) + +Reliability: + +- No terminal corruption on exit (restore cursor, clear alternate buffer if used). +- Handles terminal resize gracefully. +- Works on macOS and Linux. If metrics are unsupported, show "-" and keep UI functional. + +Security/Privacy: + +- Do not exfiltrate code or logs; all data stays local. +- Avoid rendering secrets in the UI where possible (best-effort; user controls what runs). + +## Success Metrics + +Quantitative: + +- 90%+ of interactive "start cluster from text" actions succeed without leaving the TUI. +- 0 known cases of terminal left in broken state on normal exit. +- Monitor view can handle 100 clusters in the registry without freezing (best-effort; virtualization if needed later). + +Qualitative: + +- Users can launch, monitor, and drill down without remembering command syntax. +- Navigation feels consistent (Esc always goes back). + +## Out of Scope / Deferred + +- Full parity with all CLI flags (`--docker`, `--ship`, `--worktree`, etc) in the launcher input. + - These can be added incrementally via `/run --docker ...` style. +- Rich graphs (true layout engine). MVP uses simplified ASCII representation. +- Advanced UX (themes, mouse, split panes, persistent layouts). +- AI summary panel (provider/model choice + cost controls TBD). + +## Resolved Decisions (from product feedback) + +1. Ambiguous launcher input: + - Launcher treats non-`/` input as plain text (including `123`). + - Use `/issue 123` (or another issue ref) to run an issue. +2. Step timeline schema: + - MVP derives timeline from existing workflow triggers (`WORKFLOW_TRIGGERS`). + - We keep a pin to later enrich with additional lifecycle events. +3. AI summary: + - Deferred (pin). Implement after core TUI flows ship. +4. Guidance injection: + - Implement live injection when provider supports it (inject into the provider CLI stdin/PTY). + - Otherwise, queue and apply at safe points. diff --git a/package-lock.json b/package-lock.json index 86f4f173..42c3c21b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,13 @@ "blessed-contrib": "^4.11.0", "chalk": "^4.1.2", "commander": "^14.0.2", + "ink": "^6.6.0", "md-to-pdf": "^5.2.5", "node-pty": "^1.1.0", "omelette": "^0.4.17", "pidusage": "^4.0.1", - "proper-lockfile": "^4.1.2" + "proper-lockfile": "^4.1.2", + "react": "^19.2.4" }, "bin": { "zeroshot": "cli/index.js" @@ -32,6 +34,8 @@ "@semantic-release/github": "^11.0.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.3", + "@types/react": "^19.2.9", + "@types/react-reconciler": "^0.33.0", "c8": "^10.1.3", "chai": "^6.2.1", "depcheck": "^1.4.7", @@ -94,6 +98,46 @@ "dev": true, "license": "MIT" }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz", + "integrity": "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1799,6 +1843,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2271,6 +2335,18 @@ "node": ">= 4.0.0" } }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/b4a": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", @@ -2991,6 +3067,18 @@ "node": ">=6" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", @@ -3253,6 +3341,18 @@ "dev": true, "license": "MIT" }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -3430,6 +3530,15 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3506,6 +3615,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -4182,7 +4298,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4230,6 +4345,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5210,7 +5335,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5833,35 +5957,360 @@ "dev": true, "license": "MIT", "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha512-BYqTHXTGUIvg7t1r4sJNKcbDZkL92nkXA8YtRpbjFHRHGDL/NtUeiBJMeE60kIFN/Mg8ESaWQvftaYMGJzQZCQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ink": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz", + "integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.2.1", + "ansi-escapes": "^7.2.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.6.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^5.1.1", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.39.10", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^8.1.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": "^6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink/node_modules/ansi-escapes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ink/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ink/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/ink/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/ink/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", + "node_modules/ink/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=0.8.19" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/indent-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", - "integrity": "sha512-BYqTHXTGUIvg7t1r4sJNKcbDZkL92nkXA8YtRpbjFHRHGDL/NtUeiBJMeE60kIFN/Mg8ESaWQvftaYMGJzQZCQ==", + "node_modules/ink/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, + "node_modules/ink/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { "node": ">=18" }, @@ -5869,30 +6318,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/into-stream": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", @@ -6014,6 +6439,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -6959,15 +7399,15 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", - "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "dev": true, "license": "MIT" }, @@ -7784,9 +8224,9 @@ } }, "node_modules/npm": { - "version": "11.7.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.7.0.tgz", - "integrity": "sha512-wiCZpv/41bIobCoJ31NStIWKfAxxYyD1iYnWCtiyns8s5v3+l8y0HCP/sScuH6B5+GhIfda4HQKiqeGZwJWhFw==", + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.8.0.tgz", + "integrity": "sha512-n19sJeW+RGKdkHo8SCc5xhSwkKhQUFfZaFzSc+EsYXLjSqIV0tl72aDYQVuzVvfrbysGwdaQsNLNy58J10EBSQ==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -7866,8 +8306,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.1.9", - "@npmcli/config": "^10.4.5", + "@npmcli/arborist": "^9.1.10", + "@npmcli/config": "^10.5.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -7875,7 +8315,7 @@ "@npmcli/promise-spawn": "^9.0.1", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.3", - "@sigstore/tuf": "^4.0.0", + "@sigstore/tuf": "^4.0.1", "abbrev": "^4.0.0", "archy": "~1.0.0", "cacache": "^20.0.3", @@ -7892,11 +8332,11 @@ "is-cidr": "^6.0.1", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.0.12", - "libnpmexec": "^10.1.11", - "libnpmfund": "^7.0.12", + "libnpmdiff": "^8.0.13", + "libnpmexec": "^10.1.12", + "libnpmfund": "^7.0.13", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.0.12", + "libnpmpack": "^9.0.13", "libnpmpublish": "^11.1.3", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -7925,11 +8365,11 @@ "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.0", "supports-color": "^10.2.2", - "tar": "^7.5.2", + "tar": "^7.5.4", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", - "validate-npm-package-name": "^7.0.0", + "validate-npm-package-name": "^7.0.2", "which": "^6.0.0" }, "bin": { @@ -8009,7 +8449,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.1.9", + "version": "9.1.10", "dev": true, "inBundle": true, "license": "ISC", @@ -8027,7 +8467,7 @@ "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", - "common-ancestor-path": "^1.0.1", + "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", @@ -8056,7 +8496,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.4.5", + "version": "10.5.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8251,7 +8691,7 @@ } }, "node_modules/npm/node_modules/@sigstore/core": { - "version": "3.0.0", + "version": "3.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", @@ -8269,52 +8709,43 @@ } }, "node_modules/npm/node_modules/@sigstore/sign": { - "version": "4.0.1", + "version": "4.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.0.0", + "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0", - "make-fetch-happen": "^15.0.2", - "proc-log": "^5.0.0", + "make-fetch-happen": "^15.0.3", + "proc-log": "^6.1.0", "promise-retry": "^2.0.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/proc-log": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "4.0.0", + "version": "4.0.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", - "tuf-js": "^4.0.0" + "tuf-js": "^4.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.0.0", + "version": "3.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.0.0", + "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -8331,33 +8762,18 @@ } }, "node_modules/npm/node_modules/@tufjs/models": { - "version": "4.0.0", + "version": "4.1.0", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" + "minimatch": "^10.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/@tufjs/models/node_modules/minimatch": { - "version": "9.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/npm/node_modules/abbrev": { "version": "4.0.0", "dev": true, @@ -8397,12 +8813,6 @@ "inBundle": true, "license": "MIT" }, - "node_modules/npm/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/bin-links": { "version": "6.0.0", "dev": true, @@ -8431,15 +8841,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/npm/node_modules/cacache": { "version": "20.0.3", "dev": true, @@ -8533,10 +8934,13 @@ } }, "node_modules/npm/node_modules/common-ancestor-path": { - "version": "1.0.1", + "version": "2.0.0", "dev": true, "inBundle": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } }, "node_modules/npm/node_modules/cssesc": { "version": "3.0.0", @@ -8568,7 +8972,7 @@ } }, "node_modules/npm/node_modules/diff": { - "version": "8.0.2", + "version": "8.0.3", "dev": true, "inBundle": true, "license": "BSD-3-Clause", @@ -8763,7 +9167,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.0.1", + "version": "10.1.0", "dev": true, "inBundle": true, "license": "MIT", @@ -8866,12 +9270,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.0.12", + "version": "8.0.13", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.9", + "@npmcli/arborist": "^9.1.10", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -8885,12 +9289,12 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.1.11", + "version": "10.1.12", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.9", + "@npmcli/arborist": "^9.1.10", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -8908,12 +9312,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.12", + "version": "7.0.13", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.9" + "@npmcli/arborist": "^9.1.10" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -8933,12 +9337,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.0.12", + "version": "9.0.13", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.9", + "@npmcli/arborist": "^9.1.10", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -9008,10 +9412,10 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "11.2.2", + "version": "11.2.4", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -9422,7 +9826,7 @@ } }, "node_modules/npm/node_modules/path-scurry": { - "version": "2.0.0", + "version": "2.0.1", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -9438,7 +9842,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.0", + "version": "7.1.1", "dev": true, "inBundle": true, "license": "MIT", @@ -9581,17 +9985,17 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "4.0.0", + "version": "4.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.0.0", + "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.0.0", - "@sigstore/tuf": "^4.0.0", - "@sigstore/verify": "^3.0.0" + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9728,7 +10132,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.2", + "version": "7.5.4", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -9819,14 +10223,14 @@ } }, "node_modules/npm/node_modules/tuf-js": { - "version": "4.0.0", + "version": "4.1.0", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "@tufjs/models": "4.0.0", - "debug": "^4.4.1", - "make-fetch-happen": "^15.0.0" + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9883,7 +10287,7 @@ } }, "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "7.0.0", + "version": "7.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -10390,6 +10794,15 @@ "dev": true, "license": "MIT" }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -11094,6 +11507,30 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", @@ -11640,6 +12077,12 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/scslre": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", @@ -12830,6 +13273,27 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -14265,6 +14729,71 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/with": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", @@ -14570,6 +15099,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 0eed2a76..22bf509d 100644 --- a/package.json +++ b/package.json @@ -101,11 +101,13 @@ "blessed-contrib": "^4.11.0", "chalk": "^4.1.2", "commander": "^14.0.2", + "ink": "^6.6.0", "md-to-pdf": "^5.2.5", "node-pty": "^1.1.0", "omelette": "^0.4.17", "pidusage": "^4.0.1", - "proper-lockfile": "^4.1.2" + "proper-lockfile": "^4.1.2", + "react": "^19.2.4" }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", @@ -113,6 +115,8 @@ "@semantic-release/github": "^11.0.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.3", + "@types/react": "^19.2.9", + "@types/react-reconciler": "^0.33.0", "c8": "^10.1.3", "chai": "^6.2.1", "depcheck": "^1.4.7", diff --git a/src/agent-wrapper.js b/src/agent-wrapper.js index edef2765..2814900a 100644 --- a/src/agent-wrapper.js +++ b/src/agent-wrapper.js @@ -423,8 +423,12 @@ class AgentWrapper { isolation: this.isolation, }); - // Record when this iteration started so future "since: last_agent_start" filters work - this.lastAgentStartTime = Date.now(); + // Record when this iteration started so future "since: last_agent_start" filters work. + const latestMessage = this.messageBus.findLast({ cluster_id: this.cluster.id }); + const latestTimestamp = latestMessage?.timestamp; + const now = Date.now(); + this.lastAgentStartTime = + typeof latestTimestamp === 'number' ? Math.max(now, latestTimestamp + 1) : now; return context; } diff --git a/src/agent/agent-context-builder.js b/src/agent/agent-context-builder.js index 2be362f7..37d4b4fd 100644 --- a/src/agent/agent-context-builder.js +++ b/src/agent/agent-context-builder.js @@ -5,13 +5,20 @@ * - Context assembly from multiple message sources * - Context strategy evaluation (topics, limits, since timestamps) * - Prompt injection and formatting - * - Token-based truncation + * - Token-budgeted context packs * - Defensive context overflow prevention */ // Defensive limit: 500,000 chars ā‰ˆ 125k tokens (safe buffer below 200k limit) // Prevents "Prompt is too long" errors that kill tasks const MAX_CONTEXT_CHARS = 500000; +const { + buildContextMetrics, + emitContextMetrics, + resolveLegacyMaxTokens, + updateTotalMetrics, +} = require('./context-metrics'); +const { buildContextPacks } = require('./context-pack-builder'); /** * Generate an example object from a JSON schema @@ -176,7 +183,7 @@ function resolveSourceSince(source, cluster, lastTaskEndTime, lastAgentStartTime const parsed = Date.parse(sinceValue); if (Number.isNaN(parsed)) { throw new Error( - `Agent context source for topic ${source.topic} has invalid since value "${sinceValue}". ` + + `Unknown context source "since" value "${sinceValue}" for topic ${source.topic}. ` + 'Use cluster_start, last_task_end, last_agent_start, or an ISO timestamp.' ); } @@ -201,38 +208,108 @@ function formatSourceMessagesSection(source, messages) { return context; } -function buildSourcesSection({ - strategy, +function resolveSourceSelection(source, { compact = false } = {}) { + const baseAmount = source.amount ?? source.limit; + const baseStrategy = source.strategy ?? (baseAmount !== undefined ? 'latest' : 'all'); + + if (!compact) { + return { amount: baseAmount, strategy: baseStrategy }; + } + + const compactAmount = source.compactAmount ?? (baseAmount !== undefined ? 1 : 1); + const compactStrategy = + source.compactStrategy ?? (baseStrategy === 'all' ? 'latest' : baseStrategy); + + return { amount: compactAmount, strategy: compactStrategy }; +} + +function resolveSourceMessages({ + source, messageBus, cluster, lastTaskEndTime, lastAgentStartTime, + compact = false, }) { - let context = ''; - for (const source of strategy.sources) { - const sinceTimestamp = resolveSourceSince(source, cluster, lastTaskEndTime, lastAgentStartTime); - const messages = messageBus.query({ - cluster_id: cluster.id, - topic: source.topic, - sender: source.sender, - since: sinceTimestamp, - limit: source.limit, - }); + const sinceTimestamp = resolveSourceSince(source, cluster, lastTaskEndTime, lastAgentStartTime); + const { amount, strategy } = resolveSourceSelection(source, { compact }); + const order = strategy === 'latest' ? 'desc' : 'asc'; + const messages = messageBus.query({ + cluster_id: cluster.id, + topic: source.topic, + sender: source.sender, + since: sinceTimestamp, + limit: amount, + order, + }); - if (messages.length > 0) { - context += formatSourceMessagesSection(source, messages); - } + if (strategy !== 'latest' || messages.length <= 1) { + return messages; } - return context; + + return messages.slice().reverse(); } -function collectCannotValidateCriteria(prevValidations) { +function resolveSourcePriority(source) { + if (source.priority) { + return source.priority; + } + if (source.topic === 'STATE_SNAPSHOT') { + return 'required'; + } + if (source.topic === 'ISSUE_OPENED' || source.topic === 'PLAN_READY') { + return 'required'; + } + if (source.topic === 'VALIDATION_RESULT' || source.topic === 'IMPLEMENTATION_READY') { + return 'high'; + } + return 'medium'; +} + +function buildSourcePack({ + source, + index, + messageBus, + cluster, + lastTaskEndTime, + lastAgentStartTime, +}) { + const packId = `source:${source.topic}:${index}`; + const priority = resolveSourcePriority(source); + + const render = (compact) => { + const messages = resolveSourceMessages({ + source, + messageBus, + cluster, + lastTaskEndTime, + lastAgentStartTime, + compact, + }); + if (messages.length === 0) return ''; + return formatSourceMessagesSection(source, messages); + }; + + return { + id: packId, + section: 'sources', + priority, + render: () => render(false), + compact: () => render(true), + }; +} + +const { isPlatformMismatchReason } = require('./validation-platform'); + +function collectCannotValidateCriteria(prevValidations, options = {}) { const cannotValidateCriteria = []; + const ignoreReason = options.ignoreReason; for (const msg of prevValidations) { const criteriaResults = msg.content?.data?.criteriaResults; if (!Array.isArray(criteriaResults)) continue; for (const cr of criteriaResults) { if (cr.status !== 'CANNOT_VALIDATE' || !cr.id) continue; + if (ignoreReason && ignoreReason(cr.reason)) continue; if (cannotValidateCriteria.find((c) => c.id === cr.id)) continue; cannotValidateCriteria.push({ id: cr.id, @@ -257,7 +334,7 @@ function buildCannotValidateSection(cannotValidateCriteria) { return context; } -function buildValidatorSkipSection({ role, messageBus, cluster }) { +function buildValidatorSkipSection({ role, messageBus, cluster, isolation }) { if (role !== 'validator') return ''; const prevValidations = messageBus.query({ @@ -267,7 +344,8 @@ function buildValidatorSkipSection({ role, messageBus, cluster }) { limit: 50, }); - const cannotValidateCriteria = collectCannotValidateCriteria(prevValidations); + const ignoreReason = isolation?.enabled ? isPlatformMismatchReason : null; + const cannotValidateCriteria = collectCannotValidateCriteria(prevValidations, { ignoreReason }); return buildCannotValidateSection(cannotValidateCriteria); } @@ -281,110 +359,6 @@ function buildTriggeringMessageSection(triggeringMessage) { return context; } -function findContextSectionIndices(lines) { - let issueOpenedStart = -1; - let issueOpenedEnd = -1; - let triggeringStart = -1; - - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes('## Messages from topic: ISSUE_OPENED')) { - issueOpenedStart = i; - } - if (issueOpenedStart !== -1 && issueOpenedEnd === -1 && lines[i].startsWith('## ')) { - issueOpenedEnd = i; - } - if (lines[i].includes('## Triggering Message')) { - triggeringStart = i; - break; - } - } - - return { issueOpenedStart, issueOpenedEnd, triggeringStart }; -} - -function collectRecentLines(middleLines, budgetForRecent) { - const recentLines = []; - let recentSize = 0; - - for (let i = middleLines.length - 1; i >= 0; i--) { - const line = middleLines[i]; - const lineSize = line.length + 1; - - if (recentSize + lineSize > budgetForRecent) { - break; - } - - recentLines.unshift(line); - recentSize += lineSize; - } - - return recentLines; -} - -function truncateContextIfNeeded(context) { - const originalLength = context.length; - if (originalLength <= MAX_CONTEXT_CHARS) { - return context; - } - - console.log( - `[Context] Context too large (${originalLength} chars), truncating to prevent overflow...` - ); - - const lines = context.split('\n'); - const { issueOpenedStart, issueOpenedEnd, triggeringStart } = findContextSectionIndices(lines); - - const headerEnd = issueOpenedStart !== -1 ? issueOpenedStart : triggeringStart; - const header = lines.slice(0, headerEnd).join('\n'); - - const issueOpened = - issueOpenedStart !== -1 && issueOpenedEnd !== -1 - ? lines.slice(issueOpenedStart, issueOpenedEnd).join('\n') - : ''; - - const triggeringMsg = lines.slice(triggeringStart).join('\n'); - - const fixedSize = header.length + issueOpened.length + triggeringMsg.length; - const budgetForRecent = MAX_CONTEXT_CHARS - fixedSize - 200; - - const middleStart = issueOpenedEnd !== -1 ? issueOpenedEnd : headerEnd; - const middleEnd = triggeringStart; - const middleLines = lines.slice(middleStart, middleEnd); - const recentLines = collectRecentLines(middleLines, budgetForRecent); - - const parts = [header]; - if (issueOpened) { - parts.push(issueOpened); - } - if (recentLines.length < middleLines.length) { - const truncatedCount = middleLines.length - recentLines.length; - parts.push( - `\n[...${truncatedCount} earlier context messages truncated to prevent overflow...]\n` - ); - } - if (recentLines.length > 0) { - parts.push(recentLines.join('\n')); - } - parts.push(triggeringMsg); - - const truncatedContext = parts.join('\n'); - const truncatedLength = truncatedContext.length; - console.log( - `[Context] Truncated from ${originalLength} to ${truncatedLength} chars (${Math.round((truncatedLength / originalLength) * 100)}% retained)` - ); - - return truncatedContext; -} - -function applyLegacyMaxTokens(context, strategy) { - const maxTokens = strategy.maxTokens || 100000; - const maxChars = maxTokens * 4; - if (context.length > maxChars) { - return context.slice(0, maxChars) + '\n\n[Context truncated...]'; - } - return context; -} - /** * Build execution context for an agent * @param {object} params - Context building parameters @@ -419,24 +393,75 @@ function buildContext({ const strategy = config.contextStrategy || { sources: [] }; const isIsolated = !!(worktree?.enabled || isolation?.enabled); - let context = buildHeaderContext({ id, role, iteration, isIsolated }); - context += buildInstructionsSection({ config, selectedPrompt, id }); - context += buildLegacyOutputSchemaSection(config); - context += buildJsonSchemaSection(config); - context += buildSourcesSection({ + const header = buildHeaderContext({ id, role, iteration, isIsolated }); + const instructions = buildInstructionsSection({ config, selectedPrompt, id }); + const legacyOutputSchema = buildLegacyOutputSchemaSection(config); + const jsonSchema = buildJsonSchemaSection(config); + const validatorSkip = buildValidatorSkipSection({ role, messageBus, cluster, isolation }); + const triggeringMessageSection = buildTriggeringMessageSection(triggeringMessage); + + const packs = []; + let order = 0; + + const pushStaticPack = (packId, section, text, options = {}) => { + if (!text) return; + packs.push({ + id: packId, + section, + priority: 'required', + order: order++, + preserve: options.preserve || false, + render: () => text, + }); + }; + + pushStaticPack('header', 'header', header); + pushStaticPack('instructions', 'instructions', instructions); + pushStaticPack('legacyOutputSchema', 'legacyOutputSchema', legacyOutputSchema); + pushStaticPack('jsonSchema', 'jsonSchema', jsonSchema); + + if (Array.isArray(strategy.sources)) { + strategy.sources.forEach((source, index) => { + const pack = buildSourcePack({ + source, + index, + messageBus, + cluster, + lastTaskEndTime, + lastAgentStartTime, + }); + packs.push({ ...pack, order: order++ }); + }); + } + + pushStaticPack('validatorSkip', 'validatorSkip', validatorSkip); + pushStaticPack('triggeringMessage', 'triggeringMessage', triggeringMessageSection, { + preserve: true, + }); + + const maxTokens = resolveLegacyMaxTokens(strategy); + const packResult = buildContextPacks({ + packs, + maxTokens, + maxChars: MAX_CONTEXT_CHARS, + }); + + const metrics = buildContextMetrics({ + clusterId: cluster.id, + agentId: id, + role, + iteration, + triggeringMessage, strategy, - messageBus, - cluster, - lastTaskEndTime, - lastAgentStartTime, + packs: packResult.packDecisions, + budget: packResult.budget, + truncation: packResult.truncation, }); - context += buildValidatorSkipSection({ role, messageBus, cluster }); - context += buildTriggeringMessageSection(triggeringMessage); - context = truncateContextIfNeeded(context); - context = applyLegacyMaxTokens(context, strategy); + updateTotalMetrics(metrics, packResult.context.length); + emitContextMetrics(metrics, { messageBus, clusterId: cluster.id, agentId: id }); - return context; + return packResult.context; } module.exports = { diff --git a/src/agent/agent-lifecycle.js b/src/agent/agent-lifecycle.js index dcbbd128..3b511f40 100644 --- a/src/agent/agent-lifecycle.js +++ b/src/agent/agent-lifecycle.js @@ -14,11 +14,135 @@ const { findMatchingTrigger, evaluateTrigger } = require('./agent-trigger-evaluator'); const { executeHook } = require('./agent-hook-executor'); +const IsolationManager = require('../isolation-manager'); const { analyzeProcessHealth, isPlatformSupported, STUCK_THRESHOLD, } = require('./agent-stuck-detector'); +const { normalizeProviderName } = require('../../lib/provider-names'); +const { loadSettings } = require('../../lib/settings'); +const { findPlatformMismatchReason } = require('./validation-platform'); + +const DEFAULT_VALIDATOR_IMAGE = 'zeroshot-cluster-base'; + +function resolveValidatorIsolationConfig(agent) { + const config = agent.config?.isolation || {}; + if (config.type && config.type !== 'docker') { + return null; + } + + return { + image: config.image || DEFAULT_VALIDATOR_IMAGE, + mounts: config.mounts, + noMounts: config.noMounts, + containerHome: config.containerHome, + }; +} + +async function createValidatorIsolation(agent, isolationConfig) { + if (!IsolationManager.isDockerAvailable()) { + agent._log(`[${agent.id}] Docker not available - cannot retry validator in isolation`); + return null; + } + + const cluster = agent.cluster || {}; + const workDir = agent.config?.cwd || cluster.worktree?.path || cluster.cwd || process.cwd(); + const image = isolationConfig.image; + await IsolationManager.ensureImage(image); + + const manager = new IsolationManager({ image }); + const providerName = normalizeProviderName( + (agent._resolveProvider && agent._resolveProvider()) || + cluster.config?.forceProvider || + cluster.config?.defaultProvider || + loadSettings().defaultProvider || + 'claude' + ); + + const isolationClusterId = `${cluster.id}-validators`; + const containerId = await manager.createContainer(isolationClusterId, { + workDir, + image, + noMounts: isolationConfig.noMounts, + mounts: isolationConfig.mounts, + containerHome: isolationConfig.containerHome, + provider: providerName, + reuseExistingWorkspace: true, + }); + + const validatorIsolation = { + enabled: true, + manager, + clusterId: isolationClusterId, + containerId, + image, + workDir, + }; + + cluster.validatorIsolation = validatorIsolation; + return validatorIsolation; +} + +async function ensureValidatorIsolation(agent) { + const cluster = agent.cluster || {}; + + if (agent.isolation?.enabled) { + return agent.isolation; + } + + if (cluster.validatorIsolation?.enabled) { + agent.isolation = cluster.validatorIsolation; + return agent.isolation; + } + + if (cluster.validatorIsolationPromise) { + const isolation = await cluster.validatorIsolationPromise; + if (isolation?.enabled) { + agent.isolation = isolation; + } + return agent.isolation || null; + } + + const isolationConfig = resolveValidatorIsolationConfig(agent); + if (!isolationConfig) { + agent._log(`[${agent.id}] Validator isolation config is not docker - skipping fallback`); + return null; + } + + cluster.validatorIsolationPromise = createValidatorIsolation(agent, isolationConfig); + + try { + const isolation = await cluster.validatorIsolationPromise; + if (isolation?.enabled) { + agent.isolation = isolation; + return agent.isolation; + } + return null; + } finally { + cluster.validatorIsolationPromise = null; + } +} + +async function maybeRetryValidatorInDocker(agent, result) { + if (agent.role !== 'validator') return null; + if (agent.isolation?.enabled) return null; + if (agent._validatorIsolationAttemptedIteration === agent.iteration) { + return null; + } + + const reason = findPlatformMismatchReason(result?.result || {}); + if (!reason) return null; + + const isolation = await ensureValidatorIsolation(agent); + if (!isolation) { + return null; + } + + agent._validatorIsolationAttemptedIteration = agent.iteration; + agent._log(`[${agent.id}] Platform mismatch detected - retrying validator in Docker isolation`); + return reason; +} /** * Start the agent (begin listening for triggers) @@ -401,6 +525,13 @@ async function runTaskAttempt(agent, triggeringMessage) { throw new Error(result.error || 'Task execution failed'); } + const fallbackReason = await maybeRetryValidatorInDocker(agent, result); + if (fallbackReason) { + throw new Error( + `Validator platform mismatch detected (${fallbackReason}). Retrying in Docker isolation.` + ); + } + // Set state to idle BEFORE publishing lifecycle event // (so lifecycle message includes correct state) agent.state = 'idle'; @@ -604,8 +735,10 @@ async function executeTask(agent, triggeringMessage) { // Default: no retries (maxRetries=1 means 1 attempt only) // Set agent config `maxRetries: 3` to enable exponential backoff retries - const maxRetries = agent.config.maxRetries ?? 1; + let maxRetries = agent.config.maxRetries ?? 1; const baseDelay = 2000; // 2 seconds + let sigtermRetryGranted = false; + let noMessagesRetryGranted = false; for (let attempt = 1; attempt <= maxRetries; attempt++) { // Check if agent was stopped between retries @@ -617,6 +750,17 @@ async function executeTask(agent, triggeringMessage) { await runTaskAttempt(agent, triggeringMessage); return; } catch (error) { + const isSigterm = error.message && error.message.includes('SIGTERM'); + const isNoMessages = + error.message && error.message.toLowerCase().includes('no messages returned'); + if (isSigterm && !sigtermRetryGranted && attempt >= maxRetries) { + sigtermRetryGranted = true; + maxRetries += 1; + } + if (isNoMessages && !noMessagesRetryGranted && attempt >= maxRetries) { + noMessagesRetryGranted = true; + maxRetries += 1; + } const shouldStop = await handleTaskAttemptFailure({ agent, triggeringMessage, diff --git a/src/agent/agent-task-executor.js b/src/agent/agent-task-executor.js index 92ad8412..e0d1da8a 100644 --- a/src/agent/agent-task-executor.js +++ b/src/agent/agent-task-executor.js @@ -92,6 +92,95 @@ function sanitizeErrorMessage(error) { return error; } +function safeTail(text, maxChars) { + if (!text) return ''; + if (text.length <= maxChars) return text; + return text.slice(-maxChars); +} + +function getClaudeConfigDir() { + return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); +} + +function findLatestClaudeDebugFile(configDir) { + try { + const debugDir = path.join(configDir, 'debug'); + const latestLink = path.join(debugDir, 'latest'); + if (fs.existsSync(latestLink)) { + const resolved = fs.realpathSync(latestLink); + const stats = fs.statSync(resolved); + return { path: resolved, mtimeMs: stats.mtimeMs }; + } + + const entries = fs.readdirSync(debugDir); + let newest = null; + for (const entry of entries) { + const fullPath = path.join(debugDir, entry); + const stats = fs.statSync(fullPath); + if (!stats.isFile()) continue; + if (!newest || stats.mtimeMs > newest.mtimeMs) { + newest = { path: fullPath, mtimeMs: stats.mtimeMs }; + } + } + return newest; + } catch (error) { + return { error: error.message }; + } +} + +function readFileTail(filePath, maxBytes) { + try { + const fd = fs.openSync(filePath, 'r'); + try { + const size = fs.fstatSync(fd).size; + const start = Math.max(0, size - maxBytes); + const length = size - start; + if (length <= 0) return ''; + const buffer = Buffer.alloc(length); + fs.readSync(fd, buffer, 0, length, start); + return buffer.toString('utf8'); + } finally { + fs.closeSync(fd); + } + } catch { + return ''; + } +} + +function logNoMessagesReturned({ taskId, output, statusOutput, debug }) { + const claudeConfigDir = getClaudeConfigDir(); + const latestDebug = findLatestClaudeDebugFile(claudeConfigDir); + const latestDebugPath = latestDebug?.path || null; + const latestDebugTail = + latestDebugPath && typeof latestDebugPath === 'string' + ? safeTail(readFileTail(latestDebugPath, 4000), 4000) + : ''; + + const payload = { + event: 'NO_MESSAGES_RETURNED', + timestamp: new Date().toISOString(), + taskId, + agentId: debug?.agentId || null, + provider: debug?.providerName || null, + pid: debug?.pid || null, + cwd: debug?.cwd || null, + worktreePath: debug?.worktreePath || null, + isolation: debug?.isolation || false, + clusterId: debug?.clusterId || null, + logFilePath: debug?.logFilePath || null, + outputLen: output ? output.length : 0, + outputTail: safeTail(output || '', 1000), + statusOutputLen: statusOutput ? statusOutput.length : 0, + statusOutputTail: safeTail(statusOutput || '', 1000), + claudeConfigDir, + claudeDebugLatest: latestDebugPath, + claudeDebugLatestMtimeMs: latestDebug?.mtimeMs || null, + claudeDebugLatestTail: latestDebugTail, + }; + + console.error('[AgentTaskExecutor] Claude CLI returned no messages', payload); +} + /** * Extract error context from task output. * Shared by both isolated and non-isolated modes. @@ -101,9 +190,10 @@ function sanitizeErrorMessage(error) { * @param {string} [params.statusOutput] - Status command output (non-isolated only) * @param {string} params.taskId - Task ID for error messages * @param {boolean} [params.isNotFound=false] - True if task was not found + * @param {Object} [params.debug] - Additional debug context for logging * @returns {string|null} Sanitized error context or null if extraction failed */ -function extractErrorContext({ output, statusOutput, taskId, isNotFound = false }) { +function extractErrorContext({ output, statusOutput, taskId, isNotFound = false, debug }) { // Task not found - explicit error if (isNotFound) { return sanitizeErrorMessage(`Task ${taskId} not found (may have crashed or been killed)`); @@ -138,6 +228,14 @@ function extractErrorContext({ output, statusOutput, taskId, isNotFound = false ); } + // Claude CLI transient failure: no messages returned + if (fullOutput.includes('No messages returned')) { + logNoMessagesReturned({ taskId, output: fullOutput, statusOutput, debug }); + return sanitizeErrorMessage( + `Claude CLI returned no messages. This is usually transient; retry the task or resume the cluster.` + ); + } + // NEVER TRUNCATE OUTPUT - truncation corrupts structured JSON and causes false "crash" status // If output is too verbose, that's a prompt problem - fix the prompts, not the data const trimmedOutput = (output || '').trim(); @@ -976,7 +1074,20 @@ function handleStatusCompletion({ finalizeLogFollow(agent, state); const errorContext = !success - ? extractErrorContext({ output: state.output, statusOutput: stdout, taskId }) + ? extractErrorContext({ + output: state.output, + statusOutput: stdout, + taskId, + debug: { + agentId: agent.id, + providerName, + pid: agent.processPid, + cwd: agent.config.cwd || process.cwd(), + worktreePath: agent.worktree?.path || null, + isolation: !!agent.isolation?.enabled, + logFilePath: state.logFilePath || null, + }, + }) : null; resolve({ @@ -1419,7 +1530,21 @@ async function checkIsolatedStatus({ const success = isSuccess && !isError; const errorContext = !success - ? extractErrorContext({ output: state.fullOutput, taskId, isNotFound }) + ? extractErrorContext({ + output: state.fullOutput, + taskId, + isNotFound, + debug: { + agentId: agent.id, + providerName, + pid: agent.processPid, + cwd: agent.config.cwd || process.cwd(), + worktreePath: agent.worktree?.path || null, + isolation: true, + clusterId, + logFilePath, + }, + }) : null; const parsedResult = await agent._parseResultOutput(state.fullOutput); @@ -1547,13 +1672,13 @@ function followClaudeTaskLogsIsolated(agent, taskId) { * @returns {Promise} Parsed result data */ async function parseResultOutput(agent, output) { - // Empty or error outputs = FAIL - if (!output || output.includes('Task not found') || output.includes('Process terminated')) { + // Empty outputs = FAIL + if (!output || !output.trim()) { throw new Error('Task execution failed - no output'); } const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude'; - const { extractJsonFromOutput } = require('./output-extraction'); + const { extractJsonFromOutput, hasFatalStandaloneOutput } = require('./output-extraction'); // Use clean extraction pipeline let parsed = extractJsonFromOutput(output, providerName); @@ -1584,6 +1709,9 @@ async function parseResultOutput(agent, output) { } if (!parsed) { + if (hasFatalStandaloneOutput(output)) { + throw new Error('Task execution failed - no output'); + } const trimmedOutput = output.trim(); console.error(`\n${'='.repeat(80)}`); console.error(`šŸ”“ AGENT OUTPUT MISSING REQUIRED JSON BLOCK`); diff --git a/src/agent/context-metrics.js b/src/agent/context-metrics.js new file mode 100644 index 00000000..256cec68 --- /dev/null +++ b/src/agent/context-metrics.js @@ -0,0 +1,160 @@ +const TOKENS_PER_CHAR_ESTIMATE = 4; + +function estimateTokensFromChars(chars) { + if (!Number.isFinite(chars) || chars <= 0) { + return 0; + } + + return Math.ceil(chars / TOKENS_PER_CHAR_ESTIMATE); +} + +function buildSectionMetrics(sections) { + const sectionMetrics = {}; + let totalChars = 0; + + for (const [sectionName, text] of Object.entries(sections)) { + const safeText = typeof text === 'string' ? text : ''; + const chars = safeText.length; + const estimatedTokens = estimateTokensFromChars(chars); + sectionMetrics[sectionName] = { chars, estimatedTokens }; + totalChars += chars; + } + + return { sectionMetrics, totalChars }; +} + +function buildSectionMetricsFromPacks(packs) { + const sectionMetrics = {}; + let totalChars = 0; + + for (const pack of packs) { + if (pack.status !== 'included') continue; + const sectionName = pack.section || pack.id || 'unknown'; + const chars = Number.isFinite(pack.chars) ? pack.chars : 0; + if (!sectionMetrics[sectionName]) { + sectionMetrics[sectionName] = { chars: 0, estimatedTokens: 0 }; + } + sectionMetrics[sectionName].chars += chars; + totalChars += chars; + } + + for (const section of Object.values(sectionMetrics)) { + section.estimatedTokens = estimateTokensFromChars(section.chars); + } + + return { sectionMetrics, totalChars }; +} + +function resolveLegacyMaxTokens(strategy) { + if (!strategy) { + return 100000; + } + + return strategy.maxTokens || 100000; +} + +function buildContextMetrics({ + clusterId, + agentId, + role, + iteration, + triggeringMessage, + strategy, + sections, + packs, + budget, + truncation, +}) { + const maxTokens = resolveLegacyMaxTokens(strategy); + const sourcesCount = Array.isArray(strategy?.sources) ? strategy.sources.length : 0; + const packMetrics = Array.isArray(packs) ? packs : []; + + let sectionMetrics = {}; + let totalChars = 0; + if (packMetrics.length > 0) { + const packTotals = buildSectionMetricsFromPacks(packMetrics); + sectionMetrics = packTotals.sectionMetrics; + totalChars = packTotals.totalChars; + } else if (sections) { + const sectionTotals = buildSectionMetrics(sections); + sectionMetrics = sectionTotals.sectionMetrics; + totalChars = sectionTotals.totalChars; + } + + return { + clusterId, + agentId, + role, + iteration, + triggeredBy: triggeringMessage?.topic || null, + triggerFrom: triggeringMessage?.sender || null, + strategy: { + maxTokens, + sourcesCount, + }, + budget: { + maxTokens: budget?.maxTokens ?? maxTokens, + remainingTokens: budget?.remainingTokens === undefined ? null : budget?.remainingTokens, + overBudgetTokens: budget?.overBudgetTokens ?? 0, + finalTokens: budget?.finalTokens ?? estimateTokensFromChars(totalChars), + }, + packs: packMetrics, + sections: sectionMetrics, + total: { + chars: totalChars, + estimatedTokens: estimateTokensFromChars(totalChars), + }, + truncation: { + maxContextChars: truncation?.maxContextChars || { + applied: false, + beforeChars: totalChars, + afterChars: totalChars, + }, + }, + }; +} + +function updateTotalMetrics(metrics, chars) { + if (!metrics || !Number.isFinite(chars)) { + return; + } + + metrics.total = { + chars, + estimatedTokens: estimateTokensFromChars(chars), + }; + + if (metrics.budget) { + metrics.budget.finalTokens = estimateTokensFromChars(chars); + } + + if (metrics.truncation?.maxContextChars) { + metrics.truncation.maxContextChars.afterChars = chars; + } +} + +function emitContextMetrics(metrics, { messageBus, clusterId, agentId }) { + if (process.env.ZEROSHOT_CONTEXT_METRICS === '1') { + console.log('[ContextMetrics]', JSON.stringify(metrics)); + } + + if (process.env.ZEROSHOT_CONTEXT_METRICS_LEDGER === '1' && messageBus?.publish) { + messageBus.publish({ + cluster_id: clusterId, + topic: 'CONTEXT_METRICS', + sender: agentId, + receiver: 'system', + content: { + data: metrics, + }, + }); + } +} + +module.exports = { + estimateTokensFromChars, + resolveLegacyMaxTokens, + buildContextMetrics, + updateTotalMetrics, + emitContextMetrics, +}; diff --git a/src/agent/context-pack-builder.js b/src/agent/context-pack-builder.js new file mode 100644 index 00000000..6a13fae6 --- /dev/null +++ b/src/agent/context-pack-builder.js @@ -0,0 +1,367 @@ +const { estimateTokensFromChars } = require('./context-metrics'); + +const PRIORITY_RANK = { + required: 0, + high: 1, + medium: 2, + low: 3, +}; + +const DEFAULT_PRIORITY = 'medium'; +const TRUNCATION_SUFFIX = '\n\n[Context truncated to fit limit]\n'; + +function normalizePriority(priority, required) { + if (required) return 'required'; + if (priority && PRIORITY_RANK[priority] !== undefined) return priority; + return DEFAULT_PRIORITY; +} + +function normalizePack(pack, index) { + const priority = normalizePriority(pack.priority, pack.required); + return { + ...pack, + priority, + required: pack.required || priority === 'required', + order: pack.order ?? index, + }; +} + +function renderVariant(pack, variant, cache) { + const cacheKey = `${pack.id}:${variant}`; + if (cache.has(cacheKey)) return cache.get(cacheKey); + + let text = ''; + if (variant === 'full') { + text = typeof pack.render === 'function' ? pack.render() : ''; + } else if (variant === 'compact') { + text = typeof pack.compact === 'function' ? pack.compact() : ''; + } + + if (typeof text !== 'string') { + text = ''; + } + + const chars = text.length; + const estimatedTokens = estimateTokensFromChars(chars); + const rendered = { text, chars, estimatedTokens }; + cache.set(cacheKey, rendered); + return rendered; +} + +function sortByPriorityThenOrder(a, b) { + const priorityDelta = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]; + if (priorityDelta !== 0) return priorityDelta; + return a.order - b.order; +} + +function sortByOrder(a, b) { + return a.order - b.order; +} + +function sortByPriorityDescThenOrderDesc(a, b) { + const priorityDelta = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]; + if (priorityDelta !== 0) return priorityDelta; + return b.order - a.order; +} + +function selectVariant(pack, remainingTokens, cache) { + const full = renderVariant(pack, 'full', cache); + const compact = pack.compact ? renderVariant(pack, 'compact', cache) : null; + + const hasFull = full.chars > 0; + const hasCompact = compact && compact.chars > 0; + + if (!hasFull && !hasCompact) { + return { + status: 'skipped', + variant: null, + reason: 'empty', + chars: 0, + estimatedTokens: 0, + }; + } + + if (pack.required) { + let chosen = full; + let variant = 'full'; + + if (!hasFull && hasCompact) { + chosen = compact; + variant = 'compact'; + } else if ( + Number.isFinite(remainingTokens) && + hasCompact && + full.estimatedTokens > remainingTokens + ) { + if ( + compact.estimatedTokens <= remainingTokens || + compact.estimatedTokens < full.estimatedTokens + ) { + chosen = compact; + variant = 'compact'; + } + } + + return { + status: 'included', + variant, + chars: chosen.chars, + estimatedTokens: chosen.estimatedTokens, + text: chosen.text, + }; + } + + if (!Number.isFinite(remainingTokens) || full.estimatedTokens <= remainingTokens) { + return { + status: 'included', + variant: 'full', + chars: full.chars, + estimatedTokens: full.estimatedTokens, + text: full.text, + }; + } + + if (hasCompact && compact.estimatedTokens <= remainingTokens) { + return { + status: 'included', + variant: 'compact', + chars: compact.chars, + estimatedTokens: compact.estimatedTokens, + text: compact.text, + }; + } + + return { + status: 'skipped', + variant: null, + reason: 'budget', + chars: 0, + estimatedTokens: 0, + }; +} + +function truncateText(text, targetChars) { + if (text.length <= targetChars) { + return { text, truncated: false }; + } + + if (targetChars <= 0) { + return { text: '', truncated: true }; + } + + if (targetChars <= TRUNCATION_SUFFIX.length) { + return { text: text.slice(0, targetChars), truncated: true }; + } + + const sliceLength = targetChars - TRUNCATION_SUFFIX.length; + return { text: text.slice(0, sliceLength) + TRUNCATION_SUFFIX, truncated: true }; +} + +function applyMaxCharsGuard({ packs, selected, decisions, cache, maxChars, totalChars }) { + let currentChars = totalChars; + if (!Number.isFinite(maxChars) || currentChars <= maxChars) { + return { applied: false, beforeChars: totalChars, afterChars: totalChars }; + } + + const includedOptional = packs + .filter((pack) => selected.has(pack.id) && !pack.required) + .sort(sortByPriorityDescThenOrderDesc); + + for (const pack of includedOptional) { + if (currentChars <= maxChars) break; + const decision = decisions.get(pack.id); + if (!decision || decision.variant === 'compact' || !pack.compact) continue; + + const compact = renderVariant(pack, 'compact', cache); + if (compact.chars === 0 || compact.chars >= decision.chars) continue; + + const previousChars = decision.chars; + selected.set(pack.id, { ...compact, variant: 'compact' }); + decision.variant = 'compact'; + decision.chars = compact.chars; + decision.estimatedTokens = compact.estimatedTokens; + decision.reason = decision.reason || 'max_chars'; + + currentChars -= previousChars - compact.chars; + currentChars = Math.max(0, currentChars); + } + + for (const pack of includedOptional) { + if (currentChars <= maxChars) break; + if (!selected.has(pack.id)) continue; + + const decision = decisions.get(pack.id); + currentChars -= decision?.chars || 0; + selected.delete(pack.id); + if (decision) { + decision.status = 'skipped'; + decision.reason = decision.reason || 'max_chars'; + decision.chars = 0; + decision.estimatedTokens = 0; + } + } + + if (currentChars > maxChars) { + let overage = currentChars - maxChars; + const requiredCandidates = packs + .filter((pack) => selected.has(pack.id) && pack.required) + .sort((a, b) => { + const preserveDelta = (a.preserve ? 1 : 0) - (b.preserve ? 1 : 0); + if (preserveDelta !== 0) return preserveDelta; + const sizeDelta = (selected.get(b.id)?.chars || 0) - (selected.get(a.id)?.chars || 0); + if (sizeDelta !== 0) return sizeDelta; + return b.order - a.order; + }); + + for (const pack of requiredCandidates) { + if (overage <= 0) break; + const decision = decisions.get(pack.id); + const selectedPack = selected.get(pack.id); + if (!decision || !selectedPack) continue; + + const targetChars = Math.max(0, selectedPack.chars - overage); + const truncated = truncateText(selectedPack.text, targetChars); + if (truncated.text.length === selectedPack.chars) continue; + + const newChars = truncated.text.length; + const reduced = selectedPack.chars - newChars; + overage -= reduced; + + selected.set(pack.id, { + text: truncated.text, + chars: newChars, + estimatedTokens: estimateTokensFromChars(newChars), + variant: selectedPack.variant, + }); + + decision.chars = newChars; + decision.estimatedTokens = estimateTokensFromChars(newChars); + decision.truncated = true; + decision.reason = decision.reason || 'max_chars'; + } + } + + const afterChars = Array.from(selected.values()).reduce((sum, item) => sum + item.chars, 0); + return { applied: true, beforeChars: totalChars, afterChars }; +} + +function buildContextPacks({ packs, maxTokens, maxChars }) { + const normalized = packs.map(normalizePack); + const selectionOrder = normalized.slice().sort(sortByPriorityThenOrder); + const renderCache = new Map(); + const decisions = new Map(); + const selected = new Map(); + + let remainingTokens = Number.isFinite(maxTokens) ? maxTokens : Infinity; + let overBudgetTokens = 0; + + for (const pack of selectionOrder) { + const selection = selectVariant(pack, remainingTokens, renderCache); + const decision = { + id: pack.id, + section: pack.section || null, + priority: pack.priority, + required: pack.required, + status: selection.status, + variant: selection.variant, + chars: selection.chars, + estimatedTokens: selection.estimatedTokens, + order: pack.order, + reason: selection.reason || null, + }; + + decisions.set(pack.id, decision); + + if (selection.status !== 'included') { + continue; + } + + selected.set(pack.id, { + text: selection.text, + chars: selection.chars, + estimatedTokens: selection.estimatedTokens, + variant: selection.variant, + }); + + if (!Number.isFinite(remainingTokens)) { + continue; + } + + if (selection.estimatedTokens > remainingTokens) { + overBudgetTokens += selection.estimatedTokens - remainingTokens; + remainingTokens = 0; + } else { + remainingTokens -= selection.estimatedTokens; + } + } + + const ordered = normalized.slice().sort(sortByOrder); + let context = ''; + for (const pack of ordered) { + const selectedPack = selected.get(pack.id); + if (selectedPack) { + context += selectedPack.text; + } + } + + const totalChars = context.length; + const truncation = applyMaxCharsGuard({ + packs: ordered, + selected, + decisions, + cache: renderCache, + maxChars, + totalChars, + }); + + if (truncation.applied) { + context = ''; + for (const pack of ordered) { + const selectedPack = selected.get(pack.id); + if (selectedPack) { + context += selectedPack.text; + } + } + } + + const finalChars = context.length; + const finalTokens = estimateTokensFromChars(finalChars); + const packDecisions = ordered.map((pack) => { + const decision = decisions.get(pack.id); + return { + id: decision.id, + section: decision.section, + priority: decision.priority, + required: decision.required, + status: decision.status, + variant: decision.variant, + chars: decision.chars, + estimatedTokens: decision.estimatedTokens, + order: decision.order, + reason: decision.reason, + truncated: decision.truncated || false, + }; + }); + + return { + context, + packDecisions, + budget: { + maxTokens, + remainingTokens: Number.isFinite(remainingTokens) ? remainingTokens : null, + overBudgetTokens, + finalTokens, + }, + truncation: { + maxContextChars: { + applied: truncation.applied, + beforeChars: truncation.beforeChars, + afterChars: truncation.afterChars, + }, + }, + }; +} + +module.exports = { + buildContextPacks, +}; diff --git a/src/agent/output-extraction.js b/src/agent/output-extraction.js index 82312b38..35ad3176 100644 --- a/src/agent/output-extraction.js +++ b/src/agent/output-extraction.js @@ -108,15 +108,22 @@ function extractFromTextEvents(output, providerName) { const events = parseChunkWithProvider(provider, output); // Accumulate all text events - const textContent = events - .filter((e) => e.type === 'text') - .map((e) => e.text) - .join(''); + const textEvents = events.filter((e) => e.type === 'text').map((e) => e.text); + const textContent = textEvents.join(''); if (!textContent.trim()) return null; // Try parsing accumulated text as JSON - return extractDirectJson(textContent) || extractFromMarkdown(textContent); + const combined = extractDirectJson(textContent) || extractFromMarkdown(textContent); + if (combined) return combined; + + for (let i = textEvents.length - 1; i >= 0; i--) { + const candidate = textEvents[i]; + const parsed = extractDirectJson(candidate) || extractFromMarkdown(candidate); + if (parsed) return parsed; + } + + return null; } /** @@ -170,6 +177,26 @@ function extractDirectJson(text) { return null; } +/** + * Detects fatal standalone output lines that indicate no task output was produced. + * Only matches when the line itself is the fatal message (not when it appears inside JSON). + * + * @param {string} output - Raw output text + * @returns {boolean} True if a standalone fatal line is present + */ +function hasFatalStandaloneOutput(output) { + if (!output || typeof output !== 'string') return false; + const lines = output.split('\n'); + for (const line of lines) { + const stripped = stripTimestamp(line).trim(); + if (!stripped) continue; + if (/^(task not found|process terminated)\b/i.test(stripped)) { + return true; + } + } + return false; +} + /** * Main extraction function - tries all strategies in priority order * @@ -183,11 +210,6 @@ function extractJsonFromOutput(output, providerName = 'claude') { const trimmedOutput = output.trim(); if (!trimmedOutput) return null; - // Check for fatal error indicators - if (trimmedOutput.includes('Task not found') || trimmedOutput.includes('Process terminated')) { - return null; - } - // Strategy 1: Result wrapper (Claude format) const fromWrapper = extractFromResultWrapper(trimmedOutput); if (fromWrapper) return fromWrapper; @@ -204,6 +226,10 @@ function extractJsonFromOutput(output, providerName = 'claude') { const fromDirect = extractDirectJson(trimmedOutput); if (fromDirect) return fromDirect; + if (hasFatalStandaloneOutput(trimmedOutput)) { + return null; + } + return null; } @@ -214,4 +240,5 @@ module.exports = { extractFromMarkdown, extractDirectJson, stripTimestamp, + hasFatalStandaloneOutput, }; diff --git a/src/agent/validation-platform.js b/src/agent/validation-platform.js new file mode 100644 index 00000000..b41c7c7b --- /dev/null +++ b/src/agent/validation-platform.js @@ -0,0 +1,35 @@ +const PLATFORM_MISMATCH_REGEX = + /EBADPLATFORM|Unsupported platform|darwin-arm64|linux-x64|@esbuild\/linux-x64/i; + +function isPlatformMismatchReason(reason) { + if (!reason) return false; + return PLATFORM_MISMATCH_REGEX.test(String(reason)); +} + +function findPlatformMismatchReason(result = {}) { + const criteriaResults = result.criteriaResults; + if (Array.isArray(criteriaResults)) { + for (const criteria of criteriaResults) { + if (criteria?.status !== 'CANNOT_VALIDATE') continue; + if (isPlatformMismatchReason(criteria.reason)) { + return String(criteria.reason); + } + } + } + + const errors = result.errors; + if (Array.isArray(errors)) { + for (const error of errors) { + if (isPlatformMismatchReason(error)) { + return String(error); + } + } + } + + return null; +} + +module.exports = { + isPlatformMismatchReason, + findPlatformMismatchReason, +}; diff --git a/src/agents/git-pusher-template.js b/src/agents/git-pusher-template.js index 072eb89b..db3cd4d0 100644 --- a/src/agents/git-pusher-template.js +++ b/src/agents/git-pusher-template.js @@ -251,6 +251,17 @@ function generateGitPusherAgent(platform) { }, ], prompt: generatePrompt(config), + hooks: { + onComplete: { + action: 'publish_message', + config: { + topic: 'CLUSTER_COMPLETE', + content: { + data: { reason: 'git-pusher-complete' }, + }, + }, + }, + }, output: { topic: 'PR_CREATED', publishAfter: 'CLUSTER_COMPLETE', diff --git a/src/config-router.js b/src/config-router.js index f6618a1b..f269edae 100644 --- a/src/config-router.js +++ b/src/config-router.js @@ -37,7 +37,9 @@ function getConfig(complexity, taskType) { if (complexity === 'TRIVIAL') return 0; if (complexity === 'SIMPLE') return 1; if (complexity === 'STANDARD') return 2; - if (complexity === 'CRITICAL') return 4; + // CRITICAL uses two-stage validation pipeline (validators loaded dynamically via meta-coordinator) + // Setting validator_count=0 signals that inline validators should be skipped + if (complexity === 'CRITICAL') return 0; return 1; }; diff --git a/src/config-validator.js b/src/config-validator.js index 03ad2ae4..488658fd 100644 --- a/src/config-validator.js +++ b/src/config-validator.js @@ -330,9 +330,11 @@ function buildMessageFlowGraph(config) { }; } -function reportMissingBootstrap(topicConsumers, errors) { +function reportMissingBootstrap(topicConsumers, errors, config) { const issueOpenedConsumers = topicConsumers.get('ISSUE_OPENED') || []; - if (issueOpenedConsumers.length === 0) { + const isSubTemplate = config.params && Object.keys(config.params).length > 0; + + if (issueOpenedConsumers.length === 0 && !isSubTemplate) { errors.push( 'No agent triggers on ISSUE_OPENED. Cluster will never start. ' + 'Add a trigger: { "topic": "ISSUE_OPENED", "action": "execute_task" }' @@ -382,9 +384,17 @@ function reportOrphanTopics(topicProducers, topicConsumers, warnings) { } } -function reportUnproducedTopics(topicConsumers, topicProducers, errors) { +function reportUnproducedTopics(topicConsumers, topicProducers, errors, config) { + const EXTERNAL_TOPICS = [ + 'ISSUE_OPENED', + 'CLUSTER_RESUMED', + 'QUICK_VALIDATION_PASSED', + 'IMPLEMENTATION_READY', + ]; + const isSubTemplate = config.params && Object.keys(config.params).length > 0; + for (const [topic, consumers] of topicConsumers) { - if (topic === 'ISSUE_OPENED' || topic === 'CLUSTER_RESUMED') { + if (EXTERNAL_TOPICS.includes(topic)) { continue; } if (topic.endsWith('*')) { @@ -393,6 +403,9 @@ function reportUnproducedTopics(topicConsumers, topicProducers, errors) { const producers = topicProducers.get(topic) || []; if (producers.length === 0) { + if (isSubTemplate) { + continue; + } errors.push( `Topic '${topic}' consumed by [${consumers.join(', ')}] but never produced. ` + 'These agents will never trigger.' @@ -506,10 +519,10 @@ function analyzeMessageFlow(config) { const { topicProducers, topicConsumers, agentOutputTopics, agentInputTopics } = buildMessageFlowGraph(config); - reportMissingBootstrap(topicConsumers, errors); + reportMissingBootstrap(topicConsumers, errors, config); reportCompletionHandlers(config, errors, warnings); reportOrphanTopics(topicProducers, topicConsumers, warnings); - reportUnproducedTopics(topicConsumers, topicProducers, errors); + reportUnproducedTopics(topicConsumers, topicProducers, errors, config); reportSelfTriggeringAgents(config, agentInputTopics, agentOutputTopics, errors); reportTwoAgentCycles(config, agentInputTopics, agentOutputTopics, warnings); reportMissingValidationTriggers(config, errors); @@ -1459,9 +1472,14 @@ function validateJsonSchema(prefix, agent, errors) { function validateContextSource(prefix, source, topicProducers, errors, warnings) { const topic = source.topic; - if (topic === 'ISSUE_OPENED' || topic === 'CLUSTER_RESUMED') return; + if (topic === 'ISSUE_OPENED' || topic === 'CLUSTER_RESUMED' || topic === 'STATE_SNAPSHOT') { + return; + } if (topic.endsWith('*')) return; + const resolvedAmount = source.amount ?? source.limit; + const resolvedStrategy = source.strategy ?? (resolvedAmount !== undefined ? 'latest' : 'all'); + const producers = topicProducers.get(topic) || []; if (producers.length === 0) { warnings.push( @@ -1470,7 +1488,7 @@ function validateContextSource(prefix, source, topicProducers, errors, warnings) ); } - if (source.amount === undefined) { + if (resolvedAmount === undefined && resolvedStrategy !== 'all') { warnings.push( `[Gap 14] ${prefix}: Context source for topic '${topic}' missing 'amount' field. ` + `Defaults may not be what you expect.` @@ -1483,6 +1501,29 @@ function validateContextSource(prefix, source, topicProducers, errors, warnings) `Fix: Use 'latest', 'all', or 'oldest'.` ); } + + if (source.priority && !['required', 'high', 'medium', 'low'].includes(source.priority)) { + errors.push( + `[Gap 14] ${prefix}: Context source priority '${source.priority}' is invalid. ` + + `Fix: Use 'required', 'high', 'medium', or 'low'.` + ); + } + + if (source.compactStrategy && !['latest', 'all', 'oldest'].includes(source.compactStrategy)) { + errors.push( + `[Gap 14] ${prefix}: Context source compactStrategy '${source.compactStrategy}' is invalid. ` + + `Fix: Use 'latest', 'all', or 'oldest'.` + ); + } + + if (source.compactAmount !== undefined) { + if (!Number.isFinite(source.compactAmount) || source.compactAmount <= 0) { + errors.push( + `[Gap 14] ${prefix}: Context source compactAmount must be a positive number, got ${source.compactAmount}. ` + + `Fix: Use a positive integer like 1 or 3.` + ); + } + } } function validateContextSources(prefix, agent, topicProducers, errors, warnings) { diff --git a/src/issue-providers/azure-devops-provider.js b/src/issue-providers/azure-devops-provider.js index 14e72008..16c45b0f 100644 --- a/src/issue-providers/azure-devops-provider.js +++ b/src/issue-providers/azure-devops-provider.js @@ -7,6 +7,8 @@ const IssueProvider = require('./base-provider'); const { execSync } = require('../lib/safe-exec'); const { detectGitContext } = require('../../lib/git-remote-utils'); +const AUTH_CHECK_TIMEOUT_MS = 2000; + class AzureDevOpsProvider extends IssueProvider { static id = 'azure-devops'; static displayName = 'Azure DevOps'; @@ -76,10 +78,33 @@ class AzureDevOpsProvider extends IssueProvider { static checkAuth() { try { // First check Azure login - execSync('az account show', { encoding: 'utf8', stdio: 'pipe' }); + execSync('az account show', { + encoding: 'utf8', + stdio: 'pipe', + timeout: AUTH_CHECK_TIMEOUT_MS, + }); } catch (err) { const stderr = err.stderr || err.message || ''; + if (err.code === 'ENOENT' || stderr.includes('command not found')) { + return { + authenticated: false, + error: 'Azure CLI not installed', + recovery: [ + 'Install Azure CLI: https://docs.microsoft.com/cli/azure/', + 'Then verify: az --version', + ], + }; + } + + if (stderr.includes('Command timed out')) { + return { + authenticated: false, + error: 'az account show timed out', + recovery: ['Retry: az account show', 'If it still hangs, run: az login'], + }; + } + if (stderr.includes('az login') || stderr.includes('not logged in')) { return { authenticated: false, @@ -104,6 +129,7 @@ class AzureDevOpsProvider extends IssueProvider { const output = execSync('az extension list --query "[?name==\'azure-devops\']" -o json', { encoding: 'utf8', stdio: 'pipe', + timeout: AUTH_CHECK_TIMEOUT_MS, }); const extensions = JSON.parse(output); if (extensions.length === 0) { @@ -116,7 +142,15 @@ class AzureDevOpsProvider extends IssueProvider { ], }; } - } catch { + } catch (err) { + const stderr = err?.stderr || err?.message || ''; + if (stderr.includes('Command timed out')) { + return { + authenticated: false, + error: 'az extension list timed out', + recovery: ['Retry: az extension list', 'Ensure az is configured and responsive'], + }; + } return { authenticated: false, error: 'Could not verify Azure DevOps extension', diff --git a/src/issue-providers/github-provider.js b/src/issue-providers/github-provider.js index 39f65952..2ee0b7b7 100644 --- a/src/issue-providers/github-provider.js +++ b/src/issue-providers/github-provider.js @@ -5,6 +5,8 @@ const IssueProvider = require('./base-provider'); const { execSync } = require('../lib/safe-exec'); +const AUTH_CHECK_TIMEOUT_MS = 2000; + class GitHubProvider extends IssueProvider { static id = 'github'; static displayName = 'GitHub'; @@ -69,11 +71,31 @@ class GitHubProvider extends IssueProvider { */ static checkAuth() { try { - execSync('gh auth status', { encoding: 'utf8', stdio: 'pipe' }); + execSync('gh auth status', { + encoding: 'utf8', + stdio: 'pipe', + timeout: AUTH_CHECK_TIMEOUT_MS, + }); return { authenticated: true, error: null, recovery: [] }; } catch (err) { const stderr = err.stderr || err.message || ''; + if (err.code === 'ENOENT' || stderr.includes('command not found')) { + return { + authenticated: false, + error: 'gh CLI not installed', + recovery: ['Install gh: https://cli.github.com/', 'Then verify: gh --version'], + }; + } + + if (stderr.includes('Command timed out')) { + return { + authenticated: false, + error: 'gh auth status timed out', + recovery: ['Retry: gh auth status', 'If it still hangs, re-run: gh auth login'], + }; + } + if (stderr.includes('not logged in')) { return { authenticated: false, diff --git a/src/issue-providers/gitlab-provider.js b/src/issue-providers/gitlab-provider.js index 1f534981..c9bddae5 100644 --- a/src/issue-providers/gitlab-provider.js +++ b/src/issue-providers/gitlab-provider.js @@ -6,6 +6,8 @@ const IssueProvider = require('./base-provider'); const { execSync } = require('../lib/safe-exec'); +const AUTH_CHECK_TIMEOUT_MS = 2000; + class GitLabProvider extends IssueProvider { static id = 'gitlab'; static displayName = 'GitLab'; @@ -89,11 +91,30 @@ class GitLabProvider extends IssueProvider { const cmd = hostname ? `glab auth status --hostname ${hostname}` : 'glab auth status'; try { - execSync(cmd, { encoding: 'utf8', stdio: 'pipe' }); + execSync(cmd, { encoding: 'utf8', stdio: 'pipe', timeout: AUTH_CHECK_TIMEOUT_MS }); return { authenticated: true, error: null, recovery: [] }; } catch (err) { const stderr = err.stderr || err.message || ''; + if (err.code === 'ENOENT' || stderr.includes('command not found')) { + return { + authenticated: false, + error: 'glab CLI not installed', + recovery: [ + 'Install glab: https://gitlab.com/gitlab-org/cli', + 'Then verify: glab --version', + ], + }; + } + + if (stderr.includes('Command timed out')) { + return { + authenticated: false, + error: 'glab auth status timed out', + recovery: ['Retry: glab auth status', 'If it still hangs, run: glab auth login'], + }; + } + const hostnameHint = hostname || 'your GitLab instance'; if ( stderr.includes('not logged in') || diff --git a/src/issue-providers/jira-provider.js b/src/issue-providers/jira-provider.js index f8798d34..b5de25b6 100644 --- a/src/issue-providers/jira-provider.js +++ b/src/issue-providers/jira-provider.js @@ -6,6 +6,8 @@ const IssueProvider = require('./base-provider'); const { execSync } = require('../lib/safe-exec'); +const AUTH_CHECK_TIMEOUT_MS = 2000; + class JiraProvider extends IssueProvider { static id = 'jira'; static displayName = 'Jira'; @@ -71,11 +73,30 @@ class JiraProvider extends IssueProvider { try { // go-jira uses 'jira session' to verify authentication // If not configured, it fails with endpoint/login errors - execSync('jira session', { encoding: 'utf8', stdio: 'pipe', timeout: 5000 }); + execSync('jira session', { encoding: 'utf8', stdio: 'pipe', timeout: AUTH_CHECK_TIMEOUT_MS }); return { authenticated: true, error: null, recovery: [] }; } catch (err) { const stderr = err.stderr || err.message || ''; + if (err.code === 'ENOENT' || stderr.includes('command not found')) { + return { + authenticated: false, + error: 'Jira CLI not installed', + recovery: [ + 'Install jira CLI: https://github.com/go-jira/jira', + 'Then verify: jira version', + ], + }; + } + + if (stderr.includes('Command timed out')) { + return { + authenticated: false, + error: 'jira session timed out', + recovery: ['Retry: jira session', 'Check ~/.jira.d/config.yml for endpoint/login'], + }; + } + // go-jira has various error patterns for auth issues if ( stderr.includes('endpoint') || diff --git a/src/message-bus-bridge.js b/src/message-bus-bridge.js index 7525377e..4360ec4e 100644 --- a/src/message-bus-bridge.js +++ b/src/message-bus-bridge.js @@ -16,6 +16,11 @@ class MessageBusBridge { this.parentBus = parentBus; this.childBus = childBus; this.config = config; + this.parentTopicNames = new Set( + (config.parentTopics || []) + .map((entry) => (typeof entry === 'string' ? entry : entry?.topic)) + .filter((topic) => typeof topic === 'string' && topic.length > 0) + ); this.parentUnsubscribe = null; this.childUnsubscribe = null; @@ -30,7 +35,7 @@ class MessageBusBridge { */ _setupBridge() { // Forward specified parent topics to child - if (this.config.parentTopics && this.config.parentTopics.length > 0) { + if (this.parentTopicNames.size > 0) { this.parentUnsubscribe = this.parentBus.subscribe((message) => { this._forwardParentToChild(message); }); @@ -55,7 +60,7 @@ class MessageBusBridge { } // Only forward topics specified in config - if (!this.config.parentTopics.includes(message.topic)) { + if (!this.parentTopicNames.has(message.topic)) { return; } diff --git a/src/orchestrator.js b/src/orchestrator.js index e56a26fe..5c3bc446 100644 --- a/src/orchestrator.js +++ b/src/orchestrator.js @@ -45,6 +45,7 @@ const configValidator = require('./config-validator'); const TemplateResolver = require('./template-resolver'); const { loadSettings } = require('../lib/settings'); const { normalizeProviderName } = require('../lib/provider-names'); +const StateSnapshotter = require('./state-snapshotter'); const crypto = require('crypto'); function applyModelOverride(agentConfig, modelOverride) { @@ -290,6 +291,7 @@ class Orchestrator { }; this.clusters.set(clusterId, cluster); + this._startSnapshotter(cluster); this._log(`[Orchestrator] Loaded cluster: ${clusterId} with ${agents.length} agents`); return cluster; @@ -399,11 +401,36 @@ class Orchestrator { return agents; } + _startSnapshotter(cluster) { + if (cluster.snapshotter) { + cluster.snapshotter.start(); + return; + } + + const snapshotter = new StateSnapshotter({ + messageBus: cluster.messageBus, + clusterId: cluster.id, + }); + snapshotter.start(); + cluster.snapshotter = snapshotter; + } + /** * Ensure clusters file exists (required for file locking) * @private */ _ensureClustersFile() { + if (!fs.existsSync(this.storageDir)) { + try { + fs.mkdirSync(this.storageDir, { recursive: true }); + } catch (error) { + console.warn( + `[Orchestrator] Failed to create storage directory ${this.storageDir}: ${error.message}` + ); + return null; + } + } + const clustersFile = path.join(this.storageDir, 'clusters.json'); if (!fs.existsSync(clustersFile)) { fs.writeFileSync(clustersFile, '{}'); @@ -423,6 +450,9 @@ class Orchestrator { } const clustersFile = this._ensureClustersFile(); + if (!clustersFile) { + return; + } const lockfilePath = path.join(this.storageDir, 'clusters.json.lock'); let release; @@ -514,6 +544,14 @@ class Orchestrator { this._log( `[Orchestrator] Saved ${this.clusters.size} cluster(s), file now has ${Object.keys(existingClusters).length} total` ); + } catch (error) { + if (error.code === 'ENOENT') { + console.warn( + `[Orchestrator] Skipping cluster save; storage directory missing: ${this.storageDir}` + ); + return; + } + throw error; } finally { // Always release lock if (release) { @@ -726,6 +764,7 @@ class Orchestrator { }; this.clusters.set(clusterId, cluster); + this._startSnapshotter(cluster); try { // Fetch input (issue from provider, file, or text) @@ -1387,6 +1426,10 @@ class Orchestrator { await agent.stop(); } + if (cluster.snapshotter) { + cluster.snapshotter.stop(); + } + // Clean up isolation container if enabled // CRITICAL: Preserve workspace for resume capability - only delete on kill() if (cluster.isolation?.manager) { @@ -1397,6 +1440,14 @@ class Orchestrator { this._log(`[Orchestrator] Container stopped, workspace preserved`); } + if (cluster.validatorIsolation?.manager) { + this._log(`[Orchestrator] Cleaning up validator isolation container for ${clusterId}...`); + await cluster.validatorIsolation.manager.cleanup(cluster.validatorIsolation.clusterId, { + preserveWorkspace: false, + }); + cluster.validatorIsolation = null; + } + // Worktree cleanup on stop: preserve for resume capability // Branch stays, worktree stays - can resume work later if (cluster.worktree?.manager) { @@ -1430,6 +1481,10 @@ class Orchestrator { await agent.stop(); } + if (cluster.snapshotter) { + cluster.snapshotter.stop(); + } + // Force remove isolation container AND workspace (full cleanup, no resume) if (cluster.isolation?.manager) { this._log( @@ -1439,6 +1494,14 @@ class Orchestrator { this._log(`[Orchestrator] Container and workspace removed`); } + if (cluster.validatorIsolation?.manager) { + this._log(`[Orchestrator] Force removing validator isolation container for ${clusterId}...`); + await cluster.validatorIsolation.manager.cleanup(cluster.validatorIsolation.clusterId, { + preserveWorkspace: false, + }); + cluster.validatorIsolation = null; + } + // Force remove worktree (full cleanup, no resume) // Note: Branch is preserved for potential PR creation / inspection if (cluster.worktree?.manager) { @@ -1533,6 +1596,7 @@ class Orchestrator { await this._ensureIsolationForResume(clusterId, cluster); this._ensureWorktreeForResume(clusterId, cluster); + this._startSnapshotter(cluster); await this._restartClusterAgents(cluster); const recentMessages = this._loadRecentMessages(cluster, clusterId, 50); diff --git a/src/preflight.js b/src/preflight.js index b79461cb..3e2ad7ba 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -473,6 +473,24 @@ function runPreflight(options = {}) { const warnings = []; const settings = loadSettings(); + + if (process.platform === 'win32') { + return { + valid: false, + errors: [ + formatError( + 'Windows not supported', + 'Zeroshot currently supports Linux and macOS only; Windows (native or WSL) is deferred.', + [ + 'Use Linux or macOS to run Zeroshot', + 'Or run inside a Linux VM/container on Windows', + 'Check README for supported platforms and updates', + ] + ), + ], + warnings: [], + }; + } const providerName = normalizeProviderName( options.provider || settings.defaultProvider || 'claude' ); diff --git a/src/providers/openai/models.js b/src/providers/openai/models.js index 3283dea4..140ea9f7 100644 --- a/src/providers/openai/models.js +++ b/src/providers/openai/models.js @@ -3,9 +3,9 @@ const MODEL_CATALOG = {}; const LEVEL_MAPPING = { - level1: { rank: 1, model: null, reasoningEffort: 'low' }, - level2: { rank: 2, model: null, reasoningEffort: 'medium' }, - level3: { rank: 3, model: null, reasoningEffort: 'high' }, + level1: { rank: 1, model: null, reasoningEffort: 'medium' }, + level2: { rank: 2, model: null, reasoningEffort: 'high' }, + level3: { rank: 3, model: null, reasoningEffort: 'xhigh' }, }; const DEFAULT_LEVEL = 'level2'; diff --git a/src/schemas/sub-cluster.js b/src/schemas/sub-cluster.js index 0e05b5ae..3c363980 100644 --- a/src/schemas/sub-cluster.js +++ b/src/schemas/sub-cluster.js @@ -132,13 +132,36 @@ function validateContextStrategy(agentConfig, errors) { return; } - // Validate each parent topic is a string - for (const topic of parentTopics) { - if (typeof topic !== 'string') { + // Validate each parent topic entry + for (const entry of parentTopics) { + if (typeof entry === 'string') { + continue; + } + + if (!entry || typeof entry !== 'object') { + errors.push( + `Sub-cluster '${agentConfig.id}' parentTopics must contain strings or objects, got ${typeof entry}` + ); + continue; + } + + if (typeof entry.topic !== 'string') { + errors.push(`Sub-cluster '${agentConfig.id}' parentTopics entry must include a string topic`); + } + + if (entry.strategy && !['latest', 'all', 'oldest'].includes(entry.strategy)) { errors.push( - `Sub-cluster '${agentConfig.id}' parentTopics must contain strings, got ${typeof topic}` + `Sub-cluster '${agentConfig.id}' parentTopics entry has invalid strategy '${entry.strategy}'` ); } + + if (entry.amount !== undefined && !Number.isFinite(entry.amount)) { + errors.push(`Sub-cluster '${agentConfig.id}' parentTopics entry amount must be a number`); + } + + if (entry.limit !== undefined && !Number.isFinite(entry.limit)) { + errors.push(`Sub-cluster '${agentConfig.id}' parentTopics entry limit must be a number`); + } } } diff --git a/src/state-snapshot.js b/src/state-snapshot.js new file mode 100644 index 00000000..418bcc91 --- /dev/null +++ b/src/state-snapshot.js @@ -0,0 +1,398 @@ +const SNAPSHOT_VERSION = 1; + +const LIMITS = { + errors: 5, + criteriaResults: 10, + acceptanceCriteria: 10, + filesAffected: 20, + blockers: 5, + nextSteps: 10, + rootCauses: 5, +}; + +const TEXT_LIMITS = { + task: 2000, + plan: 2000, + fixPlan: 1200, + summary: 300, + listItem: 200, +}; + +function toTimestamp(message) { + if (message && Number.isFinite(message.timestamp)) { + return message.timestamp; + } + return Date.now(); +} + +function normalizeText(value, maxLength, singleLine = false) { + if (value === undefined || value === null) return undefined; + let text = String(value); + if (singleLine) { + text = text.replace(/\s+/g, ' ').trim(); + } else { + text = text.trim(); + } + if (!text) return undefined; + if (maxLength && text.length > maxLength) { + return `${text.slice(0, maxLength - 3)}...`; + } + return text; +} + +function normalizeStringList(list, maxItems) { + if (!Array.isArray(list)) return undefined; + const normalized = list + .map((item) => normalizeText(item, TEXT_LIMITS.listItem, true)) + .filter(Boolean); + if (normalized.length === 0) return undefined; + if (maxItems && normalized.length > maxItems) { + return normalized.slice(-maxItems); + } + return normalized; +} + +function normalizeAcceptanceCriteria(criteria) { + if (!Array.isArray(criteria)) return undefined; + const normalized = criteria + .map((item) => { + if (typeof item === 'string') { + return normalizeText(item, TEXT_LIMITS.listItem, true); + } + if (!item || typeof item !== 'object') return undefined; + const id = item.id ? String(item.id) : ''; + const priority = item.priority ? ` (${item.priority})` : ''; + const criterion = item.criterion || item.text || item.summary || ''; + const label = id ? `${id}${priority}: ` : ''; + const merged = `${label}${criterion}`.trim(); + if (!merged) return undefined; + return normalizeText(merged, TEXT_LIMITS.listItem, true); + }) + .filter(Boolean); + if (normalized.length === 0) return undefined; + if (normalized.length > LIMITS.acceptanceCriteria) { + return normalized.slice(-LIMITS.acceptanceCriteria); + } + return normalized; +} + +function normalizeCriteriaEvidence(evidence) { + if (!evidence || typeof evidence !== 'object') return undefined; + const normalized = {}; + if (evidence.command) { + const command = normalizeText(evidence.command, TEXT_LIMITS.listItem, true); + if (command) normalized.command = command; + } + if (Number.isFinite(evidence.exitCode)) { + normalized.exitCode = evidence.exitCode; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function normalizeCriteriaResult(item) { + if (!item || typeof item !== 'object') return undefined; + const entry = {}; + if (item.id) entry.id = String(item.id); + if (item.status) entry.status = String(item.status); + if (item.reason) { + const reason = normalizeText(item.reason, TEXT_LIMITS.listItem, true); + if (reason) entry.reason = reason; + } + const evidence = normalizeCriteriaEvidence(item.evidence); + if (evidence) entry.evidence = evidence; + return Object.keys(entry).length > 0 ? entry : undefined; +} + +function normalizeCriteriaResults(results) { + if (!Array.isArray(results)) return undefined; + const normalized = results.map(normalizeCriteriaResult).filter(Boolean); + if (normalized.length === 0) return undefined; + if (normalized.length > LIMITS.criteriaResults) { + return normalized.slice(-LIMITS.criteriaResults); + } + return normalized; +} + +function normalizeErrors(data) { + if (!data || typeof data !== 'object') return undefined; + if (Array.isArray(data.errors)) { + return normalizeStringList(data.errors, LIMITS.errors); + } + if (Array.isArray(data.issues)) { + const mapped = data.issues.map((issue) => { + if (typeof issue === 'string') return issue; + if (!issue || typeof issue !== 'object') return undefined; + return issue.bug || issue.message || issue.error || issue.summary || undefined; + }); + return normalizeStringList(mapped, LIMITS.errors); + } + return undefined; +} + +function normalizeRootCauses(rootCauses) { + if (!Array.isArray(rootCauses)) return undefined; + const normalized = rootCauses + .map((cause) => { + if (typeof cause === 'string') { + return normalizeText(cause, TEXT_LIMITS.listItem, true); + } + if (!cause || typeof cause !== 'object') return undefined; + return normalizeText( + cause.cause || cause.summary || cause.description, + TEXT_LIMITS.listItem, + true + ); + }) + .filter(Boolean); + if (normalized.length === 0) return undefined; + if (normalized.length > LIMITS.rootCauses) { + return normalized.slice(-LIMITS.rootCauses); + } + return normalized; +} + +function normalizeFilesAffected(filesAffected) { + return normalizeStringList(filesAffected, LIMITS.filesAffected); +} + +function normalizeBoolean(value) { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; +} + +function normalizeProgressStatus(data) { + if (!data || typeof data !== 'object') return undefined; + if (data.completionStatus && typeof data.completionStatus === 'object') { + return data.completionStatus; + } + const hasProgressFields = + Object.prototype.hasOwnProperty.call(data, 'canValidate') || + Object.prototype.hasOwnProperty.call(data, 'percentComplete'); + return hasProgressFields ? data : undefined; +} + +function buildBaseState(state, message) { + return { + version: SNAPSHOT_VERSION, + updatedAt: toTimestamp(message), + clusterId: message?.cluster_id || state?.clusterId || null, + sourceMessageId: message?.id || state?.sourceMessageId || null, + task: state?.task, + plan: state?.plan, + progress: state?.progress, + validation: state?.validation, + debug: state?.debug, + }; +} + +function pruneEmpty(value) { + if (Array.isArray(value)) { + const next = value.map(pruneEmpty).filter((item) => item !== undefined); + return next.length > 0 ? next : undefined; + } + if (value && typeof value === 'object') { + const next = {}; + for (const [key, entry] of Object.entries(value)) { + const pruned = pruneEmpty(entry); + if (pruned !== undefined) { + next[key] = pruned; + } + } + return Object.keys(next).length > 0 ? next : undefined; + } + if (value === undefined || value === null) return undefined; + return value; +} + +function finalizeState(state) { + const meta = { + version: state.version ?? SNAPSHOT_VERSION, + updatedAt: state.updatedAt ?? Date.now(), + clusterId: state.clusterId ?? null, + sourceMessageId: state.sourceMessageId ?? null, + }; + const sections = pruneEmpty({ + task: state.task, + plan: state.plan, + progress: state.progress, + validation: state.validation, + debug: state.debug, + }); + return { + ...meta, + ...(sections || {}), + }; +} + +function initStateFromIssue(issueMessage) { + const content = issueMessage?.content || {}; + const data = content.data || {}; + const task = { + raw: normalizeText(content.text, TEXT_LIMITS.task), + title: normalizeText(data.title, TEXT_LIMITS.summary, true), + issueNumber: data.issue_number ?? data.issueNumber, + source: issueMessage?.metadata?.source, + }; + const base = buildBaseState(null, issueMessage); + base.task = pruneEmpty(task); + return finalizeState(base); +} + +function applyIssueOpened(state, message) { + const base = buildBaseState(state, message); + const content = message?.content || {}; + const data = content.data || {}; + const task = { + raw: normalizeText(content.text, TEXT_LIMITS.task), + title: normalizeText(data.title, TEXT_LIMITS.summary, true), + issueNumber: data.issue_number ?? data.issueNumber, + source: message?.metadata?.source, + }; + base.task = pruneEmpty(task); + return finalizeState(base); +} + +function applyPlanReady(state, message) { + const base = buildBaseState(state, message); + const content = message?.content || {}; + const data = content.data || {}; + const plan = { + text: normalizeText(content.text, TEXT_LIMITS.plan), + summary: normalizeText(data.summary, TEXT_LIMITS.summary, true), + acceptanceCriteria: normalizeAcceptanceCriteria(data.acceptanceCriteria), + filesAffected: normalizeFilesAffected(data.filesAffected), + updatedAt: toTimestamp(message), + }; + base.plan = pruneEmpty(plan); + return finalizeState(base); +} + +function applyWorkerProgress(state, message) { + const base = buildBaseState(state, message); + const content = message?.content || {}; + const status = normalizeProgressStatus(content.data || {}); + if (!status) { + return finalizeState(base); + } + const progress = { + canValidate: normalizeBoolean(status.canValidate), + percentComplete: Number.isFinite(status.percentComplete) ? status.percentComplete : undefined, + blockers: normalizeStringList(status.blockers, LIMITS.blockers), + nextSteps: normalizeStringList(status.nextSteps, LIMITS.nextSteps), + lastSummary: normalizeText(content.text || status.summary, TEXT_LIMITS.summary, true), + updatedAt: toTimestamp(message), + }; + base.progress = pruneEmpty(progress); + return finalizeState(base); +} + +function applyImplementationReady(state, message) { + return applyWorkerProgress(state, message); +} + +function applyValidationResult(state, message) { + const base = buildBaseState(state, message); + const content = message?.content || {}; + const data = content.data || {}; + const validation = { + approved: normalizeBoolean(data.approved), + errors: normalizeErrors(data), + criteriaResults: normalizeCriteriaResults(data.criteriaResults), + updatedAt: toTimestamp(message), + }; + base.validation = pruneEmpty(validation); + return finalizeState(base); +} + +function applyInvestigationComplete(state, message) { + const base = buildBaseState(state, message); + const content = message?.content || {}; + const data = content.data || {}; + const debug = { + fixPlan: normalizeText(content.text, TEXT_LIMITS.fixPlan), + successCriteria: normalizeText(data.successCriteria, TEXT_LIMITS.summary, true), + rootCauses: normalizeRootCauses(data.rootCauses), + updatedAt: toTimestamp(message), + }; + base.debug = pruneEmpty(debug); + return finalizeState(base); +} + +function buildTaskSummary(state) { + const taskTitle = normalizeText(state.task?.title || state.task?.raw, TEXT_LIMITS.summary, true); + return taskTitle ? `Task: ${taskTitle}` : undefined; +} + +function buildPlanSummary(state) { + const planSummary = normalizeText( + state.plan?.summary || state.plan?.text, + TEXT_LIMITS.summary, + true + ); + return planSummary ? `Plan: ${planSummary}` : undefined; +} + +function buildProgressSummary(state) { + if (!state.progress) return undefined; + const parts = []; + if (Number.isFinite(state.progress.percentComplete)) { + parts.push(`${state.progress.percentComplete}%`); + } + if (typeof state.progress.canValidate === 'boolean') { + parts.push(`canValidate=${state.progress.canValidate}`); + } + const nextStepText = normalizeText(state.progress.nextSteps?.[0], TEXT_LIMITS.listItem, true); + if (nextStepText) { + parts.push(`next: ${nextStepText}`); + } + return parts.length > 0 ? `Progress: ${parts.join(' | ')}` : undefined; +} + +function resolveValidationStatus(approved) { + if (approved === true) return 'approved'; + if (approved === false) return 'rejected'; + return 'pending'; +} + +function buildValidationSummary(state) { + if (!state.validation) return undefined; + const status = resolveValidationStatus(state.validation.approved); + const errorCount = state.validation.errors?.length || 0; + return `Validation: ${status}${errorCount ? ` (${errorCount} errors)` : ''}`; +} + +function buildDebugSummary(state) { + const debugSummary = normalizeText( + state.debug?.fixPlan || state.debug?.successCriteria, + TEXT_LIMITS.summary, + true + ); + return debugSummary ? `Debug: ${debugSummary}` : undefined; +} + +function renderStateSummary(state) { + if (!state || typeof state !== 'object') return ''; + const lines = [ + buildTaskSummary(state), + buildPlanSummary(state), + buildProgressSummary(state), + buildValidationSummary(state), + buildDebugSummary(state), + ].filter(Boolean); + + return lines.join('\n'); +} + +module.exports = { + SNAPSHOT_VERSION, + initStateFromIssue, + applyIssueOpened, + applyPlanReady, + applyWorkerProgress, + applyImplementationReady, + applyValidationResult, + applyInvestigationComplete, + renderStateSummary, +}; diff --git a/src/state-snapshotter.js b/src/state-snapshotter.js new file mode 100644 index 00000000..5a8eeb12 --- /dev/null +++ b/src/state-snapshotter.js @@ -0,0 +1,142 @@ +const crypto = require('crypto'); +const { + initStateFromIssue, + applyIssueOpened, + applyPlanReady, + applyWorkerProgress, + applyImplementationReady, + applyValidationResult, + applyInvestigationComplete, + renderStateSummary, +} = require('./state-snapshot'); + +const SNAPSHOT_TOPICS = [ + 'ISSUE_OPENED', + 'PLAN_READY', + 'WORKER_PROGRESS', + 'IMPLEMENTATION_READY', + 'VALIDATION_RESULT', + 'INVESTIGATION_COMPLETE', +]; + +class StateSnapshotter { + constructor({ messageBus, clusterId }) { + this.messageBus = messageBus; + this.clusterId = clusterId; + this.state = null; + this.lastHash = null; + this.unsubscribe = null; + } + + start() { + if (this.unsubscribe) { + return; + } + + this._bootstrapFromLedger(); + + this.unsubscribe = this.messageBus.subscribeTopics(SNAPSHOT_TOPICS, (message) => { + if (message.cluster_id !== this.clusterId) return; + this._handleMessage(message); + }); + } + + stop() { + if (!this.unsubscribe) return; + this.unsubscribe(); + this.unsubscribe = null; + } + + _bootstrapFromLedger() { + const existing = this.messageBus.findLast({ + cluster_id: this.clusterId, + topic: 'STATE_SNAPSHOT', + }); + + if (existing?.content?.data && typeof existing.content.data === 'object') { + this.state = existing.content.data; + this.lastHash = this._hashState(this.state); + return; + } + + const messages = SNAPSHOT_TOPICS.map((topic) => + this.messageBus.findLast({ cluster_id: this.clusterId, topic }) + ).filter(Boolean); + + if (messages.length === 0) { + return; + } + + messages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); + + let state = null; + for (const message of messages) { + state = this._applyMessage(state, message); + } + + if (state) { + this.state = state; + this._publishSnapshot(state); + } + } + + _handleMessage(message) { + const nextState = this._applyMessage(this.state, message); + if (!nextState) return; + + this.state = nextState; + this._publishSnapshot(nextState); + } + + _applyMessage(state, message) { + switch (message.topic) { + case 'ISSUE_OPENED': + return state ? applyIssueOpened(state, message) : initStateFromIssue(message); + case 'PLAN_READY': + return applyPlanReady(state, message); + case 'WORKER_PROGRESS': + return applyWorkerProgress(state, message); + case 'IMPLEMENTATION_READY': + return applyImplementationReady(state, message); + case 'VALIDATION_RESULT': + return applyValidationResult(state, message); + case 'INVESTIGATION_COMPLETE': + return applyInvestigationComplete(state, message); + default: + return state; + } + } + + _publishSnapshot(state) { + const hash = this._hashState(state); + if (this._hashEquals(hash, this.lastHash)) { + return; + } + this.lastHash = hash; + + this.messageBus.publish({ + cluster_id: this.clusterId, + topic: 'STATE_SNAPSHOT', + sender: 'state-snapshotter', + receiver: 'broadcast', + content: { + text: renderStateSummary(state), + data: state, + }, + }); + } + + _hashState(state) { + return crypto.createHash('sha256').update(JSON.stringify(state)).digest('hex'); + } + + _hashEquals(left, right) { + if (!left || !right) return false; + const leftBuffer = Buffer.from(left, 'utf8'); + const rightBuffer = Buffer.from(right, 'utf8'); + if (leftBuffer.length !== rightBuffer.length) return false; + return crypto.timingSafeEqual(leftBuffer, rightBuffer); + } +} + +module.exports = StateSnapshotter; diff --git a/src/sub-cluster-wrapper.js b/src/sub-cluster-wrapper.js index 699111fa..261b2bd9 100644 --- a/src/sub-cluster-wrapper.js +++ b/src/sub-cluster-wrapper.js @@ -16,6 +16,38 @@ const LogicEngine = require('./logic-engine'); const MessageBusBridge = require('./message-bus-bridge'); const { DEFAULT_MAX_ITERATIONS } = require('./agent/agent-config'); +function normalizeParentTopicConfig(entry) { + if (typeof entry === 'string') { + return { topic: entry, amount: 10, strategy: 'latest' }; + } + if (!entry || typeof entry !== 'object') { + return null; + } + const amount = entry.amount ?? entry.limit; + const strategy = entry.strategy ?? (amount !== undefined ? 'latest' : 'all'); + return { ...entry, amount, strategy }; +} + +function selectParentTopicMessages(messageBus, clusterId, topicConfig) { + const { topic, sender, since, until, amount, strategy } = topicConfig; + const order = strategy === 'latest' ? 'desc' : 'asc'; + const messages = messageBus.query({ + cluster_id: clusterId, + topic, + sender, + since, + until, + limit: amount, + order, + }); + + if (strategy === 'latest' && messages.length > 1) { + return messages.slice().reverse(); + } + + return messages; +} + class SubClusterWrapper { constructor(config, messageBus, parentCluster, options = {}) { this.id = config.id; @@ -308,8 +340,12 @@ class SubClusterWrapper { lines.push('## Parent Cluster Messages', ''); - for (const topic of parentTopics) { - const topicLines = this._buildTopicContextLines(topic); + for (const entry of parentTopics) { + const topicConfig = normalizeParentTopicConfig(entry); + if (!topicConfig?.topic) { + continue; + } + const topicLines = this._buildTopicContextLines(topicConfig); if (topicLines.length === 0) { continue; } @@ -318,18 +354,14 @@ class SubClusterWrapper { } } - _buildTopicContextLines(topic) { - const messages = this.messageBus.query({ - cluster_id: this.parentCluster.id, - topic, - limit: 10, - }); + _buildTopicContextLines(topicConfig) { + const messages = selectParentTopicMessages(this.messageBus, this.parentCluster.id, topicConfig); if (messages.length === 0) { return []; } - const lines = [`### Topic: ${topic}`, '']; + const lines = [`### Topic: ${topicConfig.topic}`, '']; for (const message of messages) { lines.push(...this._buildMessageContextLines(message)); diff --git a/src/tui/layout.js b/src/tui/layout.js index 7fde06d4..9e4e700d 100644 --- a/src/tui/layout.js +++ b/src/tui/layout.js @@ -10,7 +10,12 @@ */ const blessed = require('blessed'); -const contrib = require('blessed-contrib'); +// Pull in only the widgets we use to avoid loading optional picture widget. +const contrib = { + grid: require('blessed-contrib/lib/layout/grid'), + table: require('blessed-contrib/lib/widget/table'), + log: require('blessed-contrib/lib/widget/log'), +}; /** * Create main TUI layout with grid-based widget organization diff --git a/task-lib/attachable-watcher.js b/task-lib/attachable-watcher.js index 42524f00..fedeaf0e 100644 --- a/task-lib/attachable-watcher.js +++ b/task-lib/attachable-watcher.js @@ -9,7 +9,11 @@ import { appendFileSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { updateTask } from './store.js'; -import { detectStreamingModeError, recoverStructuredOutput } from './claude-recovery.js'; +import { + detectFatalClaudeError, + detectStreamingModeError, + recoverStructuredOutput, +} from './claude-recovery.js'; import { createRequire } from 'module'; // ═══════════════════════════════════════════════════════════════════════════ @@ -70,6 +74,7 @@ const cwd = cwdArg; const logFile = logFileArg; const args = JSON.parse(argsJsonArg); const config = configJsonArg ? JSON.parse(configJsonArg) : {}; +let server = null; const SOCKET_DIR = join(homedir(), '.zeroshot', 'sockets'); const socketPath = join(SOCKET_DIR, `${taskId}.sock`); @@ -95,6 +100,7 @@ const silentJsonMode = let finalResultJson = null; let outputBuffer = ''; let streamingModeError = null; +let fatalError = null; function splitBufferLines(buffer, chunk) { const nextBuffer = buffer + chunk; @@ -103,6 +109,29 @@ function splitBufferLines(buffer, chunk) { return { lines, remaining }; } +function maybeHandleFatalError(line, timestamp) { + if (!enableRecovery || fatalError) { + return false; + } + + const detected = detectFatalClaudeError(line); + if (!detected) { + return false; + } + + fatalError = detected; + + if (silentJsonMode) { + log(`[${timestamp}]${line}\n`); + } + log(`[${timestamp}][FATAL] ${detected}\n`); + + if (server) { + server.stop('SIGTERM').catch(() => {}); + } + return true; +} + function captureStreamingError(line, timestamp) { if (!enableRecovery) { return false; @@ -131,6 +160,7 @@ function maybeCaptureStructuredOutput(line) { function handleSilentJsonLines(lines, timestamp) { for (const line of lines) { if (!line.trim()) continue; + maybeHandleFatalError(line, timestamp); if (captureStreamingError(line, timestamp)) { continue; } @@ -140,6 +170,7 @@ function handleSilentJsonLines(lines, timestamp) { function handleStreamingLines(lines, timestamp) { for (const line of lines) { + maybeHandleFatalError(line, timestamp); if (captureStreamingError(line, timestamp)) { continue; } @@ -159,6 +190,7 @@ function flushOutputBuffer(timestamp) { return; } + maybeHandleFatalError(outputBuffer, timestamp); if (captureStreamingError(outputBuffer, timestamp)) { return; } @@ -205,7 +237,7 @@ function writeCompletionFooter(code, signal) { log(`Exit code: ${code}, Signal: ${signal}\n`); } -const server = new AttachServer({ +server = new AttachServer({ id: taskId, socketPath, command, @@ -244,13 +276,13 @@ server.on('exit', async ({ exitCode, signal }) => { writeCompletionFooter(code, signal); - const resolvedCode = recovered?.payload ? 0 : code; + const resolvedCode = fatalError ? 1 : recovered?.payload ? 0 : code; const status = resolvedCode === 0 ? 'completed' : 'failed'; try { await updateTask(taskId, { status, exitCode: resolvedCode, - error: resolvedCode !== 0 && signal ? `Killed by ${signal}` : null, + error: fatalError || (resolvedCode !== 0 && signal ? `Killed by ${signal}` : null), socketPath: null, }); } catch (updateError) { diff --git a/task-lib/claude-recovery.js b/task-lib/claude-recovery.js index 81462c02..1220704f 100644 --- a/task-lib/claude-recovery.js +++ b/task-lib/claude-recovery.js @@ -3,6 +3,7 @@ import { join } from 'path'; import { homedir } from 'os'; export const STREAMING_MODE_ERROR = 'only prompt commands are supported in streaming mode'; +export const NO_MESSAGES_RETURNED = 'No messages returned'; export function detectStreamingModeError(line) { const trimmed = typeof line === 'string' ? line.trim() : ''; @@ -30,6 +31,27 @@ export function detectStreamingModeError(line) { return null; } +export function detectFatalClaudeError(line) { + if (typeof line !== 'string') return null; + const trimmed = line.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith('{')) { + try { + JSON.parse(trimmed); + return null; + } catch { + // Not valid JSON, continue detection + } + } + + if (trimmed.toLowerCase().includes(NO_MESSAGES_RETURNED.toLowerCase())) { + return `Claude CLI error: ${NO_MESSAGES_RETURNED}`; + } + + return null; +} + function findSessionJsonlPath(sessionId) { const claudeDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); const projectsDir = join(claudeDir, 'projects'); diff --git a/task-lib/tui/layout.js b/task-lib/tui/layout.js index d2127621..ac32b8ba 100644 --- a/task-lib/tui/layout.js +++ b/task-lib/tui/layout.js @@ -9,7 +9,8 @@ */ import blessed from 'blessed'; -import contrib from 'blessed-contrib'; +import Grid from 'blessed-contrib/lib/layout/grid.js'; +import Log from 'blessed-contrib/lib/widget/log.js'; /** * Create task logs TUI layout @@ -19,7 +20,7 @@ import contrib from 'blessed-contrib'; */ function createLayout(screen, taskId) { // Create 20x12 grid for responsive layout - const grid = new contrib.grid({ rows: 20, cols: 12, screen }); + const grid = new Grid({ rows: 20, cols: 12, screen }); // ============================================================ // TASK INFO BOX (3 rows x 12 cols) @@ -46,7 +47,7 @@ function createLayout(screen, taskId) { // Scrollable log output with auto-scroll // ============================================================ - const logsBox = grid.set(3, 0, 15, 12, contrib.log, { + const logsBox = grid.set(3, 0, 15, 12, Log, { fg: 'white', label: ' Live Logs ', border: { type: 'line', fg: 'cyan' }, diff --git a/task-lib/watcher.js b/task-lib/watcher.js index 7ad58348..ea36169b 100644 --- a/task-lib/watcher.js +++ b/task-lib/watcher.js @@ -8,7 +8,11 @@ import { spawn } from 'child_process'; import { appendFileSync } from 'fs'; import { updateTask } from './store.js'; -import { detectStreamingModeError, recoverStructuredOutput } from './claude-recovery.js'; +import { + detectFatalClaudeError, + detectStreamingModeError, + recoverStructuredOutput, +} from './claude-recovery.js'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); @@ -42,6 +46,7 @@ const silentJsonMode = let finalResultJson = null; let streamingModeError = null; +let fatalError = null; let stdoutBuffer = ''; let stderrBuffer = ''; @@ -53,6 +58,42 @@ function splitBufferLines(buffer, chunk) { return { lines, remaining }; } +function maybeHandleFatalError(line, timestamp) { + if (!enableRecovery || fatalError) { + return false; + } + + const detected = detectFatalClaudeError(line); + if (!detected) { + return false; + } + + fatalError = detected; + + if (silentJsonMode) { + log(`[${timestamp}]${line}\n`); + } + log(`[${timestamp}][FATAL] ${detected}\n`); + + try { + child.kill('SIGTERM'); + } catch { + // Ignore - process may already be dead + } + + setTimeout(() => { + if (child.exitCode === null) { + try { + child.kill('SIGKILL'); + } catch { + // Ignore - process may already be dead + } + } + }, 5000); + + return true; +} + function captureStreamingError(line, timestamp) { if (!enableRecovery) { return false; @@ -81,6 +122,7 @@ function maybeCaptureStructuredOutput(line) { function handleSilentJsonLines(lines, timestamp) { for (const line of lines) { if (!line.trim()) continue; + maybeHandleFatalError(line, timestamp); if (captureStreamingError(line, timestamp)) { continue; } @@ -90,6 +132,7 @@ function handleSilentJsonLines(lines, timestamp) { function handleStreamingLines(lines, timestamp) { for (const line of lines) { + maybeHandleFatalError(line, timestamp); if (captureStreamingError(line, timestamp)) { continue; } @@ -109,6 +152,7 @@ function flushStdoutBuffer(timestamp) { return; } + maybeHandleFatalError(stdoutBuffer, timestamp); if (captureStreamingError(stdoutBuffer, timestamp)) { return; } @@ -123,6 +167,7 @@ function flushStdoutBuffer(timestamp) { function flushStderrBuffer(timestamp) { if (stderrBuffer.trim()) { + maybeHandleFatalError(stderrBuffer, timestamp); log(`[${timestamp}]${stderrBuffer}\n`); } } @@ -201,13 +246,13 @@ child.on('close', async (code, signal) => { writeCompletionFooter(code, signal); - const resolvedCode = recovered?.payload ? 0 : code; + const resolvedCode = fatalError ? 1 : recovered?.payload ? 0 : code; const status = resolvedCode === 0 ? 'completed' : 'failed'; try { await updateTask(taskId, { status, exitCode: resolvedCode, - error: resolvedCode !== 0 && signal ? `Killed by ${signal}` : null, + error: fatalError || (resolvedCode !== 0 && signal ? `Killed by ${signal}` : null), }); } catch (updateError) { log(`[${Date.now()}][ERROR] Failed to update task status: ${updateError.message}\n`); diff --git a/tests/cannot-validate-status.test.js b/tests/cannot-validate-status.test.js index 25f68a37..83baf75d 100644 --- a/tests/cannot-validate-status.test.js +++ b/tests/cannot-validate-status.test.js @@ -48,6 +48,7 @@ const baseContextParams = (overrides = {}) => ({ messageBus: { query: () => [] }, cluster: { id: 'test-cluster', createdAt: Date.now() - 60000 }, triggeringMessage: { topic: 'IMPLEMENTATION_READY', sender: 'worker' }, + isolation: null, ...overrides, }); @@ -258,6 +259,28 @@ describe('CANNOT_VALIDATE Context Builder - Core Behavior', function () { assert.ok(context.includes('Do NOT re-attempt'), 'Missing skip instruction'); }); + it('should ignore platform mismatch reasons when running in docker isolation', function () { + const criteria = [ + { + id: 'AC1', + status: 'CANNOT_VALIDATE', + reason: 'npm install fails on darwin-arm64 (EBADPLATFORM for @esbuild/linux-x64)', + }, + { id: 'AC2', status: 'CANNOT_VALIDATE', reason: 'No SSH access to prod' }, + ]; + + const context = buildContext( + baseContextParams({ + messageBus: mockBusWithCriteria(criteria), + isolation: { enabled: true }, + }) + ); + + assert.ok(!context.includes('**AC1**'), 'Should skip platform mismatch criteria'); + assert.ok(!context.includes('EBADPLATFORM'), 'Should remove platform mismatch reason'); + assert.ok(context.includes('**AC2**'), 'Should keep non-platform criteria'); + }); + it('should NOT inject skip section for non-validator roles', function () { const criteria = [{ id: 'AC1', status: 'CANNOT_VALIDATE', reason: 'test' }]; diff --git a/tests/config-validator.test.js b/tests/config-validator.test.js index 80970198..11c07e02 100644 --- a/tests/config-validator.test.js +++ b/tests/config-validator.test.js @@ -2298,6 +2298,37 @@ describe('Semantic Validation - Medium Gaps (8-9)', function () { const contextWarnings = result.warnings.filter((w) => w.includes('[Gap 9]')); assert.ok(contextWarnings.length > 0, 'Should have Gap 9 warning'); }); + + it('should not warn for STATE_SNAPSHOT context source', function () { + const config = { + agents: [ + { + id: 'worker', + role: 'implementation', + triggers: [{ topic: 'ISSUE_OPENED' }], + contextStrategy: { + sources: [{ topic: 'STATE_SNAPSHOT', amount: 1 }], + }, + hooks: { + onComplete: { + action: 'publish_message', + config: { topic: 'DONE', content: {} }, + }, + }, + }, + { + id: 'completion', + role: 'orchestrator', + triggers: [{ topic: 'DONE', action: 'stop_cluster' }], + }, + ], + }; + const result = validateConfig(config); + const snapshotWarnings = result.warnings.filter( + (warning) => warning.includes('[Gap 9]') && warning.includes('STATE_SNAPSHOT') + ); + assert.strictEqual(snapshotWarnings.length, 0, 'STATE_SNAPSHOT should not trigger Gap 9'); + }); }); }); @@ -2590,7 +2621,7 @@ describe('Semantic Validation - Medium Gap 14', function () { role: 'implementation', triggers: [{ topic: 'ISSUE_OPENED' }], contextStrategy: { - sources: [{ topic: 'TEST' }], // Missing amount + sources: [{ topic: 'TEST', strategy: 'latest' }], // Missing amount }, hooks: { onComplete: { @@ -2622,6 +2653,48 @@ describe('Semantic Validation - Medium Gap 14', function () { assert.ok(amountWarnings.length > 0, 'Should have Gap 14 warning'); assert.ok(amountWarnings[0].includes('amount')); }); + + it('should accept limit as amount alias without warning', function () { + const config = { + agents: [ + { + id: 'worker', + role: 'implementation', + triggers: [{ topic: 'ISSUE_OPENED' }], + contextStrategy: { + sources: [{ topic: 'TEST', limit: 1 }], + }, + hooks: { + onComplete: { + action: 'publish_message', + config: { topic: 'DONE', content: {} }, + }, + }, + }, + { + id: 'tester', + role: 'validator', + triggers: [{ topic: 'X' }], + hooks: { + onComplete: { + action: 'publish_message', + config: { topic: 'TEST', content: {} }, + }, + }, + }, + { + id: 'completion', + role: 'orchestrator', + triggers: [{ topic: 'DONE', action: 'stop_cluster' }], + }, + ], + }; + const result = validateConfig(config); + const amountWarnings = result.warnings.filter( + (w) => w.includes('[Gap 14]') && w.includes('amount') + ); + assert.strictEqual(amountWarnings.length, 0); + }); }); }); diff --git a/tests/context-packs.test.js b/tests/context-packs.test.js new file mode 100644 index 00000000..cf5dd38d --- /dev/null +++ b/tests/context-packs.test.js @@ -0,0 +1,144 @@ +const assert = require('assert'); +const AgentWrapper = require('../src/agent-wrapper'); +const MessageBus = require('../src/message-bus'); +const Ledger = require('../src/ledger'); +const { buildContextPacks } = require('../src/agent/context-pack-builder'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +describe('context packs', () => { + let tempDir; + let ledger; + let messageBus; + let clusterId; + let clusterCreatedAt; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-context-packs-')); + const dbPath = path.join(tempDir, 'test-ledger.db'); + + ledger = new Ledger(dbPath); + messageBus = new MessageBus(ledger); + + clusterId = 'test-cluster-789'; + clusterCreatedAt = Date.now(); + }); + + afterEach(() => { + if (ledger) ledger.close(); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + function createWorker(contextStrategy) { + const workerConfig = { + id: 'worker', + role: 'implementation', + modelLevel: 'level2', + timeout: 0, + contextStrategy, + }; + + const mockCluster = { + id: clusterId, + createdAt: clusterCreatedAt, + agents: [], + }; + + return new AgentWrapper(workerConfig, messageBus, mockCluster, { + testMode: true, + mockSpawnFn: () => {}, + }); + } + + function publishMessage(topic, text, timestamp) { + messageBus.publish({ + cluster_id: clusterId, + topic, + sender: 'system', + content: { text }, + timestamp, + }); + } + + function buildTriggeringMessage(timestamp, text = 'triggered') { + return { + topic: 'WORKER_PROGRESS', + sender: 'system', + timestamp, + content: { text }, + }; + } + + it('keeps triggering message and required anchors under tight budgets', () => { + const baseTime = Date.now(); + publishMessage('ISSUE_OPENED', 'Implement feature X', baseTime); + publishMessage('PLAN_READY', '1. Do the thing', baseTime + 10); + publishMessage('OPTIONAL_TOPIC', 'optional-detail', baseTime + 20); + publishMessage('VALIDATION_RESULT', 'rejected: missing test', baseTime + 30); + + const worker = createWorker({ + sources: [ + { topic: 'ISSUE_OPENED', amount: 1 }, + { topic: 'PLAN_READY', amount: 1 }, + { topic: 'OPTIONAL_TOPIC', amount: 3 }, + { topic: 'VALIDATION_RESULT', amount: 3 }, + ], + maxTokens: 1, + }); + + const context = worker._buildContext(buildTriggeringMessage(baseTime + 40, 'kickoff')); + + assert(context.includes('## Triggering Message'), 'Triggering message section must exist'); + assert(context.includes('kickoff'), 'Triggering message content must be preserved'); + assert(context.includes('Messages from topic: ISSUE_OPENED'), 'Issue anchor must be preserved'); + assert(context.includes('Messages from topic: PLAN_READY'), 'Plan anchor must be preserved'); + assert(!context.includes('optional-detail'), 'Low-priority context should be dropped'); + }); + + it('compacts high-priority packs before skipping low-priority packs', () => { + const packs = [ + { + id: 'header', + section: 'header', + priority: 'required', + render: () => 'REQ\n', + }, + { + id: 'high', + section: 'sources', + priority: 'high', + render: () => 'H'.repeat(40), + compact: () => 'H\n', + }, + { + id: 'low', + section: 'sources', + priority: 'low', + render: () => 'L'.repeat(40), + compact: () => 'L\n', + }, + { + id: 'trigger', + section: 'triggeringMessage', + priority: 'required', + preserve: true, + render: () => 'TRIG\n', + }, + ]; + + const result = buildContextPacks({ packs, maxTokens: 4 }); + const highPack = result.packDecisions.find((pack) => pack.id === 'high'); + const lowPack = result.packDecisions.find((pack) => pack.id === 'low'); + + assert.strictEqual(highPack.status, 'included'); + assert.strictEqual(highPack.variant, 'compact'); + assert.strictEqual(lowPack.status, 'skipped'); + assert(result.context.includes('H\n'), 'High-priority compact content should be included'); + assert(!result.context.includes('L'.repeat(40)), 'Low-priority full content should be dropped'); + }); +}); diff --git a/tests/context-source-selection.test.js b/tests/context-source-selection.test.js new file mode 100644 index 00000000..0b35b7c4 --- /dev/null +++ b/tests/context-source-selection.test.js @@ -0,0 +1,171 @@ +const assert = require('assert'); +const AgentWrapper = require('../src/agent-wrapper'); +const MessageBus = require('../src/message-bus'); +const Ledger = require('../src/ledger'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +describe('context source selection', () => { + let tempDir; + let ledger; + let messageBus; + let clusterId; + let clusterCreatedAt; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-test-')); + const dbPath = path.join(tempDir, 'test-ledger.db'); + + ledger = new Ledger(dbPath); + messageBus = new MessageBus(ledger); + + clusterId = 'test-cluster-456'; + clusterCreatedAt = Date.now(); + }); + + afterEach(() => { + if (ledger) ledger.close(); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + function createWorker(contextStrategy) { + const workerConfig = { + id: 'worker', + role: 'implementation', + modelLevel: 'level2', + timeout: 0, + contextStrategy, + }; + + const mockCluster = { + id: clusterId, + createdAt: clusterCreatedAt, + agents: [], + }; + + return new AgentWrapper(workerConfig, messageBus, mockCluster, { + testMode: true, + mockSpawnFn: () => {}, + }); + } + + function publishMessage(topic, text, timestamp) { + messageBus.publish({ + cluster_id: clusterId, + topic, + sender: 'system', + content: { text }, + timestamp, + }); + } + + function buildTriggeringMessage(timestamp) { + return { + topic: 'ISSUE_OPENED', + sender: 'system', + timestamp, + }; + } + + it('selects latest messages in chronological order', () => { + const baseTime = Date.now(); + publishMessage('TEST_TOPIC', 'first-message', baseTime); + publishMessage('TEST_TOPIC', 'second-message', baseTime + 10); + publishMessage('TEST_TOPIC', 'third-message', baseTime + 20); + + const worker = createWorker({ + sources: [{ topic: 'TEST_TOPIC', amount: 2, strategy: 'latest' }], + }); + + const context = worker._buildContext(buildTriggeringMessage(baseTime + 30)); + + assert(!context.includes('first-message'), 'Should not include oldest message'); + assert(context.includes('second-message'), 'Should include second message'); + assert(context.includes('third-message'), 'Should include latest message'); + assert( + context.indexOf('second-message') < context.indexOf('third-message'), + 'Latest messages should render in chronological order' + ); + }); + + it('selects oldest messages in chronological order', () => { + const baseTime = Date.now(); + publishMessage('TEST_TOPIC', 'alpha-message', baseTime); + publishMessage('TEST_TOPIC', 'beta-message', baseTime + 10); + publishMessage('TEST_TOPIC', 'gamma-message', baseTime + 20); + + const worker = createWorker({ + sources: [{ topic: 'TEST_TOPIC', amount: 2, strategy: 'oldest' }], + }); + + const context = worker._buildContext(buildTriggeringMessage(baseTime + 30)); + + assert(context.includes('alpha-message'), 'Should include oldest message'); + assert(context.includes('beta-message'), 'Should include second message'); + assert(!context.includes('gamma-message'), 'Should not include latest message'); + assert( + context.indexOf('alpha-message') < context.indexOf('beta-message'), + 'Oldest messages should render in chronological order' + ); + }); + + it('selects all messages in chronological order', () => { + const baseTime = Date.now(); + publishMessage('TEST_TOPIC', 'one-message', baseTime); + publishMessage('TEST_TOPIC', 'two-message', baseTime + 10); + publishMessage('TEST_TOPIC', 'three-message', baseTime + 20); + + const worker = createWorker({ + sources: [{ topic: 'TEST_TOPIC', strategy: 'all' }], + }); + + const context = worker._buildContext(buildTriggeringMessage(baseTime + 30)); + + assert(context.includes('one-message'), 'Should include first message'); + assert(context.includes('two-message'), 'Should include second message'); + assert(context.includes('three-message'), 'Should include third message'); + assert( + context.indexOf('one-message') < context.indexOf('two-message'), + 'All messages should render in chronological order' + ); + assert( + context.indexOf('two-message') < context.indexOf('three-message'), + 'All messages should render in chronological order' + ); + }); + + it('uses limit as amount alias with latest default', () => { + const baseTime = Date.now(); + publishMessage('TEST_TOPIC', 'old-message', baseTime); + publishMessage('TEST_TOPIC', 'newer-message', baseTime + 10); + + const worker = createWorker({ + sources: [{ topic: 'TEST_TOPIC', limit: 1 }], + }); + + const context = worker._buildContext(buildTriggeringMessage(baseTime + 20)); + + assert(!context.includes('old-message'), 'Should not include older message'); + assert(context.includes('newer-message'), 'Should include latest message'); + }); + + it('prefers amount when both amount and limit are set', () => { + const baseTime = Date.now(); + publishMessage('TEST_TOPIC', 'older-message', baseTime); + publishMessage('TEST_TOPIC', 'newest-message', baseTime + 10); + + const worker = createWorker({ + sources: [{ topic: 'TEST_TOPIC', amount: 1, limit: 2, strategy: 'latest' }], + }); + + const context = worker._buildContext(buildTriggeringMessage(baseTime + 20)); + + assert(!context.includes('older-message'), 'Should honor amount over limit'); + assert(context.includes('newest-message'), 'Should include latest message'); + }); +}); diff --git a/tests/integration/context-metrics.test.js b/tests/integration/context-metrics.test.js new file mode 100644 index 00000000..febe161e --- /dev/null +++ b/tests/integration/context-metrics.test.js @@ -0,0 +1,133 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Ledger = require('../../src/ledger'); +const MessageBus = require('../../src/message-bus'); +const { buildContext } = require('../../src/agent/agent-context-builder'); + +const MAX_CONTEXT_CHARS = 500000; + +describe('Context Metrics Integration', function () { + let tempDir; + let ledger; + let messageBus; + let originalMetricsEnv; + let originalLedgerEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-context-metrics-')); + const dbPath = path.join(tempDir, 'test-ledger.db'); + ledger = new Ledger(dbPath); + messageBus = new MessageBus(ledger); + + originalMetricsEnv = process.env.ZEROSHOT_CONTEXT_METRICS; + originalLedgerEnv = process.env.ZEROSHOT_CONTEXT_METRICS_LEDGER; + process.env.ZEROSHOT_CONTEXT_METRICS = '0'; + process.env.ZEROSHOT_CONTEXT_METRICS_LEDGER = '1'; + }); + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + if (originalMetricsEnv === undefined) { + delete process.env.ZEROSHOT_CONTEXT_METRICS; + } else { + process.env.ZEROSHOT_CONTEXT_METRICS = originalMetricsEnv; + } + + if (originalLedgerEnv === undefined) { + delete process.env.ZEROSHOT_CONTEXT_METRICS_LEDGER; + } else { + process.env.ZEROSHOT_CONTEXT_METRICS_LEDGER = originalLedgerEnv; + } + }); + + it('publishes metrics to the ledger and records truncation stages', function () { + const clusterId = 'cluster-metrics-1'; + const createdAt = Date.now() - 60000; + const secretMarker = 'SECRET_CONTEXT_PAYLOAD'; + const hugeText = `${secretMarker}${'x'.repeat(MAX_CONTEXT_CHARS + 200000)}`; + + messageBus.publish({ + cluster_id: clusterId, + topic: 'ISSUE_OPENED', + sender: 'user', + content: { text: hugeText }, + }); + + messageBus.publish({ + cluster_id: clusterId, + topic: 'HUGE_TOPIC', + sender: 'tester', + content: { text: 'optional-context' }, + }); + + const context = buildContext({ + id: 'worker', + role: 'implementation', + iteration: 1, + config: { + contextStrategy: { + sources: [ + { topic: 'ISSUE_OPENED', since: 'cluster_start', limit: 1 }, + { topic: 'HUGE_TOPIC', since: 'cluster_start', limit: 1 }, + ], + maxTokens: 1, + }, + }, + messageBus, + cluster: { id: clusterId, createdAt }, + triggeringMessage: { + topic: 'TASK_READY', + sender: 'planner', + content: { text: 'go' }, + }, + }); + + assert.ok(context.length > 0, 'Context should be generated'); + + const metricsMessages = ledger.query({ cluster_id: clusterId, topic: 'CONTEXT_METRICS' }); + assert.strictEqual(metricsMessages.length, 1, 'Should publish one CONTEXT_METRICS message'); + + const metrics = metricsMessages[0].content.data; + assert.strictEqual(metrics.clusterId, clusterId); + assert.strictEqual(metrics.agentId, 'worker'); + assert.strictEqual(metrics.role, 'implementation'); + assert.strictEqual(metrics.iteration, 1); + assert.strictEqual(metrics.strategy.maxTokens, 1); + assert.strictEqual(metrics.strategy.sourcesCount, 2); + + assert.strictEqual(metrics.truncation.maxContextChars.applied, true); + assert.ok( + metrics.truncation.maxContextChars.beforeChars > + metrics.truncation.maxContextChars.afterChars, + 'Max context truncation should reduce size' + ); + assert.ok( + metrics.truncation.maxContextChars.afterChars <= MAX_CONTEXT_CHARS, + 'Final context should respect max char limit' + ); + assert.strictEqual(metrics.budget.maxTokens, 1); + assert.strictEqual(metrics.total.chars, context.length); + + const metricsJson = JSON.stringify(metrics); + assert.ok(!metricsJson.includes(secretMarker), 'Metrics should not include raw context'); + assert.ok(metrics.sections.sources.chars > 0, 'Sources section should be counted'); + assert.ok( + metrics.packs.some( + (pack) => pack.id.startsWith('source:ISSUE_OPENED') && pack.status === 'included' + ), + 'Required issue pack should be included' + ); + assert.ok( + metrics.packs.some( + (pack) => pack.id.startsWith('source:HUGE_TOPIC') && pack.status === 'skipped' + ), + 'Optional pack should be skipped when over budget' + ); + }); +}); diff --git a/tests/nested-cluster.test.js b/tests/nested-cluster.test.js index d4f0edbd..aee0c053 100644 --- a/tests/nested-cluster.test.js +++ b/tests/nested-cluster.test.js @@ -295,6 +295,61 @@ function defineSubClusterWrapperTests() { assert.strictEqual(wrapper.role, 'orchestrator'); assert.strictEqual(wrapper.state, 'idle'); }); + + it('should select latest parent topic messages for child context', function () { + const dbPath = path.join(TEST_STORAGE, 'parent-context.db'); + const ledger = new Ledger(dbPath); + const messageBus = new MessageBus(ledger); + + const config = { + id: 'test-subcluster', + type: 'subcluster', + role: 'orchestrator', + config: { + agents: [ + { + id: 'worker', + role: 'implementation', + modelLevel: 'level1', + triggers: [{ topic: 'START' }], + }, + ], + }, + triggers: [{ topic: 'BEGIN' }], + contextStrategy: { + parentTopics: [{ topic: 'PLAN_READY', strategy: 'latest', amount: 1 }], + }, + }; + + const parentCluster = { id: 'parent-cluster' }; + const wrapper = new SubClusterWrapper(config, messageBus, parentCluster, { quiet: true }); + + const baseTime = Date.now(); + messageBus.publish({ + cluster_id: parentCluster.id, + topic: 'PLAN_READY', + sender: 'planner', + content: { text: 'Old plan' }, + timestamp: baseTime, + }); + messageBus.publish({ + cluster_id: parentCluster.id, + topic: 'PLAN_READY', + sender: 'planner', + content: { text: 'Newest plan' }, + timestamp: baseTime + 10, + }); + + const context = wrapper._buildChildContext({ + topic: 'BEGIN', + sender: 'system', + timestamp: baseTime + 20, + content: { text: 'Trigger' }, + }); + + assert(context.includes('Newest plan'), 'Should include latest parent topic message'); + assert(!context.includes('Old plan'), 'Should not include older parent topic message'); + }); }); } diff --git a/tests/state-snapshot.test.js b/tests/state-snapshot.test.js new file mode 100644 index 00000000..4f4fc0f2 --- /dev/null +++ b/tests/state-snapshot.test.js @@ -0,0 +1,381 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Orchestrator = require('../src/orchestrator'); +const Ledger = require('../src/ledger'); +const MessageBus = require('../src/message-bus'); +const StateSnapshotter = require('../src/state-snapshotter'); +const MockTaskRunner = require('./helpers/mock-task-runner'); +const { + initStateFromIssue, + applyPlanReady, + applyWorkerProgress, + applyValidationResult, + applyInvestigationComplete, +} = require('../src/state-snapshot'); + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-snapshot-')); +} + +function cleanupTempDir(dir) { + if (dir && fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function buildMessage({ clusterId, topic, sender, content }) { + return { + id: `msg_${Math.random().toString(16).slice(2)}`, + timestamp: Date.now(), + cluster_id: clusterId, + topic, + sender, + receiver: 'broadcast', + content, + }; +} + +async function waitForClusterState(orchestrator, clusterId, target, timeoutMs = 5000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const status = orchestrator.getStatus(clusterId); + if (status.state === target) { + return; + } + } catch { + // Cluster may be removed during shutdown + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Cluster ${clusterId} did not reach ${target} within ${timeoutMs}ms`); +} + +describe('State snapshot builder', () => { + it('should replace plan text and criteria on PLAN_READY', () => { + const clusterId = 'cluster-plan'; + const issueMessage = buildMessage({ + clusterId, + topic: 'ISSUE_OPENED', + sender: 'system', + content: { text: 'Fix login bug', data: { title: 'Login bug', issue_number: 133 } }, + }); + + let state = initStateFromIssue(issueMessage); + + state = applyPlanReady( + state, + buildMessage({ + clusterId, + topic: 'PLAN_READY', + sender: 'planner', + content: { + text: 'Plan v1', + data: { + summary: 'Old plan', + acceptanceCriteria: [{ id: 'AC1', criterion: 'Old criteria', priority: 'MUST' }], + filesAffected: ['old.js'], + }, + }, + }) + ); + + state = applyPlanReady( + state, + buildMessage({ + clusterId, + topic: 'PLAN_READY', + sender: 'planner', + content: { + text: 'Plan v2', + data: { + summary: 'New plan', + acceptanceCriteria: [{ id: 'AC1', criterion: 'New criteria', priority: 'MUST' }], + filesAffected: ['new.js'], + }, + }, + }) + ); + + assert.strictEqual(state.plan.text, 'Plan v2'); + assert.deepStrictEqual(state.plan.acceptanceCriteria, ['AC1 (MUST): New criteria']); + assert.deepStrictEqual(state.plan.filesAffected, ['new.js']); + }); + + it('should update progress fields from WORKER_PROGRESS', () => { + const clusterId = 'cluster-progress'; + const issueMessage = buildMessage({ + clusterId, + topic: 'ISSUE_OPENED', + sender: 'system', + content: { text: 'Add endpoint', data: { title: 'Add endpoint' } }, + }); + + let state = initStateFromIssue(issueMessage); + state = applyWorkerProgress( + state, + buildMessage({ + clusterId, + topic: 'WORKER_PROGRESS', + sender: 'worker', + content: { + text: 'WIP', + data: { + completionStatus: { + canValidate: false, + percentComplete: 45, + nextSteps: ['Write tests', 'Run lint'], + }, + }, + }, + }) + ); + + assert.strictEqual(state.progress.percentComplete, 45); + assert.deepStrictEqual(state.progress.nextSteps, ['Write tests', 'Run lint']); + }); + + it('should update validation approval and errors', () => { + const clusterId = 'cluster-validation'; + const issueMessage = buildMessage({ + clusterId, + topic: 'ISSUE_OPENED', + sender: 'system', + content: { text: 'Fix build', data: { title: 'Build fix' } }, + }); + + let state = initStateFromIssue(issueMessage); + state = applyValidationResult( + state, + buildMessage({ + clusterId, + topic: 'VALIDATION_RESULT', + sender: 'validator', + content: { + data: { + approved: false, + errors: ['Missing tests', 'Type error'], + }, + }, + }) + ); + + assert.strictEqual(state.validation.approved, false); + assert.deepStrictEqual(state.validation.errors, ['Missing tests', 'Type error']); + }); + + it('should update debug fields from INVESTIGATION_COMPLETE', () => { + const clusterId = 'cluster-debug'; + const issueMessage = buildMessage({ + clusterId, + topic: 'ISSUE_OPENED', + sender: 'system', + content: { text: 'Debug failure', data: { title: 'Debug issue' } }, + }); + + let state = initStateFromIssue(issueMessage); + state = applyInvestigationComplete( + state, + buildMessage({ + clusterId, + topic: 'INVESTIGATION_COMPLETE', + sender: 'investigator', + content: { + text: 'Apply guard clauses and add tests', + data: { + successCriteria: 'All tests pass', + rootCauses: [{ cause: 'Null input not handled' }], + }, + }, + }) + ); + + assert.strictEqual(state.debug.fixPlan, 'Apply guard clauses and add tests'); + assert.deepStrictEqual(state.debug.rootCauses, ['Null input not handled']); + }); +}); + +describe('StateSnapshotter publishing', () => { + let tempDir; + let ledger; + let messageBus; + + beforeEach(() => { + tempDir = createTempDir(); + ledger = new Ledger(path.join(tempDir, 'ledger.db')); + messageBus = new MessageBus(ledger); + }); + + afterEach(() => { + ledger.close(); + cleanupTempDir(tempDir); + }); + + it('should publish STATE_SNAPSHOT on PLAN_READY and VALIDATION_RESULT', () => { + const clusterId = 'cluster-publish'; + const snapshotter = new StateSnapshotter({ messageBus, clusterId }); + snapshotter.start(); + + messageBus.publish({ + cluster_id: clusterId, + topic: 'ISSUE_OPENED', + sender: 'system', + content: { text: 'Issue text', data: { title: 'Issue title' } }, + metadata: { source: 'text' }, + }); + + messageBus.publish({ + cluster_id: clusterId, + topic: 'PLAN_READY', + sender: 'planner', + content: { + text: 'Plan text', + data: { + summary: 'Plan summary', + acceptanceCriteria: [{ id: 'AC1', criterion: 'Do thing', priority: 'MUST' }], + }, + }, + }); + + const planSnapshot = messageBus.findLast({ cluster_id: clusterId, topic: 'STATE_SNAPSHOT' }); + assert.ok(planSnapshot, 'STATE_SNAPSHOT should be published'); + assert.strictEqual(planSnapshot.content.data.plan.text, 'Plan text'); + + messageBus.publish({ + cluster_id: clusterId, + topic: 'VALIDATION_RESULT', + sender: 'validator', + content: { + data: { + approved: false, + errors: ['Test failure'], + }, + }, + }); + + const validationSnapshot = messageBus.findLast({ + cluster_id: clusterId, + topic: 'STATE_SNAPSHOT', + }); + assert.strictEqual(validationSnapshot.content.data.validation.approved, false); + assert.deepStrictEqual(validationSnapshot.content.data.validation.errors, ['Test failure']); + }); +}); + +describe('Snapshotter orchestration integration', () => { + let tempDir; + let orchestrator; + let mockRunner; + + beforeEach(() => { + tempDir = createTempDir(); + mockRunner = new MockTaskRunner(); + }); + + afterEach(() => { + if (orchestrator) { + orchestrator.close(); + } + cleanupTempDir(tempDir); + }); + + it('should inject STATE_SNAPSHOT into worker context', async () => { + mockRunner.when('worker').returns('{"summary":"done"}'); + + orchestrator = new Orchestrator({ + quiet: true, + storageDir: tempDir, + taskRunner: mockRunner, + }); + + const config = { + agents: [ + { + id: 'worker', + role: 'implementation', + timeout: 0, + contextStrategy: { + sources: [ + { topic: 'STATE_SNAPSHOT', priority: 'required', strategy: 'latest', amount: 1 }, + { topic: 'ISSUE_OPENED', priority: 'required', strategy: 'latest', amount: 1 }, + ], + }, + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + prompt: 'Do work', + hooks: { + onComplete: { + action: 'publish_message', + config: { topic: 'CLUSTER_COMPLETE', content: { text: 'done' } }, + }, + }, + }, + { + id: 'completion-detector', + role: 'orchestrator', + timeout: 0, + triggers: [{ topic: 'CLUSTER_COMPLETE', action: 'stop_cluster' }], + }, + ], + }; + + const result = await orchestrator.start(config, { text: 'Do the thing' }); + await waitForClusterState(orchestrator, result.id, 'stopped', 5000); + + mockRunner.assertContextIncludes('worker', 'STATE_SNAPSHOT'); + }); + + it('should publish STATE_SNAPSHOT when loading legacy clusters', async () => { + const clusterId = 'legacy-cluster'; + const clusterDir = tempDir; + const dbPath = path.join(clusterDir, `${clusterId}.db`); + + const ledger = new Ledger(dbPath); + ledger.append({ + cluster_id: clusterId, + topic: 'ISSUE_OPENED', + sender: 'system', + content: { text: 'Legacy issue', data: { title: 'Legacy' } }, + metadata: { source: 'text' }, + }); + ledger.append({ + cluster_id: clusterId, + topic: 'PLAN_READY', + sender: 'planner', + content: { text: 'Legacy plan', data: { summary: 'Legacy summary' } }, + }); + ledger.close(); + + const fixturePath = path.join(__dirname, 'fixtures', 'single-worker.json'); + const config = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + const clustersFile = path.join(clusterDir, 'clusters.json'); + fs.writeFileSync( + clustersFile, + JSON.stringify( + { + [clusterId]: { + id: clusterId, + config, + state: 'stopped', + createdAt: Date.now(), + pid: null, + }, + }, + null, + 2 + ) + ); + + orchestrator = await Orchestrator.create({ storageDir: clusterDir, quiet: true }); + const cluster = orchestrator.getCluster(clusterId); + const snapshot = cluster.messageBus.findLast({ + cluster_id: clusterId, + topic: 'STATE_SNAPSHOT', + }); + + assert.ok(snapshot, 'STATE_SNAPSHOT should be created during load'); + assert.strictEqual(snapshot.content.data.plan.text, 'Legacy plan'); + }); +}); diff --git a/tests/template-resolver.test.js b/tests/template-resolver.test.js index 4c3fdb74..85df7891 100644 --- a/tests/template-resolver.test.js +++ b/tests/template-resolver.test.js @@ -32,7 +32,7 @@ function getConfig(complexity, taskType) { if (complexity === 'TRIVIAL') return 0; if (complexity === 'SIMPLE') return 1; if (complexity === 'STANDARD') return 2; - if (complexity === 'CRITICAL') return 4; + if (complexity === 'CRITICAL') return 0; // Two-stage validation with dynamic loading return 1; }; @@ -134,7 +134,7 @@ describe('TemplateResolver', function () { planner_level: 'level3', worker_level: 'level2', validator_level: 'level2', - validator_count: 4, + validator_count: 0, // CRITICAL uses two-stage validation (validators loaded dynamically) }); assert.ok(resolved.agents); @@ -142,9 +142,13 @@ describe('TemplateResolver', function () { const planner = resolved.agents.find((a) => a.id === 'planner'); assert.strictEqual(planner.modelLevel, 'level3'); - // Should have 5 validators for CRITICAL + // CRITICAL tasks use two-stage validation: meta-coordinator loads validators dynamically + // So inline validators should be filtered out, meta-coordinator should be present const validators = resolved.agents.filter((a) => a.role === 'validator'); - assert.strictEqual(validators.length, 5); + assert.strictEqual(validators.length, 0); + + const metaCoordinator = resolved.agents.find((a) => a.id === 'meta-coordinator'); + assert.ok(metaCoordinator, 'meta-coordinator should be present for CRITICAL tasks'); }); it('should fail on missing required params', function () { @@ -206,11 +210,11 @@ describe('2D Classification Routing', function () { assert.strictEqual(params.planner_level, 'level2'); }); - it('CRITICAL should use level3 planner and 4 validators', function () { + it('CRITICAL should use level3 planner and two-stage validation', function () { const { base, params } = getConfig('CRITICAL', 'TASK'); assert.strictEqual(base, 'full-workflow'); assert.strictEqual(params.planner_level, 'level3'); - assert.strictEqual(params.validator_count, 4); + assert.strictEqual(params.validator_count, 0); // Two-stage validation with dynamic loading assert.strictEqual(params.max_iterations, DEFAULT_MAX_ITERATIONS); }); diff --git a/tests/two-stage-validation.test.js b/tests/two-stage-validation.test.js new file mode 100644 index 00000000..e55e226b --- /dev/null +++ b/tests/two-stage-validation.test.js @@ -0,0 +1,195 @@ +/** + * Tests for two-stage validation pipeline (quick → heavy) + */ + +const assert = require('assert'); +const path = require('path'); +const TemplateResolver = require('../src/template-resolver'); + +describe('Two-Stage Validation Pipeline', function () { + let resolver; + + before(function () { + const templatesDir = path.join(__dirname, '..', 'cluster-templates'); + resolver = new TemplateResolver(templatesDir); + }); + + describe('quick-validation template', function () { + it('should contain validator-requirements and validator-code', function () { + const resolved = resolver.resolve('quick-validation', {}); + assert.ok(resolved.agents, 'Template should have agents'); + + const requirements = resolved.agents.find((a) => a.id === 'validator-requirements'); + assert.ok(requirements, 'validator-requirements should exist'); + + const code = resolved.agents.find((a) => a.id === 'validator-code'); + assert.ok(code, 'validator-code should exist'); + }); + + it('should trigger on IMPLEMENTATION_READY', function () { + const resolved = resolver.resolve('quick-validation', {}); + + const requirements = resolved.agents.find((a) => a.id === 'validator-requirements'); + const trigger = requirements.triggers.find((t) => t.topic === 'IMPLEMENTATION_READY'); + assert.ok(trigger, 'validator-requirements should trigger on IMPLEMENTATION_READY'); + + const code = resolved.agents.find((a) => a.id === 'validator-code'); + const codeTrigger = code.triggers.find((t) => t.topic === 'IMPLEMENTATION_READY'); + assert.ok(codeTrigger, 'validator-code should trigger on IMPLEMENTATION_READY'); + }); + + it('should have consensus-coordinator publishing QUICK_VALIDATION_PASSED on success', function () { + const resolved = resolver.resolve('quick-validation', {}); + + const coordinator = resolved.agents.find((a) => a.id === 'consensus-coordinator'); + assert.ok(coordinator, 'consensus-coordinator should exist'); + + const hook = coordinator.hooks.onComplete; + assert.ok(hook, 'consensus-coordinator should have onComplete hook'); + assert.strictEqual(hook.action, 'publish_message'); + }); + + it('should publish VALIDATION_RESULT if any validator rejects', function () { + const resolved = resolver.resolve('quick-validation', {}); + + const coordinator = resolved.agents.find((a) => a.id === 'consensus-coordinator'); + const logicScript = coordinator.triggers.find((t) => t.logic)?.logic?.script; + + assert.ok(logicScript, 'consensus-coordinator should have logic script'); + assert.ok( + logicScript.includes('VALIDATION_RESULT'), + 'Logic should publish VALIDATION_RESULT on rejection' + ); + }); + }); + + describe('heavy-validation template', function () { + it('should contain validator-security and validator-tester', function () { + const resolved = resolver.resolve('heavy-validation', {}); + assert.ok(resolved.agents, 'Template should have agents'); + + const security = resolved.agents.find((a) => a.id === 'validator-security'); + assert.ok(security, 'validator-security should exist'); + + const tester = resolved.agents.find((a) => a.id === 'validator-tester'); + assert.ok(tester, 'validator-tester should exist'); + }); + + it('should trigger on QUICK_VALIDATION_PASSED', function () { + const resolved = resolver.resolve('heavy-validation', {}); + + const security = resolved.agents.find((a) => a.id === 'validator-security'); + const trigger = security.triggers.find((t) => t.topic === 'QUICK_VALIDATION_PASSED'); + assert.ok(trigger, 'validator-security should trigger on QUICK_VALIDATION_PASSED'); + + const tester = resolved.agents.find((a) => a.id === 'validator-tester'); + const testerTrigger = tester.triggers.find((t) => t.topic === 'QUICK_VALIDATION_PASSED'); + assert.ok(testerTrigger, 'validator-tester should trigger on QUICK_VALIDATION_PASSED'); + }); + + it('should have consensus-coordinator publishing VALIDATION_RESULT', function () { + const resolved = resolver.resolve('heavy-validation', {}); + + const coordinator = resolved.agents.find((a) => a.id === 'consensus-coordinator'); + assert.ok(coordinator, 'consensus-coordinator should exist'); + + const hook = coordinator.hooks.onComplete; + assert.ok(hook, 'consensus-coordinator should have onComplete hook'); + assert.strictEqual(hook.action, 'publish_message'); + + const logicScript = coordinator.triggers.find((t) => t.logic)?.logic?.script; + assert.ok( + logicScript.includes('VALIDATION_RESULT'), + 'Logic should publish VALIDATION_RESULT' + ); + }); + + it('should have contextStrategy for QUICK_VALIDATION_PASSED', function () { + const resolved = resolver.resolve('heavy-validation', {}); + + const security = resolved.agents.find((a) => a.id === 'validator-security'); + const contextSource = security.contextStrategy?.sources?.find( + (s) => s.topic === 'QUICK_VALIDATION_PASSED' + ); + + assert.ok(contextSource, 'validator-security should have QUICK_VALIDATION_PASSED context'); + assert.strictEqual(contextSource.priority, 'required'); + }); + }); + + describe('full-workflow integration', function () { + it('should load meta-coordinator for CRITICAL tasks', function () { + const resolved = resolver.resolve('full-workflow', { + task_type: 'TASK', + complexity: 'CRITICAL', + max_tokens: 150000, + max_iterations: 25, + planner_level: 'level3', + worker_level: 'level2', + validator_level: 'level2', + validator_count: 0, + }); + + const metaCoordinator = resolved.agents.find((a) => a.id === 'meta-coordinator'); + assert.ok(metaCoordinator, 'meta-coordinator should be present for CRITICAL tasks'); + + // Inline validators should be filtered out + const validators = resolved.agents.filter((a) => a.role === 'validator'); + assert.strictEqual(validators.length, 0, 'No inline validators for CRITICAL tasks'); + }); + + it('should NOT load meta-coordinator for STANDARD tasks', function () { + const resolved = resolver.resolve('full-workflow', { + task_type: 'TASK', + complexity: 'STANDARD', + max_tokens: 100000, + max_iterations: 25, + planner_level: 'level2', + worker_level: 'level2', + validator_level: 'level2', + validator_count: 2, + }); + + const metaCoordinator = resolved.agents.find((a) => a.id === 'meta-coordinator'); + assert.ok(!metaCoordinator, 'meta-coordinator should NOT be present for STANDARD tasks'); + + // Inline validators should be present + const validators = resolved.agents.filter((a) => a.role === 'validator'); + assert.strictEqual(validators.length, 2, 'STANDARD tasks should have 2 inline validators'); + }); + }); + + describe('Sequential execution order', function () { + it('Stage 2 cannot trigger without QUICK_VALIDATION_PASSED', function () { + const heavyResolved = resolver.resolve('heavy-validation', {}); + + // Heavy validators ONLY trigger on QUICK_VALIDATION_PASSED + const heavySecurity = heavyResolved.agents.find((a) => a.id === 'validator-security'); + const triggers = heavySecurity.triggers.filter((t) => t.topic !== 'QUICK_VALIDATION_PASSED'); + + assert.strictEqual( + triggers.length, + 0, + 'Heavy validators should ONLY trigger on QUICK_VALIDATION_PASSED' + ); + }); + + it('Consensus-coordinator publishes VALIDATION_RESULT, not QUICK_VALIDATION_PASSED on rejection', function () { + const resolved = resolver.resolve('quick-validation', {}); + + const coordinator = resolved.agents.find((a) => a.id === 'consensus-coordinator'); + const hookLogic = coordinator.hooks.onComplete?.logic?.script; + + assert.ok(hookLogic, 'consensus-coordinator should have hook logic script'); + assert.ok( + hookLogic.includes('!result.allApproved') || + hookLogic.includes('result.approved === false'), + 'Logic should check for rejections' + ); + assert.ok( + hookLogic.includes('VALIDATION_RESULT'), + 'Should publish VALIDATION_RESULT on rejection (skips QUICK_VALIDATION_PASSED)' + ); + }); + }); +}); diff --git a/tests/unit/claude-fatal-error-detection.test.js b/tests/unit/claude-fatal-error-detection.test.js new file mode 100644 index 00000000..a7deed4c --- /dev/null +++ b/tests/unit/claude-fatal-error-detection.test.js @@ -0,0 +1,48 @@ +const { describe, it } = require('mocha'); +const { expect } = require('chai'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +function loadRecoveryModule() { + const modulePath = path.resolve(__dirname, '../../task-lib/claude-recovery.js'); + return import(pathToFileURL(modulePath).href); +} + +describe('Claude fatal error detection', () => { + it('detects "No messages returned" in error output', async () => { + const { detectFatalClaudeError, NO_MESSAGES_RETURNED } = await loadRecoveryModule(); + const line = 'Error: No messages returned'; + + const detected = detectFatalClaudeError(line); + + expect(detected).to.equal(`Claude CLI error: ${NO_MESSAGES_RETURNED}`); + }); + + it('is case-insensitive', async () => { + const { detectFatalClaudeError, NO_MESSAGES_RETURNED } = await loadRecoveryModule(); + const line = 'error: NO MESSAGES RETURNED'; + + const detected = detectFatalClaudeError(line); + + expect(detected).to.equal(`Claude CLI error: ${NO_MESSAGES_RETURNED}`); + }); + + it('returns null for unrelated output', async () => { + const { detectFatalClaudeError } = await loadRecoveryModule(); + + expect(detectFatalClaudeError('All good')).to.equal(null); + expect(detectFatalClaudeError('')).to.equal(null); + }); + + it('does not flag valid JSON output that contains the message', async () => { + const { detectFatalClaudeError } = await loadRecoveryModule(); + const jsonLine = JSON.stringify({ + type: 'result', + structured_output: { + summary: 'No messages returned in issue description', + }, + }); + + expect(detectFatalClaudeError(jsonLine)).to.equal(null); + }); +}); diff --git a/tests/unit/cli-provider-override.test.js b/tests/unit/cli-provider-override.test.js new file mode 100644 index 00000000..59bc0752 --- /dev/null +++ b/tests/unit/cli-provider-override.test.js @@ -0,0 +1,74 @@ +/** + * Test: CLI Provider Override + * + * Verifies that provider override is only applied when explicitly set + * via --provider or ZEROSHOT_PROVIDER. + */ + +const assert = require('assert'); + +function normalizeProviderName(name) { + if (!name || typeof name !== 'string') return name; + const normalized = name.toLowerCase(); + if (normalized === 'anthropic') return 'claude'; + if (normalized === 'openai') return 'codex'; + if (normalized === 'google') return 'gemini'; + return normalized; +} + +// Mirrors resolveProviderOverride in cli/index.js +function resolveProviderOverride(options) { + const override = options.provider || process.env.ZEROSHOT_PROVIDER; + if (!override || (typeof override === 'string' && !override.trim())) { + return null; + } + return normalizeProviderName(override); +} + +describe('CLI Provider Override', function () { + const originalEnv = process.env.ZEROSHOT_PROVIDER; + + afterEach(function () { + if (originalEnv === undefined) { + delete process.env.ZEROSHOT_PROVIDER; + } else { + process.env.ZEROSHOT_PROVIDER = originalEnv; + } + }); + + it('returns null when no override is set', function () { + delete process.env.ZEROSHOT_PROVIDER; + const result = resolveProviderOverride({}); + assert.strictEqual(result, null); + }); + + it('uses --provider when provided', function () { + delete process.env.ZEROSHOT_PROVIDER; + const result = resolveProviderOverride({ provider: 'claude' }); + assert.strictEqual(result, 'claude'); + }); + + it('normalizes provider aliases', function () { + delete process.env.ZEROSHOT_PROVIDER; + const result = resolveProviderOverride({ provider: 'Anthropic' }); + assert.strictEqual(result, 'claude'); + }); + + it('uses ZEROSHOT_PROVIDER when --provider is missing', function () { + process.env.ZEROSHOT_PROVIDER = 'codex'; + const result = resolveProviderOverride({}); + assert.strictEqual(result, 'codex'); + }); + + it('ignores empty ZEROSHOT_PROVIDER', function () { + process.env.ZEROSHOT_PROVIDER = ' '; + const result = resolveProviderOverride({}); + assert.strictEqual(result, null); + }); + + it('prefers --provider over ZEROSHOT_PROVIDER', function () { + process.env.ZEROSHOT_PROVIDER = 'gemini'; + const result = resolveProviderOverride({ provider: 'claude' }); + assert.strictEqual(result, 'claude'); + }); +}); diff --git a/tests/unit/cli-resume-loads-clusters.test.js b/tests/unit/cli-resume-loads-clusters.test.js new file mode 100644 index 00000000..3516bb93 --- /dev/null +++ b/tests/unit/cli-resume-loads-clusters.test.js @@ -0,0 +1,24 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +describe('CLI resume command', function () { + it('should load clusters before checking for a task fallback', function () { + const cliPath = path.join(__dirname, '..', '..', 'cli', 'index.js'); + const cliCode = fs.readFileSync(cliPath, 'utf8'); + + const resumeStart = cliCode.indexOf(".command('resume"); + assert(resumeStart !== -1, 'resume command not found in cli/index.js'); + + const resumeEnd = cliCode.indexOf(".command('finish", resumeStart); + const resumeBlock = cliCode.slice(resumeStart, resumeEnd === -1 ? cliCode.length : resumeEnd); + + const usesCreate = + resumeBlock.includes('OrchestratorModule.create') || resumeBlock.includes('getOrchestrator('); + + assert( + usesCreate, + 'resume command should load clusters via Orchestrator.create or getOrchestrator' + ); + }); +}); diff --git a/tests/unit/context-metrics.test.js b/tests/unit/context-metrics.test.js new file mode 100644 index 00000000..b78a03de --- /dev/null +++ b/tests/unit/context-metrics.test.js @@ -0,0 +1,107 @@ +const assert = require('assert'); +const { buildContextMetrics, estimateTokensFromChars } = require('../../src/agent/context-metrics'); + +describe('Context Metrics', function () { + it('estimates tokens using ceil(chars / 4)', function () { + assert.strictEqual(estimateTokensFromChars(0), 0); + assert.strictEqual(estimateTokensFromChars(1), 1); + assert.strictEqual(estimateTokensFromChars(4), 1); + assert.strictEqual(estimateTokensFromChars(5), 2); + }); + + it('builds section breakdown with totals', function () { + const metrics = buildContextMetrics({ + clusterId: 'cluster-1', + agentId: 'agent-1', + role: 'worker', + iteration: 2, + triggeringMessage: { topic: 'TASK', sender: 'user' }, + strategy: { sources: [{ topic: 'A' }, { topic: 'B' }], maxTokens: 10 }, + packs: [ + { id: 'header', section: 'header', status: 'included', chars: 4, estimatedTokens: 1 }, + { + id: 'instructions', + section: 'instructions', + status: 'included', + chars: 5, + estimatedTokens: 2, + }, + { + id: 'jsonSchema', + section: 'jsonSchema', + status: 'included', + chars: 2, + estimatedTokens: 1, + }, + { + id: 'validatorSkip', + section: 'validatorSkip', + status: 'included', + chars: 1, + estimatedTokens: 1, + }, + { + id: 'triggeringMessage', + section: 'triggeringMessage', + status: 'included', + chars: 1, + estimatedTokens: 1, + }, + { + id: 'sources-a', + section: 'sources', + status: 'included', + chars: 3, + estimatedTokens: 1, + }, + { + id: 'sources-skipped', + section: 'sources', + status: 'skipped', + chars: 100, + estimatedTokens: 25, + }, + ], + budget: { + maxTokens: 10, + remainingTokens: 2, + overBudgetTokens: 0, + finalTokens: 6, + }, + }); + + assert.strictEqual(metrics.sections.header.chars, 4); + assert.strictEqual(metrics.sections.header.estimatedTokens, 1); + assert.strictEqual(metrics.sections.instructions.chars, 5); + assert.strictEqual(metrics.sections.instructions.estimatedTokens, 2); + assert.strictEqual(metrics.sections.jsonSchema.chars, 2); + assert.strictEqual(metrics.sections.validatorSkip.chars, 1); + assert.strictEqual(metrics.sections.triggeringMessage.chars, 1); + assert.strictEqual(metrics.sections.sources.chars, 3); + assert.strictEqual(metrics.total.chars, 4 + 5 + 2 + 1 + 1 + 3); + assert.strictEqual(metrics.total.estimatedTokens, Math.ceil(metrics.total.chars / 4)); + + assert.strictEqual(metrics.strategy.maxTokens, 10); + assert.strictEqual(metrics.strategy.sourcesCount, 2); + assert.strictEqual(metrics.triggeredBy, 'TASK'); + assert.strictEqual(metrics.triggerFrom, 'user'); + assert.strictEqual(metrics.budget.maxTokens, 10); + assert.strictEqual(metrics.budget.remainingTokens, 2); + assert.strictEqual(metrics.truncation.maxContextChars.beforeChars, metrics.total.chars); + }); + + it('defaults maxTokens when not provided', function () { + const metrics = buildContextMetrics({ + clusterId: 'cluster-1', + agentId: 'agent-1', + role: 'worker', + iteration: 1, + triggeringMessage: { topic: 'TASK', sender: 'user' }, + strategy: { sources: [] }, + packs: [{ id: 'header', section: 'header', status: 'included', chars: 1 }], + }); + + assert.strictEqual(metrics.strategy.maxTokens, 100000); + assert.strictEqual(metrics.budget.maxTokens, 100000); + }); +}); diff --git a/tests/unit/output-extraction-fatal-strings.test.js b/tests/unit/output-extraction-fatal-strings.test.js new file mode 100644 index 00000000..ae790a86 --- /dev/null +++ b/tests/unit/output-extraction-fatal-strings.test.js @@ -0,0 +1,30 @@ +const assert = require('assert'); +const { + extractJsonFromOutput, + hasFatalStandaloneOutput, +} = require('../../src/agent/output-extraction'); + +describe('Output Extraction - fatal strings handling', () => { + it('extracts JSON when fatal substrings appear inside JSON events', () => { + const output = [ + '{"type":"item.completed","item":{"id":"item_1","type":"command_execution","aggregated_output":"Task not found"}}', + '{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\\"summary\\":\\"ok\\",\\"completionStatus\\":{\\"canValidate\\":true,\\"percentComplete\\":100}}"}}', + '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}', + ].join('\n'); + + const parsed = extractJsonFromOutput(output, 'codex'); + + assert.deepStrictEqual(parsed, { + summary: 'ok', + completionStatus: { + canValidate: true, + percentComplete: 100, + }, + }); + }); + + it('detects standalone fatal output lines', () => { + const output = 'Task not found\n'; + assert.strictEqual(hasFatalStandaloneOutput(output), true); + }); +}); diff --git a/tests/validation-platform.test.js b/tests/validation-platform.test.js new file mode 100644 index 00000000..d47d0723 --- /dev/null +++ b/tests/validation-platform.test.js @@ -0,0 +1,49 @@ +const assert = require('assert'); +const { + isPlatformMismatchReason, + findPlatformMismatchReason, +} = require('../src/agent/validation-platform'); + +describe('Validation platform mismatch detection', function () { + it('detects platform mismatch from reason strings', function () { + assert.ok(isPlatformMismatchReason('EBADPLATFORM @esbuild/linux-x64')); + assert.ok(isPlatformMismatchReason('Unsupported platform for @esbuild/linux-x64')); + assert.ok(isPlatformMismatchReason('darwin-arm64 vs linux-x64')); + assert.ok(!isPlatformMismatchReason('kubectl not installed')); + }); + + it('finds platform mismatch in criteriaResults', function () { + const result = { + criteriaResults: [ + { id: 'AC1', status: 'PASS' }, + { + id: 'AC2', + status: 'CANNOT_VALIDATE', + reason: 'npm install fails on darwin-arm64 (EBADPLATFORM for @esbuild/linux-x64)', + }, + ], + }; + + const reason = findPlatformMismatchReason(result); + assert.ok(reason, 'Should return a platform mismatch reason'); + assert.ok(reason.includes('EBADPLATFORM'), 'Should keep original reason'); + }); + + it('finds platform mismatch in errors array', function () { + const result = { + errors: ['EBADPLATFORM for @esbuild/linux-x64'], + }; + + const reason = findPlatformMismatchReason(result); + assert.ok(reason, 'Should return a platform mismatch reason'); + }); + + it('returns null when no mismatch found', function () { + const result = { + criteriaResults: [{ id: 'AC1', status: 'CANNOT_VALIDATE', reason: 'kubectl not installed' }], + errors: ['No SSH access'], + }; + + assert.strictEqual(findPlatformMismatchReason(result), null); + }); +}); From 0edf0680b61c346af1960e88a6587e5460ac74fa Mon Sep 17 00:00:00 2001 From: Eivind Meyer Date: Thu, 19 Feb 2026 20:44:17 +0100 Subject: [PATCH 2/4] release: ship dev to main (not full TUI release) (#411) ## Release scope This release promotes the current `dev` branch to `main` for stable non-TUI improvements and fixes. ## Important This is **not** the full TUI release. - The TUI work is included only in its current in-progress state. - Final/complete TUI UX release is intentionally deferred to a later release. ## Notes - Reconciliation PR `#409` was merged into `dev` first to unblock `dev -> main`. - CI must pass and merge queue policy is respected. --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Eivind Meyer Co-authored-by: Michael Eichelbeck <141341133+mkceichelbeck@users.noreply.github.com> Co-authored-by: tomdps Co-authored-by: tomdps <60640908+tomdps@users.noreply.github.com> Co-authored-by: Michael Eichelbeck Co-authored-by: Ubuntu Co-authored-by: Eivind --- .github/dependabot.yml | 39 +- .github/workflows/ci.yml | 42 +- .github/workflows/codeql.yml | 8 +- .github/workflows/release.yml | 90 +- .gitignore | 7 +- .husky/pre-commit | 9 + .mocharc.cjs | 16 + .mocharc.json | 7 - .releaserc.json | 7 +- .zeroshot/settings.json | 8 + AGENTS.md | 100 +- CHANGELOG.md | 8 +- CLAUDE.md | 46 +- CONTRIBUTING.md | 28 +- PUBLISHING.md | 17 +- README.md | 71 + cli/index.js | 350 +- cli/message-formatters-watch.js | 6 +- .../base-templates/full-workflow.json | 22 +- .../base-templates/heavy-validation.json | 25 +- .../base-templates/quick-validation.json | 6 +- .../base-templates/worker-validator.json | 2 +- cluster-templates/conductor-bootstrap.json | 4 +- codecov.yml | 2 +- docker/zeroshot-cluster/Dockerfile | 5 +- docs/LIVE_MONITORING_PLAN.md | 233 -- docs/ZEROSHOT-DISRUPTIVE-TUI-DECISIONS.md | 72 + .../2026-01-31-pr-base-detached.md | 49 + docs/tui-v2/protocol.md | 463 +++ eslint.config.mjs | 20 +- lib/id-detector.js | 15 +- lib/repo-settings.js | 69 + lib/settings.js | 41 +- lib/start-cluster.js | 348 ++ lib/stream-json-parser.js | 75 +- lib/tui-binary.js | 121 + lib/tui-launcher.js | 160 + libexec/zeroshot-tui | Bin 0 -> 1734144 bytes package-lock.json | 281 +- package.json | 20 +- scripts/fix-node-pty-permissions.js | 2 +- scripts/install-tui-binary.js | 367 +++ scripts/validate-templates.js | 83 +- src/agent-wrapper.js | 33 +- src/agent/agent-config.js | 9 +- src/agent/agent-context-builder.js | 7 +- src/agent/agent-hook-executor.js | 235 ++ src/agent/agent-input-injector.js | 141 + src/agent/agent-lifecycle.js | 141 +- src/agent/agent-task-executor.js | 114 +- src/agent/guidance-queue.js | 77 + src/agent/output-extraction.js | 126 +- src/agent/rate-limit-backoff.js | 82 + src/agents/git-pusher-template.js | 337 +- src/attach/index.js | 2 + src/attach/send-input.js | 88 + src/config-validator.js | 6 +- src/guidance-topics.js | 10 + src/isolation-manager.js | 107 +- src/issue-providers/github-provider.js | 35 +- src/ledger.js | 65 +- src/message-buffer.js | 81 + src/message-bus.js | 7 + src/orchestrator.js | 699 +++- src/preflight.js | 91 +- src/providers/anthropic/index.js | 13 + src/providers/anthropic/models.js | 3 +- src/providers/anthropic/output-parser.js | 3 +- src/providers/base-provider.js | 99 +- src/providers/google/index.js | 20 + src/providers/openai/index.js | 18 + src/providers/openai/models.js | 13 +- src/providers/openai/output-parser.js | 64 +- src/state-snapshot.js | 2 +- src/sub-cluster-wrapper.js | 66 +- src/template-validation/index.js | 89 + .../simulate-consensus-gates.js | 159 + .../simulate-two-stage-validation.js | 270 ++ src/tui-backend/index.ts | 4 + src/tui-backend/protocol/constants.ts | 33 + src/tui-backend/protocol/dispatcher.ts | 88 + src/tui-backend/protocol/index.ts | 13 + src/tui-backend/protocol/schemas.ts | 599 ++++ src/tui-backend/protocol/stdio-framing.ts | 83 + src/tui-backend/protocol/types.ts | 177 + src/tui-backend/protocol/validator.ts | 250 ++ src/tui-backend/server.ts | 504 +++ src/tui-backend/services/cluster-launcher.ts | 127 + src/tui-backend/services/cluster-logs.ts | 299 ++ src/tui-backend/services/cluster-registry.ts | 295 ++ src/tui-backend/services/cluster-timeline.ts | 298 ++ src/tui-backend/services/cluster-topology.ts | 145 + src/tui-backend/services/guidance-delivery.ts | 74 + src/tui-backend/services/index.ts | 7 + src/tui-backend/services/start-cluster.ts | 1 + src/tui-backend/subscriptions/index.ts | 69 + src/tui/CHANGES.txt | 133 - src/tui/LAYOUT.md | 261 -- src/tui/README.txt | 192 -- src/tui/TWO-LEVEL-NAVIGATION.md | 186 -- src/tui/data-poller.js | 349 -- src/tui/demo.js | 208 -- src/tui/formatters.js | 129 - src/tui/index.js | 193 -- src/tui/keybindings.js | 324 -- src/tui/renderer.js | 184 -- task-lib/config.js | 7 +- tests/add-agents-trigger-merge.test.js | 257 ++ tests/agent-task-not-found.test.js | 159 + tests/cluster-operations.test.js | 28 + tests/conductor-republish-metadata.test.js | 4 +- tests/config-validator.test.js | 3 +- tests/fixtures/detached-daemon.js | 55 + .../invalid.params.getClusterSummary.json | 8 + .../invalid.request.missing-method.json | 4 + .../notification.clusterLogLines.json | 19 + .../notification.clusterTimelineEvents.json | 19 + .../protocol/request.getClusterSummary.json | 8 + .../protocol/request.getClusterTopology.json | 8 + .../tui-v2/protocol/request.initialize.json | 17 + .../protocol/request.listClusterMetrics.json | 8 + .../tui-v2/protocol/request.listClusters.json | 5 + .../protocol/request.sendGuidanceToAgent.json | 11 + .../request.sendGuidanceToCluster.json | 10 + .../request.startClusterFromIssue.json | 9 + .../request.startClusterFromText.json | 10 + .../request.subscribeClusterLogs.json | 9 + .../request.subscribeClusterTimeline.json | 8 + .../tui-v2/protocol/request.unsubscribe.json | 8 + .../protocol/response.getClusterSummary.json | 15 + .../protocol/response.getClusterTopology.json | 33 + .../tui-v2/protocol/response.initialize.json | 28 + .../protocol/response.listClusterMetrics.json | 20 + .../protocol/response.listClusters.json | 26 + .../response.sendGuidanceToAgent.json | 12 + .../response.sendGuidanceToCluster.json | 28 + .../response.startClusterFromIssue.json | 7 + .../response.startClusterFromText.json | 7 + .../response.subscribeClusterLogs.json | 7 + .../response.subscribeClusterTimeline.json | 7 + .../tui-v2/protocol/response.unsubscribe.json | 7 + tests/integration/detached-stop.test.js | 135 + .../guidance-queue-context.test.js | 83 + tests/integration/orchestrator-flow.test.js | 11 +- .../integration/orchestrator-worktree.test.js | 28 +- tests/integration/trigger-evaluation.test.js | 121 + tests/integration/worktree-isolation.test.js | 4 +- tests/max-model.test.js | 4 +- tests/message-buffering-while-busy.test.js | 153 + tests/model-selection.test.js | 1 + tests/openai-output-parser.test.js | 5 + .../orchestrator-completion-detector.test.js | 87 + tests/orchestrator.test.js | 54 + tests/output-extraction.test.js | 174 + tests/prompt-selection.test.js | 10 +- tests/settings-providers.test.js | 13 + tests/structuredOutput-mapping.test.js | 108 + tests/transform-sandbox-ledger.test.js | 296 ++ tests/tui-layout.test.js | 319 -- tests/two-stage-validation.test.js | 161 + tests/unit/.test-storage/clusters.json | 147 + tests/unit/.test-storage/emerald-prism-4.db | Bin 0 -> 4096 bytes .../unit/.test-storage/emerald-prism-4.db-shm | Bin 0 -> 32768 bytes .../unit/.test-storage/emerald-prism-4.db-wal | Bin 0 -> 412032 bytes tests/unit/attach-stdin.test.js | 84 + tests/unit/cli-default-entry.test.js | 46 + tests/unit/cli-invalid-command.test.js | 21 + tests/unit/cli-pr-base-env.test.js | 137 + tests/unit/cli-tui-binary-resolution.test.js | 103 + tests/unit/cli-tui-entrypoints.test.js | 23 + tests/unit/cli-tui-launcher.test.js | 39 + tests/unit/cli-tui-provider-override.test.js | 31 + tests/unit/cli-tui-ui-variant.test.js | 25 + .../github-provider-parse-identifier.test.js | 67 + tests/unit/guidance-delivery.test.js | 276 ++ tests/unit/guidance-mailbox.test.js | 132 + tests/unit/guidance-queue.test.js | 193 ++ .../orchestrator-agent-error-stop.test.js | 85 + ...orchestrator-issue-duplicate-check.test.js | 38 + .../unit/orchestrator-provider-reload.test.js | 100 + tests/unit/provider-retryable-errors.test.js | 35 + tests/unit/rate-limit-backoff.test.js | 103 + tests/unit/stream-json-parser-codex.test.js | 51 + tests/unit/template-simulation.test.js | 84 + tests/unit/template-validation-deep.test.js | 14 + .../unit/tui-backend-cluster-launcher.test.js | 91 + .../unit/tui-backend-cluster-registry.test.js | 113 + tests/unit/tui-backend-smoke.test.js | 49 + tests/unit/tui-backend-stdio.test.js | 707 ++++ tests/unit/tui-backend-subscriptions.test.js | 64 + .../tui-backend/protocol-validation.test.js | 83 + tests/unit/tui-binary.test.js | 82 + tests/unit/tui-start-cluster.test.js | 82 + tests/verify-github-pr-hook.test.js | 327 ++ tests/worktree-compose-cleanup.test.js | 266 ++ tests/worktree-cwd-injection.test.js | 6 +- tsconfig.json | 8 +- tsconfig.tui-backend.json | 18 + tui-rs/Cargo.lock | 651 ++++ tui-rs/Cargo.toml | 3 + tui-rs/crates/zeroshot-tui/Cargo.toml | 10 + .../zeroshot-tui/src/app/agent_microscope.rs | 48 + .../crates/zeroshot-tui/src/app/animation.rs | 102 + tui-rs/crates/zeroshot-tui/src/app/mod.rs | 2889 +++++++++++++++++ .../zeroshot-tui/src/app/spine_completion.rs | 179 + .../crates/zeroshot-tui/src/app/spine_hint.rs | 346 ++ .../zeroshot-tui/src/backend/framing.rs | 130 + tui-rs/crates/zeroshot-tui/src/backend/mod.rs | 126 + .../crates/zeroshot-tui/src/backend/stdio.rs | 638 ++++ .../zeroshot-tui/src/commands/dispatcher.rs | 272 ++ .../crates/zeroshot-tui/src/commands/mod.rs | 20 + .../zeroshot-tui/src/commands/parser.rs | 66 + .../crates/zeroshot-tui/src/commands/types.rs | 41 + tui-rs/crates/zeroshot-tui/src/input.rs | 489 +++ tui-rs/crates/zeroshot-tui/src/lib.rs | 15 + tui-rs/crates/zeroshot-tui/src/main.rs | 557 ++++ .../crates/zeroshot-tui/src/protocol/mod.rs | 393 +++ .../crates/zeroshot-tui/src/screens/agent.rs | 304 ++ .../src/screens/agent_microscope.rs | 511 +++ .../zeroshot-tui/src/screens/cluster.rs | 414 +++ .../src/screens/cluster_canvas.rs | 1688 ++++++++++ .../zeroshot-tui/src/screens/launcher.rs | 97 + .../zeroshot-tui/src/screens/metrics.rs | 90 + tui-rs/crates/zeroshot-tui/src/screens/mod.rs | 8 + .../zeroshot-tui/src/screens/monitor.rs | 231 ++ .../crates/zeroshot-tui/src/screens/radar.rs | 673 ++++ tui-rs/crates/zeroshot-tui/src/terminal.rs | 70 + tui-rs/crates/zeroshot-tui/src/ui/launcher.rs | 156 + tui-rs/crates/zeroshot-tui/src/ui/mod.rs | 443 +++ tui-rs/crates/zeroshot-tui/src/ui/scene.rs | 82 + tui-rs/crates/zeroshot-tui/src/ui/shared.rs | 434 +++ tui-rs/crates/zeroshot-tui/src/ui/theme.rs | 190 ++ .../src/ui/widgets/command_bar.rs | 67 + .../crates/zeroshot-tui/src/ui/widgets/mod.rs | 9 + .../zeroshot-tui/src/ui/widgets/scrub_bar.rs | 244 ++ .../zeroshot-tui/src/ui/widgets/spine.rs | 290 ++ .../zeroshot-tui/src/ui/widgets/stream.rs | 479 +++ .../zeroshot-tui/src/ui/widgets/test_utils.rs | 10 + .../zeroshot-tui/src/ui/widgets/toast.rs | 32 + .../zeroshot-tui/src/ui/widgets/topology.rs | 161 + .../zeroshot-tui/tests/animation_smoothing.rs | 40 + .../zeroshot-tui/tests/app_navigation.rs | 143 + .../zeroshot-tui/tests/backend_framing.rs | 57 + .../zeroshot-tui/tests/backend_integration.rs | 203 ++ .../tests/cluster_canvas_snapshots.rs | 115 + .../zeroshot-tui/tests/cluster_reducer.rs | 88 + .../zeroshot-tui/tests/disruptive_render.rs | 80 + .../zeroshot-tui/tests/input_routing.rs | 312 ++ .../zeroshot-tui/tests/launcher_reducer.rs | 61 + .../tests/microscope_snapshots.rs | 114 + .../zeroshot-tui/tests/monitor_reducer.rs | 107 + .../zeroshot-tui/tests/protocol_fixtures.rs | 85 + .../crates/zeroshot-tui/tests/radar_layout.rs | 39 + .../zeroshot-tui/tests/radar_snapshots.rs | 54 + .../zeroshot-tui/tests/scrub_bar_snapshots.rs | 74 + .../zeroshot-tui/tests/spine_snapshots.rs | 74 + .../crates/zeroshot-tui/tests/spine_submit.rs | 87 + .../zeroshot-tui/tests/startup_options.rs | 20 + .../zeroshot-tui/tests/topology_widget.rs | 82 + .../zeroshot-tui/tests/ui_snapshot_helpers.rs | 39 + .../zeroshot-tui/tests/ui_variant_parsing.rs | 25 + 261 files changed, 30316 insertions(+), 3490 deletions(-) create mode 100644 .mocharc.cjs delete mode 100644 .mocharc.json create mode 100644 .zeroshot/settings.json delete mode 100644 docs/LIVE_MONITORING_PLAN.md create mode 100644 docs/ZEROSHOT-DISRUPTIVE-TUI-DECISIONS.md create mode 100644 docs/postmortems/2026-01-31-pr-base-detached.md create mode 100644 docs/tui-v2/protocol.md create mode 100644 lib/repo-settings.js create mode 100644 lib/start-cluster.js create mode 100644 lib/tui-binary.js create mode 100644 lib/tui-launcher.js create mode 100755 libexec/zeroshot-tui create mode 100644 scripts/install-tui-binary.js create mode 100644 src/agent/agent-input-injector.js create mode 100644 src/agent/guidance-queue.js create mode 100644 src/agent/rate-limit-backoff.js create mode 100644 src/attach/send-input.js create mode 100644 src/guidance-topics.js create mode 100644 src/message-buffer.js create mode 100644 src/template-validation/index.js create mode 100644 src/template-validation/simulate-consensus-gates.js create mode 100644 src/template-validation/simulate-two-stage-validation.js create mode 100644 src/tui-backend/index.ts create mode 100644 src/tui-backend/protocol/constants.ts create mode 100644 src/tui-backend/protocol/dispatcher.ts create mode 100644 src/tui-backend/protocol/index.ts create mode 100644 src/tui-backend/protocol/schemas.ts create mode 100644 src/tui-backend/protocol/stdio-framing.ts create mode 100644 src/tui-backend/protocol/types.ts create mode 100644 src/tui-backend/protocol/validator.ts create mode 100644 src/tui-backend/server.ts create mode 100644 src/tui-backend/services/cluster-launcher.ts create mode 100644 src/tui-backend/services/cluster-logs.ts create mode 100644 src/tui-backend/services/cluster-registry.ts create mode 100644 src/tui-backend/services/cluster-timeline.ts create mode 100644 src/tui-backend/services/cluster-topology.ts create mode 100644 src/tui-backend/services/guidance-delivery.ts create mode 100644 src/tui-backend/services/index.ts create mode 100644 src/tui-backend/services/start-cluster.ts create mode 100644 src/tui-backend/subscriptions/index.ts delete mode 100644 src/tui/CHANGES.txt delete mode 100644 src/tui/LAYOUT.md delete mode 100644 src/tui/README.txt delete mode 100644 src/tui/TWO-LEVEL-NAVIGATION.md delete mode 100644 src/tui/data-poller.js delete mode 100644 src/tui/demo.js delete mode 100644 src/tui/formatters.js delete mode 100644 src/tui/index.js delete mode 100644 src/tui/keybindings.js delete mode 100644 src/tui/renderer.js create mode 100644 tests/add-agents-trigger-merge.test.js create mode 100644 tests/agent-task-not-found.test.js create mode 100644 tests/fixtures/detached-daemon.js create mode 100644 tests/fixtures/tui-v2/protocol/invalid.params.getClusterSummary.json create mode 100644 tests/fixtures/tui-v2/protocol/invalid.request.missing-method.json create mode 100644 tests/fixtures/tui-v2/protocol/notification.clusterLogLines.json create mode 100644 tests/fixtures/tui-v2/protocol/notification.clusterTimelineEvents.json create mode 100644 tests/fixtures/tui-v2/protocol/request.getClusterSummary.json create mode 100644 tests/fixtures/tui-v2/protocol/request.getClusterTopology.json create mode 100644 tests/fixtures/tui-v2/protocol/request.initialize.json create mode 100644 tests/fixtures/tui-v2/protocol/request.listClusterMetrics.json create mode 100644 tests/fixtures/tui-v2/protocol/request.listClusters.json create mode 100644 tests/fixtures/tui-v2/protocol/request.sendGuidanceToAgent.json create mode 100644 tests/fixtures/tui-v2/protocol/request.sendGuidanceToCluster.json create mode 100644 tests/fixtures/tui-v2/protocol/request.startClusterFromIssue.json create mode 100644 tests/fixtures/tui-v2/protocol/request.startClusterFromText.json create mode 100644 tests/fixtures/tui-v2/protocol/request.subscribeClusterLogs.json create mode 100644 tests/fixtures/tui-v2/protocol/request.subscribeClusterTimeline.json create mode 100644 tests/fixtures/tui-v2/protocol/request.unsubscribe.json create mode 100644 tests/fixtures/tui-v2/protocol/response.getClusterSummary.json create mode 100644 tests/fixtures/tui-v2/protocol/response.getClusterTopology.json create mode 100644 tests/fixtures/tui-v2/protocol/response.initialize.json create mode 100644 tests/fixtures/tui-v2/protocol/response.listClusterMetrics.json create mode 100644 tests/fixtures/tui-v2/protocol/response.listClusters.json create mode 100644 tests/fixtures/tui-v2/protocol/response.sendGuidanceToAgent.json create mode 100644 tests/fixtures/tui-v2/protocol/response.sendGuidanceToCluster.json create mode 100644 tests/fixtures/tui-v2/protocol/response.startClusterFromIssue.json create mode 100644 tests/fixtures/tui-v2/protocol/response.startClusterFromText.json create mode 100644 tests/fixtures/tui-v2/protocol/response.subscribeClusterLogs.json create mode 100644 tests/fixtures/tui-v2/protocol/response.subscribeClusterTimeline.json create mode 100644 tests/fixtures/tui-v2/protocol/response.unsubscribe.json create mode 100644 tests/integration/detached-stop.test.js create mode 100644 tests/integration/guidance-queue-context.test.js create mode 100644 tests/message-buffering-while-busy.test.js create mode 100644 tests/orchestrator-completion-detector.test.js create mode 100644 tests/structuredOutput-mapping.test.js create mode 100644 tests/transform-sandbox-ledger.test.js delete mode 100644 tests/tui-layout.test.js create mode 100644 tests/unit/.test-storage/clusters.json create mode 100644 tests/unit/.test-storage/emerald-prism-4.db create mode 100644 tests/unit/.test-storage/emerald-prism-4.db-shm create mode 100644 tests/unit/.test-storage/emerald-prism-4.db-wal create mode 100644 tests/unit/attach-stdin.test.js create mode 100644 tests/unit/cli-default-entry.test.js create mode 100644 tests/unit/cli-pr-base-env.test.js create mode 100644 tests/unit/cli-tui-binary-resolution.test.js create mode 100644 tests/unit/cli-tui-entrypoints.test.js create mode 100644 tests/unit/cli-tui-launcher.test.js create mode 100644 tests/unit/cli-tui-provider-override.test.js create mode 100644 tests/unit/cli-tui-ui-variant.test.js create mode 100644 tests/unit/github-provider-parse-identifier.test.js create mode 100644 tests/unit/guidance-delivery.test.js create mode 100644 tests/unit/guidance-mailbox.test.js create mode 100644 tests/unit/guidance-queue.test.js create mode 100644 tests/unit/orchestrator-agent-error-stop.test.js create mode 100644 tests/unit/orchestrator-issue-duplicate-check.test.js create mode 100644 tests/unit/orchestrator-provider-reload.test.js create mode 100644 tests/unit/provider-retryable-errors.test.js create mode 100644 tests/unit/rate-limit-backoff.test.js create mode 100644 tests/unit/stream-json-parser-codex.test.js create mode 100644 tests/unit/template-simulation.test.js create mode 100644 tests/unit/template-validation-deep.test.js create mode 100644 tests/unit/tui-backend-cluster-launcher.test.js create mode 100644 tests/unit/tui-backend-cluster-registry.test.js create mode 100644 tests/unit/tui-backend-smoke.test.js create mode 100644 tests/unit/tui-backend-stdio.test.js create mode 100644 tests/unit/tui-backend-subscriptions.test.js create mode 100644 tests/unit/tui-backend/protocol-validation.test.js create mode 100644 tests/unit/tui-binary.test.js create mode 100644 tests/unit/tui-start-cluster.test.js create mode 100644 tests/verify-github-pr-hook.test.js create mode 100644 tests/worktree-compose-cleanup.test.js create mode 100644 tsconfig.tui-backend.json create mode 100644 tui-rs/Cargo.lock create mode 100644 tui-rs/Cargo.toml create mode 100644 tui-rs/crates/zeroshot-tui/Cargo.toml create mode 100644 tui-rs/crates/zeroshot-tui/src/app/agent_microscope.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/app/animation.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/app/mod.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/app/spine_completion.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/app/spine_hint.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/backend/framing.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/backend/mod.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/backend/stdio.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/commands/dispatcher.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/commands/mod.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/commands/parser.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/commands/types.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/input.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/lib.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/main.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/protocol/mod.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/agent.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/agent_microscope.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/cluster.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/cluster_canvas.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/launcher.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/metrics.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/mod.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/monitor.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/screens/radar.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/terminal.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/launcher.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/mod.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/scene.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/shared.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/theme.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/widgets/command_bar.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/widgets/mod.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/widgets/scrub_bar.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/widgets/spine.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/widgets/stream.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/widgets/test_utils.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/widgets/toast.rs create mode 100644 tui-rs/crates/zeroshot-tui/src/ui/widgets/topology.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/animation_smoothing.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/app_navigation.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/backend_framing.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/backend_integration.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/cluster_canvas_snapshots.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/cluster_reducer.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/disruptive_render.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/input_routing.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/launcher_reducer.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/microscope_snapshots.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/monitor_reducer.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/protocol_fixtures.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/radar_layout.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/radar_snapshots.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/scrub_bar_snapshots.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/spine_snapshots.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/spine_submit.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/startup_options.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/topology_widget.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/ui_snapshot_helpers.rs create mode 100644 tui-rs/crates/zeroshot-tui/tests/ui_variant_parsing.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a32285d4..899ca270 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,39 +1,40 @@ version: 2 updates: # Enable version updates for npm - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' + target-branch: 'dev' schedule: - interval: "weekly" - day: "monday" - time: "09:00" - timezone: "Europe/Oslo" + interval: 'weekly' + day: 'monday' + time: '09:00' + timezone: 'Europe/Oslo' # Limit number of open PRs open-pull-requests-limit: 10 # Group minor and patch updates groups: development-dependencies: - dependency-type: "development" + dependency-type: 'development' update-types: - - "minor" - - "patch" + - 'minor' + - 'patch' production-dependencies: - dependency-type: "production" + dependency-type: 'production' update-types: - - "minor" - - "patch" + - 'minor' + - 'patch' # Auto-approve and merge patch updates for dev dependencies # (Requires GitHub Actions workflow with dependabot-auto-merge) labels: - - "dependencies" - - "automated" + - 'dependencies' + - 'automated' # Ignore major version bumps for these (manual review required) ignore: - - dependency-name: "*" - update-types: ["version-update:semver-major"] + - dependency-name: '*' + update-types: ['version-update:semver-major'] # Rebase strategy (cleaner history) - rebase-strategy: "auto" + rebase-strategy: 'auto' # Commit message prefix for semantic versioning commit-message: - prefix: "chore" - include: "scope" + prefix: 'chore' + include: 'scope' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac5fcbed..90d6ac26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,8 @@ jobs: cache: 'npm' - name: Install dependencies + env: + ZEROSHOT_TUI_BINARY_SKIP: '1' run: npm ci - name: Security audit @@ -75,6 +77,12 @@ jobs: - name: Type check run: npm run typecheck + - name: Type check (tui-backend) + run: npm run typecheck:tui-backend + + - name: Build tui-backend + run: npm run build:tui-backend + - name: Unit tests with coverage (fast) run: npm run test:coverage timeout-minutes: 3 @@ -83,6 +91,36 @@ jobs: run: npm run test:slow timeout-minutes: 15 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + tui-rs/target + key: rust-${{ runner.os }}-${{ hashFiles('tui-rs/Cargo.lock') }} + restore-keys: rust-${{ runner.os }}- + + - name: Rust fmt + run: cargo fmt --manifest-path tui-rs/Cargo.toml --all -- --check + + - name: Rust build + run: cargo build --release --manifest-path tui-rs/Cargo.toml + + - name: Rust test + run: cargo test --manifest-path tui-rs/Cargo.toml + + - name: Rust clippy + run: cargo clippy --manifest-path tui-rs/Cargo.toml -- -D warnings + + - name: Smoke test Rust TUI binary + run: ./tui-rs/target/release/zeroshot-tui --version + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: @@ -100,7 +138,7 @@ jobs: if: | github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') || - (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/main') + github.event_name == 'merge_group' strategy: fail-fast: false matrix: @@ -122,6 +160,8 @@ jobs: cache: 'npm' - name: Install dependencies + env: + ZEROSHOT_TUI_BINARY_SKIP: '1' run: npm ci - name: Link CLI diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d7e3a20a..92ca8290 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,10 +1,10 @@ -name: "CodeQL Security Analysis" +name: 'CodeQL Security Analysis' on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] schedule: # Run at 3 AM UTC every Monday - cron: '0 3 * * 1' @@ -56,4 +56,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: - category: "/language:javascript" + category: '/language:javascript' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54af8efa..34f14aa3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: # Auto-release after CI passes on main workflow_run: - workflows: ["CI"] + workflows: ['CI'] types: [completed] branches: [main] # Manual trigger for DRY-RUN ONLY (testing release process) @@ -50,6 +50,8 @@ jobs: cache: 'npm' - name: Install dependencies + env: + ZEROSHOT_TUI_BINARY_SKIP: '1' run: npm ci - name: Link CLI @@ -107,8 +109,72 @@ jobs: console.log('āœ“ StatusFooter test passed'); " + tui-binary-matrix: + if: | + (github.event_name == 'workflow_dispatch' && inputs.dry_run == true) || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux + arch: x64 + target: x86_64-unknown-linux-gnu + - os: ubuntu-24.04-arm64 + platform: linux + arch: arm64 + target: aarch64-unknown-linux-gnu + - os: macos-13 + platform: darwin + arch: x64 + target: x86_64-apple-darwin + - os: macos-14 + platform: darwin + arch: arm64 + target: aarch64-apple-darwin + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build zeroshot-tui + run: | + cargo build --release -p zeroshot-tui --target ${{ matrix.target }} --manifest-path tui-rs/Cargo.toml + + - name: Smoke test binary + run: | + BIN_PATH="tui-rs/target/${{ matrix.target }}/release/zeroshot-tui" + "$BIN_PATH" --version + "$BIN_PATH" --smoke-test + + - name: Package release asset + run: | + BIN_DIR="tui-rs/target/${{ matrix.target }}/release" + ASSET="zeroshot-tui-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz" + tar -czf "$ASSET" -C "$BIN_DIR" zeroshot-tui + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$ASSET" > "$ASSET.sha256" + else + shasum -a 256 "$ASSET" > "$ASSET.sha256" + fi + + - name: Upload TUI binary artifact + uses: actions/upload-artifact@v4 + with: + name: tui-binary-${{ matrix.platform }}-${{ matrix.arch }} + path: | + zeroshot-tui-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz + zeroshot-tui-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz.sha256 + release: - needs: [install-matrix] + needs: [install-matrix, tui-binary-matrix] # SAFETY: Only run if CI passed OR manual dry-run # Real releases MUST go through CI (workflow_run with success) # Manual trigger MUST be dry-run only (cannot publish untested code) @@ -137,8 +203,23 @@ jobs: cache: 'npm' - name: Install dependencies + env: + ZEROSHOT_TUI_BINARY_SKIP: '1' run: npm ci + - name: Download TUI binaries + uses: actions/download-artifact@v4 + with: + pattern: tui-binary-* + path: dist/tui + merge-multiple: true + + - name: Prepare TUI binary for install validation + run: | + ls -la dist/tui + tar -xzf dist/tui/zeroshot-tui-linux-x64.tar.gz -C dist/tui + chmod +x dist/tui/zeroshot-tui + - name: Verify package before release run: | # Create tarball (respects 'files' array in package.json) @@ -147,13 +228,16 @@ jobs: echo "Created: $TARBALL" # Install from tarball (simulates npm install -g @covibes/zeroshot) - npm install -g "./$TARBALL" + ZEROSHOT_TUI_BINARY_PATH="$PWD/dist/tui/zeroshot-tui" npm install -g "./$TARBALL" # Smoke tests - must all pass echo "=== Testing installed package ===" zeroshot --version zeroshot --help zeroshot list + INSTALLED_ROOT="$(npm root -g)/@covibes/zeroshot" + test -x "$INSTALLED_ROOT/libexec/zeroshot-tui" + "$INSTALLED_ROOT/libexec/zeroshot-tui" --version # Cleanup npm uninstall -g @covibes/zeroshot diff --git a/.gitignore b/.gitignore index 2a205d66..5d2f06d4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ dist/ build/ *.tsbuildinfo +# Rust build output +tui-rs/target/ + # Environment .env .env.local @@ -40,7 +43,8 @@ tmp/ *.tmp # Zeroshot runtime -.zeroshot/ +.zeroshot/* +!.zeroshot/settings.json .claude-zeroshots/ zeroshot-isolated/ zeroshot-cluster-configs/ @@ -56,3 +60,4 @@ report/ # Generated/temp files test-metadata-manual.sh test-isolated-fix.js +lib/tui-backend/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 9fe39a25..ada93a93 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -7,6 +7,15 @@ if [ -n "$BAD_FILES" ]; then exit 1 fi +# Enforce Rust formatting when Rust files are staged +if git diff --cached --name-only -- '*.rs' | grep -q .; then + if ! command -v rustfmt >/dev/null 2>&1; then + echo "ERROR: rustfmt is required to format Rust (install Rust toolchain)." + exit 1 + fi + git diff --cached --name-only -z -- '*.rs' | xargs -0 rustfmt --edition 2021 --check +fi + # Lint and format only staged files npx lint-staged diff --git a/.mocharc.cjs b/.mocharc.cjs new file mode 100644 index 00000000..a9b624d6 --- /dev/null +++ b/.mocharc.cjs @@ -0,0 +1,16 @@ +const hasCliTestFile = process.argv + .slice(2) + .some((arg) => typeof arg === 'string' && /\.test\.[jt]s$/.test(arg)); + +const config = { + parallel: true, + jobs: 4, + timeout: 10000, + slow: 1000, +}; + +if (!hasCliTestFile) { + config.spec = 'tests/**/*.test.js'; +} + +module.exports = config; diff --git a/.mocharc.json b/.mocharc.json deleted file mode 100644 index 651732a1..00000000 --- a/.mocharc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "parallel": true, - "jobs": 4, - "timeout": 10000, - "slow": 1000, - "spec": "tests/**/*.test.js" -} diff --git a/.releaserc.json b/.releaserc.json index 90723e6d..0b914af6 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -9,6 +9,11 @@ "npmPublish": true } ], - "@semantic-release/github" + [ + "@semantic-release/github", + { + "assets": ["dist/tui/zeroshot-tui-*.tar.gz", "dist/tui/zeroshot-tui-*.tar.gz.sha256"] + } + ] ] } diff --git a/.zeroshot/settings.json b/.zeroshot/settings.json new file mode 100644 index 00000000..838c097f --- /dev/null +++ b/.zeroshot/settings.json @@ -0,0 +1,8 @@ +{ + "github": { + "prBase": "dev" + }, + "worktree": { + "baseRef": "origin/dev" + } +} diff --git a/AGENTS.md b/AGENTS.md index 803bbfe1..8a522258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Operational rules and references for automated agents working on this repo. Inst - Never use git in validator prompts. Validate files directly. - Never ask questions. Agents run non-interactively; make autonomous decisions. - Never edit `CLAUDE.md` unless explicitly asked to update docs. +- Detached (`-d`) runs must forward all `zeroshot run` options via `ZEROSHOT_RUN_OPTIONS` (see `buildDaemonEnv` + `buildStartOptions`) so PR/worktree config cannot be dropped. Worker git operations are allowed only with isolation (`--worktree`, `--docker`, `--pr`, `--ship`). They are forbidden without isolation. @@ -20,22 +21,70 @@ Destructive commands (need permission): `zeroshot kill`, `zeroshot clear`, `zero ## Where to Look -| Concept | File | -| ------------------------ | ----------------------------------- | -| Conductor classification | `src/conductor-bootstrap.js` | -| Base templates | `cluster-templates/base-templates/` | -| Message bus | `src/message-bus.js` | -| Ledger (SQLite) | `src/ledger.js` | -| Trigger evaluation | `src/logic-engine.js` | -| Agent wrapper | `src/agent-wrapper.js` | -| Providers registry | `src/providers/index.js` | -| Provider implementations | `src/providers/` | -| Provider detection | `lib/provider-detection.js` | -| Provider capabilities | `src/providers/capabilities.js` | -| TUI dashboard | `src/tui/` | -| Docker mounts/env | `lib/docker-config.js` | -| Container lifecycle | `src/isolation-manager.js` | -| Settings | `lib/settings.js` | +| Concept | File | +| ------------------------------ | ---------------------------------------------------------- | +| Conductor classification | `src/conductor-bootstrap.js` | +| Base templates | `cluster-templates/base-templates/` | +| Message bus | `src/message-bus.js` | +| Ledger (SQLite) | `src/ledger.js` | +| Guidance topics | `src/guidance-topics.js` | +| Guidance mailbox helper | `src/ledger.js` | +| Guidance live injection | `src/orchestrator.js` | +| Trigger evaluation | `src/logic-engine.js` | +| Agent wrapper | `src/agent-wrapper.js` | +| Providers registry | `src/providers/index.js` | +| Provider implementations | `src/providers/` | +| Provider detection | `lib/provider-detection.js` | +| Provider capabilities | `src/providers/capabilities.js` | +| TUI backend entrypoint | `src/tui-backend/index.ts` | +| TUI backend server | `src/tui-backend/server.ts` | +| TUI backend services | `src/tui-backend/services/` | +| TUI backend subscriptions | `src/tui-backend/subscriptions/` | +| TUI backend build output | `lib/tui-backend/` | +| TUI launcher (Node) | `lib/tui-launcher.js` | +| TUI binary mapping | `lib/tui-binary.js` | +| TUI start-cluster helper | `lib/start-cluster.js` | +| TUI binary installer | `scripts/install-tui-binary.js` | +| TUI v2 protocol spec | `docs/tui-v2/protocol.md` | +| TUI v2 protocol types (TS) | `src/tui-backend/protocol/` | +| TUI v2 protocol types (Rust) | `tui-rs/crates/zeroshot-tui/src/protocol/` | +| Rust TUI backend client | `tui-rs/crates/zeroshot-tui/src/backend/` | +| Rust TUI entrypoint | `tui-rs/crates/zeroshot-tui/src/main.rs` | +| Rust TUI core loop (MVU) | `tui-rs/crates/zeroshot-tui/src/app/mod.rs` | +| Rust TUI spine completion | `tui-rs/crates/zeroshot-tui/src/app/spine_completion.rs` | +| Rust TUI input routing | `tui-rs/crates/zeroshot-tui/src/input.rs` | +| Rust TUI commands | `tui-rs/crates/zeroshot-tui/src/commands/` | +| Rust TUI command parser | `tui-rs/crates/zeroshot-tui/src/commands/parser.rs` | +| Rust TUI command dispatch | `tui-rs/crates/zeroshot-tui/src/commands/dispatcher.rs` | +| Rust TUI command types | `tui-rs/crates/zeroshot-tui/src/commands/types.rs` | +| Rust TUI screens | `tui-rs/crates/zeroshot-tui/src/screens/` | +| Rust TUI Fleet Radar screen | `tui-rs/crates/zeroshot-tui/src/screens/radar.rs` | +| Rust TUI Cluster Canvas screen | `tui-rs/crates/zeroshot-tui/src/screens/cluster_canvas.rs` | +| Rust TUI render entrypoint | `tui-rs/crates/zeroshot-tui/src/ui/mod.rs` | +| Rust TUI widgets | `tui-rs/crates/zeroshot-tui/src/ui/widgets/` | +| Rust TUI toast widget | `tui-rs/crates/zeroshot-tui/src/ui/widgets/toast.rs` | +| Rust TUI command bar widget | `tui-rs/crates/zeroshot-tui/src/ui/widgets/command_bar.rs` | +| Rust TUI terminal guard | `tui-rs/crates/zeroshot-tui/src/terminal.rs` | +| Docker mounts/env | `lib/docker-config.js` | +| Container lifecycle | `src/isolation-manager.js` | +| Settings | `lib/settings.js` | + +TUI v2 (Rust) convention: +Ratatui is the only supported TUI; legacy UI removed. centralized key routing in `src/input.rs`; `app::update()` is pure and returns effects; `ui::render()` is pure and performs no IO. Adding a screen requires a `ScreenId` variant plus a screen reducer and render entry. +TUI v2 (Rust) command flow: `Effect::Command(CommandRequest)` is emitted by `app::update()` and executed in `src/main.rs` via `commands::dispatch()`, with failures surfaced through `BackendAction::Error`. +TUI v2 (Rust) provider override lives in `AppState.provider_override` and is forwarded when launching clusters (e.g. `StartClusterFromText`). +TUI v2 (Rust) command bar: `AppState.command_bar` captures input; `/` opens it outside Launcher; Esc closes; Submit dispatches. Toast output lives in `AppState.toast` and renders via `ui/widgets/toast.rs`. +TUI v2 (Rust) Agent Microscope renders phase markers derived from cluster timeline events (deduped, capped) in a left margin when space allows. +TUI v2 (Rust) Disruptive zoom stack: `ScreenId::IntentConsole` (root), `FleetRadar`, `ClusterCanvas { id }`, `AgentMicroscope { cluster_id, agent_id }`; zoom stack context drives spine whisper targets. +TUI v2 (Rust) Cluster Canvas overlays: use `ui/widgets/stream.rs` StreamOverlay + placement helper in `screens/cluster_canvas.rs` to render bounded log/timeline slices near focus, clamped to canvas bounds and never intersecting the spine; render after the canvas draw. +TUI v2 (Rust) calm empty states: use `ui/shared.rs::calm_empty_state` for centered headline/detail/footer cards in Disruptive screens. +TUI v2 (Rust) Disruptive stream windowing: `TimeCursor` (mode, `t_ms`, `window_ms`) plus `TimeIndexedBuffer` in `ui/shared.rs` back logs/timeline window queries; cluster canvas overlay renders windowed slices from time-indexed buffers. +TUI v2 (Rust) motion/smoothing: `app/animation.rs` defines `AnimClock` + smoothing helpers; `AppState.anim_clock` + `last_tick_ms` advance on `Tick`; Fleet Radar smooths orb radius/intensity in `FleetRadarState.orb_states` and uses `pulse_factor` for error pulses; Cluster Canvas uses camera target/velocity smoothing via `State.tick_camera()` (render consumes smoothed camera). +TUI v2 (Rust) spine intent submit detects issue refs (`123`, `owner/repo#123`, GitHub issue URL) → `StartClusterFromIssue`; otherwise `StartClusterFromText`. +TUI v2 (Rust) Disruptive pre-M3 decisions live in `docs/ZEROSHOT-DISRUPTIVE-TUI-DECISIONS.md` (focus, labels, topology, scrub, spine height). +TUI backend test envs: `ZEROSHOT_TUI_BACKEND_MOCK_LAUNCH`, `ZEROSHOT_TUI_BACKEND_MOCK_GUIDANCE`, `ZEROSHOT_TUI_BACKEND_METRICS_PLATFORM` (override platform for metrics; unsupported values force `supported=false`). +TUI backend path override: `ZEROSHOT_TUI_BACKEND_PATH`. +TUI launcher env: `ZEROSHOT_TUI_BINARY_PATH` overrides the installed Rust binary, `ZEROSHOT_TUI_PATH`/`ZEROSHOT_TUI_BIN` override Rust binary path, `ZEROSHOT_TUI_BINARY_URL` overrides release asset URL, `ZEROSHOT_TUI_BINARY_SKIP` skips download, `ZEROSHOT_TUI_INITIAL_SCREEN` + `ZEROSHOT_TUI_PROVIDER_OVERRIDE` + `ZEROSHOT_TUI_UI` feed Rust startup defaults (UI variants: classic, disruptive; CLI: `zeroshot tui --ui `). ## CLI Quick Reference @@ -44,6 +93,7 @@ Destructive commands (need permission): `zeroshot kill`, `zeroshot clear`, `zero zeroshot run 123 # Local, no isolation zeroshot run 123 --worktree # Git worktree isolation zeroshot run 123 --pr # Worktree + create PR +zeroshot run 123 --pr --pr-base dev # PR base: dev, worktree base: origin/dev (incl. -d) zeroshot run 123 --ship # Worktree + PR + auto-merge zeroshot run 123 --docker # Docker container isolation zeroshot run 123 -d # Background (daemon) mode @@ -57,7 +107,9 @@ zeroshot stop # Graceful stop zeroshot kill # Force kill # Utilities -zeroshot watch # TUI dashboard +zeroshot # TUI (TTY only; Rust default) +zeroshot tui # TUI explicit entry +zeroshot watch # TUI Monitor view zeroshot export # Export conversation zeroshot agents list # Available agents zeroshot settings # View/modify settings @@ -70,7 +122,7 @@ UX modes: - Daemon (`-d`): background, Ctrl+C detaches. - Attach (`zeroshot attach`): connect to daemon, Ctrl+C detaches only. -Settings: `defaultProvider`, `providerSettings` (claude/codex/gemini), legacy `maxModel`, `defaultConfig`, `logLevel`. +Settings: `defaultProvider`, `providerSettings` (claude/codex/gemini), legacy `maxModel`, `defaultConfig`, `logLevel`, robustness (`maxRetries`, `backoffBaseMs`, `backoffMaxMs`, `jitterFactor`, `maxRestartAttempts`, `maxTotalRestarts`, `staleWarningsBeforeKill`). ## Architecture @@ -89,6 +141,15 @@ Agent A -> publish() -> SQLite Ledger -> LogicEngine -> trigger match -> Agent B | Logic Script | JS predicate for complex conditions | | Hook | Post-task action (publish message, execute command) | +Restart persistence: orchestrator publishes `AGENT_RESTART_ATTEMPT` to the ledger so restart limits survive orchestrator restarts. + +### Guidance Messaging + +- Topics: `USER_GUIDANCE_CLUSTER`, `USER_GUIDANCE_AGENT` (see `src/guidance-topics.js`). +- Mailbox helper: `ledger.queryGuidanceMailbox()` with `messageBus.queryGuidanceMailbox()` passthrough. +- Live injection: `Orchestrator.sendGuidanceToAgent()` uses `agent.injectInput()` to attempt PTY stdin; always persists `USER_GUIDANCE_AGENT` with `metadata.delivery` (`status: injected|unsupported`, `method: pty`, `taskId`, `reason`). +- Safe-point queue fallback: `AgentWrapper._buildContext()` pulls queued guidance via `collectQueuedGuidance()` and injects a delimited block in `agent-context-builder` between Instructions and Output Schema. Cursor: `agent.lastGuidanceAppliedAt`. + ### Agent Configuration (Minimal) ```json @@ -375,6 +436,8 @@ npm run lint npm run test ``` +Mocha config: `.mocharc.cjs` applies defaults; passing explicit `*.test.js` files on the CLI skips the default `tests/**/*.test.js` spec. + Workers are now explicitly ordered to treat every `VALIDATION_RESULT` line as non-negotiable law before typing again. Failing to read and address each validator complaint before claiming completion will be rejected automatically. ## CI Failure Diagnosis @@ -418,3 +481,4 @@ Do NOT assume single root cause. | Multiple impl files (-v2) | Pre-commit hook | | Spawn without permission | Runtime check | | Git stash usage | Pre-commit hook | +| Rust formatting drift | Pre-commit hook | diff --git a/CHANGELOG.md b/CHANGELOG.md index 394c5c34..3ac46ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -461,7 +461,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `zeroshot kill` - Force stop running cluster - `zeroshot clear` - Remove all stopped clusters - `zeroshot export` - Export conversation as JSON or Markdown -- `zeroshot watch` - Interactive TUI dashboard (htop-style) +- `zeroshot` - Interactive Ink TUI (TTY only) +- `zeroshot tui` - Open Ink TUI explicitly +- `zeroshot watch` - Open Ink TUI Monitor view (htop-style) - `zeroshot agents` - View available agent definitions - `zeroshot settings` - Manage global settings - Shell completion support via omelette @@ -493,9 +495,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Auto-merge support via git-pusher agent - Token authentication with hosts.yml fallback -#### TUI Dashboard +#### Ink TUI -- Real-time cluster monitoring with blessed/blessed-contrib +- Real-time cluster monitoring in Ink TUI - Cluster list with state, agent count, and message count - Message viewer with topic filtering - Agent status display with iteration tracking diff --git a/CLAUDE.md b/CLAUDE.md index 97be5516..bbfbff7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,12 +6,37 @@ Message-passing primitives for multi-agent workflows. **Install:** `npm i -g @co ## šŸ”“ CRITICAL RULES -| Rule | Why | Forbidden | Required | -| ---------------------------------- | ---------------------------- | ------------------------------------------- | ------------------------------------------------ | -| **Never spawn without permission** | Consumes API credits | "I'll run zeroshot on 123" | User says "run zeroshot" | -| **Never use git in validators** | Git state unreliable | `git diff`, `git status` in prompts | Validate files directly | -| **Never ask questions** | Agents run non-interactively | `AskUserQuestion`, waiting for confirmation | Make autonomous decisions | -| **Never edit CLAUDE.md** | Context file for Claude Code | Editing this file | Read-only unless explicitly asked to update docs | +| Rule | Why | Forbidden | Required | +| ---------------------------------- | ---------------------------- | -------------------------------------------- | ------------------------------------------------ | +| **GENERAL PURPOSE ONLY** | Zeroshot runs on ANY repo | Hardcoded paths, scripts, languages, domains | Discover from target repo's CLAUDE.md/README | +| **Never spawn without permission** | Consumes API credits | "I'll run zeroshot on 123" | User says "run zeroshot" | +| **Never use git in validators** | Git state unreliable | `git diff`, `git status` in prompts | Validate files directly | +| **Never ask questions** | Agents run non-interactively | `AskUserQuestion`, waiting for confirmation | Make autonomous decisions | +| **Never edit CLAUDE.md** | Context file for Claude Code | Editing this file | Read-only unless explicitly asked to update docs | + +### šŸ”“ GENERAL PURPOSE REQUIREMENT (CRITICAL) + +**Zeroshot is a GENERAL-PURPOSE multi-agent orchestrator. It MUST work on ANY repository, ANY programming language, ANY domain.** + +**FORBIDDEN in templates/prompts:** + +- Hardcoded script names (`check-all.sh`, `validate.sh`) +- Hardcoded test commands (`npm test`, `pytest`, `cargo test`) +- Hardcoded file paths (`server/`, `src/`, `tests/`) +- Hardcoded context file names (`CLAUDE.md` - other providers use different files) +- Language-specific assumptions (TypeScript, Python, Rust) +- Domain-specific assumptions (web, CLI, mobile) +- Provider-specific assumptions (Claude, Codex, Gemini) +- Covibes-specific patterns + +**REQUIRED:** + +- Discover validation commands from target repo's context files (README, Makefile, package.json, pyproject.toml, Cargo.toml, etc.) +- Discover test runners from target repo's build system +- Use generic examples in prompts (e.g., "the repo's validation script" NOT "./scripts/check-all.sh") +- Use generic terms for context files ("repo context files" NOT "CLAUDE.md") +- Work correctly on: Python projects, Rust crates, Go modules, Ruby gems, Java/Kotlin, C/C++, etc. +- Work correctly with: Claude, Codex, Gemini, OpenAI, and any future providers **Worker git operations:** Allowed with isolation (`--worktree`, `--docker`, `--pr`, `--ship`). Forbidden without isolation (auto-injected restriction). @@ -19,6 +44,8 @@ Message-passing primitives for multi-agent workflows. **Install:** `npm i -g @co **Destructive (needs permission):** `zeroshot kill`, `zeroshot clear`, `zeroshot purge` +**Detached runs:** Always forward `zeroshot run` options via `ZEROSHOT_RUN_OPTIONS` (see `buildDaemonEnv` + `buildStartOptions`) so PR/worktree config survives daemon mode. + ## šŸ”“ BEHAVIORAL STANDARDS ``` @@ -45,7 +72,7 @@ IS THIS HOW A SENIOR STAFF ARCHITECT WOULD DO IT? ACT LIKE ONE. | Ledger (SQLite) | `src/ledger.js` | | Trigger evaluation | `src/logic-engine.js` | | Agent wrapper | `src/agent-wrapper.js` | -| TUI dashboard | `src/tui/` | +| Rust TUI (Ratatui) | `tui-rs/crates/zeroshot-tui/` | | Docker mounts/env | `lib/docker-config.js` | | Container lifecycle | `src/isolation-manager.js` | | Issue providers | `src/issue-providers/` | @@ -60,6 +87,7 @@ IS THIS HOW A SENIOR STAFF ARCHITECT WOULD DO IT? ACT LIKE ONE. zeroshot run 123 # Local, no isolation zeroshot run 123 --worktree # Git worktree isolation zeroshot run 123 --pr # Worktree + create PR +zeroshot run 123 --pr --pr-base dev # PR base: dev, worktree base: origin/dev (incl. -d) zeroshot run 123 --ship # Worktree + PR + auto-merge zeroshot run 123 --docker # Docker container isolation zeroshot run 123 -d # Background (daemon) mode @@ -73,7 +101,9 @@ zeroshot stop # Graceful stop zeroshot kill # Force kill # Utilities -zeroshot watch # TUI dashboard +zeroshot # Rust TUI (TTY only) +zeroshot tui # Rust TUI explicit entry +zeroshot watch # Rust TUI Monitor view zeroshot export # Export conversation zeroshot agents list # Available agents zeroshot settings # View/modify settings diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe29475e..964e5a3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -351,29 +351,41 @@ evaluate(script, agent, message) { ## Debugging -### Debug the TUI (zeroshot watch) +### Debug the Rust TUI (zeroshot, zeroshot tui, zeroshot watch) + +Use `zeroshot` (TTY only) or `zeroshot tui` for a normal session. Use `zeroshot watch` to open Monitor view directly. 1. **Run in development mode** ```bash - zeroshot watch + zeroshot tui ``` -2. **Common TUI issues** +2. **CI note** + + The Rust TUI backend integration tests require the Node TUI backend to be built. + CI enforces this (fails if missing). Locally, run: + + ```bash + npm ci + npm run build:tui-backend + ``` + +3. **Common TUI issues** | Issue | Fix | | ------------------ | ----------------------------------------------------- | | Garbled output | Terminal too small - resize to 80x24+ | | Missing agents | Cluster not running - start with `zeroshot run` first | | Stats not updating | File polling delay - wait 2-5 seconds | - | Crash on resize | Known blessed bug - restart TUI | + | Resize glitches | Restart TUI | -3. **Debug TUI rendering** +4. **Debug TUI rendering** - Edit `src/tui/index.js` and add: + Edit `tui-rs/crates/zeroshot-tui/src/` (screen or widget) and add: - ```javascript - screen.log(`Debug: ${JSON.stringify(data)}`); + ```rust + eprintln!("Debug: {data:?}"); ``` ### Debug Agent Execution diff --git a/PUBLISHING.md b/PUBLISHING.md index 64d73c18..3c987bf2 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -25,6 +25,7 @@ If you get an error or "organization not found", you need to create it: ### Create the organization: 1. Log in to npm: + ```bash npm login ``` @@ -65,6 +66,7 @@ For CI/CD publishing, you need an **automation token** (not a classic token). ### Token Permissions: Automation tokens have these capabilities: + - āœ… Publish packages - āœ… Update package metadata - āœ… Read private packages (if your org has any) @@ -144,6 +146,7 @@ Once the `NPM_TOKEN` secret is configured, publishing happens automatically: 1. **Make changes** to the codebase 2. **Commit with conventional commit messages**: + ```bash git commit -m "feat: add new feature" # Minor version bump (0.1.0 → 0.2.0) git commit -m "fix: fix bug" # Patch version bump (0.1.0 → 0.1.1) @@ -151,6 +154,7 @@ Once the `NPM_TOKEN` secret is configured, publishing happens automatically: ``` 3. **Push to main branch**: + ```bash git push origin main ``` @@ -171,12 +175,12 @@ Once the `NPM_TOKEN` secret is configured, publishing happens automatically: semantic-release uses conventional commits to determine version bumps: -| Commit Type | Version Bump | Example | -|-------------|-------------|---------| -| `fix:` | Patch (0.1.0 → 0.1.1) | `fix: resolve memory leak` | -| `feat:` | Minor (0.1.0 → 0.2.0) | `feat: add cluster resume` | -| `feat!:` or `BREAKING CHANGE:` | Major (0.1.0 → 1.0.0) | `feat!: change API signature` | -| `docs:`, `chore:`, `style:`, etc. | No release | `docs: update README` | +| Commit Type | Version Bump | Example | +| --------------------------------- | --------------------- | ----------------------------- | +| `fix:` | Patch (0.1.0 → 0.1.1) | `fix: resolve memory leak` | +| `feat:` | Minor (0.1.0 → 0.2.0) | `feat: add cluster resume` | +| `feat!:` or `BREAKING CHANGE:` | Major (0.1.0 → 1.0.0) | `feat!: change API signature` | +| `docs:`, `chore:`, `style:`, etc. | No release | `docs: update README` | ### Breaking changes: @@ -203,6 +207,7 @@ git commit -m "feat: new API" -m "BREAKING CHANGE: removes old API" **Cause:** The NPM_TOKEN doesn't have permission to publish to @covibes. **Fix:** + 1. Verify you're a member of the @covibes npm organization 2. Regenerate the automation token 3. Update the GitHub secret diff --git a/README.md b/README.md index f6f29b8b..5b036bab 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,77 @@ Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before participating. For security issues, see [SECURITY.md](SECURITY.md). +## TUI + +Ratatui (Rust) is the only supported TUI. Entry points: + +- `zeroshot` (TTY + no args) +- `zeroshot tui` +- `zeroshot watch` + +### TUI Development + +The Rust TUI spawns a Node backend over stdio. Run both while iterating. + +Single command dev loop (auto-rebuild + restart): + +```bash +cargo install cargo-watch +npm run dev:tui +``` + +1. Install deps + + ```bash + npm ci + ``` + +2. Build the TUI backend in watch mode + + ```bash + npm run build:tui-backend -- --watch + # or + npx tsc -p tsconfig.tui-backend.json -w + ``` + +3. Run the Rust TUI (second terminal) + + ```bash + cd tui-rs + cargo run -p zeroshot-tui -- --ui disruptive + ``` + +### Local CLI From This Repo + +If you want `zeroshot` to run from the dev branch globally: + +```bash +npm run dev:link +``` + +One command to link + run the TUI dev loop: + +```bash +npm run dev:bootstrap +``` + +### CLI Integration Loop + +Use the Node CLI to launch your local Rust binary: + +```bash +cd tui-rs +cargo build -p zeroshot-tui +cd .. +ZEROSHOT_TUI_BINARY_PATH="$PWD/tui-rs/target/debug/zeroshot-tui" node cli/index.js tui +``` + +### TUI Overrides + +- `ZEROSHOT_TUI_BACKEND_PATH` points to a specific `lib/tui-backend/server.js` +- `ZEROSHOT_TUI_BINARY_PATH` points to a local Rust binary +- `ZEROSHOT_TUI_UI=classic|disruptive` forces UI variant + --- MIT - [Covibes](https://github.com/covibes) diff --git a/cli/index.js b/cli/index.js index 46dee9ea..bb02265f 100755 --- a/cli/index.js +++ b/cli/index.js @@ -49,6 +49,17 @@ const { const { normalizeProviderName } = require('../lib/provider-names'); const { getProvider, parseProviderChunk } = require('../src/providers'); const { MOUNT_PRESETS, resolveEnvs } = require('../lib/docker-config'); +const { launchTuiSession } = require('../lib/tui-launcher'); +const { + detectGitRepoRoot, + detectRunInput, + loadClusterConfig, + resolveConfigPath, + resolveProviderOverride, + startClusterFromFile, + startClusterFromIssue, + startClusterFromText, +} = require('../lib/start-cluster'); const { requirePreflight } = require('../src/preflight'); const { providersCommand, setDefaultCommand, setupCommand } = require('./commands/providers'); // Setup wizard removed - use: zeroshot settings set @@ -152,43 +163,6 @@ process.on('unhandledRejection', (reason) => { // Package root directory (for resolving default config paths) const PACKAGE_ROOT = path.resolve(__dirname, '..'); -/** - * Detect git repository root from current directory - * Critical for CWD propagation - agents must work in the target repo, not where CLI was invoked - * @returns {string} Git repo root, or process.cwd() if not in a git repo - */ -function detectGitRepoRoot() { - const { execSync } = require('child_process'); - try { - const root = execSync('git rev-parse --show-toplevel', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - return root; - } catch { - // Not in a git repo - use current directory - return process.cwd(); - } -} - -/** - * Parse CLI mount specs (host:container[:ro]) into mount config objects - * @param {string[]} specs - Array of mount specs from CLI - * @returns {Array<{host: string, container: string, readonly: boolean}>} - */ -function parseMountSpecs(specs) { - return specs.map((spec) => { - const parts = spec.split(':'); - if (parts.length < 2) { - throw new Error(`Invalid mount spec: "${spec}". Format: host:container[:ro]`); - } - const host = parts[0]; - const container = parts[1]; - const readonly = parts[2] === 'ro'; - return { host, container, readonly }; - }); -} - function normalizeRunOptions(options) { if (options.ship) { options.pr = true; @@ -204,47 +178,6 @@ function normalizeRunOptions(options) { } } -function detectRunInput(inputArg) { - const input = {}; - - // Check if it looks like an issue URL or key - const isGitHubUrl = /^https?:\/\/github\.com\/[\w-]+\/[\w-]+\/issues\/\d+/.test(inputArg); - const isGitLabUrl = /gitlab\.(com|[\w.-]+)\/[\w-]+\/[\w-]+\/-\/issues\/\d+/.test(inputArg); - const isJiraUrl = /(atlassian\.net|jira\.[\w.-]+)\/browse\/[A-Z][A-Z0-9]+-\d+/.test(inputArg); - const isAzureUrl = - /dev\.azure\.com\/.*\/_workitems\/edit\/\d+/.test(inputArg) || - /visualstudio\.com\/.*\/_workitems\/edit\/\d+/.test(inputArg); - const isJiraKey = /^[A-Z][A-Z0-9]+-\d+$/.test(inputArg); - const isIssueNumber = /^\d+$/.test(inputArg); - const isRepoIssue = /^[\w-]+\/[\w-]+#\d+$/.test(inputArg); - const isMarkdownFile = /\.(md|markdown)$/i.test(inputArg); - - if ( - isGitHubUrl || - isGitLabUrl || - isJiraUrl || - isAzureUrl || - isJiraKey || - isIssueNumber || - isRepoIssue - ) { - input.issue = inputArg; - } else if (isMarkdownFile) { - input.file = inputArg; - } else { - input.text = inputArg; - } - return input; -} - -function resolveProviderOverride(options) { - const override = options.provider || process.env.ZEROSHOT_PROVIDER; - if (!override || (typeof override === 'string' && !override.trim())) { - return null; - } - return normalizeProviderName(override); -} - function runClusterPreflight({ input, options, providerOverride, settings, forceProvider }) { // Detect which issue provider tool is needed let issueProvider = null; @@ -303,7 +236,23 @@ function createDaemonLogFile(clusterId) { return fs.openSync(logPath, 'w'); } +function serializeRunOptions(options) { + try { + return JSON.stringify(options); + } catch { + return ''; + } +} + +function resolveMergeQueueEnv(value) { + if (value === true) return '1'; + if (value === false) return '0'; + return ''; +} + function buildDaemonEnv(options, clusterId, targetCwd) { + const mergeQueueEnv = resolveMergeQueueEnv(options.mergeQueue); + const runOptionsEnv = serializeRunOptions(options); return { ...process.env, ZEROSHOT_DAEMON: '1', @@ -315,6 +264,10 @@ function buildDaemonEnv(options, clusterId, targetCwd) { ZEROSHOT_WORKERS: options.workers?.toString() || '', ZEROSHOT_MODEL: options.model || '', ZEROSHOT_PROVIDER: options.provider || '', + ZEROSHOT_RUN_OPTIONS: runOptionsEnv, + ZEROSHOT_PR_BASE: options.prBase || '', + ZEROSHOT_MERGE_QUEUE: mergeQueueEnv, + ZEROSHOT_CLOSE_ISSUE: options.closeIssue || '', ZEROSHOT_CWD: targetCwd, }; } @@ -344,42 +297,6 @@ function resolveConfigName(options, settings) { return options.config || settings.defaultConfig; } -function resolveConfigPath(configName) { - if (path.isAbsolute(configName) || configName.startsWith('./') || configName.startsWith('../')) { - return path.resolve(process.cwd(), configName); - } - if (configName.endsWith('.json')) { - return path.join(PACKAGE_ROOT, 'cluster-templates', configName); - } - return path.join(PACKAGE_ROOT, 'cluster-templates', `${configName}.json`); -} - -function ensureConfigProviderDefaults(config, settings) { - if (!config.defaultProvider) { - config.defaultProvider = settings.defaultProvider || 'claude'; - } - config.defaultProvider = normalizeProviderName(config.defaultProvider) || 'claude'; -} - -function applyProviderOverrideToConfig(config, providerOverride, settings) { - const provider = getProvider(providerOverride); - const providerSettings = settings.providerSettings?.[providerOverride] || {}; - config.forceProvider = providerOverride; - config.defaultProvider = providerOverride; - config.forceLevel = providerSettings.defaultLevel || provider.getDefaultLevel(); - config.defaultLevel = config.forceLevel; - console.log(chalk.dim(`Provider override: ${providerOverride} (all agents)`)); -} - -function loadClusterConfig(orchestrator, configPath, settings, providerOverride) { - const config = orchestrator.loadConfig(configPath); - ensureConfigProviderDefaults(config, settings); - if (providerOverride) { - applyProviderOverrideToConfig(config, providerOverride, settings); - } - return config; -} - function trackActiveCluster(clusterId, orchestrator) { activeClusterId = clusterId; orchestratorInstance = orchestrator; @@ -438,8 +355,11 @@ function applyModelOverrideToConfig(config, modelOverride, providerOverride, set ); } - if (providerName === 'claude' && ['opus', 'sonnet', 'haiku'].includes(modelOverride)) { - const { validateModelAgainstMax } = require('../lib/settings'); + if (providerName === 'claude') { + const { validateModelAgainstMax, VALID_MODELS } = require('../lib/settings'); + if (!VALID_MODELS.includes(modelOverride)) { + return; + } try { validateModelAgainstMax(modelOverride, settings.maxModel); } catch (err) { @@ -457,34 +377,6 @@ function applyModelOverrideToConfig(config, modelOverride, providerOverride, set console.log(chalk.dim(`Model override: ${modelOverride} (all agents)`)); } -function buildStartOptions({ - clusterId, - options, - settings, - providerOverride, - modelOverride, - forceProvider, -}) { - const targetCwd = process.env.ZEROSHOT_CWD || detectGitRepoRoot(); - return { - clusterId, - cwd: targetCwd, - isolation: options.docker || process.env.ZEROSHOT_DOCKER === '1' || settings.defaultDocker, - isolationImage: options.dockerImage || process.env.ZEROSHOT_DOCKER_IMAGE || undefined, - worktree: options.worktree || process.env.ZEROSHOT_WORKTREE === '1', - autoPr: options.pr || process.env.ZEROSHOT_PR === '1', - autoMerge: process.env.ZEROSHOT_MERGE === '1', - autoPush: process.env.ZEROSHOT_PUSH === '1', - modelOverride: modelOverride || undefined, - providerOverride: providerOverride || undefined, - noMounts: options.noMounts || false, - mounts: options.mount ? parseMountSpecs(options.mount) : undefined, - containerHome: options.containerHome || undefined, - forceProvider: forceProvider || undefined, - settings, // Pass settings for provider detection - }; -} - function createStatusFooter(clusterId, messageBus) { const statusFooter = new StatusFooter({ refreshInterval: 1000, @@ -2327,6 +2219,9 @@ Examples: ${chalk.cyan('zeroshot run "Implement feature X"')} Run cluster from plain text ${chalk.cyan('zeroshot run 123 -d')} Run in background (detached) ${chalk.cyan('zeroshot run 123 --docker')} Run in Docker container (safe for e2e tests) + ${chalk.cyan('zeroshot')} Open TUI (TTY only) + ${chalk.cyan('zeroshot tui')} Open TUI explicitly + ${chalk.cyan('zeroshot watch')} Open TUI Monitor view ${chalk.cyan('zeroshot task run "Fix the bug"')} Run single-agent background task ${chalk.cyan('zeroshot list')} List all tasks and clusters ${chalk.cyan('zeroshot task list')} List tasks only @@ -2383,12 +2278,23 @@ program '--ship', 'Full automation: worktree isolation + PR + auto-merge (use --docker for Docker)' ) + .option('--pr-base ', 'Target branch for PRs (default: repo default branch)') + .option('--merge-queue', 'Use GitHub merge queue instead of direct merge') + .option( + '--close-issue ', + 'When to close issue after merge: auto|always|never (default: from .zeroshot/settings.json or never)' + ) .option('--workers ', 'Max sub-agents for worker to spawn in parallel', parseInt) .option( '--provider ', 'Override all agents to use a provider (claude, codex, gemini, opencode)' ) .option('--model ', 'Override all agent models (provider-specific model id)') + .option( + '--sim ', + 'Token-free simulation gate for templates (off|fast|deep). Default: fast', + 'fast' + ) .option('-G, --github', 'Force GitHub as issue source') .option('-L, --gitlab', 'Force GitLab as issue source') .option('-J, --jira', 'Force Jira as issue source') @@ -2447,6 +2353,30 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps) // Preflight checks runClusterPreflight({ input, options, providerOverride, settings, forceProvider }); + // Secondary preflight: token-free template simulation/validation + const simMode = String(options.sim || 'fast').toLowerCase(); + if (simMode !== 'off') { + const { validateTemplates } = require('../src/template-validation'); + const templatesDir = path.join(PACKAGE_ROOT, 'cluster-templates'); + const deep = simMode === 'deep'; + const report = await validateTemplates({ templatesDir, deep }); + if (!report.valid) { + console.error('\n' + '='.repeat(60)); + console.error(`TEMPLATE VALIDATION FAILED (sim=${simMode})`); + console.error('='.repeat(60)); + for (const { filePath, result } of report.results) { + if (result.valid) continue; + const rel = path.relative(process.cwd(), filePath); + console.error(`\nāŒ ${rel}`); + for (const err of result.errors) { + console.error(` ERROR: ${err}`); + } + } + console.error('\nFix template errors before running to avoid token burn.\n'); + process.exit(1); + } + } + const { generateName } = require('../src/name-generator'); if (shouldRunDetached(options)) { @@ -2472,17 +2402,46 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps) const modelOverride = resolveModelOverride(options); applyModelOverrideToConfig(config, modelOverride, providerOverride, settings); - const startOptions = buildStartOptions({ - clusterId, - options, - settings, - providerOverride, - modelOverride, - forceProvider, - }); - - // Start cluster - const cluster = await orchestrator.start(config, input, startOptions); + let cluster = null; + if (input.text) { + cluster = await startClusterFromText({ + orchestrator, + text: input.text, + config, + settings, + providerOverride, + modelOverride, + forceProvider, + clusterId, + options, + }); + } else if (input.issue) { + cluster = await startClusterFromIssue({ + orchestrator, + issue: input.issue, + config, + settings, + providerOverride, + modelOverride, + forceProvider, + clusterId, + options, + }); + } else if (input.file) { + cluster = await startClusterFromFile({ + orchestrator, + file: input.file, + config, + settings, + providerOverride, + modelOverride, + forceProvider, + clusterId, + options, + }); + } else { + throw new Error('Invalid run input: expected text, issue, or file'); + } if (!process.env.ZEROSHOT_DAEMON) { await streamClusterInForeground(cluster, orchestrator, clusterId); @@ -3338,25 +3297,61 @@ program } }); -// Watch command - interactive TUI dashboard +// Watch command - TUI Monitor view program .command('watch') - .description('Interactive TUI to monitor clusters') + .description('Open TUI in Monitor view') .option('--refresh-rate ', 'Refresh interval in milliseconds', '1000') - .action(async (options) => { + .action((_options) => { try { - const TUI = require('../src/tui'); - const tui = new TUI({ - orchestrator: await getOrchestrator(), - refreshRate: parseInt(options.refreshRate, 10), - }); - await tui.start(); + launchTuiSession({ initialView: 'monitor' }); + } catch (error) { + console.error('Error starting TUI:', error.message); + process.exit(1); + } + }); + +// TUI command - TUI session +program + .command('tui') + .description('Open TUI') + .option( + '--provider ', + 'Override provider for this TUI session (claude, codex, gemini, opencode)' + ) + .option('--ui ', 'Select UI variant (classic, disruptive)') + .allowExcessArguments(true) + .allowUnknownOption(true) + .action((options) => { + try { + launchTuiSession(options); } catch (error) { console.error('Error starting TUI:', error.message); process.exit(1); } }); +function registerTuiEntrypoint(commandName, providerName) { + program + .command(commandName) + .description(`Interactive TUI to monitor clusters (provider: ${providerName})`) + .allowExcessArguments(true) + .allowUnknownOption(true) + .action(() => { + try { + launchTuiSession({ provider: providerName }); + } catch (error) { + console.error('Error starting TUI:', error.message); + process.exit(1); + } + }); +} + +registerTuiEntrypoint('codex', 'codex'); +registerTuiEntrypoint('claude', 'claude'); +registerTuiEntrypoint('gemini', 'gemini'); +registerTuiEntrypoint('opencode', 'opencode'); + // Settings management const settingsCmd = program.command('settings').description('Manage zeroshot settings'); @@ -4420,6 +4415,8 @@ function formatTokenUsage(tokensByRole) { // Total line const inputTokens = total.inputTokens || 0; const outputTokens = total.outputTokens || 0; + const cacheReadTokens = total.cacheReadInputTokens || 0; + const uncachedInputTokens = inputTokens - cacheReadTokens; const totalTokens = inputTokens + outputTokens; const cost = total.totalCostUsd || 0; @@ -4433,6 +4430,17 @@ function formatTokenUsage(tokensByRole) { chalk.dim(' out)') ); + // Cache breakdown (if cache data available) + if (cacheReadTokens > 0) { + const cachePercent = Math.round((cacheReadTokens / inputTokens) * 100); + lines.push( + chalk.dim('Cache: ') + + chalk.green(fmt(cacheReadTokens) + ' cached') + + chalk.dim(' (' + cachePercent + '%) + ') + + chalk.yellow(fmt(uncachedInputTokens) + ' new') + ); + } + // Cost line (if available) if (cost > 0) { lines.push(chalk.dim('Cost: ') + chalk.green('$' + cost.toFixed(4))); @@ -5273,9 +5281,21 @@ async function main() { // Check for updates (non-blocking if offline) await checkForUpdates({ quiet: isQuiet }); + let args = process.argv.slice(2); + + if (args.length === 0) { + const isInteractiveTty = Boolean(process.stdin.isTTY && process.stdout.isTTY); + if (isInteractiveTty) { + process.argv.splice(2, 0, 'tui'); + } else { + program.outputHelp(); + return; + } + } + // Default command handling: if first arg doesn't match a known command, treat it as 'run' // This allows `zeroshot "task"` to work the same as `zeroshot run "task"` - const args = process.argv.slice(2); + args = process.argv.slice(2); if (args.length > 0) { const firstArg = args[0]; diff --git a/cli/message-formatters-watch.js b/cli/message-formatters-watch.js index 288561c2..b09613b0 100644 --- a/cli/message-formatters-watch.js +++ b/cli/message-formatters-watch.js @@ -4,7 +4,11 @@ */ const chalk = require('chalk'); -const { buildClusterPrefix, getColorForSender, parseDataField } = require('./message-formatter-utils'); +const { + buildClusterPrefix, + getColorForSender, + parseDataField, +} = require('./message-formatter-utils'); /** * Format AGENT_ERROR for watch mode diff --git a/cluster-templates/base-templates/full-workflow.json b/cluster-templates/base-templates/full-workflow.json index 72ae777a..d7a7da3f 100644 --- a/cluster-templates/base-templates/full-workflow.json +++ b/cluster-templates/base-templates/full-workflow.json @@ -108,7 +108,7 @@ "required": ["plan", "summary", "filesAffected", "acceptanceCriteria"] }, "prompt": { - "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a planning agent for a {{complexity}} {{task_type}} task.\n\n## Your Job\nCreate a FLAT LIST of executable steps. The worker will execute them IN ORDER.\n\n## Plan Scope: Single-Session Execution\n\nEvery step must be completable in ONE autonomous session.\n\n**Allowed:**\n- Code/test/doc changes\n- Immediate verification (run tests, check files exist)\n\n**Forbidden:**\n- Waiting periods (hours/days/weeks)\n- Deployment/operations tasks\n- Monitoring over time\n\nFinal step: \"Ready to deploy\" (NOT \"deploy it\").\n\n## šŸ”“ PLAN FORMAT (CRITICAL)\n\nOutput a flat list of numbered steps in the `plan` field. Each step is ONE concrete action.\n\n**EXAMPLE - CORRECT:**\n```\n1. Create server/services/rate-limiter.ts with RateLimiter class\n2. Add middleware registration in server/src/server.ts:45\n3. Add config constant in server/config/limits.ts\n4. Write test in tests/unit/rate-limiter.test.ts\n5. Run npm test to verify\n```\n\n**FORBIDDEN:**\n- \"Phase 1\", \"Phase 2\" → NO PHASES. Just steps.\n- \"Future work\" → NO. Everything NOW.\n- \"We could do X or Y\" → NO OPTIONS. Pick one.\n- Delegation to sub-agents → NO. Worker does it all.\n- Deferring anything → FORBIDDEN.\n\nJust numbered steps. Execute in order. Done.\n\n## šŸ”“ ONE PLAN. THE BEST PLAN.\n\nāŒ ABSOLUTELY FORBIDDEN:\n- 'Option 1... Option 2...'\n- 'Alternative approaches include...'\n- 'We could either X or Y'\n- Hedging with 'alternatively'\n\nāœ… REQUIRED:\n- ONE decisive implementation approach\n- The approach a FAANG Staff/Principal Engineer would choose\n- Clean architecture, no hacks\n\nYou are a STAFF LEVEL PRINCIPAL ENGINEER. Make THE decision. Present THE plan.\n\n## Planning Process\n1. Analyze requirements thoroughly\n2. Explore codebase to understand architecture\n3. Identify ALL files that need changes\n4. Break down into concrete, actionable steps\n5. Consider cross-component dependencies\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - EXTRA SCRUTINY\n- This is HIGH RISK (auth, payments, security, production)\n- Plan must include rollback strategy\n- Consider blast radius of changes\n- Identify all possible failure modes\n{{/if}}\n\n## šŸ”“ ACCEPTANCE CRITERIA (REQUIRED - minItems: 3)\n\nYou MUST output explicit, testable acceptance criteria.\n\n### BAD vs GOOD Criteria:\n\nāŒ BAD: \"Dark mode works correctly\"\nāœ… GOOD: \"Toggle dark mode → all text readable (contrast ratio >4.5:1), background #1a1a1a\"\n\nāŒ BAD: \"API handles errors\"\nāœ… GOOD: \"POST /api/users with invalid email → returns 400 + {error: 'Invalid email format'}\"\n\nāŒ BAD: \"Tests pass\"\nāœ… GOOD: \"Test suite passes with 100% success, coverage >80% on new files\"\n\n### Criteria Format:\nEach criterion MUST have:\n- **id**: AC1, AC2, AC3, etc.\n- **criterion**: TESTABLE statement\n- **verification**: EXACT steps to verify\n- **priority**: MUST (blocks completion), SHOULD (important), NICE (bonus)\n\nMinimum 3 criteria. At least 1 MUST be priority=MUST.\n\n## šŸ”“ OUTPUT CONCISENESS (CRITICAL)\n\nYour plan will be consumed by other agents. Be CONCISE.\n\n**FORBIDDEN:**\n- āŒ Paragraphs explaining WHY\n- āŒ Background context\n- āŒ Explaining obvious steps\n- āŒ Code examples for trivial changes\n\n**REQUIRED:**\n- āœ… Steps as imperative commands (\"Add X to file.ts:123\")\n- āœ… File paths without explanations\n- āœ… Target: <2000 words total\n\n**EXAMPLE - BAD:**\n\"First, we need to update the health monitor service located at server/services/preview/health-monitor.ts. This file is responsible for monitoring container health...\"\n\n**EXAMPLE - GOOD:**\n\"Refactor health-monitor.ts:validateContainerHealth() - delegate to executor.checkContainerHealth()\"\n\nDO NOT implement - planning only." + "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a planning agent for a {{complexity}} {{task_type}} task.\n\n## Your Job\nCreate a FLAT LIST of executable steps. The worker will execute them IN ORDER.\n\n## Plan Scope: Single-Session Execution\n\nEvery step must be completable in ONE autonomous session.\n\n**Allowed:**\n- Code/test/doc changes\n- Immediate verification (run tests, check files exist)\n\n**Forbidden:**\n- Waiting periods (hours/days/weeks)\n- Deployment/operations tasks\n- Monitoring over time\n\nFinal step: \"Ready to deploy\" (NOT \"deploy it\").\n\n## šŸ”“ PLAN FORMAT (CRITICAL)\n\nOutput a flat list of numbered steps in the `plan` field. Each step is ONE concrete action.\n\n**EXAMPLE - CORRECT:**\n```\n1. Create server/services/rate-limiter.ts with RateLimiter class\n2. Add middleware registration in server/src/server.ts:45\n3. Add config constant in server/config/limits.ts\n4. Write test in tests/unit/rate-limiter.test.ts\n5. Run npm test to verify\n```\n\n## šŸ”“ ACTIONABLE STEPS - NO RE-EXPLORATION\n\nYour plan must be complete enough that ANY developer could implement it without re-reading the codebase.\n\nYou already analyzed the code. The worker should NOT re-analyze it.\n\n**INCLUDE IN EACH STEP:**\n- What to create/change\n- Key signatures, structures, patterns needed\n- Reference file:lines for quick lookup (not deep reading)\n\n**NEVER write:** \"Read file X to understand Y\" - you already understand it. Write what you learned.\n**ALWAYS write:** Concrete details the worker needs to execute immediately.\n**BE CONCISE:** Patterns in 1-2 lines, not tutorials. Target <2500 chars total.\n\n**FORBIDDEN:**\n- \"Phase 1\", \"Phase 2\" → NO PHASES. Just steps.\n- \"Future work\" → NO. Everything NOW.\n- \"We could do X or Y\" → NO OPTIONS. Pick one.\n- Delegation to sub-agents → NO. Worker does it all.\n- Deferring anything → FORBIDDEN.\n\nJust numbered steps. Execute in order. Done.\n\n## šŸ”“ ONE PLAN. THE BEST PLAN.\n\nāŒ ABSOLUTELY FORBIDDEN:\n- 'Option 1... Option 2...'\n- 'Alternative approaches include...'\n- 'We could either X or Y'\n- Hedging with 'alternatively'\n\nāœ… REQUIRED:\n- ONE decisive implementation approach\n- The approach a FAANG Staff/Principal Engineer would choose\n- Clean architecture, no hacks\n\nYou are a STAFF LEVEL PRINCIPAL ENGINEER. Make THE decision. Present THE plan.\n\n## Planning Process\n1. Analyze requirements thoroughly\n2. Explore codebase to understand architecture\n3. Identify ALL files that need changes\n4. Break down into concrete, actionable steps\n5. Consider cross-component dependencies\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - EXTRA SCRUTINY\n- This is HIGH RISK (auth, payments, security, production)\n- Plan must include rollback strategy\n- Consider blast radius of changes\n- Identify all possible failure modes\n{{/if}}\n\n## šŸ”“ ACCEPTANCE CRITERIA (REQUIRED - minItems: 3)\n\nYou MUST output explicit, testable acceptance criteria.\n\n### BAD vs GOOD Criteria:\n\nāŒ BAD: \"Dark mode works correctly\"\nāœ… GOOD: \"Toggle dark mode → all text readable (contrast ratio >4.5:1), background #1a1a1a\"\n\nāŒ BAD: \"API handles errors\"\nāœ… GOOD: \"POST /api/users with invalid email → returns 400 + {error: 'Invalid email format'}\"\n\nāŒ BAD: \"Tests pass\"\nāœ… GOOD: \"Test suite passes with 100% success, coverage >80% on new files\"\n\n### Criteria Format:\nEach criterion MUST have:\n- **id**: AC1, AC2, AC3, etc.\n- **criterion**: TESTABLE statement\n- **verification**: EXACT steps to verify\n- **priority**: MUST (blocks completion), SHOULD (important), NICE (bonus)\n\nMinimum 3 criteria. At least 1 MUST be priority=MUST.\n\n## šŸ”“ OUTPUT CONCISENESS (CRITICAL)\n\nYour plan will be consumed by other agents. Be CONCISE.\n\n**FORBIDDEN:**\n- āŒ Paragraphs explaining WHY\n- āŒ Background context\n- āŒ Explaining obvious steps\n- āŒ Code examples for trivial changes\n\n**REQUIRED:**\n- āœ… Steps as imperative commands (\"Add X to file.ts:123\")\n- āœ… File paths without explanations\n- āœ… Target: <2000 words total\n\n**EXAMPLE - BAD:**\n\"First, we need to update the health monitor service located at server/services/preview/health-monitor.ts. This file is responsible for monitoring container health...\"\n\n**EXAMPLE - GOOD:**\n\"Refactor health-monitor.ts:validateContainerHealth() - delegate to executor.checkContainerHealth()\"\n\nDO NOT implement - planning only." }, "contextStrategy": { "sources": [ @@ -252,7 +252,7 @@ "topic": "VALIDATION_RESULT", "logic": { "engine": "javascript", - "script": "const validators = cluster.getAgentsByRole('validator');\nconst lastPush = ledger.findLast({ topic: 'IMPLEMENTATION_READY' });\nif (!lastPush) return false;\nconst responses = ledger.query({ topic: 'VALIDATION_RESULT', since: lastPush.timestamp });\nif (responses.length < validators.length) return false;\nreturn responses.some(r => r.content?.data?.approved === false || r.content?.data?.approved === 'false');" + "script": "const validators = cluster.getAgentsByRole('validator');\nconst lastPush = ledger.findLast({ topic: 'IMPLEMENTATION_READY' });\nif (!lastPush) return false;\n\nconst responses = ledger.query({ topic: 'VALIDATION_RESULT', since: lastPush.timestamp });\nif (responses.length === 0) return false;\n\nconst validatorIds = new Set(validators.map((v) => v.id));\nconst validatorResponses = responses.filter((r) => validatorIds.has(r.sender));\n\n// Support both patterns:\n// - Per-validator VALIDATION_RESULT (STANDARD) → wait for all validators.\n// - Consensus-only VALIDATION_RESULT (staged quick/heavy) → use consensus messages.\nif (validators.length > 0 && validatorResponses.length > 0) {\n const latestByValidator = new Map();\n for (const msg of validatorResponses) {\n latestByValidator.set(msg.sender, msg);\n }\n if (latestByValidator.size < validators.length) return false;\n return Array.from(latestByValidator.values()).some(\n (r) => r.content?.data?.approved === false || r.content?.data?.approved === 'false'\n );\n}\n\nreturn responses.some((r) => r.content?.data?.approved === false || r.content?.data?.approved === 'false');" }, "action": "execute_task" } @@ -324,10 +324,18 @@ "triggers": [ { "topic": "IMPLEMENTATION_READY", + "logic": { + "engine": "javascript", + "script": "return !(message && message.metadata && message.metadata._republished);" + }, "action": "execute_task" }, { "topic": "QUICK_VALIDATION_PASSED", + "logic": { + "engine": "javascript", + "script": "return !(message && message.metadata && message.metadata._republished);" + }, "action": "execute_task" } ], @@ -339,7 +347,7 @@ }, "transform": { "engine": "javascript", - "script": "const template = result.action === 'load_quick' ? 'quick-validation' : 'heavy-validation'; return { topic: 'CLUSTER_OPERATIONS', content: { text: result.action === 'load_quick' ? 'Stage 1 (quick)' : 'Stage 2 (heavy)', data: { operations: [{ operation: 'load_config', config: { base: template, params: { validator_level: '{{validator_level}}', max_tokens: {{max_tokens}}, timeout: {{timeout}} } } }] } } };" + "script": "const template = result.action === 'load_quick' ? 'quick-validation' : 'heavy-validation'; const republishTopic = result.action === 'load_quick' ? 'IMPLEMENTATION_READY' : 'QUICK_VALIDATION_PASSED'; const republishContent = triggeringMessage && triggeringMessage.content ? triggeringMessage.content : { text: '', data: {} }; return { topic: 'CLUSTER_OPERATIONS', content: { text: result.action === 'load_quick' ? 'Stage 1 (quick)' : 'Stage 2 (heavy)', data: { operations: [{ action: 'load_config', config: { base: template, params: { validator_level: '{{validator_level}}', max_tokens: {{max_tokens}}, timeout: {{timeout}} } } }, { action: 'publish', topic: republishTopic, content: republishContent, metadata: { _republished: true } }] } } };" }, "logic": { "engine": "javascript", @@ -413,7 +421,7 @@ "required": ["approved", "summary"] }, "prompt": { - "system": "# REQUIREMENTS VALIDATOR\n\nVerify implementation meets ALL requirements from issue. Hold a HIGH BAR.\n\n## WORKFLOW\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific validation\n2. Parse acceptanceCriteria from PLAN_READY\n3. For EACH criterion: run verification, record evidence\n4. If repo has validation script (e.g. `./scripts/check-all.sh`), RUN IT\n\n## VERIFICATION\n- SEARCH before claiming 'missing' (Glob, Grep, Read)\n- RUN commands, capture output as evidence\n- CANNOT_VALIDATE only for: tool not installed, no network, permission denied\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- 'Phase 2 deferred' = REJECT\n- 'Will add tests later' = REJECT\n- ANY priority=MUST criterion fails = REJECT\n\n## APPROVAL\n- approved:true = ALL MUST criteria pass + no blocking issues\n- approved:false = any MUST fails OR incomplete implementation\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON with these REQUIRED fields:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"blocking issue 1\", \"blocking issue 2\"],\n \"criteriaResults\": [{\"id\": \"AC1\", \"status\": \"PASS|FAIL|CANNOT_VALIDATE\", \"evidence\": {\"command\": \"...\", \"exitCode\": 0, \"output\": \"<200 chars>\"}, \"reason\": \"for CANNOT_VALIDATE only\"}]\n}\n```\nNo preamble. JSON only." + "system": "# REQUIREMENTS VALIDATOR\n\nVerify implementation meets ALL requirements from issue. Hold a HIGH BAR.\n\n## WORKFLOW\n1. Read context files (README, CONTRIBUTING, Makefile, package.json, etc.) for repo-specific validation\n2. Parse acceptanceCriteria from PLAN_READY\n3. For EACH criterion: run verification, record evidence\n4. If repo has validation script (validation script, make check, etc.), RUN IT\n\n## VERIFICATION\n- SEARCH before claiming 'missing' (Glob, Grep, Read)\n- RUN commands, capture output as evidence\n- CANNOT_VALIDATE only for: tool not installed, no network, permission denied\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- 'Phase 2 deferred' = REJECT\n- 'Will add tests later' = REJECT\n- ANY priority=MUST criterion fails = REJECT\n\n## APPROVAL\n- approved:true = ALL MUST criteria pass + no blocking issues\n- approved:false = any MUST fails OR incomplete implementation\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON with these REQUIRED fields:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"blocking issue 1\", \"blocking issue 2\"],\n \"criteriaResults\": [{\"id\": \"AC1\", \"status\": \"PASS|FAIL|CANNOT_VALIDATE\", \"evidence\": {\"command\": \"...\", \"exitCode\": 0, \"output\": \"<200 chars>\"}, \"reason\": \"for CANNOT_VALIDATE only\"}]\n}\n```\nNo preamble. JSON only." }, "contextStrategy": { "sources": [ @@ -496,7 +504,7 @@ "required": ["approved", "summary"] }, "prompt": { - "system": "# CODE VALIDATOR\n\nSenior engineer code review. Catch REAL bugs, not style preferences.\n\n## WORKFLOW\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific validation\n2. SEARCH before claiming 'missing' (Glob, Grep, Read)\n3. RUN validation scripts if specified\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- Dangerous fallbacks hiding failures = REJECT\n\n## šŸ”“ GENERALIZATION CHECK (CRITICAL)\nWorker fixed a bug? Verify they fixed ALL instances:\n1. Identify the PATTERN (not just the line)\n2. `grep -rn \"pattern\" .` - search codebase\n3. If N > 1 exists → Did worker fix ALL? If NO → REJECT\n\nExamples: null check in one handler? Check ALL. SQL injection in one query? Check ALL. A fix that leaves identical bugs elsewhere is NOT a fix.\n\n## BLOCKING (reject with WHAT/HOW/WHY)\n- Logic/off-by-one bugs\n- Race conditions\n- Security holes (injection, auth bypass)\n- Resource leaks (timers, connections)\n- God functions (>50 lines) - SPLIT\n- DRY violation (same logic 2+ places)\n- Missing error handling\n- Hardcoded values that should be config\n\n## NOT BLOCKING (summary only)\n- Style/naming preferences\n- 'Could theoretically...' without proof\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"WHAT: X. HOW: Y. WHY: Z\"]\n}\n```\nNo preamble. JSON only." + "system": "# CODE VALIDATOR\n\nSenior engineer code review. Catch REAL bugs, not style preferences.\n\n## WORKFLOW\n1. Read context files (README, CONTRIBUTING, Makefile, package.json, etc.) for repo-specific validation\n2. SEARCH before claiming 'missing' (Glob, Grep, Read)\n3. RUN validation scripts if specified\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- Dangerous fallbacks hiding failures = REJECT\n\n## šŸ”“ GENERALIZATION CHECK (CRITICAL)\nWorker fixed a bug? Verify they fixed ALL instances:\n1. Identify the PATTERN (not just the line)\n2. `grep -rn \"pattern\" .` - search codebase\n3. If N > 1 exists → Did worker fix ALL? If NO → REJECT\n\nExamples: null check in one handler? Check ALL. SQL injection in one query? Check ALL. A fix that leaves identical bugs elsewhere is NOT a fix.\n\n## šŸ”“ CLEAN DESIGN CHECK (CRITICAL)\nUnless the issue EXPLICITLY requests backwards compatibility, REJECT any of these:\n- Old function/class kept alongside new one (\"deprecated but still works\") → REJECT. Delete old, update ALL callers.\n- Re-exports or wrappers forwarding to new implementation → REJECT. Update imports at call sites.\n- `_unused` parameter renames to preserve old signatures → REJECT. Change the signature, update callers.\n- Fallback paths handling \"old format\" or \"legacy data\" → REJECT. Migrate the data, remove the fallback.\n- Comments like \"kept for backwards compatibility\" → REJECT. Remove the old code.\n- Feature flags toggling between old and new behavior → REJECT. Ship the new behavior. Delete the old.\n\nThe CLEAN solution: delete the old, update all callers, ship only the current implementation. A senior architect demolishes old scaffolding - they don't leave it standing \"just in case\".\n\n## BLOCKING (reject with WHAT/HOW/WHY)\n- Logic/off-by-one bugs\n- Race conditions\n- Security holes (injection, auth bypass)\n- Resource leaks (timers, connections)\n- God functions (>50 lines) - SPLIT\n- DRY violation (same logic 2+ places)\n- Missing error handling\n- Hardcoded values that should be config\n- Backwards compatibility shims or legacy wrappers (see CLEAN DESIGN CHECK)\n\n## NOT BLOCKING (summary only)\n- Style/naming preferences\n- 'Could theoretically...' without proof\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"WHAT: X. HOW: Y. WHY: Z\"]\n}\n```\nNo preamble. JSON only." }, "contextStrategy": { "sources": [ @@ -578,7 +586,7 @@ "required": ["approved", "summary"] }, "prompt": { - "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\n## šŸ”“ READ CONTEXT FILES FOR REPO-SPECIFIC VALIDATION\n\n**BEFORE approving any implementation:**\n1. Read the repo's context files (CLAUDE.md, AGENTS.md, README if they exist)\n2. Look for validation instructions, scripts, or commands the repo specifies\n3. If context files say to run a validation script (e.g., `./scripts/check-all.sh`), RUN IT\n4. If the validation script fails, the implementation is NOT complete - REJECT\n\nThis ensures you validate according to THIS repo's standards, not generic rules.\n\n## šŸ”“ VERIFICATION PROTOCOL (REQUIRED - PREVENTS FALSE CLAIMS)\n\nBefore making ANY claim about security vulnerabilities or missing protections:\n\n1. **SEARCH FIRST** - Use Glob to find ALL relevant files\n2. **READ THE CODE** - Use Read to inspect actual implementation\n3. **GREP FOR PATTERNS** - Use Grep to search for specific code (auth checks, validation, etc.)\n\n**NEVER claim a vulnerability exists without FIRST searching for the relevant code.**\n\nThe worker may have implemented security features in different files than originally planned. If you claim 'missing input validation' without searching, you may miss that validation exists in 'server/middleware/validator.ts' instead of the controller.\n\n### Example Verification Flow:\n```\n1. Claim: 'Missing SQL injection protection'\n2. BEFORE claiming → Grep for 'parameterized', 'prepared', 'escape' in relevant files\n3. BEFORE claiming → Read the actual database query code\n4. ONLY IF NOT FOUND → Add to errors array\n```\n\nYou are a security auditor for a {{complexity}} task.\n\n## Security Review Checklist\n1. Input validation (injection attacks)\n2. Authentication/authorization checks\n3. Sensitive data handling\n4. OWASP Top 10 vulnerabilities\n5. Secrets management\n6. Error messages don't leak info\n\n## Output\n- approved: true if no security issues\n- summary: Security assessment\n- errors: Security vulnerabilities found\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" + "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\n## šŸ”“ READ CONTEXT FILES FOR REPO-SPECIFIC VALIDATION\n\n**BEFORE approving any implementation:**\n1. Read the repo's context files (README, CONTRIBUTING, Makefile, package.json, etc.)\n2. Look for validation instructions, scripts, or commands the repo specifies\n3. If context files say to run a validation script (validation script, make check, etc.), RUN IT\n4. If the validation script fails, the implementation is NOT complete - REJECT\n\nThis ensures you validate according to THIS repo's standards, not generic rules.\n\n## šŸ”“ VERIFICATION PROTOCOL (REQUIRED - PREVENTS FALSE CLAIMS)\n\nBefore making ANY claim about security vulnerabilities or missing protections:\n\n1. **SEARCH FIRST** - Use Glob to find ALL relevant files\n2. **READ THE CODE** - Use Read to inspect actual implementation\n3. **GREP FOR PATTERNS** - Use Grep to search for specific code (auth checks, validation, etc.)\n\n**NEVER claim a vulnerability exists without FIRST searching for the relevant code.**\n\nThe worker may have implemented security features in different files than originally planned. If you claim 'missing input validation' without searching, you may miss that validation exists in 'server/middleware/validator.ts' instead of the controller.\n\n### Example Verification Flow:\n```\n1. Claim: 'Missing SQL injection protection'\n2. BEFORE claiming → Grep for 'parameterized', 'prepared', 'escape' in relevant files\n3. BEFORE claiming → Read the actual database query code\n4. ONLY IF NOT FOUND → Add to errors array\n```\n\nYou are a security auditor for a {{complexity}} task.\n\n## Security Review Checklist\n1. Input validation (injection attacks)\n2. Authentication/authorization checks\n3. Sensitive data handling\n4. OWASP Top 10 vulnerabilities\n5. Secrets management\n6. Error messages don't leak info\n\n## Output\n- approved: true if no security issues\n- summary: Security assessment\n- errors: Security vulnerabilities found\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" }, "contextStrategy": { "sources": [ @@ -663,7 +671,7 @@ "required": ["approved", "summary"] }, "prompt": { - "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n- testResults field: ONLY include pass/fail counts and key errors, NOT full test output\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a TEST EXECUTOR. Your job is to RUN TESTS, not read them.\n\n## šŸ”“ CORE PRINCIPLE: RUN THE TESTS, DON'T JUST READ THEM\n\n**Reading test code is NOT verification. You must EXECUTE tests.**\n\n- 'Tests look correct' = NOT ACCEPTABLE\n- 'Test output shows 15/15 passing' = ACTUAL VERIFICATION\n\n## šŸ”“ STEP 1: FIND AND RUN THE TEST SUITE (MANDATORY)\n\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific test commands\n2. Find the test runner: `npm test`, `pytest`, `go test`, `cargo test`, etc.\n3. **RUN THE TESTS** using Bash tool\n4. Record FULL output in testResults field\n5. If ANY tests fail → REJECT immediately\n\n**This is not optional. You MUST run tests, not just search for them.**\n\n## šŸ”“ STEP 2: RUN REPO-SPECIFIC VALIDATION\n\nIf context files specify validation commands (e.g., `./scripts/check-all.sh`):\n1. RUN THEM\n2. Record output\n3. If they fail → REJECT\n\n## šŸ”“ STEP 3: VERIFY TEST QUALITY BY RUNNING\n\n**DO NOT assess quality by reading code. Assess by execution:**\n\n1. Run tests with verbose output: `npm test -- --verbose`\n2. Check coverage: `npm test -- --coverage`\n3. Record actual numbers in testResults\n\n**Quality indicators from EXECUTION:**\n- Coverage percentage (from actual run)\n- Number of test cases (from actual output)\n- Test duration (from actual output)\n\n## FORBIDDEN PATTERNS\n\n- āŒ 'Tests appear to have good coverage' without running them\n- āŒ 'Test assertions look correct' without executing them\n- āŒ 'The test file exists' as evidence of testing\n- āŒ Approving without testResults containing actual test output\n\n## APPROVAL CRITERIA\n\nONLY approve if:\n1. You RAN the test suite (actual output in testResults)\n2. All tests pass (verified by execution)\n3. Repo-specific validation commands pass (if specified)\n4. Coverage is acceptable for the repo (from actual coverage report)\n\n## Output\n- **approved**: true if tests RAN and PASSED\n- **summary**: Assessment based on ACTUAL test execution results\n- **errors**: Issues found (from running tests, not reading code)\n- **testResults**: ACTUAL OUTPUT from running test commands (REQUIRED)\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" + "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n- testResults field: ONLY include pass/fail counts and key errors, NOT full test output\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a TEST EXECUTOR. Your job is to RUN TESTS, not read them.\n\n## šŸ”“ CORE PRINCIPLE: RUN THE TESTS, DON'T JUST READ THEM\n\n**Reading test code is NOT verification. You must EXECUTE tests.**\n\n- 'Tests look correct' = NOT ACCEPTABLE\n- 'Test output shows 15/15 passing' = ACTUAL VERIFICATION\n\n## šŸ”“ STEP 1: FIND AND RUN THE TEST SUITE (MANDATORY)\n\n1. Read context files (README, CONTRIBUTING, Makefile, package.json, etc.) for repo-specific test commands\n2. Find the test runner: `npm test`, `pytest`, `go test`, `cargo test`, etc.\n3. **RUN THE TESTS** using Bash tool\n4. Record FULL output in testResults field\n5. If ANY tests fail → REJECT immediately\n\n**This is not optional. You MUST run tests, not just search for them.**\n\n## šŸ”“ STEP 2: RUN REPO-SPECIFIC VALIDATION\n\nIf context files specify validation commands (validation script, make check, etc.):\n1. RUN THEM\n2. Record output\n3. If they fail → REJECT\n\n## šŸ”“ STEP 3: VERIFY TEST QUALITY BY RUNNING\n\n**DO NOT assess quality by reading code. Assess by execution:**\n\n1. Run tests with verbose output: `npm test -- --verbose`\n2. Check coverage: `npm test -- --coverage`\n3. Record actual numbers in testResults\n\n**Quality indicators from EXECUTION:**\n- Coverage percentage (from actual run)\n- Number of test cases (from actual output)\n- Test duration (from actual output)\n\n## FORBIDDEN PATTERNS\n\n- āŒ 'Tests appear to have good coverage' without running them\n- āŒ 'Test assertions look correct' without executing them\n- āŒ 'The test file exists' as evidence of testing\n- āŒ Approving without testResults containing actual test output\n\n## APPROVAL CRITERIA\n\nONLY approve if:\n1. You RAN the test suite (actual output in testResults)\n2. All tests pass (verified by execution)\n3. Repo-specific validation commands pass (if specified)\n4. Coverage is acceptable for the repo (from actual coverage report)\n\n## Output\n- **approved**: true if tests RAN and PASSED\n- **summary**: Assessment based on ACTUAL test execution results\n- **errors**: Issues found (from running tests, not reading code)\n- **testResults**: ACTUAL OUTPUT from running test commands (REQUIRED)\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" }, "contextStrategy": { "sources": [ diff --git a/cluster-templates/base-templates/heavy-validation.json b/cluster-templates/base-templates/heavy-validation.json index 960f8518..50e76024 100644 --- a/cluster-templates/base-templates/heavy-validation.json +++ b/cluster-templates/base-templates/heavy-validation.json @@ -44,7 +44,7 @@ "required": ["approved", "summary"] }, "prompt": { - "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\n## šŸ”“ READ CONTEXT FILES FOR REPO-SPECIFIC VALIDATION\n\n**BEFORE approving any implementation:**\n1. Read the repo's context files (CLAUDE.md, AGENTS.md, README if they exist)\n2. Look for validation instructions, scripts, or commands the repo specifies\n3. If context files say to run a validation script (e.g., `./scripts/check-all.sh`), RUN IT\n4. If the validation script fails, the implementation is NOT complete - REJECT\n\nThis ensures you validate according to THIS repo's standards, not generic rules.\n\n## šŸ”“ VERIFICATION PROTOCOL (REQUIRED - PREVENTS FALSE CLAIMS)\n\nBefore making ANY claim about security vulnerabilities or missing protections:\n\n1. **SEARCH FIRST** - Use Glob to find ALL relevant files\n2. **READ THE CODE** - Use Read to inspect actual implementation\n3. **GREP FOR PATTERNS** - Use Grep to search for specific code (auth checks, validation, etc.)\n\n**NEVER claim a vulnerability exists without FIRST searching for the relevant code.**\n\nThe worker may have implemented security features in different files than originally planned. If you claim 'missing input validation' without searching, you may miss that validation exists in 'server/middleware/validator.ts' instead of the controller.\n\n### Example Verification Flow:\n```\n1. Claim: 'Missing SQL injection protection'\n2. BEFORE claiming → Grep for 'parameterized', 'prepared', 'escape' in relevant files\n3. BEFORE claiming → Read the actual database query code\n4. ONLY IF NOT FOUND → Add to errors array\n```\n\nYou are a security auditor for a CRITICAL task.\n\n## Security Review Checklist\n1. Input validation (injection attacks)\n2. Authentication/authorization checks\n3. Sensitive data handling\n4. OWASP Top 10 vulnerabilities\n5. Secrets management\n6. Error messages don't leak info\n\n## Output\n- approved: true if no security issues\n- summary: Security assessment\n- errors: Security vulnerabilities found\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" + "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n\n## 🚫 YOU ARE A VALIDATOR - READ-ONLY MODE\n\nYour ONLY job is to VALIDATE and OUTPUT JSON.\n- NEVER use Edit, Write, or any file modification tools\n- NEVER fix code - REJECT with errors array instead\n- NEVER create files, modify files, or make commits\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\n## šŸ”“ READ CONTEXT FILES FOR REPO-SPECIFIC VALIDATION\n\n**BEFORE approving any implementation:**\n1. Read the repo's context files (README, CONTRIBUTING, Makefile, package.json, etc.)\n2. Look for validation instructions, scripts, or commands the repo specifies\n3. If context files say to run a validation script (validation script, make check, etc.), RUN IT\n4. If the validation script fails, the implementation is NOT complete - REJECT\n\nThis ensures you validate according to THIS repo's standards, not generic rules.\n\n## šŸ”“ VERIFICATION PROTOCOL (REQUIRED - PREVENTS FALSE CLAIMS)\n\nBefore making ANY claim about security vulnerabilities or missing protections:\n\n1. **SEARCH FIRST** - Use Glob to find ALL relevant files\n2. **READ THE CODE** - Use Read to inspect actual implementation\n3. **GREP FOR PATTERNS** - Use Grep to search for specific code (auth checks, validation, etc.)\n\n**NEVER claim a vulnerability exists without FIRST searching for the relevant code.**\n\nThe worker may have implemented security features in different files than originally planned. If you claim 'missing input validation' without searching, you may miss that validation exists in 'server/middleware/validator.ts' instead of the controller.\n\n### Example Verification Flow:\n```\n1. Claim: 'Missing SQL injection protection'\n2. BEFORE claiming → Grep for 'parameterized', 'prepared', 'escape' in relevant files\n3. BEFORE claiming → Read the actual database query code\n4. ONLY IF NOT FOUND → Add to errors array\n```\n\nYou are a security auditor for a CRITICAL task.\n\n## Security Review Checklist\n1. Input validation (injection attacks)\n2. Authentication/authorization checks\n3. Sensitive data handling\n4. OWASP Top 10 vulnerabilities\n5. Secrets management\n6. Error messages don't leak info\n\n## Output\n- approved: true if no security issues\n- summary: Security assessment\n- errors: Security vulnerabilities found\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" }, "contextStrategy": { "sources": [ @@ -129,12 +129,30 @@ }, "testResults": { "type": "string" + }, + "skippedTests": { + "type": "array", + "description": "Tests that couldn't run due to missing env vars or external service unavailability. Treated as WARNING, not failure.", + "items": { + "type": "object", + "properties": { + "test": { + "type": "string", + "description": "Test file or suite name" + }, + "reason": { + "type": "string", + "description": "Why it was skipped (missing env var, unavailable service, etc.)" + } + }, + "required": ["test", "reason"] + } } }, "required": ["approved", "summary"] }, "prompt": { - "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n- testResults field: ONLY include pass/fail counts and key errors, NOT full test output\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a TEST EXECUTOR. Your job is to RUN TESTS, not read them.\n\n## šŸ”“ CORE PRINCIPLE: RUN THE TESTS, DON'T JUST READ THEM\n\n**Reading test code is NOT verification. You must EXECUTE tests.**\n\n- 'Tests look correct' = NOT ACCEPTABLE\n- 'Test output shows 15/15 passing' = ACTUAL VERIFICATION\n\n## šŸ”“ STEP 1: FIND AND RUN THE TEST SUITE (MANDATORY)\n\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific test commands\n2. Find the test runner: `npm test`, `pytest`, `go test`, `cargo test`, etc.\n3. **RUN THE TESTS** using Bash tool\n4. Record FULL output in testResults field\n5. If ANY tests fail → REJECT immediately\n\n**This is not optional. You MUST run tests, not just search for them.**\n\n## šŸ”“ STEP 2: RUN REPO-SPECIFIC VALIDATION\n\nIf context files specify validation commands (e.g., `./scripts/check-all.sh`):\n1. RUN THEM\n2. Record output\n3. If they fail → REJECT\n\n## šŸ”“ STEP 3: VERIFY TEST QUALITY BY RUNNING\n\n**DO NOT assess quality by reading code. Assess by execution:**\n\n1. Run tests with verbose output: `npm test -- --verbose`\n2. Check coverage: `npm test -- --coverage`\n3. Record actual numbers in testResults\n\n**Quality indicators from EXECUTION:**\n- Coverage percentage (from actual run)\n- Number of test cases (from actual output)\n- Test duration (from actual output)\n\n## FORBIDDEN PATTERNS\n\n- āŒ 'Tests appear to have good coverage' without running them\n- āŒ 'Test assertions look correct' without executing them\n- āŒ 'The test file exists' as evidence of testing\n- āŒ Approving without testResults containing actual test output\n\n## APPROVAL CRITERIA\n\nONLY approve if:\n1. You RAN the test suite (actual output in testResults)\n2. All tests pass (verified by execution)\n3. Repo-specific validation commands pass (if specified)\n4. Coverage is acceptable for the repo (from actual coverage report)\n\n## Output\n- **approved**: true if tests RAN and PASSED\n- **summary**: Assessment based on ACTUAL test execution results\n- **errors**: Issues found (from running tests, not reading code)\n- **testResults**: ACTUAL OUTPUT from running test commands (REQUIRED)\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" + "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n- testResults field: ONLY include pass/fail counts and key errors, NOT full test output\n\n## 🚫 YOU ARE A VALIDATOR - READ-ONLY MODE\n\nYour ONLY job is to VALIDATE and OUTPUT JSON.\n- NEVER use Edit, Write, or any file modification tools\n- NEVER fix code - REJECT with errors array instead\n- NEVER create files, modify files, or make commits\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a TEST EXECUTOR. Your job is to RUN TESTS, not read them.\n\n## šŸ”“ CORE PRINCIPLE: RUN THE TESTS, DON'T JUST READ THEM\n\n**Reading test code is NOT verification. You must EXECUTE tests.**\n\n- 'Tests look correct' = NOT ACCEPTABLE\n- 'Test output shows 15/15 passing' = ACTUAL VERIFICATION\n\n## šŸ”“ STEP 1: DISCOVER TEST COMMANDS FROM REPO CONTEXT\n\n1. Read context files (README, CONTRIBUTING, Makefile, package.json, pyproject.toml, Cargo.toml, etc.)\n2. Find the test runner command - DO NOT assume any specific tool\n3. Common patterns: `npm test`, `pytest`, `go test ./...`, `cargo test`, `make test`\n4. Use what the repo specifies, not hardcoded commands\n\n## šŸ”“ STEP 2: RUN TARGETED TESTS (NOT FULL SUITE)\n\n1. Identify changed files from IMPLEMENTATION_READY message\n2. Find tests RELATED to changed files:\n - Same directory (e.g., `src/foo.ts` → `src/foo.test.ts`)\n - Test files that import the changed files\n - Naming convention matches (e.g., `auth.ts` → `tests/auth.test.ts`)\n3. Run ONLY related tests using repo's \"related tests\" feature if available:\n - Jest: `--findRelatedTests `\n - pytest: `pytest `\n - go test: `go test ./path/to/package`\n4. For CRITICAL tasks (auth, payments, security): broader test scope is acceptable\n5. Record output in testResults field\n6. If ANY tests fail → REJECT immediately\n\n**DO NOT run the full test suite when targeted tests suffice.**\n\n## šŸ”“ STEP 3: RUN REPO-SPECIFIC VALIDATION\n\nIf context files specify validation commands (validation script, make check, etc.):\n1. RUN THEM\n2. Record output\n3. If they fail → REJECT\n\n## FORBIDDEN PATTERNS\n\n- āŒ 'Tests appear to have good coverage' without running them\n- āŒ 'Test assertions look correct' without executing them\n- āŒ 'The test file exists' as evidence of testing\n- āŒ Approving without testResults containing actual test output\n- āŒ Hardcoding `npm test` or any specific test runner without checking repo context\n- āŒ Running full test suite + coverage when only 2 files changed\n\n## šŸ”“ HANDLING SKIPPED TESTS\n\nIf a test SKIPS due to missing prerequisites (env vars, external services, credentials):\n- Add to skippedTests array with test name and reason\n- Do NOT add to errors array\n- Do NOT reject for skipped tests alone\n- Core tests must still pass\n\nSkipped = WARNING, not FAILURE. Same as CANNOT_VALIDATE in requirements validation.\n\n## APPROVAL CRITERIA\n\nONLY approve if:\n1. You RAN tests related to changed files\n2. All RUNNABLE tests pass (skipped tests = warning only)\n3. Repo-specific validation commands pass\n4. Changed code has test coverage\n\n## Output\n- **approved**: true if runnable tests passed\n- **summary**: Assessment based on test execution\n- **errors**: Issues from running tests\n- **testResults**: Actual test output (REQUIRED)\n- **skippedTests**: Tests that couldn't run (warning only)\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" }, "contextStrategy": { "sources": [ @@ -189,6 +207,7 @@ "approved": "{{result.approved}}", "errors": "{{result.errors}}", "testResults": "{{result.testResults}}", + "skippedTests": "{{result.skippedTests}}", "validatorId": "validator-tester" } } @@ -234,7 +253,7 @@ "topic": "HEAVY_VALIDATION_RESULT", "logic": { "engine": "javascript", - "script": "const results = ledger.query({ topic: 'HEAVY_VALIDATION_RESULT', since: ledger.findLast({ topic: 'QUICK_VALIDATION_PASSED' })?.timestamp || 0 }); return results.length === 2;" + "script": "const validators = ['validator-security', 'validator-tester']; const stageStart = ledger.findLast({ topic: 'QUICK_VALIDATION_PASSED' }); if (!stageStart) return false; const lastHeavyConsensus = ledger.findLast({ topic: 'VALIDATION_RESULT', since: stageStart.timestamp }); const since = Math.max(stageStart.timestamp, lastHeavyConsensus?.timestamp || 0); const responses = ledger.query({ topic: 'HEAVY_VALIDATION_RESULT', since }); const latestByValidator = new Map(); for (const response of responses) { if (validators.includes(response.sender)) { latestByValidator.set(response.sender, response); } } return validators.every((validatorId) => latestByValidator.has(validatorId));" }, "action": "execute_task" } diff --git a/cluster-templates/base-templates/quick-validation.json b/cluster-templates/base-templates/quick-validation.json index bffabd89..8025f5c8 100644 --- a/cluster-templates/base-templates/quick-validation.json +++ b/cluster-templates/base-templates/quick-validation.json @@ -82,7 +82,7 @@ "required": ["approved", "summary"] }, "prompt": { - "system": "# REQUIREMENTS VALIDATOR\n\nVerify implementation meets ALL requirements from issue. Hold a HIGH BAR.\n\n## WORKFLOW\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific validation\n2. Parse acceptanceCriteria from PLAN_READY\n3. For EACH criterion: run verification, record evidence\n4. If repo has validation script (e.g. `./scripts/check-all.sh`), RUN IT\n\n## VERIFICATION\n- SEARCH before claiming 'missing' (Glob, Grep, Read)\n- RUN commands, capture output as evidence\n- CANNOT_VALIDATE only for: tool not installed, no network, permission denied\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- 'Phase 2 deferred' = REJECT\n- 'Will add tests later' = REJECT\n- ANY priority=MUST criterion fails = REJECT\n\n## APPROVAL\n- approved:true = ALL MUST criteria pass + no blocking issues\n- approved:false = any MUST fails OR incomplete implementation\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON with these REQUIRED fields:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"blocking issue 1\", \"blocking issue 2\"],\n \"criteriaResults\": [{\"id\": \"AC1\", \"status\": \"PASS|FAIL|CANNOT_VALIDATE\", \"evidence\": {\"command\": \"...\", \"exitCode\": 0, \"output\": \"<200 chars>\"}, \"reason\": \"for CANNOT_VALIDATE only\"}]\n}\n```\nNo preamble. JSON only." + "system": "# REQUIREMENTS VALIDATOR\n\n## 🚫 YOU ARE A VALIDATOR - READ-ONLY MODE\n\nYour ONLY job is to VALIDATE and OUTPUT JSON.\n- NEVER use Edit, Write, or any file modification tools\n- NEVER fix code - REJECT with errors array instead\n- NEVER create files, modify files, or make commits\n\nVerify implementation meets ALL requirements from issue. Hold a HIGH BAR.\n\n## WORKFLOW\n1. Read context files (README, CONTRIBUTING, Makefile, package.json, etc.) for repo-specific validation\n2. Parse acceptanceCriteria from PLAN_READY\n3. For EACH criterion: run verification, record evidence\n4. If repo has validation script (validation script, make check, etc.), RUN IT\n\n## VERIFICATION\n- SEARCH before claiming 'missing' (Glob, Grep, Read)\n- RUN commands, capture output as evidence\n- CANNOT_VALIDATE only for: tool not installed, no network, permission denied\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- 'Phase 2 deferred' = REJECT\n- 'Will add tests later' = REJECT\n- ANY priority=MUST criterion fails = REJECT\n\n## APPROVAL\n- approved:true = ALL MUST criteria pass + no blocking issues\n- approved:false = any MUST fails OR incomplete implementation\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON with these REQUIRED fields:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"blocking issue 1\", \"blocking issue 2\"],\n \"criteriaResults\": [{\"id\": \"AC1\", \"status\": \"PASS|FAIL|CANNOT_VALIDATE\", \"evidence\": {\"command\": \"...\", \"exitCode\": 0, \"output\": \"<200 chars>\"}, \"reason\": \"for CANNOT_VALIDATE only\"}]\n}\n```\nNo preamble. JSON only." }, "contextStrategy": { "sources": [ @@ -165,7 +165,7 @@ "required": ["approved", "summary"] }, "prompt": { - "system": "# CODE VALIDATOR\n\nSenior engineer code review. Catch REAL bugs, not style preferences.\n\n## WORKFLOW\n1. Read context files (CLAUDE.md, AGENTS.md, README) for repo-specific validation\n2. SEARCH before claiming 'missing' (Glob, Grep, Read)\n3. RUN validation scripts if specified\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- Dangerous fallbacks hiding failures = REJECT\n\n## šŸ”“ GENERALIZATION CHECK (CRITICAL)\nWorker fixed a bug? Verify they fixed ALL instances:\n1. Identify the PATTERN (not just the line)\n2. `grep -rn \"pattern\" .` - search codebase\n3. If N > 1 exists → Did worker fix ALL? If NO → REJECT\n\nExamples: null check in one handler? Check ALL. SQL injection in one query? Check ALL. A fix that leaves identical bugs elsewhere is NOT a fix.\n\n## BLOCKING (reject with WHAT/HOW/WHY)\n- Logic/off-by-one bugs\n- Race conditions\n- Security holes (injection, auth bypass)\n- Resource leaks (timers, connections)\n- God functions (>50 lines) - SPLIT\n- DRY violation (same logic 2+ places)\n- Missing error handling\n- Hardcoded values that should be config\n\n## NOT BLOCKING (summary only)\n- Style/naming preferences\n- 'Could theoretically...' without proof\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"WHAT: X. HOW: Y. WHY: Z\"]\n}\n```\nNo preamble. JSON only." + "system": "# CODE VALIDATOR\n\n## 🚫 YOU ARE A VALIDATOR - READ-ONLY MODE\n\nYour ONLY job is to VALIDATE and OUTPUT JSON.\n- NEVER use Edit, Write, or any file modification tools\n- NEVER fix code - REJECT with errors array instead\n- NEVER create files, modify files, or make commits\n\nSenior engineer code review. Catch REAL bugs, not style preferences.\n\n## WORKFLOW\n1. Read context files (README, CONTRIBUTING, Makefile, package.json, etc.) for repo-specific validation\n2. SEARCH before claiming 'missing' (Glob, Grep, Read)\n3. RUN validation scripts if specified\n\n## INSTANT REJECT\n- TODO/FIXME/placeholder = REJECT\n- Silent error swallowing = REJECT\n- Dangerous fallbacks hiding failures = REJECT\n\n## šŸ”“ GENERALIZATION CHECK (CRITICAL)\nWorker fixed a bug? Verify they fixed ALL instances:\n1. Identify the PATTERN (not just the line)\n2. `grep -rn \"pattern\" .` - search codebase\n3. If N > 1 exists → Did worker fix ALL? If NO → REJECT\n\nExamples: null check in one handler? Check ALL. SQL injection in one query? Check ALL. A fix that leaves identical bugs elsewhere is NOT a fix.\n\n## šŸ”“ CLEAN DESIGN CHECK (CRITICAL)\nUnless the issue EXPLICITLY requests backwards compatibility, REJECT any of these:\n- Old function/class kept alongside new one (\"deprecated but still works\") → REJECT. Delete old, update ALL callers.\n- Re-exports or wrappers forwarding to new implementation → REJECT. Update imports at call sites.\n- `_unused` parameter renames to preserve old signatures → REJECT. Change the signature, update callers.\n- Fallback paths handling \"old format\" or \"legacy data\" → REJECT. Migrate the data, remove the fallback.\n- Comments like \"kept for backwards compatibility\" → REJECT. Remove the old code.\n- Feature flags toggling between old and new behavior → REJECT. Ship the new behavior. Delete the old.\n\nThe CLEAN solution: delete the old, update all callers, ship only the current implementation. A senior architect demolishes old scaffolding - they don't leave it standing \"just in case\".\n\n## BLOCKING (reject with WHAT/HOW/WHY)\n- Logic/off-by-one bugs\n- Race conditions\n- Security holes (injection, auth bypass)\n- Resource leaks (timers, connections)\n- God functions (>50 lines) - SPLIT\n- DRY violation (same logic 2+ places)\n- Missing error handling\n- Hardcoded values that should be config\n- Backwards compatibility shims or legacy wrappers (see CLEAN DESIGN CHECK)\n\n## NOT BLOCKING (summary only)\n- Style/naming preferences\n- 'Could theoretically...' without proof\n\n🚫 NO questions. Make safe choice and proceed.\n\n## šŸ”“ OUTPUT FORMAT (CRITICAL)\n\nYou MUST return valid JSON:\n```json\n{\n \"approved\": boolean,\n \"summary\": \"<100 chars max>\",\n \"errors\": [\"WHAT: X. HOW: Y. WHY: Z\"]\n}\n```\nNo preamble. JSON only." }, "contextStrategy": { "sources": [ @@ -259,7 +259,7 @@ "topic": "QUICK_VALIDATION_RESULT", "logic": { "engine": "javascript", - "script": "const results = ledger.query({ topic: 'QUICK_VALIDATION_RESULT', since: ledger.findLast({ topic: 'IMPLEMENTATION_READY' })?.timestamp || 0 }); return results.length === 2;" + "script": "const lastImpl = ledger.findLast({ topic: 'IMPLEMENTATION_READY' }); const since = lastImpl?.timestamp || 0; return helpers.allResponded(['validator-requirements', 'validator-code'], 'QUICK_VALIDATION_RESULT', since);" }, "action": "execute_task" } diff --git a/cluster-templates/base-templates/worker-validator.json b/cluster-templates/base-templates/worker-validator.json index 9bc74d3b..16f23bd7 100644 --- a/cluster-templates/base-templates/worker-validator.json +++ b/cluster-templates/base-templates/worker-validator.json @@ -178,7 +178,7 @@ "required": ["approved", "summary", "errors"] }, "prompt": { - "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a validator for a SIMPLE {{task_type}} task.\n\n## šŸ”“ VERIFICATION PROTOCOL (REQUIRED - PREVENTS FALSE CLAIMS)\n\nBefore making ANY claim about missing functionality or code issues:\n\n1. **SEARCH FIRST** - Use Glob to find ALL relevant files\n2. **READ THE CODE** - Use Read to inspect actual implementation\n3. **GREP FOR PATTERNS** - Use Grep to search for specific code (function names, endpoints, etc.)\n\n**NEVER claim something doesn't exist without FIRST searching for it.**\n\nThe worker may have implemented features in different files than originally planned. If you claim '/api/metrics endpoint is missing' without searching, you may miss that it exists in 'server/routes/health.ts' instead of 'server/routes/api.ts'.\n\n### Example Verification Flow:\n```\n1. Claim: 'Missing error handling for network failures'\n2. BEFORE claiming → Grep for 'catch', 'error', 'try' in relevant files\n3. BEFORE claiming → Read the actual implementation\n4. ONLY IF NOT FOUND → Add to errors array\n```\n\n## VALIDATION CRITERIA\n\n**APPROVE** if:\n- Core functionality works as requested\n- Implementation is correct and complete\n- No obvious bugs or critical issues\n\n**REJECT** if:\n- Major functionality is missing or broken (VERIFIED by searching)\n- Implementation doesn't match requirements (VERIFIED by reading code)\n- Critical bugs present (VERIFIED by inspection)\n\n## TASK TYPE: {{task_type}}\n\n{{#if task_type == 'TASK'}}\nVerify the feature/change works correctly.\n{{/if}}\n\n{{#if task_type == 'DEBUG'}}\nVerify the bug is actually fixed at root cause.\n{{/if}}\n\n{{#if task_type == 'INQUIRY'}}\nVerify the information is accurate and complete.\n{{/if}}\n\nFor SIMPLE tasks, don't nitpick. Focus on: Does it work and meet requirements?\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" + "system": "## šŸ”“ OUTPUT FORMAT (CRITICAL - READ FIRST)\n\nYour output MUST be MINIMAL and STRUCTURED:\n- Output ONLY the required JSON schema fields\n- NO preambles (\"Here is my analysis...\", \"Let me explain...\")\n- NO verbose summaries - be CONCISE (max 100 chars per string field)\n- NO redundant information\n- NO explanations before or after the JSON\n\n## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a validator for a SIMPLE {{task_type}} task.\n\n## šŸ”“ VERIFICATION PROTOCOL (REQUIRED - PREVENTS FALSE CLAIMS)\n\nBefore making ANY claim about missing functionality or code issues:\n\n1. **SEARCH FIRST** - Use Glob to find ALL relevant files\n2. **READ THE CODE** - Use Read to inspect actual implementation\n3. **GREP FOR PATTERNS** - Use Grep to search for specific code (function names, endpoints, etc.)\n\n**NEVER claim something doesn't exist without FIRST searching for it.**\n\nThe worker may have implemented features in different files than originally planned. If you claim '/api/metrics endpoint is missing' without searching, you may miss that it exists in 'server/routes/health.ts' instead of 'server/routes/api.ts'.\n\n### Example Verification Flow:\n```\n1. Claim: 'Missing error handling for network failures'\n2. BEFORE claiming → Grep for 'catch', 'error', 'try' in relevant files\n3. BEFORE claiming → Read the actual implementation\n4. ONLY IF NOT FOUND → Add to errors array\n```\n\n## šŸ”“ CLEAN DESIGN CHECK (CRITICAL)\nUnless the issue EXPLICITLY requests backwards compatibility, REJECT any of these:\n- Old function/class kept alongside new one (\"deprecated but still works\") → REJECT. Delete old, update ALL callers.\n- Re-exports or wrappers forwarding to new implementation → REJECT. Update imports at call sites.\n- `_unused` parameter renames to preserve old signatures → REJECT. Change the signature, update callers.\n- Fallback paths handling \"old format\" or \"legacy data\" → REJECT. Migrate the data, remove the fallback.\n- Comments like \"kept for backwards compatibility\" → REJECT. Remove the old code.\n- Feature flags toggling between old and new behavior → REJECT. Ship the new behavior. Delete the old.\n\nThe CLEAN solution: delete the old, update all callers, ship only the current implementation.\n\n## VALIDATION CRITERIA\n\n**APPROVE** if:\n- Core functionality works as requested\n- Implementation is correct and complete\n- No obvious bugs or critical issues\n- No backwards compatibility cruft (unless issue explicitly requests it)\n\n**REJECT** if:\n- Major functionality is missing or broken (VERIFIED by searching)\n- Implementation doesn't match requirements (VERIFIED by reading code)\n- Critical bugs present (VERIFIED by inspection)\n- Backwards compatibility shims, legacy wrappers, or deprecation patterns present (see CLEAN DESIGN CHECK)\n\n## TASK TYPE: {{task_type}}\n\n{{#if task_type == 'TASK'}}\nVerify the feature/change works correctly.\n{{/if}}\n\n{{#if task_type == 'DEBUG'}}\nVerify the bug is actually fixed at root cause.\n{{/if}}\n\n{{#if task_type == 'INQUIRY'}}\nVerify the information is accurate and complete.\n{{/if}}\n\nFor SIMPLE tasks, don't nitpick. Focus on: Does it work and meet requirements?\n\n## šŸ”“ DEBUGGING METHODOLOGY CHECK\n\nBefore approving, verify the worker didn't take shortcuts:\n\n### Ad Hoc Fix Detection\n- Did worker fix ONE instance? → Grep for similar patterns. If N > 1 exists, REJECT.\n- Example: Fixed null check in `auth.ts:42`? → `grep -r \"similar pattern\" .` - are there others?\n\n### Root Cause vs Symptom\n- Did worker add a workaround? → Find the ACTUAL bug. If workaround hides real issue, REJECT.\n- Example: Added `|| []` fallback? → WHY is it undefined? Fix THAT.\n\n### Lazy Debugging Red Flags (INSTANT REJECT)\n- Worker suggests \"restart the service\" → REJECT (hides the bug)\n- Worker suggests \"clear the cache\" → REJECT (hides the bug)\n- Worker says \"works on my machine\" → REJECT (not a fix)\n- Worker blames the test → REJECT unless they PROVE test is wrong with evidence" }, "contextStrategy": { "sources": [ diff --git a/cluster-templates/conductor-bootstrap.json b/cluster-templates/conductor-bootstrap.json index cb927c22..2ec3fc4e 100644 --- a/cluster-templates/conductor-bootstrap.json +++ b/cluster-templates/conductor-bootstrap.json @@ -5,7 +5,7 @@ { "id": "junior-conductor", "role": "conductor", - "modelLevel": "level1", + "modelLevel": "level2", "useDirectApi": true, "outputFormat": "json", "jsonSchema": { @@ -61,7 +61,7 @@ { "id": "senior-conductor", "role": "conductor", - "modelLevel": "level2", + "modelLevel": "level3", "useDirectApi": true, "outputFormat": "json", "jsonSchema": { diff --git a/codecov.yml b/codecov.yml index bed7d4a6..9c258698 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,6 +9,6 @@ coverage: target: auto comment: - layout: "reach,diff,flags,files" + layout: 'reach,diff,flags,files' behavior: default require_changes: true diff --git a/docker/zeroshot-cluster/Dockerfile b/docker/zeroshot-cluster/Dockerfile index 9e39221b..2929bc38 100644 --- a/docker/zeroshot-cluster/Dockerfile +++ b/docker/zeroshot-cluster/Dockerfile @@ -18,6 +18,7 @@ ARG HELM_VERSION=3.13.3 ARG INFRACOST_VERSION=0.10.32 ARG TFLINT_VERSION=0.50.0 ARG TFSEC_VERSION=1.28.4 +ARG CLAUDE_CODE_VERSION=2.1.20 # Install system dependencies for e2e testing and development RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -127,8 +128,8 @@ COPY docker/zeroshot-cluster/pre-baked-deps.json /pre-baked-deps/package.json RUN cd /pre-baked-deps && npm install --ignore-scripts \ && chown -R node:node /pre-baked-deps -# Install Claude CLI globally -RUN npm install -g @anthropic-ai/claude-code +# Install Claude CLI globally (version pinned via ARG) +RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} # Install Playwright (uses system Chromium) RUN npx playwright install-deps chromium 2>/dev/null || true diff --git a/docs/LIVE_MONITORING_PLAN.md b/docs/LIVE_MONITORING_PLAN.md deleted file mode 100644 index aa4aca0a..00000000 --- a/docs/LIVE_MONITORING_PLAN.md +++ /dev/null @@ -1,233 +0,0 @@ -# Live Monitoring for Zeroshot - -## Problem Statement - -When running `zeroshot run` or `zeroshot task`, users can only see agent output. They have no visibility into: -- Which workers are active vs idle -- CPU/memory usage per worker -- Network activity (API calls in progress) -- Whether a worker is stuck or making progress - -## Design Goals - -1. **Always visible** - Status footer shown during ALL zeroshot executions (not just attach) -2. **Non-intrusive** - Footer doesn't disrupt terminal output scrolling -3. **Real-time** - Update every 1-2 seconds -4. **Low overhead** - Minimal CPU cost for monitoring itself -5. **Cross-platform** - Linux-first (/proc), graceful degradation on macOS - -## Architecture Options - -### Option A: Status Bar in AttachClient (Recommended for MVP) - -Add a persistent header showing metrics for the attached agent: - -``` -ā”Œā”€ worker [sonnet] ─ CPU: 12% │ Mem: 45MB │ Net: ↓2.1KB/s ↑0.3KB/s │ Tokens: 1.2K ─┐ -│ ... terminal output ... │ -ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ -``` - -**Pros:** -- Simple implementation -- Natural extension of attach -- Single agent focus - -**Cons:** -- Only one agent visible -- Requires terminal manipulation - -### Option B: Enhance `zeroshot watch` (Recommended for Full Solution) - -Extend existing TUI dashboard with per-agent metrics: - -``` -ā”Œā”€ Cluster: cosmic-meteor-87 ───────────────────────────────────────────────────────┐ -│ │ -│ AGENT STATE CPU MEM NET I/O TOKENS LAST OUTPUT │ -│ ───────────────────────────────────────────────────────────────────────────── │ -│ worker executing 23% 67MB ↓4.2K ↑1.1K 3.4K 2s ago │ -│ validator-1 idle 0% 12MB - 0 waiting │ -│ validator-2 idle 0% 12MB - 0 waiting │ -│ │ -│ [CPU CHART] [NETWORK CHART] │ -│ ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 23% ā–ā–‚ā–ƒā–…ā–‡ā–ˆā–…ā–ƒā–‚ā– 4.2KB/s │ -│ │ -└─ Press 'q' to quit │ 'a' to attach │ 'l' for logs ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ -``` - -**Pros:** -- All agents visible -- Charts for trends -- Interactive (attach from dashboard) - -**Cons:** -- More complex -- Can't see terminal output simultaneously - -### Option C: Side-by-Side Split (Future) - -Terminal multiplexing with tmux-style panes. - -## Implementation Phases - -### Phase 1: Process Metrics Module (1-2 hours) - -Create `src/process-metrics.js`: - -```javascript -/** - * Get real-time metrics for a process and its children - * @param {number} pid - Process ID - * @param {number} [samplePeriodMs=1000] - Sampling period for rate calculations - * @returns {Promise} - */ -async function getProcessMetrics(pid, samplePeriodMs = 1000) { - // Read /proc/{pid}/stat for CPU - // Read /proc/{pid}/status for memory - // Read /proc/{pid}/io for I/O rates - // Use ss for network state - // Aggregate across child processes -} - -interface ProcessMetrics { - pid: number; - cpuPercent: number; - memoryMB: number; - ioReadBytesPerSec: number; - ioWriteBytesPerSec: number; - networkState: { - established: number; - sendQueueBytes: number; - recvQueueBytes: number; - }; - childCount: number; -} -``` - -**Leverage existing code:** -- `agent-stuck-detector.js` already has `getProcessState()` and `getNetworkState()` -- Refactor into reusable module - -### Phase 2: AttachClient Status Bar (2-3 hours) - -Modify `src/attach/attach-client.js`: - -1. Add metrics polling interval (every 1s) -2. Render status bar using ANSI escape codes -3. Handle terminal resize to reposition bar -4. Add Ctrl+B m to toggle metrics display - -```javascript -class AttachClient extends EventEmitter { - constructor(options) { - // ... existing code ... - this.showMetrics = options.showMetrics ?? true; - this.metricsInterval = null; - } - - _startMetricsPolling() { - this.metricsInterval = setInterval(async () => { - const metrics = await getProcessMetrics(this.processPid); - this._renderStatusBar(metrics); - }, 1000); - } - - _renderStatusBar(metrics) { - // Save cursor position - // Move to line 1 - // Clear line - // Write formatted metrics - // Restore cursor position - } -} -``` - -### Phase 3: Enhanced `zeroshot watch` (3-4 hours) - -Extend `src/tui/dashboard.js`: - -1. Add metrics column to agent table -2. Add sparkline charts for CPU/network -3. Poll metrics for all agents in cluster -4. Add 'a' key to attach to selected agent - -```javascript -// New component: AgentMetricsTable -const table = blessed.listtable({ - headers: ['AGENT', 'STATE', 'CPU', 'MEM', 'NET', 'TOKENS', 'LAST'], - data: agents.map(a => [ - a.id, - a.state, - `${a.metrics.cpuPercent}%`, - `${a.metrics.memoryMB}MB`, - formatNetworkRate(a.metrics), - a.tokenCount || '?', - formatTimeSince(a.lastOutputTime) - ]) -}); -``` - -## File Changes - -| File | Change | -|------|--------| -| `src/process-metrics.js` | NEW - Metrics collection module | -| `src/attach/attach-client.js` | Add status bar rendering | -| `src/tui/dashboard.js` | Add metrics table and charts | -| `src/agent-wrapper.js` | Expose PID to callers | -| `cli/index.js` | Add `--no-metrics` flag to attach | - -## Testing Strategy - -1. **Unit tests** for process-metrics.js - - Mock /proc filesystem - - Test rate calculations - - Test child process aggregation - -2. **Integration tests** - - Spawn real process, verify metrics - - Test with Claude CLI running - -3. **Visual testing** - - Manual verification of TUI rendering - - Test terminal resize handling - -## Platform Support - -| Platform | CPU/Mem | Network | I/O | Support Level | -|----------|---------|---------|-----|---------------| -| Linux | `/proc/stat` | `ss -tunp` | `/proc/io` | Full | -| macOS | `ps -o %cpu,rss` | `lsof -i` | N/A (requires sudo) | Full (no I/O) | -| Windows WSL | `/proc/stat` | `ss -tunp` | `/proc/io` | Full | - -```javascript -// Platform-aware metrics collection -function getProcessMetrics(pid) { - if (process.platform === 'darwin') { - return getMetricsDarwin(pid); // ps + lsof - } - return getMetricsLinux(pid); // /proc + ss -} -``` - -## Open Questions - -1. **Token counting** - Can we parse Claude CLI output to track token usage? - - Yes, stream-json has `usage` events - -2. **Network traffic attribution** - Per-process network bytes? - - Not easily without eBPF - - Use socket queue sizes as proxy for activity - -3. **Historical data** - Keep history for charts? - - Ring buffer of last 60 samples (1 minute) - - Store in memory, not persisted - -## Success Criteria - -- [ ] `zeroshot attach` shows live metrics without disrupting output -- [ ] `zeroshot watch` shows all agents with metrics -- [ ] Metrics update every 1 second -- [ ] No noticeable CPU overhead (<1% additional) -- [ ] Works on Linux and degrades gracefully on macOS diff --git a/docs/ZEROSHOT-DISRUPTIVE-TUI-DECISIONS.md b/docs/ZEROSHOT-DISRUPTIVE-TUI-DECISIONS.md new file mode 100644 index 00000000..72238615 --- /dev/null +++ b/docs/ZEROSHOT-DISRUPTIVE-TUI-DECISIONS.md @@ -0,0 +1,72 @@ +# Zeroshot Disruptive TUI — Pre-M3 Decisions + +Date: 2026-02-01 +Status: Accepted (M3 baseline) + +## Context + +M3 (Live Cluster Canvas v1) needs stable interaction decisions to avoid reworking navigation and rendering. These choices set the baseline for M3 implementation and can be revised post-M3. + +## Decisions + +### 1) Focus model + +**Decision:** Explicit focus ring navigation. + +- Focus moves only via explicit keys (Tab/Shift-Tab, Left/Right where applicable). +- The focused pane is visually distinct (border + title treatment); unfocused panes do not capture navigation inputs. +- No automatic ā€œnearestā€ focus selection based on cursor position or layout changes. + +**Rationale:** Predictable keyboard navigation across resizes/layout tweaks; reduces accidental focus shifts and layout-driven churn. + +**Revisit if:** We add pointer/hover interactions or multi-focus gestures that justify spatial focus heuristics. + +### 2) Label strategy + +**Decision:** Always show stable identifiers; no hover/tooltip dependence. + +- Primary labels show stable IDs (cluster id, agent id/role) everywhere labels appear. +- Long labels are truncated with ellipsis; detailed metadata is shown in a detail pane or status line. +- Hover/tooltips are not a dependency (terminal UX). + +**Rationale:** Terminal UIs lack reliable hover, and IDs are the most unambiguous cross-view reference. + +**Revisit if:** We add a persistent details panel or toggled ā€œverbose labelsā€ mode that can replace truncation. + +### 3) Topology fidelity + +**Decision:** Stable, semantic layout (deterministic), not force-directed. + +- Use deterministic ordering and semantic positioning (e.g., workflow tiers or adjacency ordering). +- Avoid physics-based layouts that jitter across updates. + +**Rationale:** Stability beats visual fidelity in a text terminal; jitter breaks scanning and makes deltas hard to read. + +**Revisit if:** We add a dedicated ā€œexploreā€ mode with user-controlled layout and persistence. + +### 4) Scrub semantics + +**Decision:** Default scrub scope is per-cluster; per-agent in agent-focused views. + +- Cluster screen: scrub/scroll/timeline navigation applies to the cluster aggregate view. +- Agent screen: scrub/scroll is per-agent by default. +- Scope never switches implicitly; it follows the active screen/focus. + +**Rationale:** Matches user intent in each view and avoids hidden scope changes during navigation. + +**Revisit if:** We add multi-pane synchronized scrubbing or explicit cross-filtering controls. + +### 5) Spine height + +**Decision:** Strict 1-line spine; no persistent second hint line. + +- Core spine UI remains one line for maximum vertical space. +- Hints and transient guidance use the toast/status area instead of expanding the spine. + +**Rationale:** Preserves vertical space for live data and avoids layout reflow during bursts of guidance. + +**Revisit if:** Onboarding or accessibility testing shows a persistent hint line materially improves success rates. + +## Decision Outputs (for M3 issues) + +All M3 implementation issues should reference these decisions and keep behavior aligned unless a follow-up ADR explicitly revises them. diff --git a/docs/postmortems/2026-01-31-pr-base-detached.md b/docs/postmortems/2026-01-31-pr-base-detached.md new file mode 100644 index 00000000..5eb96465 --- /dev/null +++ b/docs/postmortems/2026-01-31-pr-base-detached.md @@ -0,0 +1,49 @@ +# Postmortem: Detached PR base dropped (2026-01-31) + +## Timeline + +- 2026-01-31 13:45Z: Fix for detached `--pr-base` merged (PR #258). +- 2026-01-31 14:00Z: PR #259 created for issue #250. +- 2026-01-31 14:00Z–14:20Z: PR branch repeatedly rebased/pushed with commits from `main`. +- 2026-01-31 14:33Z: PR #259 merged after manual cleanup. + +## Impact + +- PR #259 included many unrelated commits from `main`, confusing review and CI. +- Time lost cleaning the branch and coordinating cluster shutdown. +- Reduced trust in automated PR creation. + +## Root Cause + +- Detached (`-d`) cluster runs did not forward `--pr-base` and related PR options to the daemon. +- The daemon therefore defaulted to `main` for rebase/PR base, even when `--pr-base dev` was supplied. + +## Contributing Factors + +- Cluster was started from a local checkout that did not include the 13:45Z fix yet. +- No single source of truth for run options in daemon mode; new flags required manual env wiring. +- No guardrail to alert when daemon options differ from CLI intent. + +## Detection + +- User observed PR #259 contained unrelated commits and questioned the base branch. + +## Resolution + +- Killed the active cluster to stop further pushes. +- Rebuilt the PR branch from a clean `origin/dev` base and re-opened PR #259 on `dev`. +- Merged fix PR #258 to preserve `--pr-base` in detached runs. + +## Prevention + +- Forward all run options via a single `ZEROSHOT_RUN_OPTIONS` payload in daemon mode. +- Parse daemon run options in `buildStartOptions` as a fallback for any missing CLI flags. +- Add unit coverage to ensure env-run-options are honored. +- Update operator docs to explicitly require forwarding options for detached runs. + +## Action Items + +- [x] Preserve `--pr-base`/`--merge-queue`/`--close-issue` in detached runs (PR #258). +- [x] Document daemon option forwarding in `AGENTS.md` and `CLAUDE.md`. +- [x] Add `ZEROSHOT_RUN_OPTIONS` fallback for daemon runs and unit tests. +- [ ] Add a preflight warning when local branch is behind remote base (optional follow-up). diff --git a/docs/tui-v2/protocol.md b/docs/tui-v2/protocol.md new file mode 100644 index 00000000..e30113e6 --- /dev/null +++ b/docs/tui-v2/protocol.md @@ -0,0 +1,463 @@ +protocolVersion: 1 + +# TUI v2 JSON-RPC Protocol (v0) + +## Framing + +- Each message is framed as: `Content-Length: \r\n\r\n` followed by `` bytes of UTF-8 JSON. +- Ignore unknown headers. +- Reject frames larger than 10MB with RPC error `-32600` (invalid request). + +## Envelope (JSON-RPC 2.0) + +All messages are JSON-RPC 2.0 objects: + +- `jsonrpc`: must be `"2.0"`. +- `id`: string or number (required for requests/responses, omitted for notifications). +- `method`: string (required for requests/notifications). +- `params`: optional; object | array | null. +- `result` or `error` (responses only; exactly one). + +## Error Object + +```json +{ + "code": -32600, + "message": "Invalid request", + "data": { + "detail": "optional details", + "fields": { + "/params/clusterId": "must be string" + } + } +} +``` + +- `data.detail` is a human-readable summary (optional). +- `data.fields` maps JSON pointers to per-field messages (optional). + +## Error Codes + +- `-32700` parse error +- `-32600` invalid request +- `-32601` method not found +- `-32602` invalid params +- `-32603` internal error +- `-32000` protocol version mismatch +- `-32001` orchestrator unavailable +- `-32002` cluster not found +- `-32003` unsupported capability + +## Domain Types + +### ClusterSummary + +```json +{ + "id": "cluster-123", + "state": "running", + "provider": "codex", + "createdAt": 1769810000000, + "agentCount": 3, + "messageCount": 120, + "cwd": "/path/to/workdir" +} +``` + +### ClusterMetrics + +```json +{ + "id": "cluster-123", + "supported": true, + "cpuPercent": 12.3, + "memoryMB": 256.7 +} +``` + +### ClusterLogLine + +```json +{ + "id": "line-1", + "timestamp": 1769811111000, + "text": "Agent output", + "agent": "worker", + "role": "implementation", + "sender": "worker" +} +``` + +### TimelineEvent + +```json +{ + "id": "evt-1", + "timestamp": 1769811111000, + "topic": "PLAN_READY", + "label": "Plan ready", + "approved": null, + "sender": "planner" +} +``` + +### TopologyAgent / TopologyEdge / ClusterTopology + +```json +{ + "agents": [{ "id": "worker", "role": "implementation" }], + "edges": [ + { "from": "system", "to": "ISSUE_OPENED", "topic": "ISSUE_OPENED", "kind": "source" }, + { "from": "ISSUE_OPENED", "to": "worker", "topic": "ISSUE_OPENED", "kind": "trigger" } + ], + "topics": ["ISSUE_OPENED"] +} +``` + +### GuidanceDeliveryResult / ClusterGuidanceDelivery + +```json +{ + "summary": { "injected": 1, "queued": 0, "total": 1 }, + "agents": { + "worker": { "status": "injected", "reason": null, "method": "pty", "taskId": "task-1" } + }, + "timestamp": 1769811111000 +} +``` + +## Methods (Requests/Responses) + +### initialize + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "client": { "name": "zeroshot-tui", "version": "0.1.0", "pid": 12345 }, + "capabilities": { "wantsMetrics": true, "wantsTopology": false } + } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "server": { "name": "zeroshot-backend", "version": "5.4.0" }, + "capabilities": { + "methods": ["initialize", "listClusters", "getClusterSummary", "unsubscribe"], + "notifications": ["clusterLogLines", "clusterTimelineEvents"] + } + } +} +``` + +### listClusters + +Request: `listClusters()` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "clusters": [ + /* ClusterSummary[] */ + ] + } +} +``` + +### getClusterSummary + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "getClusterSummary", + "params": { "clusterId": "cluster-123" } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "summary": { + /* ClusterSummary */ + } + } +} +``` + +### listClusterMetrics + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "listClusterMetrics", + "params": { "clusterIds": ["cluster-123"] } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "metrics": [ + /* ClusterMetrics[] */ + ] + } +} +``` + +### startClusterFromText + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "startClusterFromText", + "params": { + "text": "Implement the requested feature", + "providerOverride": "codex", + "clusterId": "cluster-123" + } +} +``` + +Response: + +```json +{ "jsonrpc": "2.0", "id": 5, "result": { "clusterId": "cluster-123" } } +``` + +### startClusterFromIssue + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "method": "startClusterFromIssue", + "params": { "ref": "covibes/zeroshot#240", "providerOverride": null } +} +``` + +Response: + +```json +{ "jsonrpc": "2.0", "id": 6, "result": { "clusterId": "cluster-456" } } +``` + +### sendGuidanceToAgent + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "method": "sendGuidanceToAgent", + "params": { + "clusterId": "cluster-123", + "agentId": "worker", + "text": "Focus on tests", + "timeoutMs": 5000 + } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "result": { + "result": { + /* GuidanceDeliveryResult */ + } + } +} +``` + +### sendGuidanceToCluster + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 8, + "method": "sendGuidanceToCluster", + "params": { "clusterId": "cluster-123", "text": "Ship it", "timeoutMs": 5000 } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 8, + "result": { + "result": { + /* ClusterGuidanceDelivery */ + } + } +} +``` + +### subscribeClusterLogs + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 9, + "method": "subscribeClusterLogs", + "params": { "clusterId": "cluster-123", "agentId": "worker" } +} +``` + +Response: + +```json +{ "jsonrpc": "2.0", "id": 9, "result": { "subscriptionId": "sub-logs-1" } } +``` + +### subscribeClusterTimeline + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 10, + "method": "subscribeClusterTimeline", + "params": { "clusterId": "cluster-123" } +} +``` + +Response: + +```json +{ "jsonrpc": "2.0", "id": 10, "result": { "subscriptionId": "sub-timeline-1" } } +``` + +### unsubscribe + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 11, + "method": "unsubscribe", + "params": { "subscriptionId": "sub-logs-1" } +} +``` + +Response: + +```json +{ "jsonrpc": "2.0", "id": 11, "result": { "removed": true } } +``` + +### getClusterTopology + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "method": "getClusterTopology", + "params": { "clusterId": "cluster-123" } +} +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "result": { + "topology": { + /* ClusterTopology */ + } + } +} +``` + +## Notifications + +### clusterLogLines + +```json +{ + "jsonrpc": "2.0", + "method": "clusterLogLines", + "params": { + "subscriptionId": "sub-logs-1", + "clusterId": "cluster-123", + "lines": [ + /* ClusterLogLine[] */ + ], + "droppedCount": 0 + } +} +``` + +### clusterTimelineEvents + +```json +{ + "jsonrpc": "2.0", + "method": "clusterTimelineEvents", + "params": { + "subscriptionId": "sub-timeline-1", + "clusterId": "cluster-123", + "events": [ + /* TimelineEvent[] */ + ], + "droppedCount": 0 + } +} +``` + +## Versioning Rules + +- `protocolVersion` is required in `initialize` and must match this spec. +- If a client sends an unsupported `protocolVersion`, return error `-32000`. +- Additive changes (new methods/fields) must be backward compatible within the same major protocol version. +- Breaking changes require incrementing `protocolVersion` and updating this spec. diff --git a/eslint.config.mjs b/eslint.config.mjs index 761d66a5..c9147b56 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -216,7 +216,6 @@ export default [ { // TUI/CLI/streaming files use ANSI escape codes for terminal colors - allow control characters files: [ - 'src/tui/**/*.js', 'src/streaming/*.js', 'src/streaming/**/*.js', 'src/status-footer.js', @@ -231,6 +230,16 @@ export default [ 'sonarjs/no-unused-vars': 'off', }, }, + { + // lib/tui-backend output is generated by tsc; allow compiler helpers and comparisons + files: ['lib/tui-backend/**/*.js'], + rules: { + 'no-global-this': 'off', + 'sonarjs/no-global-this': 'off', + 'no-shadow': 'off', + eqeqeq: 'off', + }, + }, { // Large files that need refactoring - temporary overrides // TODO: Split these files into smaller modules @@ -254,7 +263,14 @@ export default [ }, }, { - ignores: ['node_modules/**', 'dist/**', 'coverage/**', 'cluster-hooks/**', 'hooks/**'], + ignores: [ + 'node_modules/**', + 'dist/**', + 'coverage/**', + 'cluster-hooks/**', + 'hooks/**', + 'lib/tui-backend/**', + ], }, prettierConfig, ]; diff --git a/lib/id-detector.js b/lib/id-detector.js index f708c143..d00dcd13 100644 --- a/lib/id-detector.js +++ b/lib/id-detector.js @@ -12,19 +12,18 @@ const fs = require('fs'); const os = require('os'); const Database = require('better-sqlite3'); -// Storage paths -const CLUSTER_DIR = path.join(os.homedir(), '.zeroshot'); -const TASK_DIR = path.join(os.homedir(), '.claude-zeroshot'); -const TASK_DB_FILE = path.join(TASK_DIR, 'store.db'); - /** * Detect if ID is a cluster or task * @param {string} id - The ID to check * @returns {'cluster'|'task'|null} - Type of ID or null if not found */ function detectIdType(id) { + const homeDir = + process.env.ZEROSHOT_HOME || process.env.HOME || process.env.USERPROFILE || os.homedir(); + const clusterFile = path.join(homeDir, '.zeroshot', 'clusters.json'); + const taskDbFile = path.join(homeDir, '.claude-zeroshot', 'store.db'); + // Check clusters - const clusterFile = path.join(CLUSTER_DIR, 'clusters.json'); if (fs.existsSync(clusterFile)) { try { const clusters = JSON.parse(fs.readFileSync(clusterFile, 'utf8')); @@ -37,9 +36,9 @@ function detectIdType(id) { } // Check tasks in SQLite - if (fs.existsSync(TASK_DB_FILE)) { + if (fs.existsSync(taskDbFile)) { try { - const db = new Database(TASK_DB_FILE, { readonly: true, timeout: 5000 }); + const db = new Database(taskDbFile, { readonly: true, timeout: 5000 }); const row = db.prepare('SELECT id FROM tasks WHERE id = ?').get(id); db.close(); if (row) { diff --git a/lib/repo-settings.js b/lib/repo-settings.js new file mode 100644 index 00000000..05625079 --- /dev/null +++ b/lib/repo-settings.js @@ -0,0 +1,69 @@ +/** + * Repo-local settings for zeroshot + * + * Optional per-repository config file: + * /.zeroshot/settings.json + * + * This complements the global user settings at: + * ~/.zeroshot/settings.json + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +function _safeJsonParse(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function _getGitRoot(dir) { + try { + return execSync('git rev-parse --show-toplevel', { + cwd: dir, + encoding: 'utf8', + stdio: 'pipe', + }).trim(); + } catch { + return null; + } +} + +function _readSettingsFile(filePath) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = _safeJsonParse(raw); + if (!parsed || typeof parsed !== 'object') { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Read repo-local settings if present. + * + * @param {string} startDir - Directory inside the repo (usually process.cwd()). + * @returns {{repoRoot: string|null, settings: object|null, settingsPath: string|null}} + */ +function readRepoSettings(startDir) { + const repoRoot = _getGitRoot(startDir); + if (!repoRoot) { + return { repoRoot: null, settings: null, settingsPath: null }; + } + + const settingsPath = path.join(repoRoot, '.zeroshot', 'settings.json'); + if (!fs.existsSync(settingsPath)) { + return { repoRoot, settings: null, settingsPath }; + } + + const settings = _readSettingsFile(settingsPath); + return { repoRoot, settings, settingsPath }; +} + +module.exports = { readRepoSettings }; diff --git a/lib/settings.js b/lib/settings.js index b579baa1..a7428da3 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -121,6 +121,14 @@ const DEFAULT_SETTINGS_BASE = { // Container home directory - where $HOME resolves in container paths // Default: /home/node (matches zeroshot-cluster-base image) dockerContainerHome: '/home/node', + // Retry/restart robustness defaults + maxRetries: 3, // Agent task retries (per execution) for retryable errors + maxRestartAttempts: 3, // Agent restarts since last TASK_COMPLETED + maxTotalRestarts: 10, // Safety valve (never resets) + staleWarningsBeforeKill: 2, // Consecutive stale warnings before restart + backoffBaseMs: 2000, // Initial retry backoff + backoffMaxMs: 30000, // Max retry backoff + jitterFactor: 0.2, // Random jitter ±20% // Issue provider settings - defaultIssueSource is here, others come from providers defaultIssueSource: 'github', // 'github' | 'gitlab' | 'jira' | 'azure-devops' }; @@ -340,6 +348,37 @@ function validateSetting(key, value) { return `Unknown setting: ${key}`; } + if ( + [ + 'maxRetries', + 'maxRestartAttempts', + 'maxTotalRestarts', + 'staleWarningsBeforeKill', + 'backoffBaseMs', + 'backoffMaxMs', + ].includes(key) + ) { + if (!Number.isFinite(value)) { + return `${key} must be a number`; + } + if (!Number.isInteger(value)) { + return `${key} must be an integer`; + } + const min = ['backoffBaseMs', 'backoffMaxMs'].includes(key) ? 0 : 1; + if (value < min) { + return `${key} must be >= ${min}`; + } + } + + if (key === 'jitterFactor') { + if (!Number.isFinite(value)) { + return 'jitterFactor must be a number'; + } + if (value < 0 || value > 1) { + return 'jitterFactor must be between 0 and 1'; + } + } + if (key === 'maxModel' && !VALID_MODELS.includes(value)) { return `Invalid model: ${value}. Valid models: ${VALID_MODELS.join(', ')}`; } @@ -423,7 +462,7 @@ function coerceValue(key, value) { } if (typeof defaultValue === 'number') { - const parsed = parseInt(value); + const parsed = parseFloat(value); if (isNaN(parsed)) { throw new Error(`Invalid number: ${value}`); } diff --git a/lib/start-cluster.js b/lib/start-cluster.js new file mode 100644 index 00000000..620bf336 --- /dev/null +++ b/lib/start-cluster.js @@ -0,0 +1,348 @@ +const path = require('path'); +const { execSync } = require('child_process'); +const chalk = require('chalk'); +const { normalizeProviderName } = require('./provider-names'); +const { getProvider } = require('../src/providers'); + +const PACKAGE_ROOT = path.resolve(__dirname, '..'); + +/** + * Detect git repository root from current directory. + * @returns {string} Git repo root, or process.cwd() if not in a git repo. + */ +function detectGitRepoRoot() { + try { + const root = execSync('git rev-parse --show-toplevel', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + return root; + } catch { + return process.cwd(); + } +} + +function resolveOptionalString(value) { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveEnvBool(value) { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim().toLowerCase(); + if (trimmed === '1' || trimmed === 'true' || trimmed === 'yes') return true; + if (trimmed === '0' || trimmed === 'false' || trimmed === 'no') return false; + return undefined; +} + +function resolveCloseIssueMode(value) { + const trimmed = resolveOptionalString(value); + if (!trimmed) return undefined; + const normalized = trimmed.toLowerCase(); + if (normalized === 'auto' || normalized === 'always' || normalized === 'never') { + return normalized; + } + return undefined; +} + +function parseRunOptionsEnv() { + const raw = resolveOptionalString(process.env.ZEROSHOT_RUN_OPTIONS); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Parse CLI mount specs (host:container[:ro]) into mount config objects. + * @param {string[]} specs - Array of mount specs from CLI. + * @returns {Array<{host: string, container: string, readonly: boolean}>} + */ +function parseMountSpecs(specs) { + return specs.map((spec) => { + const parts = spec.split(':'); + if (parts.length < 2) { + throw new Error(`Invalid mount spec: "${spec}". Format: host:container[:ro]`); + } + const host = parts[0]; + const container = parts[1]; + const readonly = parts[2] === 'ro'; + return { host, container, readonly }; + }); +} + +function buildTextInput(text) { + return { text }; +} + +function buildIssueInput(issue) { + return { issue }; +} + +function buildFileInput(file) { + return { file }; +} + +function detectRunInput(inputArg) { + const isGitHubUrl = /^https?:\/\/github\.com\/[\w-]+\/[\w-]+\/issues\/\d+/.test(inputArg); + const isGitLabUrl = /gitlab\.(com|[\w.-]+)\/[\w-]+\/[\w-]+\/-\/issues\/\d+/.test(inputArg); + const isJiraUrl = /(atlassian\.net|jira\.[\w.-]+)\/browse\/[A-Z][A-Z0-9]+-\d+/.test(inputArg); + const isAzureUrl = + /dev\.azure\.com\/.*\/_workitems\/edit\/\d+/.test(inputArg) || + /visualstudio\.com\/.*\/_workitems\/edit\/\d+/.test(inputArg); + const isJiraKey = /^[A-Z][A-Z0-9]+-\d+$/.test(inputArg); + const isIssueNumber = /^\d+$/.test(inputArg); + const isRepoIssue = /^[\w-]+\/[\w-]+#\d+$/.test(inputArg); + const isMarkdownFile = /\.(md|markdown)$/i.test(inputArg); + + if ( + isGitHubUrl || + isGitLabUrl || + isJiraUrl || + isAzureUrl || + isJiraKey || + isIssueNumber || + isRepoIssue + ) { + return buildIssueInput(inputArg); + } + if (isMarkdownFile) { + return buildFileInput(inputArg); + } + return buildTextInput(inputArg); +} + +function resolveProviderOverride(options = {}) { + const override = options.provider || options.envProvider || process.env.ZEROSHOT_PROVIDER; + if (!override || (typeof override === 'string' && !override.trim())) { + return null; + } + const normalized = normalizeProviderName(override); + if (options.validateProvider) { + getProvider(normalized); + } + return normalized; +} + +function resolveConfigPath(configName) { + if (path.isAbsolute(configName) || configName.startsWith('./') || configName.startsWith('../')) { + return path.resolve(process.cwd(), configName); + } + if (configName.endsWith('.json')) { + return path.join(PACKAGE_ROOT, 'cluster-templates', configName); + } + return path.join(PACKAGE_ROOT, 'cluster-templates', `${configName}.json`); +} + +function ensureConfigProviderDefaults(config, settings) { + if (!config.defaultProvider) { + config.defaultProvider = settings.defaultProvider || 'claude'; + } + config.defaultProvider = normalizeProviderName(config.defaultProvider) || 'claude'; +} + +function applyProviderOverrideToConfig(config, providerOverride, settings) { + const provider = getProvider(providerOverride); + const providerSettings = settings.providerSettings?.[providerOverride] || {}; + config.forceProvider = providerOverride; + config.defaultProvider = providerOverride; + config.forceLevel = providerSettings.defaultLevel || provider.getDefaultLevel(); + config.defaultLevel = config.forceLevel; + console.log(chalk.dim(`Provider override: ${providerOverride} (all agents)`)); +} + +function loadClusterConfig(orchestrator, configPath, settings = {}, providerOverride) { + const config = orchestrator.loadConfig(configPath); + ensureConfigProviderDefaults(config, settings); + if (providerOverride) { + applyProviderOverrideToConfig(config, providerOverride, settings); + } + return config; +} + +function buildStartOptions({ + clusterId, + options = {}, + settings = {}, + providerOverride, + modelOverride, + forceProvider, +}) { + const envRunOptions = parseRunOptionsEnv(); + const mergedOptions = envRunOptions ? { ...envRunOptions, ...options } : options; + const targetCwd = process.env.ZEROSHOT_CWD || detectGitRepoRoot(); + const envPrBase = resolveOptionalString(process.env.ZEROSHOT_PR_BASE); + const envMergeQueue = resolveEnvBool(process.env.ZEROSHOT_MERGE_QUEUE); + const envCloseIssue = resolveCloseIssueMode(process.env.ZEROSHOT_CLOSE_ISSUE); + const prBase = resolveOptionalString(mergedOptions.prBase) || envPrBase; + const mergeQueue = + typeof mergedOptions.mergeQueue === 'boolean' ? mergedOptions.mergeQueue : envMergeQueue; + const closeIssue = resolveCloseIssueMode(mergedOptions.closeIssue) || envCloseIssue; + return { + clusterId, + cwd: targetCwd, + isolation: + mergedOptions.docker || process.env.ZEROSHOT_DOCKER === '1' || settings.defaultDocker, + isolationImage: mergedOptions.dockerImage || process.env.ZEROSHOT_DOCKER_IMAGE || undefined, + worktree: mergedOptions.worktree || process.env.ZEROSHOT_WORKTREE === '1', + autoPr: mergedOptions.pr || process.env.ZEROSHOT_PR === '1', + autoMerge: process.env.ZEROSHOT_MERGE === '1', + autoPush: process.env.ZEROSHOT_PUSH === '1', + modelOverride: modelOverride || undefined, + providerOverride: providerOverride || undefined, + noMounts: mergedOptions.mounts === false || mergedOptions.noMounts === true, + mounts: mergedOptions.mount ? parseMountSpecs(mergedOptions.mount) : undefined, + containerHome: mergedOptions.containerHome || undefined, + forceProvider: forceProvider || undefined, + // PR configuration options (CLI args) + prBase: prBase || undefined, + mergeQueue: mergeQueue, + closeIssue: closeIssue || undefined, + settings, + }; +} + +function resolveConfigOrThrow({ + orchestrator, + config, + configPath, + configName, + settings, + providerOverride, +}) { + if (config) { + return config; + } + const resolvedPath = configPath || (configName ? resolveConfigPath(configName) : null); + if (!resolvedPath) { + throw new Error('configPath or configName is required when config is not provided'); + } + return loadClusterConfig(orchestrator, resolvedPath, settings, providerOverride); +} + +function startClusterFromText({ + orchestrator, + text, + config, + configPath, + configName, + settings, + providerOverride, + modelOverride, + forceProvider, + clusterId, + options = {}, +}) { + if (!orchestrator) { + throw new Error('orchestrator is required'); + } + const resolvedConfig = resolveConfigOrThrow({ + orchestrator, + config, + configPath, + configName, + settings, + providerOverride, + }); + const startOptions = buildStartOptions({ + clusterId, + options, + settings, + providerOverride, + modelOverride, + forceProvider, + }); + return orchestrator.start(resolvedConfig, buildTextInput(text), startOptions); +} + +function startClusterFromIssue({ + orchestrator, + issue, + config, + configPath, + configName, + settings, + providerOverride, + modelOverride, + forceProvider, + clusterId, + options = {}, +}) { + if (!orchestrator) { + throw new Error('orchestrator is required'); + } + const resolvedConfig = resolveConfigOrThrow({ + orchestrator, + config, + configPath, + configName, + settings, + providerOverride, + }); + const startOptions = buildStartOptions({ + clusterId, + options, + settings, + providerOverride, + modelOverride, + forceProvider, + }); + return orchestrator.start(resolvedConfig, buildIssueInput(issue), startOptions); +} + +function startClusterFromFile({ + orchestrator, + file, + config, + configPath, + configName, + settings, + providerOverride, + modelOverride, + forceProvider, + clusterId, + options = {}, +}) { + if (!orchestrator) { + throw new Error('orchestrator is required'); + } + const resolvedConfig = resolveConfigOrThrow({ + orchestrator, + config, + configPath, + configName, + settings, + providerOverride, + }); + const startOptions = buildStartOptions({ + clusterId, + options, + settings, + providerOverride, + modelOverride, + forceProvider, + }); + return orchestrator.start(resolvedConfig, buildFileInput(file), startOptions); +} + +module.exports = { + buildTextInput, + buildIssueInput, + buildFileInput, + detectRunInput, + resolveProviderOverride, + resolveConfigPath, + loadClusterConfig, + buildStartOptions, + startClusterFromText, + startClusterFromIssue, + startClusterFromFile, + detectGitRepoRoot, +}; diff --git a/lib/stream-json-parser.js b/lib/stream-json-parser.js index 5f523ee9..7226f252 100644 --- a/lib/stream-json-parser.js +++ b/lib/stream-json-parser.js @@ -1,8 +1,77 @@ /** - * Compatibility wrapper for Claude stream-json parsing. - * Prefer provider-specific parsers in src/providers. + * Provider-agnostic stream-json parser for `zeroshot logs`. + * + * Goals: + * - Accept a single stream of JSONL events from multiple provider CLIs (Claude/Codex/Gemini/Opencode) + * - Produce the common event types expected by the logs renderer: + * text, thinking, tool_call, tool_result, result, ... + * + * NOTE: Provider-specific parsers live in `src/providers//output-parser.js`. + * This file is a thin compatibility wrapper so the CLI can parse logs without + * knowing the provider up front (task logs often don’t have provider metadata). */ -const { parseEvent, parseChunk } = require('../src/providers/anthropic/output-parser'); + +const anthropic = require('../src/providers/anthropic/output-parser'); +const openai = require('../src/providers/openai/output-parser'); +const google = require('../src/providers/google/output-parser'); +const opencode = require('../src/providers/opencode/output-parser'); + +const googleState = {}; + +function stripTimestampPrefix(line) { + if (!line || typeof line !== 'string') return ''; + let trimmed = line.trim().replace(/\r$/, ''); + if (!trimmed) return ''; + + const tsMatch = trimmed.match(/^\[(\d{13})\](.*)$/); + if (tsMatch) trimmed = (tsMatch[2] || '').trimStart(); + + // In cluster logs, lines can be prefixed like: + // "validator | {json...}" + // Strip the " | " prefix so provider parsers can JSON.parse the event. + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + const pipeMatch = trimmed.match(/^[^|]{1,40}\|\s*(.*)$/); + if (pipeMatch) { + const afterPipe = (pipeMatch[1] || '').trimStart(); + if (afterPipe.startsWith('{') || afterPipe.startsWith('[')) return afterPipe; + } + } + + return trimmed; +} + +function parseEvent(line, options = {}) { + const content = stripTimestampPrefix(line); + if (!content) return null; + + // Try provider parsers in a stable order. + // Each parser returns null for unknown event types, so the first non-null wins. + return ( + anthropic.parseEvent(content, options) || + openai.parseEvent(content, options) || + opencode.parseEvent(content, options) || + google.parseEvent(content, googleState, options) || + null + ); +} + +function parseChunk(chunk, options = {}) { + const events = []; + const lines = String(chunk || '').split('\n'); + + for (const line of lines) { + if (!line.trim()) continue; + const event = parseEvent(line, options); + if (!event) continue; + if (Array.isArray(event)) { + events.push(...event); + } else { + events.push(event); + } + } + + return events; +} module.exports = { parseEvent, diff --git a/lib/tui-binary.js b/lib/tui-binary.js new file mode 100644 index 00000000..a37481d0 --- /dev/null +++ b/lib/tui-binary.js @@ -0,0 +1,121 @@ +'use strict'; +const ENV_BINARY_PATH = 'ZEROSHOT_TUI_BINARY_PATH'; +const ENV_BINARY_URL = 'ZEROSHOT_TUI_BINARY_URL'; +const ENV_BINARY_SKIP = 'ZEROSHOT_TUI_BINARY_SKIP'; + +const fs = require('fs'); +const path = require('path'); + +const PACKAGE_ROOT = path.resolve(__dirname, '..'); +const BIN_DIR = path.join(PACKAGE_ROOT, 'libexec'); +const DEFAULT_RUST_BIN_NAME = process.platform === 'win32' ? 'zeroshot-tui.exe' : 'zeroshot-tui'; + +const SUPPORTED_PLATFORMS = Object.freeze({ + darwin: 'darwin', + linux: 'linux', +}); + +const SUPPORTED_ARCHES = Object.freeze({ + x64: 'x64', + arm64: 'arm64', +}); + +function getPackageVersion() { + // eslint-disable-next-line global-require + const pkg = require(path.join(PACKAGE_ROOT, 'package.json')); + return pkg.version; +} + +function resolveTargetPlatform(platform = process.platform) { + return SUPPORTED_PLATFORMS[platform] || null; +} + +function resolveTargetArch(arch = process.arch) { + return SUPPORTED_ARCHES[arch] || null; +} + +function resolveTarget(platform = process.platform, arch = process.arch) { + const resolvedPlatform = resolveTargetPlatform(platform); + const resolvedArch = resolveTargetArch(arch); + if (!resolvedPlatform || !resolvedArch) { + return null; + } + return { platform: resolvedPlatform, arch: resolvedArch }; +} + +function getAssetName(platform, arch) { + if (!platform || !arch) { + throw new Error('platform and arch are required to build asset name'); + } + return `zeroshot-tui-${platform}-${arch}.tar.gz`; +} + +function getReleaseBaseUrl(version) { + if (!version) { + throw new Error('version is required to build release URL'); + } + return `https://github.com/covibes/zeroshot/releases/download/v${version}`; +} + +function resolveDownloadUrl({ version, platform, arch, overrideUrl } = {}) { + if (overrideUrl) { + return overrideUrl; + } + const target = resolveTarget(platform, arch); + if (!target) { + return null; + } + const resolvedVersion = version || getPackageVersion(); + const assetName = getAssetName(target.platform, target.arch); + return `${getReleaseBaseUrl(resolvedVersion)}/${assetName}`; +} + +function getInstallDir() { + return BIN_DIR; +} + +function getInstalledBinaryPath() { + return path.join(BIN_DIR, DEFAULT_RUST_BIN_NAME); +} + +function resolveBinaryPathOverride(env = process.env) { + const value = env[ENV_BINARY_PATH]; + if (!value) { + return null; + } + const resolved = path.resolve(value); + if (!fs.existsSync(resolved)) { + throw new Error(`Rust TUI binary not found at ${resolved}`); + } + return resolved; +} + +function shouldSkipBinaryInstall(env = process.env) { + const raw = env[ENV_BINARY_SKIP]; + if (!raw) { + return false; + } + const normalized = String(raw).trim().toLowerCase(); + return !['0', 'false', 'no', 'off'].includes(normalized); +} + +module.exports = { + BIN_DIR, + DEFAULT_RUST_BIN_NAME, + ENV_BINARY_PATH, + ENV_BINARY_URL, + ENV_BINARY_SKIP, + SUPPORTED_PLATFORMS, + SUPPORTED_ARCHES, + getAssetName, + getInstallDir, + getInstalledBinaryPath, + getPackageVersion, + getReleaseBaseUrl, + resolveBinaryPathOverride, + resolveDownloadUrl, + resolveTarget, + resolveTargetArch, + resolveTargetPlatform, + shouldSkipBinaryInstall, +}; diff --git a/lib/tui-launcher.js b/lib/tui-launcher.js new file mode 100644 index 00000000..92ca8ec0 --- /dev/null +++ b/lib/tui-launcher.js @@ -0,0 +1,160 @@ +'use strict'; +const TUI_BINARY_ENV = 'ZEROSHOT_TUI_PATH'; +const TUI_BINARY_ENV_ALT = 'ZEROSHOT_TUI_BIN'; +const TUI_INITIAL_SCREEN_ENV = 'ZEROSHOT_TUI_INITIAL_SCREEN'; +const TUI_PROVIDER_OVERRIDE_ENV = 'ZEROSHOT_TUI_PROVIDER_OVERRIDE'; +const TUI_UI_VARIANT_ENV = 'ZEROSHOT_TUI_UI'; + +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const { normalizeProviderName } = require('./provider-names'); +const { getInstalledBinaryPath, resolveBinaryPathOverride } = require('./tui-binary'); +const { getProvider } = require('../src/providers'); + +const PACKAGE_ROOT = path.resolve(__dirname, '..'); +const DEFAULT_RUST_BIN_NAME = process.platform === 'win32' ? 'zeroshot-tui.exe' : 'zeroshot-tui'; +const VALID_INITIAL_SCREENS = new Set(['launcher', 'monitor']); +const VALID_UI_VARIANTS = new Set(['classic', 'disruptive']); + +function resolveTuiProviderOverride(options = {}) { + const override = options.providerOverride ?? options.provider; + if (!override || (typeof override === 'string' && !override.trim())) { + return null; + } + const normalized = normalizeProviderName(override); + getProvider(normalized); + return normalized; +} + +function resolveInitialScreen(options = {}) { + const initial = options.initialScreen ?? options.initialView; + if (!initial || (typeof initial === 'string' && !initial.trim())) { + return null; + } + const normalized = String(initial).trim().toLowerCase(); + if (!VALID_INITIAL_SCREENS.has(normalized)) { + throw new Error( + `Unknown initial screen: ${normalized}. Valid: ${[...VALID_INITIAL_SCREENS].join(', ')}` + ); + } + return normalized; +} + +function resolveUiVariant(options = {}) { + const ui = options.ui; + if (!ui || (typeof ui === 'string' && !ui.trim())) { + return null; + } + const normalized = String(ui).trim().toLowerCase(); + if (!VALID_UI_VARIANTS.has(normalized)) { + throw new Error( + `Unknown UI variant: ${normalized}. Valid: ${[...VALID_UI_VARIANTS].join(', ')}` + ); + } + return normalized; +} + +function resolveRustTuiBinary() { + const override = resolveBinaryPathOverride(); + if (override) { + return override; + } + + const explicit = process.env[TUI_BINARY_ENV] || process.env[TUI_BINARY_ENV_ALT]; + if (explicit) { + const resolved = path.resolve(explicit); + if (!fs.existsSync(resolved)) { + throw new Error(`Rust TUI binary not found at ${resolved}`); + } + return resolved; + } + + const candidates = [ + path.join(PACKAGE_ROOT, 'tui-rs', 'target', 'debug', DEFAULT_RUST_BIN_NAME), + path.join(PACKAGE_ROOT, 'tui-rs', 'target', 'release', DEFAULT_RUST_BIN_NAME), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + const installed = getInstalledBinaryPath(); + if (fs.existsSync(installed)) { + return installed; + } + + return DEFAULT_RUST_BIN_NAME; +} + +function buildRustTuiCommand(options = {}) { + const initialScreen = resolveInitialScreen(options); + const providerOverride = resolveTuiProviderOverride(options); + const uiVariant = resolveUiVariant(options); + const command = options.binaryPath || resolveRustTuiBinary(); + const args = []; + + if (initialScreen) { + args.push('--initial-screen', initialScreen); + } + + if (uiVariant) { + args.push('--ui', uiVariant); + } + + if (providerOverride) { + args.push('--provider-override', providerOverride); + } + + const env = { ...process.env }; + if (initialScreen) { + env[TUI_INITIAL_SCREEN_ENV] = initialScreen; + } + if (uiVariant) { + env[TUI_UI_VARIANT_ENV] = uiVariant; + } + if (providerOverride) { + env[TUI_PROVIDER_OVERRIDE_ENV] = providerOverride; + } + + return { + command, + args, + env, + cwd: options.cwd || process.cwd(), + }; +} + +function launchRustTui(options = {}) { + const { command, args, env, cwd } = buildRustTuiCommand(options); + const spawnFn = options.spawn || spawn; + const child = spawnFn(command, args, { stdio: 'inherit', env, cwd }); + + if (child && typeof child.on === 'function') { + child.on('error', (error) => { + console.error(`Failed to start Rust TUI (${command}): ${error.message}`); + console.error( + `Set ${TUI_BINARY_ENV} or ZEROSHOT_TUI_BINARY_PATH to a valid Rust TUI binary.` + ); + process.exitCode = 1; + }); + } + + return child; +} + +function launchTuiSession(options = {}) { + return launchRustTui(options); +} + +module.exports = { + buildRustTuiCommand, + launchRustTui, + launchTuiSession, + resolveInitialScreen, + resolveRustTuiBinary, + resolveTuiProviderOverride, + resolveUiVariant, +}; diff --git a/libexec/zeroshot-tui b/libexec/zeroshot-tui new file mode 100755 index 0000000000000000000000000000000000000000..6a4d4539b40853d3378c07e0e65a61db48adfdd2 GIT binary patch literal 1734144 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
MdR!*Xa7u}esqHa_XEKF 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
oIGydihIon{}P%N?!$ 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

6P4zkyU zu-_iOlO~c8^IA-eBTkbLp9q|zV^TahF)oi1=C%`8wWxF-L`^=X_oM53aovd#{;0F@ zJHVs(sdq~(4<04{h^;9vD#Ftz6|H|=Y&l&0)DL>KC6wDP^VkVJnE1}<^dj~Pv%jEf zCed8PWqzMaLozWlT~OJ$&AK@BU3!k<8|6hcu+C&bWfmM-iu}z}3ziyT%>~DHndFY3-Bry;-O(NI}JL&>?O=jwuc^Kki|KEZ7NCov`2 z0WR>M*IOhoRpy!rJt!t63Hdcd7Uwxqmdc9(Q@ z>1dX+rucof`T<0!N^mI1OdO+cQ>uvAbhIT0@wGEL%=F!nM2+5qhy^Ve)eYKVQ123U z8`LP+hbwTjUo&mFb#f5&Slh9`293}I_O2Z^>=CwK_NZWdwqP_02^hmUkN^^|DHwL23z-U0GnH@= z%bY4Pm!7+^4X|nucP5s3%Lyo016=pfs%0&!-m|vDiLo}zmIR- zuK7iehRPxuesReRmvs3U!{%o;7qGb{X6a>Ek6_!!vqy}}2^cJhlNB8AL_F4M9NEsX zDpAvw`N=v>V=g@#@eR)Pj&(YOpz!yin)+~dL3XUTAT*)6V9QIY0aM^vR+`|P1rCTV zE}HhDF3Y;^EqXM}WUiAXv*YUh=jMu8lG94;7w^~!b_C@enUh!;SRs@?33>6D_g>@y z^TsZiC-5a^>r1K?bLp9mZ?NjqxI<(J0YmaV)&Rr0IM+jK!F7)AuHcE~yfg2{0xN2n z<#54d32u>ftu1;qv}CS3OJ;vbH=mm;>k1aKuFSaU&ovX;W)+zw<6<0Q#@&d=dQ2kQ z%$Rj8LPuh@u|(vG(3fm{qrISJafh#}_*hGT5OANFaRz)$nEC`JvtVnCuwYxV??Pa9 zeF8I~Vhw($2NYbo%=+7{;t>|9d$1)E(`WvDmLE)bZ+EbZPR0Ys*@ zsI!6ty+x0PU0K(`k}0{6gd>VL$JcDY_IBbo(nL9uoDi?R@*^h643GkI&X3XEhOax{XhfA{KZ7_SmHhIf}%l@PnEsUiIS6qU4cfl>7w0zS=BGX8nc&V%>1Qwyv&z8 zS_?^AxvDBjn?dADI>VP$H))Gk)G%rLDK$ zB5)D^E0>RBB_Q})-$IHAq-ekkk2v2C(11;*DsibNSidh z&-^Kyy~HV*9f|#5ifb}WbR+zst&?F5jOWK;zu4B``{w3;Qsf!NyU%c&YK>$403WG- zXtMZ7-NOZu?>ptUBc^3lLdG`?nlTEQecsi7my<&bn~ zh7x;t6vO!AF_xQ7SWkSzCg!BW3HiRX`?s-KwgO@@AECj8#Gr_^vKZv$Xb5ews!Ma( zx2m@suP*N~wBU09*9b6gg|OY5QD|<;M3E%Ks($i4?A9L;FD|kLUIu2aZ3fG1zzq4W zfLLQkgd+e4U2z6DhCU4kcLh#THfQRHUn7fYER1Q)ksm{1@5h!8yoX6JGWT&*ki)|o z8i_F<6K*A<^AY*A7&(C~{&#P0W#afQ>_Dv3wi|5^QvIGTB%yqhSVpUE5X*^LOEY+Hx)rAF9 zUeJRDSO9G1Xlucnyw-n2XZ03*Y^Ja8Nt|p;^ZrgqhTRRh%%T_;ct$9qML`#FvKkR# zZtTA_6D2B|u-L4oQ$6mLtm?)MO@nwMM_6)PLYnhd_m>9o~ z7LHb5{NX=y|2D|l(H7*Y2VA^igMf=wyiF2{P%LS|Cfegy+5KJLPhJg~osb@dKu&Rz z)x=45{JDHDA!EO)A8g@5ATF&dmTng}!z^HjIDdl%7>p`JWp8^_0}E}mqZla6UQ`fh zLXKeQHoPM2;x{HHkRK;u7cX%qlGXg9Z^ThCCFcQmMbxxnn8fGq<0iGyVkSpR%mAbA z@asrW79 zA}%UX$+##(Xxh6d8_|1oOk?EbsmX$eq?99!+Evv>*fqM6z=OpE^7;Ov$sj>3na&wx zc+*@$iW7N$>PvL{uE{KG(#~4Tdb(`dXY|e)>DfGUJay*I+5Ln1(H5s-Og18Z_JGS8 z0ClccT1PlIF=1mF3Hzcxk_Q_ z9+vYZcXCnfESvocYP9^Qcv0=29S7(-J?Hf=sKR+e|MGdSegV^wz}@p+?t&Wo@a(*| zmr$jIv{w^}L4v$Flf9tx7fkJf-nn3U7xdtQnO@M@i>7cB|S@;+-04)d^}aw z?qAlwuzedRNMXmq1-rj0)K=I7RiQ9F_T^I)45m^h4&2sh~ z+a{YKWb#4{HtP@>4itT$bkIa_Jg!EtTclulXb?@5$hklwj?046r62Ria-GBszoz3R zAJ^r$#J~F#L1;nEl8Ru93p(q^#ph3+f8sZ`Z+Bo#_{sP@c#b2Sr-Xkz;PSy(Gpiw0 z3T{D$NEmU1AkbWMq)W?5>^g8Bv+rn(m&K57ureWyD-Gv)|D=6`0M`E)JgJ!S1!$N% zO<;x@1ZD}&iAi5T5EtYBcxkiC{w|wZLKV-n1N54BUM`{XFB!U)Uh>B0Rnvz%FM0j* zYR`xFU-G!UxOh(bRL^;Z^Qz;+{d0*7?dMJXyl$K~z4Ll--t3*%lk+B<(D{U^CUi4l zI=~l9>4F|zG?R;Z@8U7i%=tY&Cis1tke#4A^Z7mABH}#oXEq)9eVy;c)fjSV&W5E+ zH7tx3%0Z&nfisFchq1)=91ZT{0we@GLt2pcSX%*#?58DvwV7fzfb?-qphs)KSEugEiPGoGiZ+C@Q|?w z|9gP805>I|CbUI!kJUFgzdf7ujU z?s7Xo*RtnT&#RVa=->6cEV&XsyzhB?=Tz|}X|He)&#A^s7MMfXI46e^c>kqD^_*&+ zGregRRsylMN#are9?8+ww^wTq^CQSTfdzTZB17=Ji4&_Cn& zT=GFV#>k%~iCmEQ*M^gYIJFRVkncm%j-*DPMmVt>MqV@7h$fe;9ns^6nMH{6JK6vr zM}I;8uKTxVzKm7(Wh@9NG8S}opU5wf?j`=9EHrjPoDS^B5gH41#oa!VbVjvL4i)h? zzPo)Kf{mtzkgeB9!Z3- zhNh40`{ba4(2@j;UId08;{0{+xg5SlN1`IZc{BuRiFnv62>1Z*MiO9t&XBD20-VRD z-;E*@9xeIPCq(W4M5%0A@5*D$>BQiQ9_kxVsuT7@u0j*FIiTCe88=U#d{zJC z8gbmjp<}_gBUwxL2zh3dnz*MWwC<{3ZB3$>e!1oRL_A+sJoE+g*Bh^a&DkhOYK zU%5pV8^bZFZ3*grB>tqnAFK&;AsK8puyXi>KZjK*D{w>dv~Ejg$X)q}ZOK2v1j9$= zWe$jag@A@QkZ;CW83c6Qp9y|UW`c@*IHJtq|7&YPj?jjD*%s@HRxSB{nE=~^HbgXV zH*f-yLyYbIxP`>dIs)!nme$0!DlrM3S31VrY2qaPBZnCBCy*b}m86xHwpl-4fU z=As-W(0r!U38Fn}`!R8ai}A=Wg3*jx)ZT8?*3f9B22dgP$tbRoU8`{^`2$=hV_J+v z0{7$Y(M&#D&gup2tzW}}`N=iL)?{xYXr08lAMkf_IBrPF5MeAG33``RIoRXqJ=-G% z;PK(VxvmgjK9TRx;+e zF$5Fj^Hz?YPhw2vNcm>!J)Oj$x+BFgFrQ(dVXW`=?8!Wx)An4U{i~1B`LH|fN|vQ} zV(nkj%h$35BA^nPPJnF2BNX9%D0j?|5MrE{g{tpb)pL5I28*m*+t- z7PD~P{|YvVuc-RLI?_jc^mBQsr7L9K1TFC#b<&`MSg4OS^tmKMf9;2!y+#1o%d1J^azl~fD5VK()xqiHaP(3DU4 zjdyw8rk|4h^hr+ZU(rYsV#u)BvlwpCqmb`xn-d;#24@Kmu;sIeJ|;)y_K5F~pb8hi z8k6T&37tiwlm7eSf@&?vkKLsL*fLrccz;<2D4t0{E;?sY((#PRoYj@HiPl-&Icxf7 zb?^9yQ#&A_|6(Am-TLQQ@?waoA@zM88PC>!2ydUAtw~VJbWP51bqy`fEv->_VV{qH z9QEEpVYIt%sjTPjyrqhs0Z!w3dW*ZX;m%l&Okqv!o;Ue5)kv7`TWUu7BBNa}9pDRQ zZ%y?sn%y;3y5zNa?k6w}X{ED;}S4O;R|23IO z=5>y!nq2WR%7ud|=;)8#M#$;TfgdL>eQMk3LL^=18XK@iEK zj)HSZQfE^){WEYZ7d`J+iQSZ()r~CU_h2o4(gzJ1&tqxITtk|k}m(2_QCa%7K-;A(~$N4O`gwh=evG8*YKa`cR@$eQVpPl zFID6E{b!BOzx8|Zsrjr@SBm60{zw6jP7tb6cO;jx=|Bl!VQc9q*0G}%5}GE@w0Ne4 zScxbZ{KS6-OGDEki%+GaYvj4We-5dHPp4ilYX=K?MHAVH)x_ckAeaxw0678}Yf2%J z#$@a_n<$F7N_B^^vSEv5xJVv{*o37N1{RbF8BfRFGXELMgOu?S>m8n%z@WpZ`?g9} z73l_2Fw6|Xn#kS58Tou5FUiJ=RC4&ij*z$Ciu#nYR!B?y`DazP>kGb6=j@4}U$|W@ zkb>a!)@~U=Q-p%HWkrR)KlyNIu}?u>fz&760ZvKL@F+ ze>p{+z13U9Y)HI3Yz}<| z5`9j^6eM8e2{F7O>eWx_;Ka$V-l1RB?}y2wDJGB8@BVK3@^6rrkIZvq{}Gw{uto{+ zQI7rhQ!C;0^Dd_pa)bCKDYOSN&u44%^^EIEU3Z64GKNjqe#kC?Kw9q|Zvp%F_BAmM zXQ+#cuThh9jr&Pp>`vH%$0R=cfgx1CYXt@F2bqlg$Fs}mZjxW+@oOU}bCq^-L_dGW zOOwN;J#YzMBw^}l2~64#SO{e!C_iTKaXK15_#t}wxWt}9S|r&z8@MZD`M!OMrqnJI zjc3y@V`{XM%S1Wa;$@Y!4QN=4I*_f)#EIErk`w`(O{)4?o4Kr-XVXltd2VBpRGma( zm{i#dwwY9g3m9@#{gMpVx+KH(F4_KNHM(ROXME{4V@jz?qxsr*C`)IvyxrSLla#$o9)irfIk zjtV?A?2!ET^vO@@AKxP74x7?#JmytGvI#|?#2Il1VUHBO28WF5#+bs%Rg1uVF#gx0 zILqY6QuJXlrt`-R^QTV+TKA|CHMj)H_S%xeC6r?f<-lbgfS!|I=i=&agxoSt@vv8* zXIQpz61h3U$1dhW2{Z0ol*MmcBoxwCE>e#Ushxn=tlqz*h|R{yo?o(=i)wetrY~W{ z>9OpsvpGGzgyY8IiSN8*cQ2^iIXk|H?{uHl&tI^-p?twIME!!LXY;}Xj`Bsbb5YkX zvaXYhW_D3$E}8r#y@Tp>NjGI@4%HLEzcUJ&wCX}QH$3E&yKIiEWbGbe)Z=5YgmlavG0cdvH#&fh1>VH$cWTAps7` zNI$qqV4h!HvL@3c;%Y1@kVlWo_q_&LugmE)yp7-)`AT^Ye&Y7&DqFH1KVXfF1A9Y3 zia$_ZL${x?H=NMNj%gy#d}==7N!m&M6Yr73&OC;BVmN9^{^HT6GWXx;_e1_6afh2X z{lG||uI5x>={X)r)4P}6dCxR{2X|B7IwSGpTqF9dxE=7d1L^2R$M{Hl5U=(R@MWonfJHq_xCY2@J%q01dH~?bu zaJ~Vzqs(s@br@?DWq!q&faRFSdwQ{Zt+;Mb=`*f-@oO2YbJmoIl(VPj=lkro9i~7n zMCx&l!8^aMC$TZaHPLcZ*ORg9PC?Vu_UnNoQS&0)u(a%wA;l0zh8UR?AvM`(0(d8y zqCn^{x(-~9Ss5Z`d8!rjfCv6mC$S{IR^z52Q(a*})8rM%RK=r*tBjVubq|--_pR6T zLx*{iPsu(}OJfozj&g%x@a)o(4whI#q6?WwgDRvn?fY{s;3#Ge#6A}$+G*_G_3>x z;@8`?30h{y6Bl}UP{VV>A(U{l%oJt0fG}v;ff@p5qCc^Nq!th-JQ6~$K-QM)YqqbEQzohlMyN9$$MfR?}25}uMkht+>Ng? zy$pFAmT9ll7Nqd2KVfn@B%reHdr)bwoQzv)NDa~rC%teq|MBts+cJOQXv2_;b2LvB zCnpm1i&^+f-itgI-O%OniAgodIfy3tkz%!A1I22kV@z^#`ZE8CU$S;8^Do9d-cyeY z2DRczUbG)yrOIgCIt@!S=*ocTOTX^u!7(I{ha8E{0(m%8B0YgS*qHt_^XF(Cu0IFq zaQ#alvd_Wr&iCRXvEbC`;r;-HLtVD38j&cnZY0HbO(RL#iO%r_7eZfAU@cGOqD;LS zbr`G`g$lDdfa#dWi;6Kz?^X;X)VcUI&gLG$F>yVKL!PK)YxtPFT{}c|@Wnr$51p!y z>nsm8Yj7eP*ceJon=IHYAh=$n$~4E#scM>rKCWY4-eb2T(rFTL`kXOy4R~x)6#`wO z3E*y2&gv|>22B18L$Lll#mNK)*@3fh*`rq6^x}FPUzzzQvut(MpDs>pTF9k2ob(*W zse;NxDP+mM;q8Qg6$2YNa1d_^2g^U8gtyiR;f1R+6Y(??{wwg*pFa@^;AssJkJ%cB zB_9|WtrSgx|Gj7em}0!(buG3AOqt*sc;S}}=70j@kY|KOJjt&IOI)%mMCfrq2^t<) zvu8P=30exrWczsjkQ3$)HljYVVx_=Fm7|Z;1Zw;9uQ{dz#yZl{bLz~WBMY4M>+Du2 zhQPWJGejtXamsv`kjC+0 z*cI}WMnoISnhTjJFC=IoJM4J<(UG&hb)qck*sxi!a8FA~`TY}8z<~^KhuWAf%=kXy zRgsh%jf&-xr-0OOIG|6ruIN|MFhx!fE$T$w+=+HGQi<%SyNvrPHUkzzM#9~|(D>2- zcQvZ5hFl*G{k_iNWpz)fdoC-z&7c^If!f+dxbGhi>d)7&vo|4pMN9P)>g)j{?GtT; z7BL`_h?r-h2>s0Oc1TcfH|%zd)S1W|DG^2~r6IB4+*)f)&gcSlppB*&2rWbdPNKSR zwt?Hv)Y<#=`hLW>wj$WBPS*F6_jbcBX0y@h`hLTx-aD-CN8)*rqGZ(cqij=DH_bM1 z?V0ydBPB_RMe4)uiP2r86}`&8L0y6xa^lKs1X6&*lZwAz7w{$)gV1fN0Fvo00SZ}- zd@DLHHAN}&#YpqO2&oPzD#tT*0j0L!1`fl}Jt6@jIjQxEg&iFP_2IzOz!+U*iGU_- zV*iC>JjE3Vs`5ul&VQOM=-9S^z5ZMRL&FUmd^h>nM>hAu`w!O1@4xQ7FwW)3P9b#% zQxpbS+v3g~Le6yh#3?zDUENQqSNL6PKFb_kO27I_!A9NsYQrE?Qc|&UJ z-I9`}n>Wz2{YuRhzjopYw~5Hn&-inH1KW)FJwKDZ?0c=PNX^7sMA(ELP~4eACdJH9 zSEOp&DE_u!50pN|vmYQ{yfh-9B`dovN95(oPgzBeM4zD3Ed@HpW|r53rV}-}RyzhP?lK zsvG;g)NK8&==-Vm`WMunBnM@a$XIwo(CNh}yzWq)st^$gr5)J-CeW2jv53k8?g(n4 zW+ZmWeTZG_#&n;+cmEe&m{s#R%3;#K&l|Pm`s6B#@K$DKf zG%q4K5)h_cDzfIWLcO4#^vW%X3A`gUW$y`_!%E-sL;l)QwAekuka<1+@BJOm@rCc9 zPX1@&5BLW@wPyJDRVV)>%_mcfMH%=Rsv|MrK$|~%TbE}MSNEk%H|)TF_OrB!E$`8H zskuv-6DL}yd?^Z@t~{@leBwY$+`u_(4r0z5=kp(+L8(y%x1SK!PyZrC_aYQl^%dYn>s+R4ByBEm`Z_(6!$zYQ% zoU{Flq;Vx?Sg8}>>0Tm5L+%34_bzPFTE4g~L&;j5qk5BYa3SxNz>O)*B$|Lx1g zND>948x^zJAew?)MqOVol)Gq3)CWQLipzB^eT?BK|8ZLoO{>aSGWPu@{tlPTnUS%< z;z%6<#BXx-GL&8KQ8*=%Y?88P)=8tu5qLzA6M_dRGeCI+ilT5~AUPm0OM(cOsW8%n zg>WTNze3BREibClBKB-~S3Dcs!VWzMK9+Ys!MF3jAZ&TP%cwr4mE_0?j2K!<;=M7s zBr3C|rGZQN655*`T_Oe2t0$!*#?B>`H=DaSKu7~iV!!f$n=x;PmY7$@!4fCWjk+Sz zg3$-J0@3EU4|+=m$3e@*P_K5+B=;}ke(|OkRsXDPOwn6m{I=(fE~=Sl$`{pyoDF;h z=S=G&u~IgF5!1iSs(ZoGa|ldg-vz@A_qnj{lFD2(^vPY6kDgpM^_TVjW#q*}M}Y8` ziz_#9^?V``jDAHqtavlX({Z4*zdndi|GT&ei*JCC9tE8;j!m!PiTCyoDppb4iDKi_ znF?dq7u~dd6=#R|$=igdzM*o!q@=x_lDYL$ zc7jtpC2yam=6YI>dxs)xE0;(EqBLUZozFP~L-; zdivzY^m|*T8C7@MG?oH+r;XG@c@aWiQQ>2fhK0AP9Cb*9?Z&(U7Ct}4FMA?dRq(8> z^b0Q1B*aAweF&c$t8=a~5BTS2>H;=@rgO;gt+6K}b7UAC|Da8U8S`yTYb; zhDA1c;HV+icMyf0WnjjdKJsmg>h@kB%;~UvK)h)B!1a_ z@@AL%48P~vA;Y^SyR0+I2q?258Sr{11lv9N%t_zxNmy=vSY;zPo3A}`n#8thp)>?bfS#Ga z0}wc15kF@EBzwV0WY?rzHvrK9dD^(grTL&;$}}MILq%Ih?LxnAdiT|Bw)g>8OFTR# z%m?vY(70`nPs~Wn4z`fG)`Wi^&R)(6iL$(em@wpwf<%VBK-f6duO|H%4JG6m_YAmz zu5itQWH;`LEyV+>A!Q0*+tYe<{AT}~+`K@2b)qXLej$9nvu%b zE+l(tbU^2R59Cz%T>^7(BaqmQBfeYUHn)vp1Lk@cD09e`#AJ!_Uwj_4v^dMa++Qjd zL4nH=TfpXO1nwCBB15X^VDXAdOEXH&gFmOQg3@>?hze))fQLb}5|A^)TTun3!JI50 zZ>1IhWt@ro_771%EPaZ`;;h)Ea4g=?WU#CQzYJQVAjjC?kp_-rUX)bK*KP%^| z`!$TQ`|`tV!4&DW_zCcMNiNvjhmw7RvU*+3KXPZ>#%)E;G-3g0k##bmy& z_Fu7+_f>X92I#GT#@|q**WBXQ)gA@)K2Y_mUS>;`-*D3(sQou>u8d$^30K`(d~Ib(Q%MxAb*2{E=IYpRyH}_NS%$?oYQ^n+g||eogf@r6v2-yu6GXNx9&(_B9z{Z)g5x^-u!dmroOw?y_hBq?$Z&d<-rwV@w{Up|V$7a9S)WX5uz8kBmaKFB{P3QV<2o^5c!;(N=oV0Nx}>#$#E{lQ3*QcP`!~8P^i%Hxg)bb@_0Q_d=9v?* zKcon=XHYAwLA}6Df4^L`C*R(OV$}0xeqT<8&Wk4uF%(Z8CZu%GJ6bXSxfk6t@r+Fj zzL0b_)yg%+HBtBUogFt56*m| z?>_h3i+^M9PQ`gk9^Yz`r09M`%6jD%62O%D8!nMqByf9Cc=E{4Z{Axvev9DrvbJ~Z z{pWZccC1e%mi)mdV7~PGEcd>B4>??%t;xw;CRq#r?g{e=;Z%?G_VFw^htKcD_m^)% zvODK59o%dYq#hIsA>T$_AZl8pMO6dBc=-2WJO>{4~!SAwm@Lpu+o}Y6w*0LS=e7GfOPNAgFdCE9JoBCQN zG2#-2fZjW8SI5o+TZc6IE|{7y@+@8?xdTZo1czfi)t`s8BTo=+BQZLg@D&PAIz&9l zy&$YRjI!WZG@G&*rViEsnVn}C330p>*uokWNI(gj)S)7AIcN!;83*qj^ra9$gtJEp zcQLtDnbJY~nObOo4+G^Z)id~H*XHff2V!Y`2%bwn+3QDg-&$Hfh8p@dj^nw%=v*V0 zUV?*PGR;@i?xkb;{BP!{^X^e`$M!stwELcjAYG4|GIn~FpaEedWv+!M=PhoU9<^QW z@1CVRW`bY$P_8_x0oV@hOHg=9rtNqn#F?r`fizQN;AM{=>t{^MLm4)EJbHF4Uki?q zC7B7kaZAW=aL-Bb$oVS?=_OW`z=L@c>dy4C$w$%g3g+C5UBgXk1$!QX~ zi2wZd4sgepTYJ7HE$y7w*ou9K7Ap5ap5$*QX(KW{&*G+HT17b4;h2|k=K4WQNL*82 zvf2r+qMi)HVz!j=pOUyVf40xIYs*Q&#Z{4vV8L3@W+Z5H&+mKiU5LsuUr-#wRfoDy z^SjLs#=GE${E8JngV~iMkYEM(M23~$CDw}a7V?~=e1Z&6^-LhmtGLQ%FQ~!1)3iDX z+9>{TXA(07eJ9!j%=ptg@ckhJ-Vpo#t(%OZQ$Wupi}>$Gh5bJzF%6M1U?*Ra)Wp)4 z(5GzSOB~&du6_wa^MlHl2;I95oQx5V{Y?p5G)BSB~Vx&YEsgl zdJOMS0Wb;NURgsSC98w(6eQ1n!+(pT46S z-WG8FCAY!&FZtsm3w%ip&bh63)ZV!yaPR!>#yhH(aI^1FnmGxazHpPL#ux52-%ds`J>Z3p?sUdpY#LxJTaaOH}ddxyLyAHM45-%;bMpU4A0 zWOm+B-5++-@2K1tlE8akkhx@jBsqRt)qlk6zOC|KG;QYiMYm4N7n8v0b(5k0y4!hM zmDZEM<8_(W=c?;D7!2cnIkIsl%e zODI*+nin)(@#l7x1U%+O#Sf{Qb{$+Kx&yO)h$BIuN=vc@-~fW>a?fNL1i|1pz|Rn< zap3xB>!RYIt>CUVeW}O9n@_$I+(qHDC_W8wdHeYV#9L;FrU+2G0+&PznE0GAt_q@b zh<%ME(s8{ncQPLgBx|!Q*F~-|m4{N}@A1(F`0;;RPKLB{ek?tkg2MZ-iSx|}!C0Zu zw~K*-%!r^_@xI+XeuvyA<`hB*GBB3|-F_#;{V}@ztZtt~hfl#@{5#?KEMGB&k<9e^ z9#>-oNCKl=tlg!EgcPF47+c2GV^-+CD3_6*cr8S5}CaPVKA^l{?h@FuSx;UEzd^22y;* zqhJKjN>Z^1Ut?Y<5;vE7+g^!H#NShGRr-l?4N6C{xPKl zXo{$AyMG1UG(pSWMKemN%q5qWQqt{TQN5&_1%8==nyT`$SGuBVuefb0wZCG^RB&He z1FlQKPSto-m|X7lwQ)){UiXG6HGbVv<$m_MrCNM0W$VKT}y1>S=|=D@@zLAMEdI+kByW&GN4 zu5l0AzH>YZ>$XaTFxJ<(MiKT}sLJF0hyfn@vQ8$Nz>G&cxZnYJzb4_&*HJ?dup}Tq zO^BeQvnPi1=~|vL$%o0Vta#k|Cde%o)0 z3u_me1zJlVvq~)%Dd8#exu`&y8!qA|Lc$24BIuC(TnIuw=BGyb{gl47DOpupyqT~n zrcD$929k6@?u#h~gz&V34lNW3%Q>1dHWc|Jp1ly#BWDE;91`&89LoKs4ixmd$J{sX zBV}l}Bf-o}kvJi8q7Pp#=l)9y0ZTqOFpDVED4#PMYr zwo(jRw-}@pkc4)Ga57^Cfm9}_F)*Ck1L}{~;WD5iWnzeRfFjAQ19xB^=tfdSNM!Fi zUSUxv2zJ*vIx=9Z=&w0HGu=+ z?F#K*p$M!9pi+M8Xb8R?zTwB2uvgnGpni1pxQu^cRfPHt2{hS+5ma^R*?e4~kwGiN@r>!QEk%*$&J*84EtNMxSRQp4B3i;mT~p4>scX1d&RD`_~)B`s%mkD)Q8 zcI58Tn7YFfA2J}NG_*s{0>~vMH;D5g*_7{$-4eBCvvBZ@;Q3L!+4D5+ zG|l5If9gHh!o%irm|Q*F)GTHu;N$~3dy-)B{^L9hIfLJgb}-j9!M}B=gTDsQ5|_M) z1ooY6ahf~=k><4OAVC*V(Q%lBsjiMr6(hw_uZ zPgmBiG5c*XEIa~h>v%In(UgndQ7gGm^~5Er^Lv3Q1kGC>n-|3SNerAYRepa2t5mtQ(St5Fdi3r`|tf>xSMde>jUDF14TjHutYF-%t{+SIc|x%Ul)vU_!@>i z{~6>8jskuh#KDp%C(J*|>dFcu$qTE(i&pdrVkO8YAKWJ?n7G50BwT`nPK1ydOu>no z!6dd4pJHMC$T%8KRB|-p)bU6B;5kv)G<_O^PhUOeGj?^Vi9{nF=Ssl(>8P-T7G(LE zYtETFIt%JtPRfOQJdWm4lBiRu@Krm;Xz7=HC?UiRYDgcV3Z3CP*aMC}=75(n8WM&W zRM6o3%dL-l$a=x+?h5C+#~+DJ2j33m4>^0+$kzq2@$VHxk2^fn^4wt_S6A5Y&F6mm zl;h{wf%9fJbM6=(dg3;C%@v z9pHUq%?sJ~yP|7s3qgK_5RBlvC+t`%6%otL;Xuj)>rYBq2RQxdcq?(RL?^DGjIZ)J z&&h{BU{2(-{Npr_IJ`eVu0HNcG`Y~@ZLi?_`T6D-{oB3mLhv4^SeG*9Q~5sPR}&-< zUN^Un&Z_7ELbOpGv33k~9!%jsxzF+*<)teLE;p??qw|!Bba3eH5-d+P-4)Usl~Kk2wxkojk9<3g=e4tL}t( zt}?^In`Xkx-UJ6#<8#jLd#d?4Gki~tK4Zk39X6pE7MQlg|1Dx#t!3Mq<8sNS{bQo5Y)?|aVq zo%jFwpU?Z$*w3@?d+oK?UTf{O_s-O`0>)jFJ8Bj7x)yhn7PegrMIX_^MXkh+YH=rN zV>4FrC2M1Ikr76eHp4@kJ9ZT|S6dDeHERO~4ruek>xA|XY6|e7-B|dJM@}hq9l~! zO)#Aj+*SJMvXlW6OqC=Vh}B&FY!ghxJpLg=O!7R5c0;&$k9*V*6M^SW(#KTb`74Yu z^>_eG{Cxg+V`K_7WQ1v!;sfvQ1rgW@eXx0gS+#y4A0!%G$X%+3X}5hinKKJ-Mr94^xU(ivuhrsOB2$R}%_`o|# z7x`rB@fRCo3iZ(BTVcT6X@I?M&GaoVF zsx#5T3&0rJVZzOJ&661rbly7PIxwE~T*=r@-l?!dwrbi`*wqW~N!;Rfyj)G(;-$P? z1Kj4|Q##EYx`_vjez@v+Ji()aR~*iBZ00p!miyBNA!xsVn>eh*g8mFZ){I4%!-N4J zMh64y2|*Yu69!Dc$_>Jxp%uvDBlCMaIA;;y3!EB=^lfl%1}E4CK}ys%L#z&ve&`60 zdEv?chB64Lyb-U2aJ~~K4zfO6BG3k071-UOO9c4pz=sQ62?NGa-yy~5yI4pP3pPn; z+Zq|=F9MzCjDbZ!zzlK}gtFN|Mwoy&&I+pm=L9nB&p^Gxnw*$@2SPYvU7+c`jD1g_ z(fBDn@V*%^nWJ@Rq*H+9vus$wLrRWp94>#dTs@0f2O!gAgtU-~u{Y~`c2IY3*cJi0 z4`2spfxZLb7Tgxu239L*dp4|5;lRIJn6DSCR)odDY9&boKPrwaTT0Ro2w1>iNT-XYn zK@%4?iVF|#ENCMfYQw4m&o{(j8;_28V3mQ!8&D7Ui33Lg1)*|*2P8`ayOD{NE<^~2 zEVvn`Jc2j?a4UveLK&z1)-g7ub^o>}4&+HCu<$ZbLh@ujk|$vTp49KqZ&6t32}Cxe zd$E`ShQ>O7r^-!P3@`7vRF{D6H}$(ID`I6yE2Cw4q!s%XpdW55ndKnozf zKomH-;e7xdj)Vn4?t5(@Ex#MC0u6koa3qJ?54G3r<;u(0d{e^ zOeYvE%)mDRiJgIm_uQHMVCb6350_h_Ya_wuI=>veCkaG>RVy}G@H;>N9%8`agD5`2 ziGy{Qb}ngnkC*~r0l0t!bO>w34X5p1#|me_sk)qe5yF`4sr<=;Anmt9%af)F!{M6O zry-wYUT!#N7jA1Sg@ZQvkbGSamtDf?ywmx?95(|lOa(*SnL6MD#)Nf3uwX3S38#4q z@U?^KfdDQ-2)H%=dN}t}fDcNnf@3=cG3^4}y#m+@A@MdL?0^vWgb;Q_2xw-hFkgho zBwU}t#G5+6n{+4_)g7%#GKnBRC=`=85N^cd_&_-c#|Blwh=ALoKnXwTFc*M@IPAdf zg^Q}-N==3w3Gqfb5Z8k6hDB?|GtuGN4h%mD7%YgN<|{<@1TZgxuqq7i-JH&ha5$I- z{3@`Bnl~f#xDE!WkeovX)e-UIkQoxXfk@~6py~S6I6c?ybpt*qeLJszP z4L0WhxDl9I&{<$~;6icS*=XArRJRlqmvHhN7oxh*HanR1p=!2xNC8_TGZ&c z0oz_8V9J0pKuHaA3}6PQa3@+jL+cQLAL+2Di{oxXm;nYx*lrx(2o99b=*pNVP*!na z%fT44 zAWN$9gJg~H6X?3?0Gy8w=k)=o;B-Dtz6o^kn+6&Qo4er(G$cr4dKs6$z^M~JJg0zV zZ&~rAl@tap@Uv71Xw4}KEmA>S{_Hmbpn^4bXx{`< zw?jPMTy%sxAP!UueNQvs1GWJ%IOrScVrAffSS8rI%-EMKPR6=HP1!Jma1j}_0Hy@B zgE5Bd{OP86$d6kF%omkVosb^O1nDX=kUa!8cTy`DVGc-#P>i;8gEk39==U-pogAR8 z5&-)THjp#SVFRue84VD$T=N4rCYKHG!k|u&fbK?R{bSt&lx2&yHbZ7ZXo*qjM?D~q z?>ceR7Atf~fdjOMr3wD2zT5^FRj98Cw*%H{GQ6N{4OkLO#=?3OsAwPxyTS-o!T@Rs zTSh-{9t`EYGg_GPgq|tHqN^E8fn8yxMrj#l7*@=*-)&iuP7Mz>3^G8k ze%n#N>JxsjJrxFo{llIFQRpk6d01RA(q#U+8ivxt8iov7I8Z|3&`LD5>-eDx;^DOJ zCPrVjA>ze`4d`CzXl{x?6#SRE0?rzsRk|dsFl3m`ut|Y-@dN&dL!+6vWR6i{;_Xa0 z_2TM<*YkUw7u1KV~A z6)~}k`MMP_g-iH)6+qvR4!JeU^TjG*y5;%m6)_X?_zERVrUGBF0w!Mp38lrkAObf<;c};g$uTo69|lN*3_l&C0S$!wEr3yh{uAJf z0tJ|Wc#;S(E8NTL9|sw4$)3kL`Yx(F9{1)3I^=K0`>X%e1JcG`(cnA z&8k0NHsfy5e9mlW(znCgu>oLw0=9OAFml1LaCBNcR51$fV*pD;eq5|N+=9kk1;L_V z?FQS@_|r8o&7hJ2J3d)HFv4qFVlcWKlP}K~u^f}D=$Q?FRk#OKF@@^f73$bhb?E#N zb-rW`Y=njcPzeq0d<|^2#t&-8fB{=Z=8xrs`JtIrS7c*>QN>_X#ji8$GeH;vkqF+K zIFORN4b7%MXa@7FKm;m3B#4CSFxvai<-yy8MO&A;p$*0;>vd#n=g` zTOnNMgjfe4Szw$EugN&*N`NKOTR{2n69D3@%LdFB zuu{N$!GR*cmZLkG;2brWgwccMBLR@%tFS0m1Z;i$p$>ucOuR=^FrJYgZw4a%Kpp{l zDF0u>r(>LfPeCUOctQo3ai*>0hq{}?#KQdM*Wm|h4FkIy$%IVWDvE(|<%Vu{D8{w} zE6Z3+Va10#mI0xFm(1rziz~@snGMInaHF$_3%SAis~fch%>@gRfyaRbXfT&X%aX$F zaFoqdzBaf@3a(1x#%518552V50^QMm8=Yc@|(`f|l6KgHV%E{ycu-Fs;NQMuhPV zbP>&Nr3?=S+^xXOh|d|b8P5oZ&AyC&76+osUO`MR1-xi$<+=<2Yq5%Sk1%9IO zjC~8)sH9#FX@FQfCmR+py8%8-fDS|qR?Og@Hn0yx??YhbAO-?)2KtB+6V3$1j2Nq3 z^I5P*jD9^*n-+7(z-t|+3{c!5#1zAW>cV0<;N&Hcdm!Go!_%-hJh$8rEMY|b|K^Xh zbjbPgeb=pBt;3q6NxpO<)t5lV6RA`RRTED{-$XaOzb}nD#sMXzv`A?x%2m7T3L@*c}28Ua*h~P>mK?zV3Utb~_??$4zQsB*% zPIPl8kWiKA6gwlbbd-0DxHK@sxuBwrGpL?Fx4T&YB&FC&CRCDQN&R$Jxe{iu`x zk{gjKPYD1R!rO0=ek#M%($v;e*WAfQ-`dd7@~0px>$Uc#28Py7YwZoKt>KM;Hz3jc z$b?`#$;XdO^nuPHFu*}2(*3Ev(0G76qvN2t#2`NgSjfT96%;B0=zq4G|KkO)@MYn` zpu)?E)iwm1(1wL>%P^R^n7MP;8T?($owG^BY|f@-@Pq%#@KaecXVV%Q;0VdrgMqzx zS2BeLz+@pAtJohUSy4zdiZ2tY7|fhat5ueh8Gk)psT3OE3Drka)776!g8`&z<4Fme zR;O&YqDe42kcp|536Lg-V=M zG8)IshwTE@WjyR}w#)YC`M0p1nKz<2C|_U|G2HOufgkX67+?`G{P06_Jt_~lSqw;9 z7+5mKfW{sJ96JWMdkk>p7(w`1+u7JUS(sWn+1TppuW={PflyNYed#10BAZ|`sSb>J zL>KXX1YZ)+A2)7pq0#}XFinF93nuM@LrT_BZ2C*lJ=DL|NA{N3G& zs3ZRt35WKehk$pdP<;q=R4F{2N+8kyXX+B@3AB&yNdz*3kT@761=JxDe1Iam;e9E< z?wrayqrxoVU;FD$3?x$VL?)S|;Yl#9P^pAqD8}EFj<@!6Weh-kAc^jYU-ms7E7*oe z1?p?+>rP=tQ28|iJG}w);XjCCOYwt|=@D$`<^cdk#Vr3d7hC9fG=vQUfD|+1sQng) zm5r{)_c*`RMxX4D$c8z^!yIz^2N|&@-W=2x~2a(tf zWXAd(hW;)vq)9GB<{ZzArTjfFW%g&efcXBf)(deue(Eh4aX7}n7 z{0J^2GEB%Y0W&)2H^AD^2~>8Df0g0S9YC`pPzgT&1Bi|NNp1vRSK?2g{;lEcSO$P6 zfRVwIEAfvy@7FZ{pnr^s?3S^rFgfpE`{_qycCY_c>`DE=Lbtz)Z3A;ByUw;02A=;_ zbQY|C8aqEh{5K%}TmUQmAB$ixoOluq??a-|fGPp$Va+EIcorE#G#;J^U?~|7`&*Lr z&7OA<{YMYmWq!dj9_t0oALwDb%rDsI!F-7jW_hsVqd5dUY?t|kiD0{IzL*^!%{S;_ zyUed2<+J%`w)<;1yL`6$YdE|8Z1>l2ah3<${WToTq3B_|%rC|Q8RL}=ccw;w6$)^l z$9l!HTs9^C#(MNUNn|%X%z)l_KL(>k{LaI(_%a&Zjp9#dGxp3cm~aVhc%}$Ia`Pe5 zXaogR7oq`D$6kaI!u zAi`faT{(P^iHcE^NI5t0n+)MA%TtOg0tr_V6H5 z{rp{EzE3n`G=tc+($K|3Z$VERxBu+6XoT5IRN?xk( zrRZ%)bG0QP)S{5d45a&!Rm6|izkcgVi|a~D?8-=k;Y9#hu`4Ya&mRAHJRajp0T$jD z(2x*7@&Lr&>Q92MCi)^00AeK(jhG))D$$qd5lr@> zxcL&OFg}U&U>6`TY`#HY?py_d-Nj>gT|`7Q0fg2tnlzzwq${I?!h|_aESRHElS*Wc zA4>|%s2((YFp;jX9;9C+%YsCVXY?x`7!5yvx*nplgr@iNP{3`vn7CjK|wBhytpQK66ge#CIjfcO@rvB`0?!r*|dq>`IPc&&GHe z2D&knn2?_=J{12w2yr;U3izKQDA+Jd#e1-%5e0>x8o?qK>}CMip}9;l`Mtr6)-pf< zeFX|6P-nOYvmz+~@8eJWS=-3qffzF{l^8%G27&?>aFOOutG?8gz6;Q}E42hlLG&HX zop>gtV-#me48$W1ZUBjnWK%L34^$3xTzF4VGArQm5Ad)aip6kP=wVvnRRfEGe+x14 zEPo8lG)NT-QWpUa(+{Hy{#`wTEj1W1H#TIFE0Lj6AhEbSUwF?MI#b30SZ0}$6jI5f2#?qWf2q5Z`!2?JE(T1TUpOo#7hzu zE*4p4hO;;&MscQYh$BK}0xhPSyZ2I9WE|1~d^kT6PY)Xi`G#9jiVpRUe~; zEH4%W{=ojSSwL1q7O+u|62EhW|08qEO3bd%Bre&8EvYeq^p{MtE13ihnani*ssUuO z)$C#rrW9oH-+=_+_*u|kyt6a=MfdxguJ@O4)SE0Xb`GdIY_G{Z$M&17 zt7ZB9++ja^vb&6_3;nfw7>ep&z|KnlE6D$>Ol3v>4NffoNu!5FY}mOoq{GSTQFg#@ zd#^9LH8Z|7|mn*vIzwXg?<9auW?xU{vZxNQIFq3*?sp@)|1-K>V%(y zf2IM9!S_=*yH|d~fuCCaa}KQd%sdeGyCMz-t?;^&DS-?=g#Cv-BG*D7TK;9}=fIAQ z@vzsuBiQ;rb~r0NTQ3>$>o+?-OJB#Vht>J z!A#B6e=Js9tnf>#@dS6^b?_+U-_gPRGm`G)b!fIaNS^}2t1n&NoapNT>sOv2K@h2| z7Na#z1ZqVe3asH!sBCKk13PP7Thq0cY{0Pf+w4AM4OTo$-cbO~$3u}y^rI|LR#^s8 z4ugfkquCPl@E|Fi%A?UdEj0PFJ)>Y7AM1Fa?6At0!W9i8Lf=<2T|B@z(gYcpJP4{I#_J zri3KVpcDADR98-=G98L!I_tZ59n4oOyEp2NzT*%2@hncvJVL5sHL1b<&>A{B&9nyV}{2)acRA3}uX zdry!<;0wQ!QL9yWd3l;A$zlFW z5APA|a5lYU(>E49i(sw4u;W2=PmXmQn>hG5yg6tbZXBT;B2X1k+*Zy+&I=rxxE;7M zPH`?-&So5yqnl$Zj>z#tb5)MP%(B^}ev+Fr%3qUa8eOzr0|NwAQF8I zF_;Fa*)$(C`cygmYM?h{uy_C@W2pG$@LTbWBrpuX#=!SG9?Ht$nVL05upAx~Y-pIl zaz7;yH1{ye%Hgd%?EDn1JPdqcjfX~;!~b#A(FD&yVTuR7HGS&lIjXimP%;z2E}Atsz*+_ev-%Vm)!bk$^K)4|ux82!wlg!pcIM}hzxVa; zrvH1T{J&WL8U&EV6dt35(ZN_@oH2etK$CvyBJ2E67u&-{CCs;y;A;L zV_{Tv)O2(>b#!zTpp$eK$<5V~S)~D=cmpGT6JZq{of)F=#eSAZXo~CbaqH;J*3pq# zDLq9;P)|q4jE5DBV@LbN#bJyTG(~OQb%La*{1VPQaK|S(%5r1a9vM7=j%TQS|2B0a z20FotfUMzKBQRqFvo^Fwixw1t<08Y-_#gaz@yu2IKLjB&G)ViW5Qc3C+bS0Ctm*vY zT@K8asd#6{-@Uf~@M5UOzo+>nHz{Yf+8Fpn08)fN2L!5i&oeg0dX(X-L(_1AmU{_VD`2oSR#lm zRRn`*cUdy1#$ok`z_6gD`NO=87Oc<|?@0{8GXa7u3jr@Ym^{LdFLonoKa}2JbPfiS z?voM&(+uXc$ncv?@&TrU801PM!tykl2bs^%YNji#!*n+=U7bXx8(YJ4y+#>sM3jOq z2A(ECSB4wan#OR6ckdz>@07`*2X1eGi8^wRC(rf)SEnaK={UGabFWuC1Xp#@2X8)o zOhl)8aE-1$CbMnVdes1ZOr52N&$T>#jP^d~ZinakI`J2SB+rQ$SPgbP?4G^GV1J9n zl*XY5gO6s#XCHSc6ywM|~l znyo(ONHrm3c3uzqkYf^DC;su_`F0cY?Ts612L()zZq^^V@~B5Yh%X12<=gG??S zGHbXc92@godX4VHEU&&<8`qrHw(3&0N?Idi(Ylv(&!X@vuxG(2{RON<3*S-}01x$EO_< zU6%WjoBfZU5M6umvZ+vc zw+dHW{A6L8ot4x0(7M5iJyvs$s2vb_T4m*8fEzs|^1*7G9ajl<(~@;Qb~OUegPhmZ zEm5_fm6p6NWcjtcXBD^Bk*_)2;T6SL-+ZTVC1H-L^@_7)K2#}^_00{k(`I@fvzBbT zOzb=Uz*^c;=IV(!Zkqza`?K+9SK07wU;jo-o@TRlb3w1;=qVf53vDj`A3JR3`rUM# zRwih>(0s+kI8S3+g<~JPH3n${0G zE~6D&;2gZ+?ECqI6%IG_Fe9YP6o+!z2#u}>CmfcjX4XEgYjaqxYZ4?dK4Zf*8Tr@! zoAo!mqmG_js2a4PATeO3qVCxZ^|Q{9Dto&&tj-j^K5$%YXFCV`t?d5eFvy5w- zwDBdqn>jyjvdDcQr5d$#vyy2;N~Q{7bLbP@a+C0s&6}(R`)5D9y*Ze+=Dyz@jN^K% zr&BDxsyg~~rnNY^p6Td6`=ZABh-ODYLe~_Z%xPQlLM@~Xa5`HaK5>?} z-G^IZcdcb*Q_P*sb2W>pl+NHgJ}wha#VIXjfpzg zQ%rPe#`JufD|Of<3P0#%^P$P*WN*KEt_qiHu^oiYrD>QA4v3bRq;4%6m%im z^{bAagMH^C*JGZtpO(wROg`0k& zew%-RI8m5pYx$tfk~na4gg?qSlGyuMS%f&Nlt>5)+jwQ(TcS2^;^@N%Qto^W=S%8i z*Sja~aO(c%wb%V~Vfou(Z4Lnx4Y{exJnXKV6pIquiXJ*R7aQNK;}_B1j0>T+~mmgmNn%JE$uEuKYY z1tA{Scu40g^Yc4Pbx0dsT3f|m(Mj(#?@oV)KSO$Gkm(s`+ezZHuhTnA681{;4tX#u z%G8TCWrGR6C)|rWTU}mt@PZf5-d!5l*S%imLpjmc7S8ilvX>c2547Q{Mc^GE%xsaIX7yGN3D-||hP<|&W8-ez`}%Ig$L+iS@|yBn~nNvTVXw!@Z+t2{)e zHPLTMm2_s&aMr&C9s96`d`RFTE9n-IYpR4)j*S%2*Djl|!)jaJHbW-Weh1df}f;_gE z`7IB=ZAN>SMm{qr+h(pYUc~D?w9RQs$kB#f(xD$?#EtY$Z3rE+ZS>yFofN9E zq^35H=X&VP5n8GJ^v|KA?~>f}trWLwrOmGL-tW4dM|*zZl)&`u=JB$d>yjI`7k)ap z;)?`lScX3)c4fDE80P`ZnKd0gVO^Va%+{Vc9`=~pB=N$wH7qF+*F9&+^zgDrJAKz_ z>V?ZDEx6rN7ZARTp1*S5w*2tSy4Smg>z}iBJ_EamSVrI<{aKF--Phlp%KrM{VlYy` zdi`t9=6@~O|J77Vpe|QkuBNWBg5cr`3h;lma86DfmV@*A{~sU!+iy_B|8|yt%I`l~ z%zrfVAFcV17W4n#%>Nqz^xx2i|0o9iHvs7Wwc&4*`u`{{{YNwZFX`kj4TLCx!1_GQ z^AC(A&+^i}Q-<1rnBx zvO9dwIIKggQ-XxkaaNA~Z{<4jId!_{U#;9;*;wEAD!tCd#r(Nm&dAiYz0-CqFRi)X zcDWg|esyWAME_&6u*Q}eRw#6=USL3OWEo%%SDCbvSR}$2`yYPcb z(=c5j`;uezqeMrC$aqDs&9f#J-S>-sYbvZXFSqL9mFCkM!lReHyInR@R;5*TM9bw7 zUM9ax)^*PF3zv6)^7p=hwb|V6w57re+iqHTKFrll^~Kb^>)h^_+i2uGf83{=+WtAJ zxGM&0xSimYbc8P>Yy2?(D>0tJS1zKDOAWP#t#{@QShM%k?H^p{H8fw7QQp_rlg)ql@s2s|l&smKCJkMo8;ZBpPC41@ zD(racXlwKI2R7p~z6u3y)eBHueMiq}1bgFs`tif(KYDn55ZWmhwLXV`a9)!=r%@^G zeR$5(<=zK2%PiZaY#+31EN5=Z-lw0BjK2;$vsHXtEOj?Aws-h?y#36TkMT84C-SJN zXJ=>Vlnf^1#eI}htvufy^lWrN((O%>H_yvQ=4go)aV3gyW$$0m+tMBARq|wKw7?}f zyV0)0-t_%Kc6;wRJ+tYbX^?%&+&rNCQAWu;9_`VTgycQn+LMO|S9kc<7xI6+N))^L za5!h*!Q&2h_6)RM{9svdoO@63zUwFVz$t?Yx<^(ToXUO_dU$*y!&>sJlIblaF}-f- zjZ?FRgBtoG&)$!?Jn}+sO1ET#vtx;;cS(1)MfX1Z?xp>ap_vk@xrY1?QZKyNY;*5X zZqVkw7yhS&V>XV>creI2zirz%Z~N7iuksadt$gt<)q0P(f3o+%cUBz*j{Nq*3Db+D z!)Q-WZ_VDTsWdOG{bFWU-G*>Mvn>`EGldU6X)fZ~cd+c*jTi}m-scJp>Ql^4U49j} zs`g&$Ec%aBkhd_X7VB z``N+%*@-(8q?gDHKImz<(r0UPwf$1totO^)Z=we_1oKTueOv!pnBbRsk!Vz*|7t_f z^-D)nkMY(FKY0ITD12pO;%S*XRR>*H=fvPw9@=y){RH(;?MKmBArjSRy>H|d5DKwZ z2ag1u<9N0F&iRQ0R}b&ncULk4%yI_KE*dpBWdvUhY)E zTP<9~!vzC-UN~hQtUPA)RN}(R5Vu+Pi?&z49`7sAO-MfS%qhS$KlhQ?T_9R&G~Tueyi5ehoq-ZW;`|=%PR{rrJlv5>~?h1lN8IE-`?GaEi!yHyHK)R zb@5T_uK46Fwr#g0y`Be(cLZm3+ZOK^>b^O&~2a zh)(*dsvFz4*KfAlTkPYQtyO3H-g%rQj~sNEzbQKIxy{F_!&|hUy_$Ig^CB$m{J?7U zLl@JZC4?M|tFaqN?^bwzyfLt*v&7-moE$ll#r{Rq&nt!fMf&H-5N@A#G$`*Gh|qd# z)|7Hw`0x$UybFW90bO2(gVk5@PQB$`S3M76h|>=Q66qffNp98qEa`ZCTX$81{2H+% zPMQs7ra=zl=5rT6wKX|3qz2a@jL;uU8664ZNhFg*sk6&+AKJNNvpsqZYO8A+be^*B;~c zs#)mA+w*lrImhdGO8=dkDV|cT@8_R5X7FZ*VB(IQ#Hn44R%;rY){!l9bXM}6(jD6R z+THcUF3*U*yljp8EjF09gCz%MbHSvU1-3ooVi6mW5JEw0VGbVa?fsr269 z{x1uAW+$wj^;-G0Raf?E;fj&i=@+dI2BhMjn3T`1t{l1`GWFKBhP0~90%XlpUZF#^ zb2@A6pGAG-dSR|6I7|J;k&Avya|>=9NtN1Rs1r(=cxvf^yL;>XtsdUXYOPZfv!bRY zj|Qz+y+d;*eq?dtorb!Tvyv{Kq@2u*64oKC+Yp7xdQ;ypr}_b=csMXkW8x#Zt?9aI3zjk|FRZtj6~#sE!k-lxE{-B74FUIZ@g>a95m;ASeH zc6tGB#0dW7MQ|}oUPbLgF8|5qUgYXr(l0|U<}*hM!B8~$iLTe~;SS1@uZaaNau*rj_5qKxMl=ifMW zkwiYnRjD3bp%>EAl6>{y1dBgSJ6Wuz>*TllO3RzdhOaN5d_5z5hxgm-b9Z8`DAVdI z;)P}uuhA1ycs{dj^R(sd(#4PR3pGE5N+xZ3ZgKrea>`57;%qgQGl8X39VPcCyxo7` zBq60_yV=}<{@DWi-MD)y-fnwLx5|`EE0X(E7J<3-Zt5tFey-MWs z+qWkoXSl^C6m=iFf3k1I$;JKQx&39y{nq96(SiG7&P0tCwBC4p|E-#QT)wx+%&VhA z+4~hsT=mHSa;ybyfR%fxn{G6S_X@#7kEg&ndy9otFN@A^5m>(-et}=(vDZ_ z<|TQwyr08ySn9F0L3L<@$;IbSNQaZR(_ z7G-_C?X4R8d2aC?E&HS~`S6d&6@zGX()C+*o?kTY)YO8sri9X-(qom+a!wwIl7D$S zeAjX7si7wf4z}#by+3i0^ZA*>HvV5eaTLd1-xZs(;ppYZoZgv%!aZYW)dpwP2K0}| zRffs>Ne6y@=0>y~?i-cAP||<8+IDPxV&$3kBQZ%EdhV3J5S#KiWqcUh*!`qk_VIlg^p#oorl<2&4{6tY)~regaKZht@= zs#vc7QTcS#unyi2Fj`O=Uclgz!f>n102NmyrAn$niX6;AK({W~@WG*L{ z7=u9PkQTQiZAbQBP!PCeR?t1;9Yq)eTsb}WcoErI6^Vj7NL^HOzdd}e? zRerA9X%p&OcKhao!!y6UBeWcwSKE`^*I54T!o5rUZ&#<*e|YRP-N8NY#C4OG+CsYq zcV?bCF|kf7{N5|;zEs_b^i#Y1V+^rhgrbkzQI8)cL_b_B@L@-2@zFf$DaVG?nl-y4 zPr7WECg(qC?6-8YT2Oh8W=D;rtxbzBjkUU8D*nFnKql?UfzFW&?JujlX3qYUIL70R zyCCcEa$IYWR2^efnsRGldBZX91NJi??h|u=_WJ2bfhU5c=PPs4TV(!A_Ey_N6nNfV~rFH zzGzi_nR5SK#%)E+9gQc8#g1P+*m9t2UjB~yYP-uv>Vq4nH3r@k;~77xwYv8Hv*x;} zr9{7|`WSk1h2&mNo(CUK-xAAiY|b5x35cIE>+R#ckGA+}7{`h5o(&uRtg%vHWt{T7 znvu^tYM<^szkJEKhsOC;AC|LF*om+Uxl==m>fsx<#wdNm- zcDSG3C%AsreWTpYvKjdyGmb?^q;%&m5liP$9V{7o8)Ve7`Ra|~IC(Asv4dKd^F>;U z-;GQ-R|cL*8mV!etz7#yrzV5!I7hoE`~KL__H$pVB6I78)2^o7ZH&(R_)NuU>m%<7 zd-rvIy_!i4E=F_PjpG|p@vIHG!8Zbd|4W%tL7A?Ia#$9>-2 zv}kz!-1W-FDZD2uir1!_Xi)l7lX@1>tHL_gzlz%R@nE``&}FrITh0m@%}lPeR5lw} z*)yO_UCdpn6jix#(*XJQa7Nnor6JGtFDJiOvmdumOXja0HJE7K7oc;vs<}2^LfT{W z5dSg1!oJel=Q%x#_S%je<=~yhD`M;T)$_Q|@s9TGci!xfz9=Sk=lJ@D631Y{l{@1K z*H0u!IyX_Op51CQyO6W!Zg^Iw*5+LgpQ|+9b#iVA+VP>SHT=REdF+;u;Dd>=CZ4HD zA2x+Q_OS`l*=JgRwo=a-qnq_G?v@wEqoJYl+0?;?(Kp=sf_;PD;GxYpHo!}W`|atadjw;qu?-6}FmxNqDt zCf~qVKjs$o*aEL(94oRC!X;wEJ;x$mq|57kqd7K|bnbEgmMC^|Uqs8?gO3EGA`jUq z%WPivfTQcoMw=JYPkUNQb@{Jd6{BNzM9u!ZYn!`P_m;c_i2~~hiSWTy+xJM0-Bb=& zPU$ckKQ@xS*q}&jB0Q;JUen&spTAuYIry?me*9aaf$^@3S5gi>QX{MHyCt~CnNWCUS6?U;x)(d3(1LhY6ZO22Hx;J^wu5RXMg3PfxT+Hu3`F~X8Sk69LI-S z0t}_cvAxxSH@qTU8U_w8q!`?9dJ*zjGM>|adSuGYqG@WMv>VSHTiaf7WuhqX+dZR5 zuj=Dd$roFXc5oi08eM<3rYNImlSWhcv}-RmnmX&aH)A42vR-0%VpP4QJXj>HVr{p+EJ+ZLz*LZ#Yq6qKweFvlxE2M(X?yy!DcfI)e-JvHp zf_HuzTYT;uZv39ZmYlHgsiTjDde#x^@5`A4^q=aVHgkQ>g1|=|GsPXO`$h}8R||FO z!ek`5tlgZ1UoxyvT5DYP(l&9SGuMN<*ZO>OBh+Wo^U0kP^$y}S_LVPPd5k+Vg3?MQ zS~McYsvH&*$`{|Q!x#@lC`R)n?vC`k219=+xNP;iblp_Re!rsj1+4 z-g;rjrbgF$5y7vQJN4Y3(;#EFCHzuu=&G{3ux~ePz9`oTD1W02C`8ie8^2w(fA6{3 z(WJgUFX4@8icW!XuvqA6gS(Z-#tdC;kMP_Hufz479_J6L*U8ciaCt1JeQ0*WzTW2x z+_stUKB19TWRB8;wtRdeAL0D*bI}dyIQNL9h;0^82e6`bl64Hs=iuaWbn?}+m zWM{a@YGv$_*DoabFO^q+=Q&)qWcMDY#W9H~v6?N!ldHm|=u7M_^?yyDlT_ZZKb9C0 znxhytmwr4+zP0C8k?cj9bbeG;g6*g3lHEe2-NhePeo=XI!)N|(LS585^((WRXZsvU`0OUaf3DK3 z@Wa$W^EDG4!%H4G@J#z6COeqhndNgk|49+ugLIDnY}eu?m&@Mgyt9mc>zpDpFQMqf zcI==|y6EDPG>`YoI-kxP6^!2W(fs@@XFc!GrLETz+>hir3ocY!y-UpAB3E)$1!t7U z^TzCeRpvB70h6b5r(0y{o?0OuzLjT(>cCh&(eez|w)2zmN?toGzeO$Cm2&3LBR}uT zowEWz$yf?1-ZOTRi&>Sskbb{s_x-N}{hK8p&Wv(9YoM?ukFTlZ3g^P(Qan2Q(=#O1 zaUJy~d&lbbtBJKPU6taKyENa>{!Ygp+OuHF&{G$I&E^Uls>IjQ&HUm*mW5Dj^eqIh zmwt@oTAn?l>A+~C*S->7odD;Y*qwWx)!QHPJLSp!@ zJin7JKmStpTo+`xy}aZ0p!j@SCAHPJyIyKM8-47C@pKWbaB zr|Caw?;b0o`0 zIJb0rd`?bDB85d>9@7_iT=sQ2A%)btd0*P@!?AicX7v22qMFO~xE+V?8;mF69w$ev z%UU|e>n#7FQ?u>Q**Z)&xxDS^!t&v>j}n}2Q|b~Io^3ntyJ0FV>WWaJgD`Jt=9gVz z8{bTGwovv?-?m=!0?#$OPM3Vg(lc25yXqm`pWhx5*!np+lJI^TePeQmMq}h>nD>L()wM*#3*KuDPVO+qHZ=c2Hm91D1c;{w* zPjd3k{SaB9`Wg? zv`$r-E8OZCm`_ePf7-BY|AIZdC3-6zqwb$J-0$fscH~J6F4smP*vxc_7j53yh`zsH zKw`MUr$)!BEqv|{PC4BN#X}wre|_j9J2Ea%lWusWOjhxUXMuWpT};nDy2ysA={Zrl zjt0gU-cogJks5g!rS?ig@gAXhJtxxJYP3%GSe0}dsil@((iuIJy`a)D-lOVlq=Z}1 zLu-r!sjzQvkpHqpdb3Z5Zj_o=iWRZ_@_fhnu|pm0UtW30eEd{*Q<3(Fc0+FJgR373 zHXj+%n7M4rl^0=E&Z`r{?yPk^t;v5Rx|t*L(b;z!9M z&Brzj8Lg@8Xb%_O%Qa0oCAl7NLYdJkaOOnBlnreI^tU@P_dA0t-@H)wDDjY0TwfwL zuy5dvL;jY^-MW?1#>Q?bGc(40c*&80e1(c}6#EP(zeS5yZm2w(iKfca~-a=lq*R+Gkjt&c5nlWg4XN|1B)5(Qe$5+}m_DIg1xF`Q6Y-MU{t?re|bl1E* z@4L!hjr#0%SBRFRZq2QD;%u-~Fu~?a*9EUZ`l{2ndlNg>ymiezbIM%o#1)A*Z>_cS zFWz}E6z887GHAHM(r7Sle4+Qvt=?BxD!z_AHey%rdj9DtV$AN&`yawCU%Ibpp_fEV zmeX@-#TK{N7&ocC=SVULIF>Uya(KGe(plE>pU!2^-yJgJ-RM=HM%_;@ibuXlKk{%Y zNlH3sWgH#8^l^@HMBBr3!=!8~Cd4oVH@!arQ|C2K;)y+p(GhS8=-^w4!RUrBE%HkR zyY+ghkwu1J=3bzV=WPLX(jK{p;~k*qIlch1vm4wduDcmN;OZRJHhYKkKRvgzHX@fh zuh9{?nRbuFkQ-qj%-p`-96c9(WB8A)VYoWsP#$_zZZcgN#-24ObBOtkv0%B`EZ2(V z>ag7ZT8FJiV`|a-wEGq*bLUHJbj!`Q)Da@oJg||ZE^CiyoH?`TTD9q+)j8&KNVtF` z6>aU%GPR}CtG_5IrHsb%cq-4ah}}5DfOgTOb%n@DQsSovh6)CkVpe~?Oevi5{v=T* zIez%)wWuf?ex8qOuO(i1%hYPQDR; z>XFcLw|`GT(ZDJJ-a)N*8!O#<4c63JZb|98+|+W9x2MBa;c=kuL#wd)W=ZkOD_^C~ z=AO>8C2b&2BvV^!P5y!fvF?gDo*I$8AI#mLeqLk9PVaod{siaYKrgG%OWO~X#!F5A zawBb&LklB2L64T9p{|E-{jCi)Zro}3 z{`!m~XU{fxZEE7}z7p3~-enOy<&NOUe)U#)u9bQ?Un^^^#nX{&c4uOukRTS8j3Y440~ z?$P+-kznwdIF*N&;o zRE)svrq;>u#k*sg6Hc8n63WKl!>liR&$PO0e&>n2J)cgtAH~UZ$Y`0*t2MK-79Fdu zw0_QFzv{GLCmcF{(5@8;nFxYJDyLwal0v_ z{4JwXy)?}?T9z-S1vox@zEv^0dP}#VWx~~a4Qs4gM9sck6f?3Ac-BnZ5`2_ry7`%? zHOXsQy7Kj#gYfy~7l$}H4qVth@o7Y#|l{t?{Ak_Uu>COohzr{@Me3a7kz<)(7Ut?qbWkEFAO}SOrKCa-S09w-qLT&6@8w6{KHXiy0`bt z96W8odoLY-aKd-h53M}yn?HUy>x-jb+0$?MS?_%HW%kdjlS0S-an=4}j{O@=~!>j&d+nUebb$$pJj@)qg)a_PF!T>@`X!Y zytBpf1718V?}omwzR>=LkC)#&{U6u=t^0${xjp|>(r4g>^Wr|N-gVZaZ%iva^xIii zKRaO8i&-q}`-#lghm53jrG;yb%6J+VCN{Smtl_$hwYWj(ge9#QZgJD227csBTIf%|y3 zR@YsbbMFf29#IDTjE(v$i>^vupHw!L%l&WqN~i{Ehfn;)+0 z`TLG%U%2u9&+dHXknE3^_j%{Ol}n3y&iicn_b)wr=)s?z^WqO}-h6J$lJ)Vw8*@;( z`{U;u?zs9tj!zkMZtj)|&-Homj9YS3m&8r~@#ox57yVE=;-}|FRsVh9xCL{^TsQrw zGhXX8DedkKxzDe^d)VB^hc7C>`N4eWn%b`>MDp(`y0f;=^~;Za;-6b;dv?FP%~cPl z6n%Kw1qVDcpx3b0KUUtH{^+Dj%km%l{hFU!FFF3>VAksAMxA&O zL96@v*S}8cyyM%YH~-{Ck;*>oH96d@ZiBi2B!^9ACfR6aY)jT z_2d8kqF3Bmm?bUmli0-xJ=Ggv`{3Xglqi14p4{^2s z{GrKSs$)Mci!M@@a@_;W6EjFoXo^)9lV8pDJSETTtgazPDua0){s6X%yU6n*`Q@ap z<5?z&Noh4e!iiFiO`lbLMo#|*^+&;39s(_y}w=Y(XD`C*VnV+R%|?Er-Aam;)7t*U>lOZQxVf;Tm`j-huZZKw_l~LnbP6 zU>r<^nUD_!u)^>#Djqd#L7#vfhIi5T;AewNa-0BU8gkGPFc!wa1egR<4Hf9gP-9qu zu7rmTThJ%rX?O;6>ODufx0W9_Rr-Jkib> z3Ym~)$U#TJOhZ0e04og7p<=7yRdgG?Zg?9NiS2z-&jB*73!hYT17S#Uheg5SYhI2o#8 zrQs~}_i(=9Dpbs-);|XhVBe01BuIg2FdZtP3bgOv1NXrr@RZ?M^fh?P(55RN1#~c+ zh5jDa8zMZ)_AuxJeIdz^jt+$`hp_ITo8dTA91p*PxrTgHM4<$#;51lf*oeLaPB)HM zkOISC1dM`(PzB3j1^fkWgqz`3_|Wh%`U!k)_!9jZ`geDnWEc!X;AMCfw!uz#7e0p1 z4d0;RJNO4Km-QC)%>3R8rb8y9y6gd!e+y>=nJsbFz5i+NrV&_0$GL}RJ?6?5B(IJ1DS6a3Ym~)$VSB^ z!%S2ZK?zhqHQaA_2;B^i8Jm(e0rkd zFgODGz|kLmhC&vMHB3d1gIRDQ z`~fb3%iwZI=A% zc;B!K{R*yn*mbT3XDj1?1Q-L+SJ-Ev6e?jUtc0`RT-X4&!iZPd-oW`g(}ST<0yTy+ zQSoQE2CjqKf#=pbEg%lMLO19IheK~T5{`lk3<>Wrez@&jpU==Q;WHkF{3U$x0SYhk zFx=N+)F-ZU4E*>t#~Fx_t8JN{hUm6;zJy{j%av7;d`YP)y*$&uAvOBqC5w2v%Y6SK zA`atj^0lS0JEf8*VUn(NaW&B?Bj@v&g=6XPg7JPfrO8D_JoSMANvG-6Jv5G1jYrg6 zr|{|(&2bzKU;j5>|I@bL{GCGccM8qlDKvkl(EOc3^LGl(-zoUtCp3Sj;D4Xc{GG!8Vc#kI zmg|4>eZX(1Z%oWv!m~xahyU{Xf_{ZOv7oZp`z006H7~A;6!MtOXaSEjC>+XTtD})o z#Z^4Deu+P!;Dh&O&iVP9!AAvN8vp$3H+BDi{VBFhJkXejNR;K3M7(EH^ZW-r(VRyk z)s*o_B%U&n&riSf_zC-%2!7lZ`{3i>zCIZu@|&t`W)hxsG#zoVCO-qptD4Uf*t~gd z^7)JQc`l>)DO<5VEWSshNjmTv5v(lMu_S7K&eIp&IAtIgQ`mUZI2((3wYw)Glm z*y-$a4%rFE+XeF{lU-MMd;L^$bNTJ#jJiGwm`-l4KWLb}#&zxZ=;wMRwNES~n!+4< zhWC#V#k}gLrk@W%%_jfza z@Ot~>4?DK389wBVpV_z5AKh^Lqge;u^sPOk;Ytb32(w<9s2#r1_&bkk)IVmWG0J!? zUrx;OnH6lOCLJ9g9;Fg{zot5@&3OH5`Zd-22%G9}=Au)>xn;Y{c0##G-4-|_x6ulT znUWiFa?I+U64q*AS#tS+G}4A6ju~jfK3*}K$bQ3^QN@OQg7q(NX6Uq^Nvtlb-Schj zo*i=*y}o@%Zbx>;(JX83%$WI&XKUQg%PjGJ7SS6xJ6@}saQY(WlM^_|-l>fh|# z-TcozJ$FuSP3}yEHMzTI*UZkHy;~i;JX67$n{= z1YvyRwZF$+gh=P?Yg+zD?C0d~epfWT7gDt89>3t8;7_Arsn!nN3)VL@{jLQonsPla zH)epCaKnb1>jqg+7sUp-xocxOW6JD*nfy7g|Iav%iy03~P~VTJ!IsmY51&sq`=jxi z+_Thk^yqlMNKS73=57XRQp_0j`Ejr^15+|=ro}-+Ma(=O7rWENho{*2Y2t9@X6xw9 z2xI3XHqH%oo)L~YFfw^2w6E7x|2$i_?kMMNbJ@&b{mTx$&oNPS*Mu_Hi_YdDlWMbx?aoEu;9)b*?nVpd1Jsi?tjjjX3^;=YfvDuD>D~L}sW`pcJqv0_uK09Zx zO{%VVJQpF2<}jX#G?;wY$qwhbo$a(6Y5bH>?&yZ|)2Kv!XS}_PYA}AT&%9ZPr(oEb z(RhBe#c*w=SCiXwXJ=HWkk`A(f9#&Q9nB&(oUAF%JauZc3SveSKh^0N)7da|xkPX1 z?A^tVX*R08`*{u@SE$oI`Zch*kg)T~FJkL@#XF&F9bnw-n9WMZg6t+2w{AEaa5dWG z68bfYuoK$<>@(fbR8;KP>UGMmTB!Q+HqstbzjV0l&;G}6OYE(>s53|Ey?Zut8gE}Q ztFlqa2A%N@x^y0DWTetJhDPmw8R`w5gS0#}ccZrg)oGwko6E>z9snrj*_A%}{pY{{fpf)>?JX?v8VQJ+f}FEdJ!e- zoZ4mEb^WjInKSEM6D{)x*vsFSv7=EBt{1m2^F7U8jsB@t5mSBe9P@8`qrvNkw59ec zFei8BvOT#h!uFXg#6H(P!{wTuF{4;k+r&DG>A!bq=c6g5G;2_^QTKYAddY6OyjfGf z{p{Q7Rd=MBbxCgCh8t)37kz8Z)KKjm^)G&W6Z7&)*|KJky(QkcWWfn1EZ7jvzG>E4 zdkFHzo6AMw`uYpSy2wsKyZYK$9IO~BwsyMqxU~zIca}}FKTFG~$x_N&oExJW(^^@% zxi?*M#U-<5Mb}@lVM7teyc1^So^YIIV!?(RZ&c|UH4aTf$Hcm!ytwXPG

~>>Skf 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 literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index 42c3c21b..dd42d363 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "ajv": "^8.17.1", + "ajv": "^8.18.0", "ansi-to-html": "^0.7.2", "better-sqlite3": "^12.5.0", "blessed": "^0.1.81", @@ -38,6 +38,7 @@ "@types/react-reconciler": "^0.33.0", "c8": "^10.1.3", "chai": "^6.2.1", + "concurrently": "^9.1.0", "depcheck": "^1.4.7", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", @@ -1934,32 +1935,6 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/visitor-keys": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", @@ -2136,9 +2111,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2374,10 +2349,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } }, "node_modules/bare-events": { "version": "2.8.2", @@ -2747,13 +2725,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -3416,11 +3396,56 @@ "dot-prop": "^5.1.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } }, "node_modules/config-chain": { "version": "1.1.13", @@ -3783,16 +3808,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/depcheck/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/depcheck/node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -3836,22 +3851,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/depcheck/node_modules/minimatch": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", - "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/depcheck/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -4522,16 +4521,6 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/eslint-plugin-sonarjs/node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4542,22 +4531,6 @@ "node": ">= 0.8" } }, - "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/eslint-plugin-sonarjs/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -5503,32 +5476,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/global-modules": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", @@ -7832,15 +7779,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -7924,16 +7874,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/mocha/node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -7967,22 +7907,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mocha/node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -12905,6 +12829,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -13741,32 +13678,6 @@ "node": ">=18" } }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-decoder": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", @@ -13953,6 +13864,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/true-myth": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/true-myth/-/true-myth-4.1.1.tgz", diff --git a/package.json b/package.json index 22bf509d..bfc2604d 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,15 @@ "test:all": "npm run test && npm run test:slow", "test:coverage": "c8 npm run test:unit", "test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'", - "postinstall": "node scripts/fix-node-pty-permissions.js", + "postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/install-tui-binary.js", "start": "node cli/index.js", + "build:tui-backend": "tsc --project tsconfig.tui-backend.json", + "build:tui": "npm run build:tui-backend", "typecheck": "tsc --noEmit", + "typecheck:tui-backend": "tsc --project tsconfig.tui-backend.json --noEmit", + "dev:link": "ZEROSHOT_TUI_BINARY_SKIP=1 npm link", + "dev:tui": "concurrently -k -n backend,tui \"npm run build:tui-backend -- --watch\" \"sh -c 'cd tui-rs && cargo watch -w . -x \\\"run -p zeroshot-tui -- --ui disruptive\\\"'\"", + "dev:bootstrap": "npm run dev:link && npm run dev:tui", "lint": "eslint .", "lint:fix": "eslint . --fix", "validate:templates": "node scripts/validate-templates.js", @@ -30,10 +36,10 @@ "deadcode:deps": "depcheck", "deadcode:all": "npm run deadcode && npm run deadcode:files && npm run deadcode:deps", "dupcheck": "jscpd src/ --min-lines 5 --min-tokens 50 --threshold 5", - "check": "npm run typecheck && npm run lint", + "check": "npm run typecheck && npm run typecheck:tui-backend && npm run lint", "check:all": "npm run check && npm run deadcode:all", "release": "semantic-release", - "prepublishOnly": "npm run lint && npm run typecheck", + "prepublishOnly": "npm run lint && npm run typecheck && npm run build:tui", "prepare": "husky" }, "c8": { @@ -94,7 +100,7 @@ "CHANGELOG.md" ], "dependencies": { - "ajv": "^8.17.1", + "ajv": "^8.18.0", "ansi-to-html": "^0.7.2", "better-sqlite3": "^12.5.0", "blessed": "^0.1.81", @@ -119,6 +125,7 @@ "@types/react-reconciler": "^0.33.0", "c8": "^10.1.3", "chai": "^6.2.1", + "concurrently": "^9.1.0", "depcheck": "^1.4.7", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", @@ -139,8 +146,9 @@ "overrides": { "xml2js": "^0.5.0", "diff": ">=8.0.3", - "tar": ">=7.5.3", - "undici": ">=7.18.2" + "tar": ">=7.5.8", + "undici": ">=7.18.2", + "minimatch": ">=10.2.1" }, "lint-staged": { "*.{js,mjs,cjs}": [ diff --git a/scripts/fix-node-pty-permissions.js b/scripts/fix-node-pty-permissions.js index 9728f6fd..1b3083b6 100644 --- a/scripts/fix-node-pty-permissions.js +++ b/scripts/fix-node-pty-permissions.js @@ -30,7 +30,7 @@ let fixed = 0; let errors = 0; try { - const platforms = fs.readdirSync(prebuildsDir).filter(f => { + const platforms = fs.readdirSync(prebuildsDir).filter((f) => { try { return fs.statSync(path.join(prebuildsDir, f)).isDirectory(); } catch { diff --git a/scripts/install-tui-binary.js b/scripts/install-tui-binary.js new file mode 100644 index 00000000..7d6e63a7 --- /dev/null +++ b/scripts/install-tui-binary.js @@ -0,0 +1,367 @@ +'use strict'; + +const crypto = require('crypto'); +const { execFile } = require('child_process'); +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const os = require('os'); +const path = require('path'); +const { pipeline } = require('stream/promises'); +const { URL } = require('url'); +const { promisify } = require('util'); + +const { + ENV_BINARY_PATH, + ENV_BINARY_URL, + ENV_BINARY_SKIP, + getAssetName, + getInstallDir, + getInstalledBinaryPath, + resolveBinaryPathOverride, + resolveDownloadUrl, + resolveTarget, + shouldSkipBinaryInstall, +} = require('../lib/tui-binary'); + +const MAX_REDIRECTS = 5; +const execFileAsync = promisify(execFile); +const PACKAGE_ROOT = path.resolve(__dirname, '..'); + +function isGitCheckout() { + let current = PACKAGE_ROOT; + while (true) { + const gitPath = path.join(current, '.git'); + if (fs.existsSync(gitPath)) { + return true; + } + const parent = path.dirname(current); + if (parent === current) { + return false; + } + current = parent; + } +} + +function isNotFoundError(error) { + if (!error || typeof error.message !== 'string') { + return false; + } + return /\(404\)/.test(error.message); +} + +async function main() { + if (shouldSkipBinaryInstall()) { + console.log(`${ENV_BINARY_SKIP} set; skipping Rust TUI binary install.`); + return; + } + + const installContext = await prepareInstallContext(); + if (installContext.overridePath) { + console.log( + `${ENV_BINARY_PATH} set; using local Rust TUI binary at ${installContext.overridePath}.` + ); + await installFromLocalBinary(installContext.overridePath, installContext.installPath); + return; + } + + const downloadContext = resolveDownloadContext(); + if (!downloadContext) { + console.log( + `Rust TUI binary not supported on ${process.platform}/${process.arch}; skipping install.` + ); + return; + } + + await downloadAndInstall(downloadContext, installContext); +} + +async function prepareInstallContext() { + const installDir = getInstallDir(); + const installPath = getInstalledBinaryPath(); + await fs.promises.mkdir(installDir, { recursive: true }); + const overridePath = resolveBinaryPathOverride(); + return { installDir, installPath, overridePath }; +} + +function resolveDownloadContext() { + const overrideUrl = process.env[ENV_BINARY_URL]; + const target = overrideUrl ? null : resolveTarget(); + if (!overrideUrl && !target) { + return null; + } + + const archiveUrl = resolveDownloadUrl({ + platform: target?.platform, + arch: target?.arch, + overrideUrl, + }); + + if (!archiveUrl) { + throw new Error('Unable to resolve Rust TUI binary download URL.'); + } + + const assetName = target + ? getAssetName(target.platform, target.arch) + : resolveAssetNameFromUrl(archiveUrl); + + return { archiveUrl, assetName }; +} + +async function downloadAndInstall(downloadContext, installContext) { + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'zeroshot-tui-')); + const archivePath = path.join(tempDir, downloadContext.assetName); + + try { + try { + await downloadToFile(downloadContext.archiveUrl, archivePath, MAX_REDIRECTS); + } catch (error) { + if (isGitCheckout() && isNotFoundError(error)) { + console.log( + 'Rust TUI binary not found for this version in git checkout; skipping install. ' + + `Set ${ENV_BINARY_URL} or ${ENV_BINARY_PATH} to override.` + ); + return; + } + throw error; + } + const shaUrl = `${downloadContext.archiveUrl}.sha256`; + const expectedSha = await fetchSha256(shaUrl); + if (expectedSha) { + await verifySha256(archivePath, expectedSha); + } + + await extractArchive(archivePath, installContext.installDir); + await ensureBinaryInstalled(installContext.installPath, installContext.installDir); + + await fs.promises.chmod(installContext.installPath, 0o755); + console.log(`Installed Rust TUI binary to ${installContext.installPath}`); + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +} + +function resolveAssetNameFromUrl(url) { + try { + const parsed = new URL(url); + const base = path.basename(parsed.pathname); + return base || 'zeroshot-tui.tar.gz'; + } catch { + return 'zeroshot-tui.tar.gz'; + } +} + +async function installFromLocalBinary(sourcePath, destinationPath) { + const stats = await fs.promises.stat(sourcePath).catch(() => null); + if (!stats || !stats.isFile()) { + throw new Error(`Rust TUI binary not found at ${sourcePath}`); + } + await fs.promises.copyFile(sourcePath, destinationPath); + await fs.promises.chmod(destinationPath, 0o755); + console.log(`Installed Rust TUI binary from ${sourcePath}`); +} + +async function downloadToFile(url, destination, redirectsRemaining) { + const client = getHttpClient(url); + await new Promise((resolve, reject) => { + const request = client.get( + url, + { headers: { 'User-Agent': '@covibes/zeroshot' } }, + (response) => { + handleDownloadResponse({ + response, + url, + destination, + redirectsRemaining, + }) + .then(resolve) + .catch(reject); + } + ); + + request.on('error', reject); + }); +} + +function getHttpClient(url) { + return url.startsWith('https://') ? https : http; +} + +function isRedirectStatus(status) { + return [301, 302, 303, 307, 308].includes(status); +} + +function resolveRedirectUrl(baseUrl, location) { + try { + return new URL(location, baseUrl).toString(); + } catch { + return location; + } +} + +async function handleDownloadResponse({ response, url, destination, redirectsRemaining }) { + const status = response.statusCode || 0; + if (isRedirectStatus(status)) { + await followRedirect(response, url, destination, redirectsRemaining); + return; + } + + if (status !== 200) { + response.resume(); + throw new Error(`Download failed (${status}) for ${url}`); + } + + await streamToFileWithLengthCheck(response, destination, url); +} + +async function followRedirect(response, url, destination, redirectsRemaining) { + if (redirectsRemaining <= 0) { + response.resume(); + throw new Error(`Too many redirects while downloading ${url}`); + } + const location = response.headers.location; + if (!location) { + response.resume(); + throw new Error(`Redirect without location while downloading ${url}`); + } + const nextUrl = resolveRedirectUrl(url, location); + response.resume(); + await downloadToFile(nextUrl, destination, redirectsRemaining - 1); +} + +async function streamToFileWithLengthCheck(response, destination, url) { + const expectedLength = parseInt(response.headers['content-length'], 10); + let downloaded = 0; + const fileStream = fs.createWriteStream(destination); + + response.on('data', (chunk) => { + downloaded += chunk.length; + }); + + await pipeline(response, fileStream); + + if (!Number.isNaN(expectedLength) && expectedLength > 0 && downloaded !== expectedLength) { + throw new Error( + `Download size mismatch for ${url}: expected ${expectedLength}, got ${downloaded}` + ); + } +} + +function fetchSha256(url, redirectsRemaining = MAX_REDIRECTS) { + const client = getHttpClient(url); + + return new Promise((resolve, reject) => { + const request = client.get( + url, + { headers: { 'User-Agent': '@covibes/zeroshot' } }, + (response) => { + const status = response.statusCode || 0; + if (isRedirectStatus(status)) { + if (redirectsRemaining <= 0) { + response.resume(); + reject(new Error(`Too many redirects while downloading ${url}`)); + return; + } + const location = response.headers.location; + if (!location) { + response.resume(); + reject(new Error(`Redirect without location while downloading ${url}`)); + return; + } + const nextUrl = resolveRedirectUrl(url, location); + response.resume(); + resolve(fetchSha256(nextUrl, redirectsRemaining - 1)); + return; + } + if (status === 404) { + response.resume(); + resolve(null); + return; + } + if (status !== 200) { + response.resume(); + reject(new Error(`Checksum download failed (${status}) for ${url}`)); + return; + } + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + const match = body.trim().match(/^([a-fA-F0-9]{64})/); + if (!match) { + reject(new Error(`Invalid sha256 contents from ${url}`)); + return; + } + resolve(match[1].toLowerCase()); + }); + } + ); + + request.on('error', reject); + }); +} + +async function verifySha256(filePath, expected) { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + await new Promise((resolve, reject) => { + stream.on('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', resolve); + }); + const actual = hash.digest('hex'); + if (actual !== expected) { + throw new Error(`Checksum mismatch for ${filePath}: expected ${expected}, got ${actual}`); + } +} + +async function extractArchive(archivePath, destinationDir) { + const entries = await listArchiveEntries(archivePath); + const unsafeEntry = entries.find((entry) => !isSafeTarEntry(entry)); + if (unsafeEntry) { + throw new Error(`Unsafe archive entry detected: ${unsafeEntry}`); + } + await execFileAsync('tar', ['-xzf', archivePath, '-C', destinationDir]); +} + +async function listArchiveEntries(archivePath) { + const { stdout } = await execFileAsync('tar', ['-tf', archivePath]); + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function isSafeTarEntry(entry) { + if (!entry || entry.includes('\0')) { + return false; + } + const normalized = path.posix.normalize(entry); + if (path.posix.isAbsolute(normalized)) { + return false; + } + if (normalized === '..' || normalized.startsWith('../')) { + return false; + } + return true; +} + +async function ensureBinaryInstalled(installPath, installDir) { + if (fs.existsSync(installPath)) { + return; + } + const defaultBinary = path.join(installDir, 'zeroshot-tui'); + if (fs.existsSync(defaultBinary)) { + await fs.promises.rename(defaultBinary, installPath); + return; + } + throw new Error(`Expected Rust TUI binary at ${installPath} after extraction`); +} + +main().catch((error) => { + console.error('Failed to install Rust TUI binary'); + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/validate-templates.js b/scripts/validate-templates.js index 5accc1b3..d47ce305 100755 --- a/scripts/validate-templates.js +++ b/scripts/validate-templates.js @@ -7,86 +7,52 @@ * Exit codes: 0 = all valid, 1 = validation errors found */ -const fs = require('fs'); const path = require('path'); -const { validateConfig } = require('../src/config-validator'); +const { validateTemplates } = require('../src/template-validation'); const TEMPLATES_DIR = path.join(__dirname, '../cluster-templates'); -function findJsonFiles(dir) { - const files = []; - if (!fs.existsSync(dir)) return files; - - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...findJsonFiles(fullPath)); - } else if (entry.name.endsWith('.json')) { - files.push(fullPath); - } - } - return files; +function parseArgs(argv) { + const deep = + argv.includes('--deep') || + argv.includes('--sim=deep') || + process.env.ZEROSHOT_TEMPLATE_SIM === 'deep'; + return { deep }; } -function validateTemplate(filePath) { - const relativePath = path.relative(process.cwd(), filePath); +async function main() { + console.log('Validating cluster templates...\n'); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const config = JSON.parse(content); + const { deep } = parseArgs(process.argv.slice(2)); + const report = await validateTemplates({ templatesDir: TEMPLATES_DIR, deep }); - // Skip non-cluster configs (like package.json) - if (!config.agents && !config.name) { - return { valid: true, skipped: true }; - } + let hasErrors = false; - const result = validateConfig(config); + for (const { filePath, result } of report.results) { + const relativePath = path.relative(process.cwd(), filePath); if (!result.valid) { + hasErrors = true; console.error(`\nāŒ ${relativePath}`); for (const error of result.errors) { console.error(` ERROR: ${error}`); } - } else if (result.warnings.length > 0) { + continue; + } + + if (result.warnings.length > 0) { console.warn(`\nāš ļø ${relativePath}`); for (const warning of result.warnings) { console.warn(` WARN: ${warning}`); } - } else { - console.log(`āœ“ ${relativePath}`); + continue; } - return result; - } catch (err) { - console.error(`\nāŒ ${relativePath}`); - console.error(` PARSE ERROR: ${err.message}`); - return { valid: false, errors: [err.message], warnings: [] }; - } -} - -function main() { - console.log('Validating cluster templates...\n'); - - const templateFiles = [...findJsonFiles(TEMPLATES_DIR)]; - - let hasErrors = false; - let validated = 0; - let skipped = 0; - - for (const file of templateFiles) { - const result = validateTemplate(file); - if (result.skipped) { - skipped++; - } else { - validated++; - if (!result.valid) { - hasErrors = true; - } - } + console.log(`āœ“ ${relativePath}`); } console.log(`\n${'='.repeat(60)}`); - console.log(`Validated: ${validated} templates, Skipped: ${skipped} files`); + console.log(`Validated: ${report.validated} templates, Skipped: ${report.skipped} files`); if (hasErrors) { console.error('\nāŒ VALIDATION FAILED - Fix errors above before merging\n'); @@ -97,4 +63,7 @@ function main() { } } -main(); +main().catch((err) => { + console.error(`\nāŒ Template validation crashed: ${err.message}\n`); + process.exit(1); +}); diff --git a/src/agent-wrapper.js b/src/agent-wrapper.js index 2814900a..7664b26f 100644 --- a/src/agent-wrapper.js +++ b/src/agent-wrapper.js @@ -12,12 +12,14 @@ const LogicEngine = require('./logic-engine'); const { validateAgentConfig } = require('./agent/agent-config'); -const { loadSettings, validateModelAgainstMax } = require('../lib/settings'); +const { loadSettings, validateModelAgainstMax, VALID_MODELS } = require('../lib/settings'); const { normalizeProviderName } = require('../lib/provider-names'); const { getProvider } = require('./providers'); const { buildContext } = require('./agent/agent-context-builder'); +const { collectQueuedGuidance } = require('./agent/guidance-queue'); const { findMatchingTrigger, evaluateTrigger } = require('./agent/agent-trigger-evaluator'); const { executeHook } = require('./agent/agent-hook-executor'); +const { injectInput: injectAgentInput } = require('./agent/agent-input-injector'); const { spawnClaudeTask, followClaudeTaskLogs, @@ -73,6 +75,8 @@ class AgentWrapper { this.lastTaskEndTime = null; // Track when last task completed (for context filtering) /** @type {number | null} */ this.lastAgentStartTime = null; // Track when agent last began executing (for context filtering) + /** @type {number | null} */ + this.lastGuidanceAppliedAt = null; // Track last queued guidance applied to prompt // LIVENESS DETECTION - Track output freshness to detect stuck agents /** @type {number | null} */ @@ -246,17 +250,17 @@ class AgentWrapper { /** * Select model based on current iteration and agent config - * Enforces legacy maxModel/minModel for Claude's haiku/sonnet/opus + * Enforces legacy maxModel/minModel aliases for Claude compatibility * @returns {string|null} * @private */ _selectModel() { const spec = this._resolveModelSpec(); const settings = loadSettings(); - const maxModel = settings.maxModel || 'sonnet'; + const maxModel = settings.maxModel; const minModel = settings.minModel || null; - if (spec.model && ['opus', 'sonnet', 'haiku'].includes(spec.model)) { + if (spec.model && maxModel && VALID_MODELS.includes(spec.model)) { return validateModelAgainstMax(spec.model, maxModel, minModel); } @@ -407,6 +411,12 @@ class AgentWrapper { */ _buildContext(triggeringMessage) { const previousAgentStart = this.lastAgentStartTime; + const queuedGuidance = collectQueuedGuidance({ + messageBus: this.messageBus, + clusterId: this.cluster.id, + agentId: this.id, + lastDeliveredAt: this.lastGuidanceAppliedAt, + }); const context = buildContext({ id: this.id, role: this.role, @@ -418,6 +428,7 @@ class AgentWrapper { lastAgentStartTime: previousAgentStart, triggeringMessage, selectedPrompt: this._selectPrompt(), + queuedGuidance: queuedGuidance.guidanceBlock, // Pass isolation state for conditional git restriction worktree: this.worktree, isolation: this.isolation, @@ -430,6 +441,10 @@ class AgentWrapper { this.lastAgentStartTime = typeof latestTimestamp === 'number' ? Math.max(now, latestTimestamp + 1) : now; + if (queuedGuidance.latestTimestamp !== null) { + this.lastGuidanceAppliedAt = queuedGuidance.latestTimestamp; + } + return context; } @@ -542,6 +557,16 @@ class AgentWrapper { await this._executeTask(triggeringMessage); } + /** + * Inject live input into a running agent task when possible + * @param {string} text + * @param {object} [options] + * @returns {Promise<{status: string, reason?: string|null, method?: string|null, taskId?: string|null}>} + */ + injectInput(text, options = {}) { + return injectAgentInput(this, text, options); + } + /** * Get current agent state */ diff --git a/src/agent/agent-config.js b/src/agent/agent-config.js index a2c150d5..489b1ed8 100644 --- a/src/agent/agent-config.js +++ b/src/agent/agent-config.js @@ -33,6 +33,13 @@ function applyOutputDefaults(config) { config.outputFormat = 'json'; } + // Map structuredOutput → jsonSchema (structuredOutput is the user-facing API name, + // jsonSchema is the internal key used by task executor and CLI args). + // Explicit jsonSchema takes precedence over structuredOutput. + if (config.structuredOutput && !config.jsonSchema) { + config.jsonSchema = config.structuredOutput; + } + // If outputFormat is json but no schema defined, use a minimal default schema if (config.outputFormat === 'json' && !config.jsonSchema) { config.jsonSchema = { @@ -205,7 +212,7 @@ function validateAgentConfig(config, options = {}) { // COST CEILING/FLOOR ENFORCEMENT: Validate model(s) against maxModel and minModel at config time // Catches violations EARLY (config load) instead of at runtime (iteration N) const settings = loadSettings(); - const maxModel = settings.maxModel || 'sonnet'; + const maxModel = settings.maxModel; const minModel = settings.minModel || null; // STRICT SCHEMA PROPAGATION: Issue #52 fix diff --git a/src/agent/agent-context-builder.js b/src/agent/agent-context-builder.js index 37d4b4fd..0be8b60a 100644 --- a/src/agent/agent-context-builder.js +++ b/src/agent/agent-context-builder.js @@ -176,7 +176,9 @@ function resolveSourceSince(source, cluster, lastTaskEndTime, lastAgentStartTime return lastTaskEndTime || cluster.createdAt; } if (sinceValue === 'last_agent_start') { - return lastAgentStartTime || cluster.createdAt; + // Use strict "after" semantics to avoid timestamp collisions in the same millisecond + // (prevents stale context from leaking across agent restarts). + return lastAgentStartTime ? lastAgentStartTime + 1 : cluster.createdAt; } if (typeof sinceValue === 'string') { @@ -387,6 +389,7 @@ function buildContext({ lastAgentStartTime, triggeringMessage, selectedPrompt, + queuedGuidance, worktree, isolation, }) { @@ -396,6 +399,7 @@ function buildContext({ const header = buildHeaderContext({ id, role, iteration, isIsolated }); const instructions = buildInstructionsSection({ config, selectedPrompt, id }); const legacyOutputSchema = buildLegacyOutputSchemaSection(config); + const queuedGuidanceSection = queuedGuidance || ''; const jsonSchema = buildJsonSchemaSection(config); const validatorSkip = buildValidatorSkipSection({ role, messageBus, cluster, isolation }); const triggeringMessageSection = buildTriggeringMessageSection(triggeringMessage); @@ -417,6 +421,7 @@ function buildContext({ pushStaticPack('header', 'header', header); pushStaticPack('instructions', 'instructions', instructions); + pushStaticPack('queuedGuidance', 'queuedGuidance', queuedGuidanceSection); pushStaticPack('legacyOutputSchema', 'legacyOutputSchema', legacyOutputSchema); pushStaticPack('jsonSchema', 'jsonSchema', jsonSchema); diff --git a/src/agent/agent-hook-executor.js b/src/agent/agent-hook-executor.js index 1cc4c7be..917906db 100644 --- a/src/agent/agent-hook-executor.js +++ b/src/agent/agent-hook-executor.js @@ -139,6 +139,127 @@ async function executeHook(params) { throw new Error('execute_system_command not implemented'); } + if (hook.action === 'verify_github_pr') { + const { extractJsonFromOutput } = require('./output-extraction'); + const structuredOutput = extractJsonFromOutput(result.output) || {}; + const claimedPrUrl = structuredOutput.pr_url || null; + const claimedPrNumber = structuredOutput.pr_number || null; + + // Skip actual gh CLI verification if explicitly disabled (for integration tests) + // Unit tests mock execSync, so they still test the verification logic + if (process.env.ZEROSHOT_SKIP_GH_VERIFY === '1') { + agent._log(`āœ… VERIFICATION SKIPPED (ZEROSHOT_SKIP_GH_VERIFY=1)`); + agent._publish({ + topic: 'CLUSTER_COMPLETE', + content: { + data: { + reason: 'git-pusher-complete-verified', + pr_number: claimedPrNumber, + pr_url: claimedPrUrl, + }, + }, + }); + return; + } + + // Use explicit PR number when available (deterministic). + // Branch-based resolution can fail after merge when branch is deleted/transitional. + const ghPrViewCmd = claimedPrNumber + ? `gh pr view ${claimedPrNumber} --json state,mergedAt,url,number` + : `gh pr view --json state,mergedAt,url,number`; + + // GitHub API is eventually consistent after `gh pr merge`. + // Merge state can take 5-30s to propagate. Poll with backoff before concluding agent lied. + // POSTMORTEM 2026-02-11: 3s retry window killed cluster gentle-hydra-56 — PR was actually merged. + const MERGE_POLL_ATTEMPTS = 6; + const MERGE_POLL_INTERVAL_MS = 5000; + + let prData; + try { + prData = JSON.parse( + execSync(ghPrViewCmd, { + encoding: 'utf8', + cwd: agent.workingDirectory, + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + } catch (err) { + if ( + err.message.includes('Could not resolve to a PullRequest') || + err.message.toLowerCase().includes('no pull requests found') + ) { + throw new Error( + `VERIFICATION FAILED: Agent claimed a PR exists for this branch, ` + + `but GitHub says it DOES NOT EXIST. Agent HALLUCINATED.` + ); + } + throw err; + } + + if (claimedPrUrl && prData.url && claimedPrUrl !== prData.url) { + throw new Error( + `VERIFICATION FAILED: Agent claimed PR URL ${claimedPrUrl}, but GitHub CLI reports ${prData.url}.` + ); + } + + // Poll for merge propagation if not yet showing as merged + if (!prData.mergedAt) { + const prNumber = prData.number; + const pollCmd = `gh pr view ${prNumber} --json state,mergedAt,url,number`; + agent._log( + `ā³ PR #${prNumber} not yet showing as merged (state="${prData.state}"). ` + + `Polling for GitHub API propagation (up to ${MERGE_POLL_ATTEMPTS} attempts, ${MERGE_POLL_INTERVAL_MS / 1000}s apart)...` + ); + + for (let attempt = 1; attempt <= MERGE_POLL_ATTEMPTS; attempt++) { + await new Promise((resolve) => setTimeout(resolve, MERGE_POLL_INTERVAL_MS)); + try { + prData = JSON.parse( + execSync(pollCmd, { + encoding: 'utf8', + cwd: agent.workingDirectory, + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + } catch { + // gh CLI error during poll — keep trying + continue; + } + if (prData.mergedAt) { + agent._log(`āœ… PR #${prNumber} merge confirmed on poll attempt ${attempt}`); + break; + } + agent._log( + `ā³ Poll ${attempt}/${MERGE_POLL_ATTEMPTS}: PR #${prNumber} still state="${prData.state}"` + ); + } + } + + if (!prData.mergedAt) { + throw new Error( + `VERIFICATION FAILED: Agent claimed PR is merged, ` + + `but GitHub says state="${prData.state}" after ${MERGE_POLL_ATTEMPTS} polls ` + + `over ${(MERGE_POLL_ATTEMPTS * MERGE_POLL_INTERVAL_MS) / 1000}s. Agent LIED.` + ); + } + + agent._log(`āœ… VERIFICATION PASSED: PR #${prData.number} actually merged`); + + // Publish CLUSTER_COMPLETE only after verification passes + agent._publish({ + topic: 'CLUSTER_COMPLETE', + content: { + data: { + reason: 'git-pusher-complete-verified', + pr_number: prData.number, + pr_url: prData.url, + }, + }, + }); + + return; + } + throw new Error(`Unknown hook action: ${hook.action}`); } @@ -214,15 +335,71 @@ async function parseTransformResultData({ context, agent, script, scriptUsesResu } function buildTransformSandbox({ resultData, context, agent }) { + const clusterId = agent.cluster?.id || context.cluster?.id || agent.cluster_id || 'unknown'; + const messageBus = agent.messageBus; + const cluster = context.cluster || agent.cluster || null; + + // Ledger API wrapper (auto-scoped to cluster) - mirrors logic-engine.js + const ledgerAPI = messageBus + ? { + query: (criteria) => { + return messageBus.query({ ...criteria, cluster_id: clusterId }); + }, + findLast: (criteria) => { + return messageBus.findLast({ ...criteria, cluster_id: clusterId }); + }, + count: (criteria) => { + return messageBus.count({ ...criteria, cluster_id: clusterId }); + }, + since: (timestamp) => { + return messageBus.since({ cluster_id: clusterId, timestamp }); + }, + } + : null; + + // Cluster API wrapper - mirrors logic-engine.js + const clusterAPI = { + id: clusterId, + getAgents: () => { + return cluster ? cluster.agents || [] : []; + }, + getAgentsByRole: (role) => { + return cluster ? (cluster.agents || []).filter((a) => a.role === role) : []; + }, + getAgent: (id) => { + return cluster ? (cluster.agents || []).find((a) => a.id === id) : null; + }, + }; + + // Helper functions - mirrors logic-engine.js const helpers = { getConfig: require('../config-router').getConfig, + allResponded: (agents, topic, since) => { + if (!ledgerAPI) return false; + const responses = ledgerAPI.query({ topic, since }); + const responders = new Set(responses.map((r) => r.sender)); + return agents.every((a) => responders.has(a.id || a)); + }, + hasConsensus: (topic, since) => { + if (!ledgerAPI) return false; + const responses = ledgerAPI.query({ topic, since }); + if (responses.length === 0) return false; + return responses.every((r) => r.content?.data?.approved === true); + }, }; return { result: resultData, triggeringMessage: context.triggeringMessage, + ledger: ledgerAPI, + cluster: clusterAPI, helpers, + // Safe built-ins JSON, + Set, + Map, + Array, + Object, console: { log: (...args) => agent._log('[transform]', ...args), error: (...args) => console.error('[transform]', ...args), @@ -530,6 +707,59 @@ function evaluateHookLogic(params) { throw new Error(`Unsupported hook logic engine: ${logic.engine}`); } + const clusterId = agent.cluster?.id || context.cluster?.id || agent.cluster_id || 'unknown'; + const messageBus = agent.messageBus; + const cluster = context.cluster || agent.cluster || null; + + // Ledger API wrapper (auto-scoped to cluster) - mirrors logic-engine.js + const ledgerAPI = messageBus + ? { + query: (criteria) => { + return messageBus.query({ ...criteria, cluster_id: clusterId }); + }, + findLast: (criteria) => { + return messageBus.findLast({ ...criteria, cluster_id: clusterId }); + }, + count: (criteria) => { + return messageBus.count({ ...criteria, cluster_id: clusterId }); + }, + since: (timestamp) => { + return messageBus.since({ cluster_id: clusterId, timestamp }); + }, + } + : null; + + // Cluster API wrapper - mirrors logic-engine.js + const clusterAPI = { + id: clusterId, + getAgents: () => { + return cluster ? cluster.agents || [] : []; + }, + getAgentsByRole: (role) => { + return cluster ? (cluster.agents || []).filter((a) => a.role === role) : []; + }, + getAgent: (id) => { + return cluster ? (cluster.agents || []).find((a) => a.id === id) : null; + }, + }; + + // Helper functions - mirrors logic-engine.js + const helpers = { + getConfig: require('../config-router').getConfig, + allResponded: (agents, topic, since) => { + if (!ledgerAPI) return false; + const responses = ledgerAPI.query({ topic, since }); + const responders = new Set(responses.map((r) => r.sender)); + return agents.every((a) => responders.has(a.id || a)); + }, + hasConsensus: (topic, since) => { + if (!ledgerAPI) return false; + const responses = ledgerAPI.query({ topic, since }); + if (responses.length === 0) return false; + return responses.every((r) => r.content?.data?.approved === true); + }, + }; + // Build sandbox context - similar to LogicEngine but focused on result data const sandbox = { // The parsed result from agent output - this is the main input @@ -545,6 +775,11 @@ function evaluateHookLogic(params) { // Triggering message (if available) message: context.triggeringMessage || null, + // APIs + ledger: ledgerAPI, + cluster: clusterAPI, + helpers, + // Safe built-ins Set, Map, diff --git a/src/agent/agent-input-injector.js b/src/agent/agent-input-injector.js new file mode 100644 index 00000000..064fa97f --- /dev/null +++ b/src/agent/agent-input-injector.js @@ -0,0 +1,141 @@ +/** + * AgentInputInjector - Live guidance injection for running agents + * + * Resolves the current task, validates attachable socket availability, + * and sends input via attach STDIN when possible. + */ + +const { getTask } = require('../../task-lib/store.js'); +const { sendInput } = require('../attach/send-input'); +const { isSocketAlive } = require('../attach/socket-discovery'); + +const DEFAULT_TIMEOUT_MS = 1500; + +function buildResult({ status, reason = null, method = null, taskId = null }) { + return { + status, + reason, + method, + taskId, + }; +} + +function ensureValidInputs(agent, text) { + if (!agent) { + throw new Error('AgentInputInjector: agent is required'); + } + if (typeof text !== 'string') { + throw new Error('AgentInputInjector: text must be a string'); + } + if (!text.trim()) { + throw new Error('AgentInputInjector: text cannot be empty'); + } +} + +function buildUnsupported(reason, taskId) { + return buildResult({ + status: 'unsupported', + reason, + taskId, + }); +} + +function getTaskId(agent) { + return agent.currentTaskId || null; +} + +function checkIsolation(agent, taskId) { + if (agent.isolation?.enabled) { + return buildUnsupported('isolation-enabled', taskId); + } + return null; +} + +function checkTaskId(taskId) { + if (!taskId) { + return buildUnsupported('no-current-task', null); + } + return null; +} + +function checkTaskInfo(taskInfo, taskId) { + if (!taskInfo) { + return buildUnsupported('task-not-found', taskId); + } + if (!taskInfo.socketPath) { + return buildUnsupported('no-socket', taskId); + } + if (!taskInfo.attachable) { + return buildUnsupported('task-not-attachable', taskId); + } + return null; +} + +async function checkSocketAlive(socketPath, taskId) { + const socketAlive = await isSocketAlive(socketPath); + if (!socketAlive) { + return buildUnsupported('socket-not-alive', taskId); + } + return null; +} + +function normalizePayload(text) { + return text.endsWith('\n') ? text : `${text}\n`; +} + +function resolveTimeout(options) { + return options.timeoutMs || DEFAULT_TIMEOUT_MS; +} + +function buildInjected(taskId) { + return buildResult({ + status: 'injected', + method: 'pty', + taskId, + }); +} + +function buildSendFailure(reason, taskId) { + return buildResult({ + status: 'unsupported', + reason: reason || 'send-failed', + method: 'pty', + taskId, + }); +} + +async function injectInput(agent, text, options = {}) { + ensureValidInputs(agent, text); + + const taskId = getTaskId(agent); + const isolationResult = checkIsolation(agent, taskId); + if (isolationResult) return isolationResult; + + const taskIdResult = checkTaskId(taskId); + if (taskIdResult) return taskIdResult; + + const taskInfo = getTask(taskId); + const taskInfoResult = checkTaskInfo(taskInfo, taskId); + if (taskInfoResult) return taskInfoResult; + + const socketResult = await checkSocketAlive(taskInfo.socketPath, taskId); + if (socketResult) return socketResult; + + const payload = normalizePayload(text); + const timeoutMs = resolveTimeout(options); + const result = await sendInput({ + socketPath: taskInfo.socketPath, + data: payload, + timeoutMs, + }); + + if (!result.ok) { + return buildSendFailure(result.error, taskId); + } + + return buildInjected(taskId); +} + +module.exports = { + injectInput, +}; diff --git a/src/agent/agent-lifecycle.js b/src/agent/agent-lifecycle.js index 3b511f40..727c976d 100644 --- a/src/agent/agent-lifecycle.js +++ b/src/agent/agent-lifecycle.js @@ -15,6 +15,8 @@ const { findMatchingTrigger, evaluateTrigger } = require('./agent-trigger-evaluator'); const { executeHook } = require('./agent-hook-executor'); const IsolationManager = require('../isolation-manager'); +const crypto = require('crypto'); +const { bufferMessage, scheduleDrain, drainBufferedMessages } = require('../message-buffer'); const { analyzeProcessHealth, isPlatformSupported, @@ -23,9 +25,20 @@ const { const { normalizeProviderName } = require('../../lib/provider-names'); const { loadSettings } = require('../../lib/settings'); const { findPlatformMismatchReason } = require('./validation-platform'); +const { calculateRateLimitDelay, isRateLimitError } = require('./rate-limit-backoff'); const DEFAULT_VALIDATOR_IMAGE = 'zeroshot-cluster-base'; +class HookExecutionError extends Error { + constructor(message, options) { + super(message); + this.name = 'HookExecutionError'; + this.hookFailure = true; + this.hookRetries = options?.hookRetries; + this.originalHookError = options?.originalHookError; + } +} + function resolveValidatorIsolationConfig(agent) { const config = agent.config?.isolation || {}; if (config.type && config.type !== 'docker') { @@ -227,6 +240,10 @@ async function stop(agent) { * @param {Object} message - Incoming message */ async function handleMessage(agent, message) { + if (!agent._bufferedMessages) { + agent._bufferedMessages = []; + } + // Check if any trigger matches FIRST (before state check) const matchingTrigger = findMatchingTrigger({ triggers: agent.config.triggers, @@ -243,8 +260,16 @@ async function handleMessage(agent, message) { return; } if (agent.state !== 'idle') { + // IMPORTANT: Never drop a message that matches a trigger. + // Dropping validation/coordinator signals can wedge clusters in "running" state. + bufferMessage(agent, message); console.warn( - `[${agent.id}] āš ļø DROPPING message (busy, state=${agent.state}): ${message.topic}` + `[${agent.id}] āøļø BUFFERING message (busy, state=${agent.state}): ${message.topic}` + ); + scheduleDrain( + agent, + () => drainBufferedMessages(agent, (next) => handleMessage(agent, next), { label: 'Agent' }), + { label: 'Agent' } ); return; } @@ -318,8 +343,10 @@ async function executeTriggerAction(agent, trigger, message) { } /** - * Execute claude-zeroshots with built context - * Retries disabled by default. Set agent config `maxRetries` to enable (e.g., 3). + * Execute task with built context + * Default: uses settings.maxRetries (default 3) for exponential backoff retries. + * Rate limit errors (429, capacity exhausted) use longer delays (30s base). + * Override via agent config `maxRetries` to change retry behavior. * @param {AgentWrapper} agent - Agent instance * @param {Object} triggeringMessage - Message that triggered execution */ @@ -374,7 +401,7 @@ async function applyValidatorJitter(agent) { return; } - const jitterMs = Math.floor(Math.random() * 15000); // 0-15 seconds + const jitterMs = crypto.randomInt(0, 15000); // 0-15 seconds if (!agent.quiet) { agent._log( `[Agent ${agent.id}] Adding ${Math.round(jitterMs / 1000)}s jitter to prevent lock contention` @@ -474,11 +501,13 @@ ${'='.repeat(80)}`); } else { console.error(`${'='.repeat(80)} `); - // All hook retries exhausted - throw to trigger task-level handling - throw new Error( + // All hook retries exhausted - FAIL THE CLUSTER (do NOT rerun the whole task). + // Retrying the task wastes tokens and cannot fix a deterministic hook/config bug. + throw new HookExecutionError( `Hook execution failed after ${hookMaxRetries} attempts. ` + `Task completed successfully but hook could not publish result. ` + - `Original error: ${hookError.message}` + `Original error: ${hookError.message}`, + { hookRetries: hookMaxRetries, originalHookError: hookError.message } ); } } @@ -555,7 +584,7 @@ ${'='.repeat(80)}`); async function handleLockContention() { // Lock contention - add significant jittered delay - const lockDelay = 10000 + Math.floor(Math.random() * 20000); // 10-30 seconds + const lockDelay = 10000 + crypto.randomInt(0, 20000); // 10-30 seconds console.error( `āš ļø Lock contention detected - waiting ${Math.round(lockDelay / 1000)}s before retry` ); @@ -619,6 +648,25 @@ ${'='.repeat(80)}`); // Non-validator agents: publish error and stop agent.state = 'error'; + // Hook failure: fail the whole cluster so it gets stopped + persisted (prevents deadlocked "running" clusters). + if (error?.hookFailure) { + agent._publish({ + topic: 'CLUSTER_FAILED', + receiver: 'broadcast', + content: { + text: `Cluster failed: onComplete hook failed for ${agent.id} - ${error.message}`, + data: { + reason: 'on_complete_hook_failed', + agentId: agent.id, + role: agent.role, + hookRetries: error.hookRetries ?? null, + originalHookError: error.originalHookError ?? null, + error: error.message, + }, + }, + }); + } + // Save failure info to cluster for resume capability agent.cluster.failureInfo = { agentId: agent.id, @@ -638,6 +686,9 @@ ${'='.repeat(80)}`); data: { error: error.message, stack: error.stack, + hookFailure: error?.hookFailure === true, + hookRetries: error?.hookRetries ?? undefined, + originalHookError: error?.originalHookError ?? undefined, agent: agent.id, role: agent.role, iteration: agent.iteration, @@ -672,19 +723,26 @@ ${'='.repeat(80)}`); agent.state = 'idle'; } -async function scheduleRetry(agent, error, attempt, maxRetries, baseDelay) { - const delay = baseDelay * Math.pow(2, attempt - 1); // 2s, 4s, 8s +async function scheduleRetry(agent, error, attempt, maxRetries, _baseDelay) { + // Use rate-limit-aware backoff (30s+ for 429s, 2s for others) + const settings = loadSettings(); + const delay = calculateRateLimitDelay(error, attempt, settings); + const isRateLimit = isRateLimitError(error); agent._publishLifecycle('RETRY_SCHEDULED', { attempt, maxRetries, delayMs: delay, error: error.message, + isRateLimitError: isRateLimit, }); - agent._log(`[${agent.id}] āš ļø Retrying in ${delay}ms... (${attempt + 1}/${maxRetries})`); + const prefix = isRateLimit ? 'šŸ”„ Rate limit - ' : 'āš ļø '; + agent._log( + `[${agent.id}] ${prefix}Retrying in ${Math.round(delay / 1000)}s... (${attempt + 1}/${maxRetries})` + ); - // Exponential backoff + // Rate-limit-aware backoff await new Promise((resolve) => setTimeout(resolve, delay)); agent._log(`[${agent.id}] šŸ”„ Starting retry attempt ${attempt + 1}/${maxRetries}`); @@ -721,9 +779,33 @@ async function handleTaskAttemptFailure({ return false; } +function maybeExtendMaxRetries({ + error, + attempt, + maxRetries, + sigtermRetryGranted, + noMessagesRetryGranted, +}) { + const message = error?.message || ''; + if (!message || attempt < maxRetries) { + return { maxRetries, sigtermRetryGranted, noMessagesRetryGranted }; + } + + if (message.includes('SIGTERM') && !sigtermRetryGranted) { + return { maxRetries: maxRetries + 1, sigtermRetryGranted: true, noMessagesRetryGranted }; + } + + if (message.toLowerCase().includes('no messages returned') && !noMessagesRetryGranted) { + return { maxRetries: maxRetries + 1, sigtermRetryGranted, noMessagesRetryGranted: true }; + } + + return { maxRetries, sigtermRetryGranted, noMessagesRetryGranted }; +} + /** * Execute claude-zeroshots with built context - * Retries disabled by default. Set agent config `maxRetries` to enable (e.g., 3). + * Default: uses settings.maxRetries (default 3) for exponential backoff retries. + * Override via agent config `maxRetries` to change retry behavior. * @param {AgentWrapper} agent - Agent instance * @param {Object} triggeringMessage - Message that triggered execution */ @@ -733,10 +815,11 @@ async function executeTask(agent, triggeringMessage) { return; } - // Default: no retries (maxRetries=1 means 1 attempt only) - // Set agent config `maxRetries: 3` to enable exponential backoff retries - let maxRetries = agent.config.maxRetries ?? 1; - const baseDelay = 2000; // 2 seconds + // Default: uses settings.maxRetries (default 3) + // Override via agent config `maxRetries` to change retry behavior + const settings = loadSettings(); + let maxRetries = agent.config.maxRetries ?? settings.maxRetries ?? 3; + const baseDelay = settings.backoffBaseMs ?? 2000; let sigtermRetryGranted = false; let noMessagesRetryGranted = false; @@ -750,17 +833,21 @@ async function executeTask(agent, triggeringMessage) { await runTaskAttempt(agent, triggeringMessage); return; } catch (error) { - const isSigterm = error.message && error.message.includes('SIGTERM'); - const isNoMessages = - error.message && error.message.toLowerCase().includes('no messages returned'); - if (isSigterm && !sigtermRetryGranted && attempt >= maxRetries) { - sigtermRetryGranted = true; - maxRetries += 1; - } - if (isNoMessages && !noMessagesRetryGranted && attempt >= maxRetries) { - noMessagesRetryGranted = true; - maxRetries += 1; + if (error instanceof HookExecutionError) { + // Hook failures are deterministic; do not waste tokens retrying the provider task. + await handleFinalFailure(agent, triggeringMessage, error, 1); + return; } + const updated = maybeExtendMaxRetries({ + error, + attempt, + maxRetries, + sigtermRetryGranted, + noMessagesRetryGranted, + }); + maxRetries = updated.maxRetries; + sigtermRetryGranted = updated.sigtermRetryGranted; + noMessagesRetryGranted = updated.noMessagesRetryGranted; const shouldStop = await handleTaskAttemptFailure({ agent, triggeringMessage, diff --git a/src/agent/agent-task-executor.js b/src/agent/agent-task-executor.js index e0d1da8a..5bc4cd77 100644 --- a/src/agent/agent-task-executor.js +++ b/src/agent/agent-task-executor.js @@ -301,6 +301,8 @@ let dangerousGitHookInstalled = false; /** * Extract token usage from NDJSON output. * Looks for the 'result' event line which contains usage data. + * Falls back to summing 'turn.completed' events for cache metrics + * when the result event doesn't include them. * * @param {string} output - Full NDJSON output from Claude CLI * @returns {Object|null} Token usage data or null if not found @@ -316,11 +318,34 @@ function extractTokenUsage(output, providerName = 'claude') { return null; } + let cacheReadInputTokens = resultEvent.cacheReadInputTokens || 0; + let cacheCreationInputTokens = resultEvent.cacheCreationInputTokens || 0; + + // Fallback: if result event has no cache data, extract from raw turn.completed events. + // Claude CLI emits turn.completed with cached_input_tokens but the result event may omit them. + if (cacheReadInputTokens === 0) { + const lines = output.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const raw = JSON.parse(trimmed); + if (raw.type === 'turn.completed' && raw.usage) { + const usage = raw.usage; + cacheReadInputTokens += usage.cached_input_tokens || usage.cache_read_input_tokens || 0; + cacheCreationInputTokens += usage.cache_creation_input_tokens || 0; + } + } catch { + // skip non-JSON lines + } + } + } + return { inputTokens: resultEvent.inputTokens || 0, outputTokens: resultEvent.outputTokens || 0, - cacheReadInputTokens: resultEvent.cacheReadInputTokens || 0, - cacheCreationInputTokens: resultEvent.cacheCreationInputTokens || 0, + cacheReadInputTokens, + cacheCreationInputTokens, totalCostUsd: resultEvent.cost || null, durationMs: resultEvent.duration || null, modelUsage: resultEvent.modelUsage || null, @@ -1002,6 +1027,54 @@ function handleStatusExecError({ agent, state, ctPath, taskId, error, stderr, re return false; } + // CRITICAL: "ID not found" means task completed or was removed - FAIL-SAFE by restarting + // We have zero confidence about what happened: + // - Task may have completed successfully + // - Task may have failed and been cleaned up + // - Task may have been manually killed + // - Zeroshot storage may be corrupted + // With zero confidence → restart is safer than assuming success + const errorMessage = error.message || ''; + const stderrMessage = stderr || ''; + const isNotFound = + errorMessage.includes('ID not found') || + errorMessage.includes('Not found in tasks') || + stderrMessage.includes('ID not found') || + stderrMessage.includes('Not found in tasks'); + + if (isNotFound) { + console.warn( + `[Agent ${agent.id}] āš ļø Task ${taskId} not found - will restart to ensure completion` + ); + + if (!state.resolved) { + state.resolved = true; + finalizeLogFollow(agent, state); + + agent._publish({ + topic: 'AGENT_ERROR', + receiver: 'broadcast', + content: { + text: `Task ${taskId} not found - restarting for safety`, + data: { + taskId, + error: 'task_not_found', + role: agent.role, + iteration: agent.iteration, + }, + }, + }); + + resolve({ + success: false, + output: state.output, + error: `Task not found - restarting for safety`, + }); + } + + return true; + } + state.consecutiveExecFailures++; if (state.consecutiveExecFailures < MAX_STATUS_FAILURES) { return true; @@ -1183,13 +1256,34 @@ function followClaudeTaskLogs(agent, taskId) { return createLogFollower({ agent, taskId, fsModule, ctPath, providerName }); } +// Cache zeroshot path at module load time (when PATH is correct) +let _cachedZeroshotPath = null; +function _resolveZeroshotPath() { + if (_cachedZeroshotPath) return _cachedZeroshotPath; + + try { + // Use safe execSync (already imported at top) with explicit PATH + const fullPath = execSync('which zeroshot', { + encoding: 'utf8', + env: { ...process.env }, // Pass current process's PATH + }).trim(); + if (fullPath) { + _cachedZeroshotPath = fullPath; + return fullPath; + } + } catch { + // which failed, fall back to bare command + } + _cachedZeroshotPath = 'zeroshot'; + return 'zeroshot'; +} + /** * Get path to claude-zeroshots executable * @returns {String} Path to zeroshot command */ function getClaudeTasksPath() { - // Use zeroshot command (unified CLI) - return 'zeroshot'; // Assumes zeroshot is installed globally + return _resolveZeroshotPath(); } /** @@ -1678,7 +1772,17 @@ async function parseResultOutput(agent, output) { } const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude'; - const { extractJsonFromOutput, hasFatalStandaloneOutput } = require('./output-extraction'); + const { + extractJsonFromOutput, + extractCliError, + hasFatalStandaloneOutput, + } = require('./output-extraction'); + + // Check for CLI errors FIRST - surface the actual error message + const cliError = extractCliError(output); + if (cliError) { + throw new Error(`CLI error (${cliError.provider}): ${cliError.error}`); + } // Use clean extraction pipeline let parsed = extractJsonFromOutput(output, providerName); diff --git a/src/agent/guidance-queue.js b/src/agent/guidance-queue.js new file mode 100644 index 00000000..5ced3638 --- /dev/null +++ b/src/agent/guidance-queue.js @@ -0,0 +1,77 @@ +const GUIDANCE_BLOCK_START = '<>'; +const GUIDANCE_BLOCK_END = '<>'; + +function formatGuidanceMessage(message) { + const timestamp = Number.isFinite(message.timestamp) + ? new Date(message.timestamp).toISOString() + : new Date().toISOString(); + const sender = message.sender || 'unknown'; + const topic = message.topic || 'GUIDANCE'; + const target = message.receiver || message.target_agent_id; + const targetSuffix = target ? ` -> ${target}` : ''; + + let formatted = `[${timestamp}] ${sender} (${topic}${targetSuffix})\n`; + if (message.content?.text) { + formatted += `${message.content.text}\n`; + } + if (message.content?.data) { + formatted += `${JSON.stringify(message.content.data, null, 2)}\n`; + } + + return formatted.trimEnd(); +} + +function formatGuidanceBlock(messages) { + if (!Array.isArray(messages) || messages.length === 0) return ''; + + const ordered = messages.slice().sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); + + let block = '## Guidance (Queued)\n\n'; + block += `${GUIDANCE_BLOCK_START}\n`; + + ordered.forEach((message, index) => { + block += `${formatGuidanceMessage(message)}\n`; + if (index < ordered.length - 1) { + block += '\n'; + } + }); + + block += `\n${GUIDANCE_BLOCK_END}\n\n`; + return block; +} + +function collectQueuedGuidance({ messageBus, clusterId, agentId, lastDeliveredAt, limit }) { + if (!messageBus) { + throw new Error('collectQueuedGuidance: messageBus is required'); + } + if (!clusterId) { + throw new Error('collectQueuedGuidance: clusterId is required'); + } + if (!agentId) { + throw new Error('collectQueuedGuidance: agentId is required'); + } + + const messages = messageBus.queryGuidanceMailbox({ + cluster_id: clusterId, + target_agent_id: agentId, + lastDeliveredAt, + limit, + }); + + if (!messages.length) { + return { messages: [], latestTimestamp: null, guidanceBlock: '' }; + } + + const ordered = messages.slice().sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); + const latestTimestamp = ordered[ordered.length - 1].timestamp; + const guidanceBlock = formatGuidanceBlock(ordered); + + return { messages: ordered, latestTimestamp, guidanceBlock }; +} + +module.exports = { + GUIDANCE_BLOCK_START, + GUIDANCE_BLOCK_END, + formatGuidanceBlock, + collectQueuedGuidance, +}; diff --git a/src/agent/output-extraction.js b/src/agent/output-extraction.js index 35ad3176..416bf404 100644 --- a/src/agent/output-extraction.js +++ b/src/agent/output-extraction.js @@ -105,7 +105,22 @@ function extractResultContent(obj) { */ function extractFromTextEvents(output, providerName) { const provider = getProvider(providerName); - const events = parseChunkWithProvider(provider, output); + const normalized = output + .split('\n') + .map((line) => stripTimestamp(line)) + .filter(Boolean) + .join('\n'); + const events = parseChunkWithProvider(provider, normalized); + + // Fast-path: many providers eventually emit the full JSON as a single text event. + // Scan from the end to find the last parseable JSON snippet without requiring + // the entire concatenated stream to be valid JSON. + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (e.type !== 'text' || typeof e.text !== 'string') continue; + const direct = extractDirectJson(e.text) || extractFromMarkdown(e.text); + if (direct) return direct; + } // Accumulate all text events const textEvents = events.filter((e) => e.type === 'text').map((e) => e.text); @@ -152,10 +167,49 @@ function extractFromMarkdown(text) { return null; } +/** + * CLI metadata fields that indicate raw provider output (not agent content). + * These objects should be rejected - they're wrapper metadata, not actual output. + */ +const CLI_METADATA_FIELDS = new Set([ + 'duration_ms', + 'duration_api_ms', + 'total_cost_usd', + 'session_id', + 'num_turns', + 'permission_denials', + 'modelUsage', +]); + +/** + * Check if an object looks like CLI metadata rather than agent output. + * CLI metadata has specific fields like duration_ms, session_id, etc. + * + * @param {object} obj - Parsed JSON object + * @returns {boolean} True if this looks like CLI metadata + */ +function isCliMetadata(obj) { + if (!obj || typeof obj !== 'object') return false; + + // If it has type:result, it's definitely CLI wrapper (should have been handled by extractFromResultWrapper) + if (obj.type === 'result') return true; + + // Check for CLI-specific metadata fields + const keys = Object.keys(obj); + const metadataFieldCount = keys.filter((k) => CLI_METADATA_FIELDS.has(k)).length; + + // If 2+ CLI metadata fields present, reject as CLI output + return metadataFieldCount >= 2; +} + /** * Strategy 4: Direct JSON parse * Handles raw JSON output (single-line or multi-line) * + * IMPORTANT: Rejects CLI metadata objects to prevent schema validation + * against wrong data structure (e.g., validating {duration_ms, session_id} + * against agent schema expecting {summary, completionStatus}). + * * @param {string} text - Text to parse * @returns {object|null} Parsed JSON or null */ @@ -168,6 +222,10 @@ function extractDirectJson(text) { try { const parsed = JSON.parse(trimmed); if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + // Reject CLI metadata - this is wrapper output, not agent content + if (isCliMetadata(parsed)) { + return null; + } return parsed; } } catch { @@ -177,6 +235,71 @@ function extractDirectJson(text) { return null; } +/** + * Extract CLI error from provider output (all providers). + * Returns the error message if the CLI reported an error, null otherwise. + * + * Provider error formats: + * - Claude: {type:"result", is_error:true, errors:["msg"]} or {type:"result", subtype:"error"} + * - Codex: {type:"turn.failed", error:{message:"msg"}} + * - Gemini: {type:"result", success:false, error:"msg"} + * - Opencode: {type:"session.error", error:{message:"msg"}} + * + * @param {string} output - Raw CLI output + * @returns {{error: string, provider: string}|null} Error info or null + */ +function extractCliError(output) { + if (!output || typeof output !== 'string') return null; + + const lines = output.split('\n'); + + for (const line of lines) { + const content = stripTimestamp(line); + if (!content.startsWith('{')) continue; + + let obj; + try { + obj = JSON.parse(content); + } catch { + continue; + } + + // Claude: {type:"result", is_error:true, errors:[...]} + if (obj.type === 'result' && obj.is_error === true) { + const errorMsg = Array.isArray(obj.errors) + ? obj.errors.join('; ') + : obj.error || obj.result || 'Unknown CLI error'; + return { error: errorMsg, provider: 'claude' }; + } + + // Claude: {type:"result", subtype:"error"} + if (obj.type === 'result' && obj.subtype === 'error') { + const errorMsg = obj.error || obj.result || 'CLI returned error'; + return { error: errorMsg, provider: 'claude' }; + } + + // Codex: {type:"turn.failed", error:{message:"..."}} + if (obj.type === 'turn.failed') { + const errorMsg = obj.error?.message || obj.error || 'Turn failed'; + return { error: errorMsg, provider: 'codex' }; + } + + // Gemini: {type:"result", success:false, error:"..."} + if (obj.type === 'result' && obj.success === false && obj.error) { + return { error: obj.error, provider: 'gemini' }; + } + + // Opencode: {type:"session.error", error:{...}} + if (obj.type === 'session.error') { + const errorMsg = + obj.error?.data?.message || obj.error?.message || obj.error?.name || 'Session error'; + return { error: errorMsg, provider: 'opencode' }; + } + } + + return null; +} + /** * Detects fatal standalone output lines that indicate no task output was produced. * Only matches when the line itself is the fatal message (not when it appears inside JSON). @@ -235,6 +358,7 @@ function extractJsonFromOutput(output, providerName = 'claude') { module.exports = { extractJsonFromOutput, + extractCliError, extractFromResultWrapper, extractFromTextEvents, extractFromMarkdown, diff --git a/src/agent/rate-limit-backoff.js b/src/agent/rate-limit-backoff.js new file mode 100644 index 00000000..460a9af6 --- /dev/null +++ b/src/agent/rate-limit-backoff.js @@ -0,0 +1,82 @@ +/** + * Rate-limit-aware backoff for API retries + * + * Rate limit errors (429, capacity exhausted, quota exceeded) need LONGER delays + * than transient errors (timeouts, network issues). + * + * - Regular errors: 2s base, exponential backoff up to 30s + * - Rate limits: 30s base, exponential backoff up to 5 minutes + * - Retry-After header: Honored if present (capped at 5 min) + */ + +/** + * Check if error is a rate limit error + * @param {Error|string} error - Error object or message + * @returns {boolean} True if this is a rate limit error + */ +function isRateLimitError(error) { + const msg = error?.message || String(error); + return /\b429\b|rate.?limit|too many requests|no capacity|quota.?exceeded|resource.?exhausted/i.test( + msg + ); +} + +/** + * Parse Retry-After from error message + * Looks for patterns like "Retry-After: 120" or "retry after 120 seconds" + * @param {Error} error - Error object + * @returns {number|null} Seconds to wait, or null if not found + */ +function parseRetryAfter(error) { + const msg = error?.message || ''; + // Match "Retry-After: 120" or "retry after 120" (seconds) + const match = msg.match(/retry.?after[:\s]+(\d+)/i); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Calculate delay for retry with rate-limit awareness + * + * Rate limit errors get 30s base delay instead of 2s. + * Regular errors use exponential backoff from 2s base. + * + * @param {Error} error - The error that occurred + * @param {number} attempt - Current attempt number (1-based) + * @param {Object} settings - Settings with backoff config + * @param {number} [settings.backoffBaseMs=2000] - Base delay for regular errors + * @param {number} [settings.backoffMaxMs=30000] - Max delay for regular errors + * @param {number} [settings.jitterFactor=0.2] - Jitter factor (±20%) + * @returns {number} Delay in milliseconds + */ +function calculateRateLimitDelay(error, attempt, settings = {}) { + const baseDelay = settings.backoffBaseMs ?? 2000; + const maxDelay = settings.backoffMaxMs ?? 30000; + const jitter = settings.jitterFactor ?? 0.2; + + // Check for Retry-After header in error message + const retryAfter = parseRetryAfter(error); + if (retryAfter) { + // Honor Retry-After but cap at 5 minutes + return Math.min(retryAfter * 1000, 300000); + } + + // Rate limits get 30s base, others get normal base + const isRateLimit = isRateLimitError(error); + const effectiveBase = isRateLimit ? 30000 : baseDelay; + + // Exponential: base * 2^(attempt-1) + let delay = effectiveBase * Math.pow(2, attempt - 1); + + // Cap at appropriate max (5 min for rate limits, settings max for others) + delay = Math.min(delay, isRateLimit ? 300000 : maxDelay); + + // Add jitter (±jitterFactor) + const jitterAmount = delay * jitter * (Math.random() * 2 - 1); + return Math.round(delay + jitterAmount); +} + +module.exports = { + calculateRateLimitDelay, + isRateLimitError, + parseRetryAfter, +}; diff --git a/src/agents/git-pusher-template.js b/src/agents/git-pusher-template.js index db3cd4d0..de81f546 100644 --- a/src/agents/git-pusher-template.js +++ b/src/agents/git-pusher-template.js @@ -18,59 +18,198 @@ const SHARED_TRIGGER_SCRIPT = `const validators = cluster.getAgentsByRole('valid const lastPush = ledger.findLast({ topic: 'IMPLEMENTATION_READY' }); if (!lastPush) return false; if (validators.length === 0) return true; + const results = ledger.query({ topic: 'VALIDATION_RESULT', since: lastPush.timestamp }); -if (results.length < validators.length) return false; -const allApproved = results.every(r => r.content?.data?.approved === 'true' || r.content?.data?.approved === true); -if (!allApproved) return false; -const hasRealEvidence = results.every(r => { - const criteria = r.content?.data?.criteriaResults || []; - return criteria.every(c => { - return c.evidence?.command && typeof c.evidence?.exitCode === 'number' && c.evidence?.output?.length > 10; +if (results.length === 0) return false; + +const validatorIds = new Set(validators.map((v) => v.id)); +const validatorResults = results.filter((r) => validatorIds.has(r.sender)); + +// Two supported patterns: +// 1) Per-validator VALIDATION_RESULT (sender is a validator) → require all validators approve. +// 2) Consensus-only VALIDATION_RESULT (sender is coordinator) → treat latest result as final. +if (validatorResults.length === 0) { + let latest = null; + for (const msg of results) { + if (!latest || (typeof msg.timestamp === 'number' && msg.timestamp > latest.timestamp)) { + latest = msg; + } + } + const approved = latest?.content?.data?.approved; + return approved === true || approved === 'true'; +} + +const latestByValidator = new Map(); +for (const msg of validatorResults) { + latestByValidator.set(msg.sender, msg); +} +if (latestByValidator.size < validators.length) return false; + +for (const validator of validators) { + const msg = latestByValidator.get(validator.id); + const approved = msg?.content?.data?.approved; + if (!(approved === true || approved === 'true')) return false; +} + +const hasSufficientEvidence = Array.from(latestByValidator.values()).every((r) => { + const criteria = r.content?.data?.criteriaResults; + if (!Array.isArray(criteria) || criteria.length === 0) return true; + return criteria.every((c) => { + const status = String(c.status || '').toUpperCase(); + if (status === 'CANNOT_VALIDATE') return true; + if (status === 'SKIPPED') return true; + if (status === 'CANNOT_VALIDATE_YET') return false; + const evidence = c.evidence || {}; + const hasCommand = typeof evidence.command === 'string' && evidence.command.trim().length > 0; + const exitCode = evidence.exitCode; + const hasExitCode = + typeof exitCode === 'number' || + (typeof exitCode === 'string' && exitCode.trim() !== '' && Number.isFinite(Number(exitCode))); + const hasOutput = evidence.output === undefined || typeof evidence.output === 'string'; + return hasCommand && hasExitCode && hasOutput; }); }); -return hasRealEvidence;`; + +return hasSufficientEvidence;`; + +const { readRepoSettings } = require('../../lib/repo-settings'); + +function getSafeBranchName(value) { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + + // Conservative allowlist to avoid shell injection in generated CLI commands. + if (!/^[A-Za-z0-9._/-]+$/.test(trimmed)) { + return null; + } + + return trimmed; +} + +function parseBool(value) { + if (typeof value === 'boolean') return value; + if (typeof value !== 'string') return null; + const trimmed = value.trim().toLowerCase(); + if (trimmed === '1' || trimmed === 'true' || trimmed === 'yes') return true; + if (trimmed === '0' || trimmed === 'false' || trimmed === 'no') return false; + return null; +} + +function normalizeCloseIssueMode(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim().toLowerCase(); + if (trimmed === 'auto') return 'auto'; + if (trimmed === 'always') return 'always'; + if (trimmed === 'never') return 'never'; + return null; +} + +/** + * Resolve GitHub configuration from CLI options and repo settings. + * Priority: CLI options > repo settings (.zeroshot/settings.json) > defaults + * + * @param {Object} options - CLI options + * @param {string} [options.prBase] - Target branch for PRs + * @param {boolean} [options.mergeQueue] - Use GitHub merge queue + * @param {string} [options.closeIssue] - When to close issue: auto|always|never + * @returns {Object} Resolved configuration + */ +function resolveGitHubConfig(options = {}) { + const repoSettingsResult = readRepoSettings(process.cwd()); + const repoSettings = repoSettingsResult.settings || {}; + const repoGithub = repoSettings.github || {}; + + // CLI options override repo settings + const prBase = getSafeBranchName(options.prBase) || getSafeBranchName(repoGithub.prBase); + + const useMergeQueue = + options.mergeQueue === true || + (options.mergeQueue !== false && parseBool(repoGithub.useMergeQueue) === true); + + const closeIssueMode = + normalizeCloseIssueMode(options.closeIssue) || + normalizeCloseIssueMode(repoGithub.closeIssue) || + (parseBool(repoGithub.closeIssue) === true ? 'always' : null) || + 'never'; + + return { prBase, useMergeQueue, closeIssueMode }; +} /** - * Platform-specific CLI commands and terminology + * Generate platform-specific configuration based on resolved GitHub config. + * + * @param {string} platform - Platform ID ('github', 'gitlab', 'azure-devops') + * @param {Object} config - Resolved GitHub config from resolveGitHubConfig() + * @returns {Object|null} Platform configuration or null if unsupported */ -const PLATFORM_CONFIGS = { - github: { - prName: 'PR', - prNameLower: 'pull request', - createCmd: 'gh pr create --title "feat: {{issue_title}}" --body "Closes #{{issue_number}}"', - mergeCmd: 'gh pr merge --merge --auto', - mergeFallbackCmd: 'gh pr merge --merge', - prUrlExample: 'https://github.com/owner/repo/pull/123', - outputFields: { urlField: 'pr_url', numberField: 'pr_number', mergedField: 'merged' }, - }, - gitlab: { - prName: 'MR', - prNameLower: 'merge request', - createCmd: - 'glab mr create --title "feat: {{issue_title}}" --description "Closes #{{issue_number}}"', - mergeCmd: 'glab mr merge --auto-merge', - mergeFallbackCmd: 'glab mr merge', - prUrlExample: 'https://gitlab.com/owner/repo/-/merge_requests/123', - outputFields: { urlField: 'mr_url', numberField: 'mr_number', mergedField: 'merged' }, - }, - 'azure-devops': { - prName: 'PR', - prNameLower: 'pull request', - createCmd: - 'az repos pr create --title "feat: {{issue_title}}" --description "Closes #{{issue_number}}"', - mergeCmd: 'az repos pr update --id --auto-complete true', - mergeFallbackCmd: 'az repos pr update --id --status completed', - prUrlExample: 'https://dev.azure.com/org/project/_git/repo/pullrequest/123', - outputFields: { - urlField: 'pr_url', - numberField: 'pr_number', - mergedField: 'merged', - autoCompleteField: 'auto_complete', +function getPlatformConfig(platform, config = {}) { + const { prBase, useMergeQueue, closeIssueMode } = config; + + const PLATFORM_CONFIGS = { + github: { + prName: 'PR', + prNameLower: 'pull request', + createCmd: `gh pr create${prBase ? ` --base ${prBase}` : ''} --title "feat: {{issue_title}}" --body "Closes #{{issue_number}}"`, + mergeCmd: useMergeQueue + ? `PR_ID="$(gh pr view --json id --jq .id)" +gh api graphql -f query='mutation($id:ID!){enqueuePullRequest(input:{pullRequestId:$id}){mergeQueueEntry{state}}}' -f id="$PR_ID" +echo "Waiting for merge..." +until gh pr view --json mergedAt --jq .mergedAt | grep -q .; do + sleep 20 +done` + : 'gh pr merge --merge --auto', + mergeFallbackCmd: useMergeQueue ? 'gh pr merge --merge --auto' : 'gh pr merge --merge', + prUrlExample: 'https://github.com/owner/repo/pull/123', + outputFields: { urlField: 'pr_url', numberField: 'pr_number', mergedField: 'merged' }, + rebaseBranch: prBase || 'main', + usesMergeQueue: useMergeQueue, + closeIssueMode: closeIssueMode || 'never', }, - // Azure requires extracting PR ID from create output - requiresPrIdExtraction: true, - }, -}; + gitlab: { + prName: 'MR', + prNameLower: 'merge request', + createCmd: + 'glab mr create --title "feat: {{issue_title}}" --description "Closes #{{issue_number}}"', + mergeCmd: 'glab mr merge --auto-merge', + mergeFallbackCmd: 'glab mr merge', + prUrlExample: 'https://gitlab.com/owner/repo/-/merge_requests/123', + outputFields: { urlField: 'mr_url', numberField: 'mr_number', mergedField: 'merged' }, + closeIssueMode: closeIssueMode || 'never', + }, + 'azure-devops': { + prName: 'PR', + prNameLower: 'pull request', + createCmd: + 'az repos pr create --title "feat: {{issue_title}}" --description "Closes #{{issue_number}}"', + mergeCmd: 'az repos pr update --id --auto-complete true', + mergeFallbackCmd: 'az repos pr update --id --status completed', + prUrlExample: 'https://dev.azure.com/org/project/_git/repo/pullrequest/123', + outputFields: { + urlField: 'pr_url', + numberField: 'pr_number', + mergedField: 'merged', + autoCompleteField: 'auto_complete', + }, + // Azure requires extracting PR ID from create output + requiresPrIdExtraction: true, + closeIssueMode: closeIssueMode || 'never', + }, + }; + + return PLATFORM_CONFIGS[platform] || null; +} + +/** + * Get list of supported platforms for git-pusher + * @returns {string[]} Array of platform IDs + */ +const SUPPORTED_PLATFORMS = ['github', 'gitlab', 'azure-devops']; /** * Generate the prompt for a specific platform @@ -87,6 +226,9 @@ function generatePrompt(config) { prUrlExample, outputFields, requiresPrIdExtraction, + rebaseBranch, + usesMergeQueue, + closeIssueMode, } = config; // Azure-specific instructions for PR ID extraction @@ -99,14 +241,20 @@ Save the PR ID to a variable for step 6.` // Azure uses different merge terminology const mergeDescription = requiresPrIdExtraction ? 'SET AUTO-COMPLETE (MANDATORY - THIS IS NOT OPTIONAL)' - : `MERGE THE ${prName} (MANDATORY - THIS IS NOT OPTIONAL)`; + : usesMergeQueue + ? `ENQUEUE INTO MERGE QUEUE AND WAIT UNTIL THE ${prName} IS MERGED (MANDATORY - THIS IS NOT OPTIONAL)` + : `MERGE THE ${prName} (MANDATORY - THIS IS NOT OPTIONAL)`; const mergeExplanation = requiresPrIdExtraction ? `Replace with the actual PR number from step 5. This enables auto-complete (auto-merge when CI passes). If auto-complete is not available or you need to merge immediately:` - : `This sets auto-merge. If it fails (e.g., no auto-merge enabled), try:`; + : usesMergeQueue + ? `This enqueues the ${prName} into GitHub's merge queue and waits until it is merged. + +If enqueue fails (merge queue not enabled, missing permissions, etc.), fall back to auto-merge:` + : `This sets auto-merge. If it fails (e.g., no auto-merge enabled), try:`; const postMergeStatus = requiresPrIdExtraction ? 'PR IS CREATED AND AUTO-COMPLETE IS SET' @@ -182,10 +330,10 @@ ${mergeFallbackCmd} \`\`\` 🚨 IF MERGE FAILS DUE TO CONFLICTS - YOU MUST RESOLVE THEM: -a) Pull latest main and rebase: +a) Pull latest ${rebaseBranch || 'main'} and rebase: \`\`\`bash - git fetch origin main - git rebase origin/main + git fetch origin ${rebaseBranch || 'main'} + git rebase origin/${rebaseBranch || 'main'} \`\`\` b) If conflicts appear - RESOLVE THEM IMMEDIATELY: - Read the conflicting files @@ -199,12 +347,45 @@ c) Force push the resolved branch: \`\`\` d) Retry merge: \`\`\`bash - ${mergeFallbackCmd} - \`\`\` +${mergeFallbackCmd} +\`\`\` REPEAT UNTIL MERGED. DO NOT GIVE UP. DO NOT SKIP. THE ${prName} MUST BE ${requiresPrIdExtraction ? 'SET TO AUTO-COMPLETE' : 'MERGED'}. If merge is blocked by CI, wait and retry. ${requiresPrIdExtraction ? 'The auto-complete will merge when CI passes.' : 'If blocked by reviews, set auto-merge.'} +${ + closeIssueMode !== 'never' + ? `### STEP 7: Close the issue (MANDATORY) +\`\`\`bash +if [ "{{issue_number}}" != "unknown" ]; then + ISSUE_STATE="$(gh issue view {{issue_number}} --json state --jq .state 2>/dev/null || true)" + if [ "$ISSUE_STATE" = "OPEN" ]; then + BASE_BRANCH="${rebaseBranch || 'main'}" + DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)" + SHOULD_CLOSE="0" + if [ "${closeIssueMode}" = "always" ]; then + SHOULD_CLOSE="1" + elif [ "${closeIssueMode}" = "auto" ]; then + if [ -z "$DEFAULT_BRANCH" ] || [ "$BASE_BRANCH" != "$DEFAULT_BRANCH" ]; then + SHOULD_CLOSE="1" + fi + fi + + if [ "$SHOULD_CLOSE" = "1" ]; then + PR_URL="$(gh pr view --json url --jq .url 2>/dev/null || true)" + if [ -n "$PR_URL" ]; then + gh issue close {{issue_number}} --comment "Implemented in $PR_URL" + else + gh issue close {{issue_number}} --comment "Implemented" + fi + fi + fi +fi +\`\`\` +Only do this AFTER the ${prName} is merged.` + : '' +} + ## CRITICAL RULES - Execute EVERY step in order (1, 2, 3, 4, 5, 6) - Do NOT skip git add -A @@ -225,14 +406,20 @@ ${finalOutputNote}`; * Generate a git-pusher agent configuration for a specific platform * * @param {string} platform - Platform ID ('github', 'gitlab', 'azure-devops') + * @param {Object} [options] - CLI options for GitHub configuration + * @param {string} [options.prBase] - Target branch for PRs + * @param {boolean} [options.mergeQueue] - Use GitHub merge queue + * @param {string} [options.closeIssue] - When to close issue: auto|always|never * @returns {Object} Agent configuration object * @throws {Error} If platform is not supported */ -function generateGitPusherAgent(platform) { - const config = PLATFORM_CONFIGS[platform]; +function generateGitPusherAgent(platform, options = {}) { + // Resolve config from CLI options and repo settings + const resolvedConfig = resolveGitHubConfig(options); + const platformConfig = getPlatformConfig(platform, resolvedConfig); - if (!config) { - const supported = Object.keys(PLATFORM_CONFIGS).join(', '); + if (!platformConfig) { + const supported = SUPPORTED_PLATFORMS.join(', '); throw new Error(`Unsupported platform '${platform}'. Supported: ${supported}`); } @@ -250,22 +437,34 @@ function generateGitPusherAgent(platform) { action: 'execute_task', }, ], - prompt: generatePrompt(config), + prompt: generatePrompt(platformConfig), hooks: { onComplete: { - action: 'publish_message', - config: { - topic: 'CLUSTER_COMPLETE', - content: { - data: { reason: 'git-pusher-complete' }, - }, - }, + action: 'verify_github_pr', + // No config needed - verification reads from result.structured_output + // and publishes CLUSTER_COMPLETE only if verification passes }, }, output: { topic: 'PR_CREATED', publishAfter: 'CLUSTER_COMPLETE', }, + structuredOutput: { + type: 'object', + properties: { + pr_number: { + type: 'number', + description: 'MUST extract from gh pr create output - NOT from git push link', + }, + pr_url: { type: 'string' }, + merged: { type: 'boolean' }, + merge_commit_sha: { + type: 'string', + description: 'MUST extract from gh pr merge output', + }, + }, + required: ['pr_number', 'pr_url', 'merged', 'merge_commit_sha'], + }, }; } @@ -274,7 +473,7 @@ function generateGitPusherAgent(platform) { * @returns {string[]} Array of platform IDs */ function getSupportedPlatforms() { - return Object.keys(PLATFORM_CONFIGS); + return SUPPORTED_PLATFORMS; } /** @@ -283,7 +482,7 @@ function getSupportedPlatforms() { * @returns {boolean} */ function isPlatformSupported(platform) { - return platform in PLATFORM_CONFIGS; + return SUPPORTED_PLATFORMS.includes(platform); } module.exports = { @@ -292,5 +491,7 @@ module.exports = { isPlatformSupported, // Export for testing SHARED_TRIGGER_SCRIPT, - PLATFORM_CONFIGS, + SUPPORTED_PLATFORMS, + resolveGitHubConfig, + getPlatformConfig, }; diff --git a/src/attach/index.js b/src/attach/index.js index ccecfc5e..657846f7 100644 --- a/src/attach/index.js +++ b/src/attach/index.js @@ -25,6 +25,7 @@ const AttachClient = require('./attach-client'); const RingBuffer = require('./ring-buffer'); const protocol = require('./protocol'); const socketDiscovery = require('./socket-discovery'); +const { sendInput } = require('./send-input'); module.exports = { AttachServer, @@ -32,4 +33,5 @@ module.exports = { RingBuffer, protocol, socketDiscovery, + sendInput, }; diff --git a/src/attach/send-input.js b/src/attach/send-input.js new file mode 100644 index 00000000..481fe53b --- /dev/null +++ b/src/attach/send-input.js @@ -0,0 +1,88 @@ +/** + * sendInput - write stdin data to a live attach socket + * + * Uses the attach protocol's STDIN message to forward input to the PTY. + * Returns { ok, error } instead of throwing on transport failures. + */ + +const net = require('net'); +const fs = require('fs'); +const protocol = require('./protocol'); + +const DEFAULT_TIMEOUT_MS = 1500; + +/** + * Send input to an attach socket via STDIN message. + * @param {object} options + * @param {string} options.socketPath - Unix socket path + * @param {Buffer|string} options.data - Data to send + * @param {number} [options.timeoutMs=1500] - Timeout in ms + * @returns {Promise<{ok: boolean, error: string|null}>} + */ +function sendInput(options = {}) { + const { socketPath, data, timeoutMs = DEFAULT_TIMEOUT_MS } = options; + + if (!socketPath) { + throw new Error('sendInput: socketPath is required'); + } + + if (data === undefined || data === null) { + throw new Error('sendInput: data is required'); + } + + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error(`sendInput: timeoutMs must be positive (got ${timeoutMs})`); + } + + if (!fs.existsSync(socketPath)) { + return { ok: false, error: `Socket not found: ${socketPath}` }; + } + + return new Promise((resolve) => { + let settled = false; + let timeout; + const socket = net.createConnection(socketPath); + + const finish = (result) => { + if (settled) return; + settled = true; + if (timeout) { + clearTimeout(timeout); + } + try { + socket.end(); + socket.destroy(); + } catch (cleanupError) { + console.warn('[sendInput] socket cleanup failed:', cleanupError); + } + resolve(result); + }; + + timeout = setTimeout(() => { + finish({ ok: false, error: 'Timeout waiting for socket connection' }); + }, timeoutMs); + + socket.on('connect', () => { + try { + const encoded = protocol.encode(protocol.createStdinMessage(data)); + socket.write(encoded, (err) => { + if (err) { + finish({ ok: false, error: err.message }); + } else { + finish({ ok: true, error: null }); + } + }); + } catch (err) { + finish({ ok: false, error: err.message }); + } + }); + + socket.on('error', (err) => { + finish({ ok: false, error: err.message }); + }); + }); +} + +module.exports = { + sendInput, +}; diff --git a/src/config-validator.js b/src/config-validator.js index 488658fd..659fc470 100644 --- a/src/config-validator.js +++ b/src/config-validator.js @@ -15,6 +15,7 @@ const { loadSettings } = require('../lib/settings'); const { VALID_PROVIDERS, normalizeProviderName } = require('../lib/provider-names'); const { getProvider } = require('./providers'); const { CAPABILITIES } = require('./providers/capabilities'); +const { GUIDANCE_TOPICS } = require('./guidance-topics'); /** * Check if config is a conductor-bootstrap style config @@ -390,6 +391,7 @@ function reportUnproducedTopics(topicConsumers, topicProducers, errors, config) 'CLUSTER_RESUMED', 'QUICK_VALIDATION_PASSED', 'IMPLEMENTATION_READY', + ...GUIDANCE_TOPICS, ]; const isSubTemplate = config.params && Object.keys(config.params).length > 0; @@ -994,7 +996,7 @@ function validateHookAction(hook, prefix, errors) { if (!hook.action) { errors.push( `[Gap 1] ${prefix}: Missing 'action' field. ` + - `Fix: Add "action": "publish_message" or "action": "execute_system_command"` + `Fix: Add "action": "publish_message", "action": "execute_system_command", or "action": "verify_github_pr"` ); } } @@ -1216,7 +1218,7 @@ function validateRuleCoverage(config) { agent, 4, 'Model rules', - 'Add catch-all rule { "iterations": "all", "model": "sonnet" } or extend existing ranges.', + 'Add catch-all rule { "iterations": "all", "modelLevel": "level2" } or extend existing ranges.', uncoveredIterations ); } diff --git a/src/guidance-topics.js b/src/guidance-topics.js new file mode 100644 index 00000000..476f99ba --- /dev/null +++ b/src/guidance-topics.js @@ -0,0 +1,10 @@ +const USER_GUIDANCE_CLUSTER = 'USER_GUIDANCE_CLUSTER'; +const USER_GUIDANCE_AGENT = 'USER_GUIDANCE_AGENT'; + +const GUIDANCE_TOPICS = [USER_GUIDANCE_CLUSTER, USER_GUIDANCE_AGENT]; + +module.exports = { + USER_GUIDANCE_CLUSTER, + USER_GUIDANCE_AGENT, + GUIDANCE_TOPICS, +}; diff --git a/src/isolation-manager.js b/src/isolation-manager.js index f978dbdd..6fe203d4 100644 --- a/src/isolation-manager.js +++ b/src/isolation-manager.js @@ -20,6 +20,7 @@ const { CLAUDE_AUTH_ENV_VARS, resolveClaudeAuth } = require('../lib/settings/cla const { normalizeProviderName } = require('../lib/provider-names'); const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config'); const { getProvider } = require('./providers'); +const { readRepoSettings } = require('../lib/repo-settings'); /** * Escape a string for safe use in shell commands @@ -55,6 +56,7 @@ class IsolationManager { this.isolatedDirs = new Map(); // clusterId -> { path, originalDir } this.clusterConfigDirs = new Map(); // clusterId -> configDirPath this.worktrees = new Map(); // clusterId -> { path, branch, repoRoot } + this._exitWatchers = new Map(); // clusterId -> ChildProcess } /** @@ -125,7 +127,44 @@ class IsolationManager { args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null'); - return this._spawnContainer(clusterId, args, workDir); + const containerId = await this._spawnContainer(clusterId, args, workDir); + this._watchContainerExit(clusterId, containerId, config.onExit); + return containerId; + } + + _watchContainerExit(clusterId, containerId, onExit) { + if (typeof onExit !== 'function') { + return; + } + + const existing = this._exitWatchers.get(clusterId); + if (existing) { + try { + existing.kill('SIGKILL'); + } catch { + // Ignore + } + this._exitWatchers.delete(clusterId); + } + + const proc = spawn('docker', ['wait', containerId], { stdio: ['ignore', 'pipe', 'ignore'] }); + this._exitWatchers.set(clusterId, proc); + + let stdout = ''; + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + const finalize = () => { + if (this._exitWatchers.get(clusterId) === proc) { + this._exitWatchers.delete(clusterId); + } + const code = parseInt(stdout.trim(), 10); + onExit({ clusterId, containerId, exitCode: Number.isFinite(code) ? code : null }); + }; + + proc.on('close', finalize); + proc.on('error', finalize); } _getRunningContainerId(clusterId) { @@ -1256,19 +1295,19 @@ class IsolationManager { /** * Create worktree-based isolation for a cluster (lightweight alternative to Docker) - * Creates a git worktree at {os.tmpdir()}/zeroshot-worktrees/{clusterId} + * Creates a git worktree at ~/.zeroshot/worktrees/{clusterId} * @param {string} clusterId - Cluster ID * @param {string} workDir - Original working directory (must be a git repo) * @returns {{ path: string, branch: string, repoRoot: string }} */ - createWorktreeIsolation(clusterId, workDir) { + createWorktreeIsolation(clusterId, workDir, options = {}) { if (!this._isGitRepo(workDir)) { throw new Error( `Worktree isolation requires a git repository. ${workDir} is not a git repo.` ); } - const worktreeInfo = this.createWorktree(clusterId, workDir); + const worktreeInfo = this.createWorktree(clusterId, workDir, options); this.worktrees.set(clusterId, worktreeInfo); console.log(`[IsolationManager] Created worktree isolation at ${worktreeInfo.path}`); @@ -1301,18 +1340,49 @@ class IsolationManager { * @param {string} workDir - Original working directory * @returns {{ path: string, branch: string, repoRoot: string }} */ - createWorktree(clusterId, workDir) { + createWorktree(clusterId, workDir, options = {}) { const repoRoot = this._getGitRoot(workDir); if (!repoRoot) { throw new Error(`Cannot find git root for ${workDir}`); } + // Priority: 1) options.baseRef, 2) repo settings, 3) HEAD (default) + let worktreeBaseRef = options.baseRef || null; + try { + const repoSettingsResult = readRepoSettings(repoRoot); + const repoSettings = repoSettingsResult.settings || {}; + const candidate = repoSettings.worktree?.baseRef; + if ( + !worktreeBaseRef && + typeof candidate === 'string' && + /^[A-Za-z0-9._/-]+$/.test(candidate.trim()) + ) { + worktreeBaseRef = candidate.trim(); + } + } catch { + // ignore + } + + // Best-effort ensure origin/ exists locally if requested. + if (worktreeBaseRef && worktreeBaseRef.startsWith('origin/')) { + const branch = worktreeBaseRef.slice('origin/'.length); + try { + execSync(`git fetch origin ${escapeShell(branch)}`, { + cwd: repoRoot, + encoding: 'utf8', + stdio: 'pipe', + }); + } catch { + // ignore + } + } + // Create branch name from cluster ID (e.g., cluster-cosmic-meteor-87 -> zeroshot/cosmic-meteor-87) const baseBranchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`; let branchName = baseBranchName; - // Worktree path in tmp - const worktreePath = path.join(os.tmpdir(), 'zeroshot-worktrees', clusterId); + // Worktree path in persistent location (survives reboots) + const worktreePath = path.join(os.homedir(), '.zeroshot', 'worktrees', clusterId); // Ensure parent directory exists const parentDir = path.dirname(worktreePath); @@ -1343,7 +1413,9 @@ class IsolationManager { // ignore } - // Create worktree with new branch based on HEAD (retry on branch collision/in-use) + const baseRef = worktreeBaseRef || 'HEAD'; + + // Create worktree with new branch based on baseRef (retry on branch collision/in-use) for (let attempt = 0; attempt < 10; attempt++) { // Best-effort delete if branch exists and is not in use by another worktree. try { @@ -1358,7 +1430,7 @@ class IsolationManager { try { execSync( - `git worktree add -b ${escapeShell(branchName)} ${escapeShell(worktreePath)} HEAD`, + `git worktree add -b ${escapeShell(branchName)} ${escapeShell(worktreePath)} ${escapeShell(baseRef)}`, { cwd: repoRoot, encoding: 'utf8', @@ -1402,6 +1474,23 @@ class IsolationManager { * @param {boolean} [options.deleteBranch=false] - Also delete the branch */ removeWorktree(worktreeInfo, _options = {}) { + // Tear down any Docker Compose services that may have been started in this worktree. + // Without this, containers keep running with host port mappings after the worktree is deleted, + // blocking port allocation for the main project or other worktrees. + const composePath = path.join(worktreeInfo.path, 'docker-compose.yml'); + if (fs.existsSync(composePath)) { + try { + execSync('docker compose down --remove-orphans --volumes --timeout 10', { + cwd: worktreeInfo.path, + encoding: 'utf8', + stdio: 'pipe', + timeout: 30000, + }); + } catch { + // Best-effort: compose project may not have been started, or Docker may not be running + } + } + // Remove the worktree (prefer git so metadata is cleaned up). try { execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, { diff --git a/src/issue-providers/github-provider.js b/src/issue-providers/github-provider.js index 2ee0b7b7..8f55c370 100644 --- a/src/issue-providers/github-provider.js +++ b/src/issue-providers/github-provider.js @@ -124,12 +124,13 @@ class GitHubProvider extends IssueProvider { return {}; } - fetchIssue(identifier, _settings) { + fetchIssue(identifier, _settings, gitContext = null) { try { - const issueNumber = this._extractIssueNumber(identifier); + const { repo, number } = this._parseIdentifier(identifier, gitContext); - // Fetch issue using gh CLI - const cmd = `gh issue view ${issueNumber} --json number,title,body,labels,assignees,comments,url`; + // ALWAYS use -R flag when repo is known - never rely on CWD git remote + const repoFlag = repo ? `-R ${repo}` : ''; + const cmd = `gh issue view ${number} ${repoFlag} --json number,title,body,labels,assignees,comments,url`; const output = execSync(cmd, { encoding: 'utf8' }); const issue = JSON.parse(output); @@ -140,24 +141,32 @@ class GitHubProvider extends IssueProvider { } /** - * Extract issue number from URL or return as-is + * Parse identifier into repo and issue number * @private + * @returns {{ repo: string|null, number: string }} */ - _extractIssueNumber(issueRef) { - // If it's a URL, extract the number - const urlMatch = issueRef.match(/\/issues\/(\d+)/); + _parseIdentifier(identifier, gitContext = null) { + // GitHub URL: https://github.com/org/repo/issues/123 + const urlMatch = identifier.match(/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/); if (urlMatch) { - return urlMatch[1]; + return { repo: urlMatch[1], number: urlMatch[2] }; } // org/repo#123 format - const repoMatch = issueRef.match(/^[\w-]+\/[\w-]+#(\d+)$/); + const repoMatch = identifier.match(/^([\w.-]+\/[\w.-]+)#(\d+)$/); if (repoMatch) { - return repoMatch[1]; + return { repo: repoMatch[1], number: repoMatch[2] }; } - // Otherwise assume it's already a number - return issueRef; + // Bare number - use gitContext if available + if (/^\d+$/.test(identifier)) { + const repo = + gitContext?.owner && gitContext?.repo ? `${gitContext.owner}/${gitContext.repo}` : null; + return { repo, number: identifier }; + } + + // Fallback: assume it's a number, no repo + return { repo: null, number: identifier }; } /** diff --git a/src/ledger.js b/src/ledger.js index df64b6ef..bf25695a 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -11,6 +11,11 @@ const Database = require('better-sqlite3'); const EventEmitter = require('events'); const crypto = require('crypto'); +const { + GUIDANCE_TOPICS, + USER_GUIDANCE_AGENT, + USER_GUIDANCE_CLUSTER, +} = require('./guidance-topics'); class Ledger extends EventEmitter { constructor(dbPath = ':memory:') { @@ -114,12 +119,13 @@ class Ledger extends EventEmitter { const timestamp = requestedTimestamp !== null ? Math.max(requestedTimestamp, baseTimestamp) : baseTimestamp; + const receiver = message.receiver || message.target_agent_id || 'broadcast'; const record = { id, timestamp, topic: message.topic, sender: message.sender, - receiver: message.receiver || 'broadcast', + receiver, content_text: message.content?.text || null, content_data: message.content?.data ? JSON.stringify(message.content.data) : null, metadata: message.metadata ? JSON.stringify(message.metadata) : null, @@ -188,12 +194,13 @@ class Ledger extends EventEmitter { // Use incrementing timestamps to preserve order within batch const timestamp = baseTimestamp + i; + const receiver = message.receiver || message.target_agent_id || 'broadcast'; const record = { id, timestamp, topic: message.topic, sender: message.sender, - receiver: message.receiver || 'broadcast', + receiver, content_text: message.content?.text || null, content_data: message.content?.data ? JSON.stringify(message.content.data) : null, metadata: message.metadata ? JSON.stringify(message.metadata) : null, @@ -304,6 +311,60 @@ class Ledger extends EventEmitter { return rows.map((row) => this._deserializeMessage(row)); } + /** + * Query guidance mailbox for cluster-wide + agent-specific guidance + * @param {Object} criteria - { cluster_id, target_agent_id, lastDeliveredAt, limit } + * @returns {Array} Guidance messages ordered by timestamp ASC + */ + queryGuidanceMailbox(criteria) { + const { cluster_id, target_agent_id, lastDeliveredAt, limit } = criteria || {}; + + if (!cluster_id) { + throw new Error('cluster_id is required for guidance mailbox queries'); + } + + const guidanceTopics = new Set(GUIDANCE_TOPICS); + if (!guidanceTopics.has(USER_GUIDANCE_CLUSTER) || !guidanceTopics.has(USER_GUIDANCE_AGENT)) { + throw new Error('GUIDANCE_TOPICS must include USER_GUIDANCE_CLUSTER and USER_GUIDANCE_AGENT'); + } + + let sinceTimestamp = null; + if (lastDeliveredAt !== undefined && lastDeliveredAt !== null) { + const candidate = + typeof lastDeliveredAt === 'number' ? lastDeliveredAt : new Date(lastDeliveredAt).getTime(); + if (!Number.isFinite(candidate)) { + throw new Error('lastDeliveredAt must be a number or valid date'); + } + sinceTimestamp = candidate; + } + + const params = [cluster_id, USER_GUIDANCE_CLUSTER]; + let sql = 'SELECT * FROM messages WHERE cluster_id = ? AND (topic = ?'; + + if (target_agent_id) { + params.push(USER_GUIDANCE_AGENT, target_agent_id); + sql += ' OR (topic = ? AND receiver = ?)'; + } + + sql += ')'; + + if (sinceTimestamp !== null) { + params.push(sinceTimestamp); + sql += ' AND timestamp > ?'; + } + + sql += ' ORDER BY timestamp ASC'; + + if (limit) { + params.push(limit); + sql += ' LIMIT ?'; + } + + const stmt = this.db.prepare(sql); + const rows = stmt.all(...params); + return rows.map((row) => this._deserializeMessage(row)); + } + /** * Find the last message matching criteria * @param {Object} criteria - Query criteria diff --git a/src/message-buffer.js b/src/message-buffer.js new file mode 100644 index 00000000..626cf13f --- /dev/null +++ b/src/message-buffer.js @@ -0,0 +1,81 @@ +/** + * Message buffering helper + * + * Ensures trigger-matching messages are never dropped just because an agent/subcluster is busy. + * Dropped workflow signals (e.g. VALIDATION_RESULT) can wedge clusters in "running" state. + */ + +function bufferMessage(target, message, options = {}) { + const maxBuffered = options.maxBuffered ?? 200; + + if (!target._bufferedMessages) { + target._bufferedMessages = []; + } + + if (target._bufferedMessages.length >= maxBuffered) { + target._bufferedMessages.shift(); + } + + target._bufferedMessages.push(message); +} + +function scheduleDrain(target, drainFn, options = {}) { + if (target._bufferDrainScheduled) { + return; + } + + target._bufferDrainScheduled = true; + + const label = options.label || 'MessageBuffer'; + const id = target.id || 'unknown'; + + const run = () => { + target._bufferDrainScheduled = false; + drainFn().catch((error) => { + console.error(`\n${'='.repeat(80)}`); + console.error(`šŸ”“ FATAL: ${label} drain crashed (${id})`); + console.error(`${'='.repeat(80)}`); + console.error(`Error: ${error.message}`); + console.error(`Stack: ${error.stack}`); + console.error(`${'='.repeat(80)}\n`); + setImmediate(() => { + throw error; + }); + }); + }; + + const current = target._currentExecution; + if (current && typeof current.finally === 'function') { + current.finally(() => setImmediate(run)); + return; + } + + setImmediate(run); +} + +async function drainBufferedMessages(target, handleFn, options = {}) { + if (!target.running) { + return; + } + + const buffer = target._bufferedMessages; + if (!buffer || buffer.length === 0) { + return; + } + + if (target.state !== 'idle') { + scheduleDrain(target, () => drainBufferedMessages(target, handleFn, options), options); + return; + } + + while (target.running && target.state === 'idle' && buffer.length > 0) { + const next = buffer.shift(); + await handleFn(next); + } +} + +module.exports = { + bufferMessage, + scheduleDrain, + drainBufferedMessages, +}; diff --git a/src/message-bus.js b/src/message-bus.js index 61afec64..3f0dc104 100644 --- a/src/message-bus.js +++ b/src/message-bus.js @@ -103,6 +103,13 @@ class MessageBus extends EventEmitter { return this.ledger.query(criteria); } + /** + * Query guidance mailbox (passthrough to ledger) + */ + queryGuidanceMailbox(criteria) { + return this.ledger.queryGuidanceMailbox(criteria); + } + /** * Find last message (passthrough to ledger) */ diff --git a/src/orchestrator.js b/src/orchestrator.js index 5c3bc446..f32fa6c0 100644 --- a/src/orchestrator.js +++ b/src/orchestrator.js @@ -38,6 +38,7 @@ const SubClusterWrapper = require('./sub-cluster-wrapper'); const MessageBus = require('./message-bus'); const Ledger = require('./ledger'); const InputHelpers = require('./input-helpers'); +const { USER_GUIDANCE_AGENT, USER_GUIDANCE_CLUSTER } = require('./guidance-topics'); const { detectProvider } = require('./issue-providers'); const IsolationManager = require('./isolation-manager'); const { generateName } = require('./name-generator'); @@ -45,6 +46,7 @@ const configValidator = require('./config-validator'); const TemplateResolver = require('./template-resolver'); const { loadSettings } = require('../lib/settings'); const { normalizeProviderName } = require('../lib/provider-names'); +const { getProvider } = require('./providers'); const StateSnapshotter = require('./state-snapshotter'); const crypto = require('crypto'); @@ -132,6 +134,38 @@ class Orchestrator { } } + /** + * Resolve provider for a cluster config using the standard precedence. + * @param {Object} clusterConfig + * @param {Object} settings + * @returns {string} + * @private + */ + _resolveClusterProvider(clusterConfig = {}, settings = loadSettings()) { + const resolved = + clusterConfig.forceProvider || + clusterConfig.defaultProvider || + settings.defaultProvider || + 'claude'; + return normalizeProviderName(resolved) || 'claude'; + } + + /** + * Resolve the model level for internal completion agents. + * Uses provider minLevel when configured, otherwise provider default min level. + * @param {Object} clusterConfig + * @returns {string} + * @private + */ + _resolveCompletionDetectorLevel(clusterConfig = {}) { + const settings = loadSettings(); + const providerName = this._resolveClusterProvider(clusterConfig, settings); + const provider = getProvider(providerName); + const providerSettings = settings.providerSettings?.[providerName] || {}; + + return providerSettings.minLevel || provider.getDefaultMinLevel(); + } + /** * Get input source type for metadata * @param {Object} input - Input object @@ -271,30 +305,40 @@ class Orchestrator { // Restore isolation manager FIRST if cluster was running in isolation mode const { isolation, isolationManager } = this._restoreClusterIsolation(clusterId, clusterData); + // Create mutable cluster context for reload path (used by AgentWrapper/SubClusterWrapper) + const clusterContext = { + ...clusterData, + id: clusterId, + isolation, + }; + // Reconstruct agent metadata from config (processes are ephemeral) // CRITICAL: Pass isolation context to agents if cluster was running in isolation const agents = this._rebuildClusterAgents( - clusterId, - clusterData, + clusterContext, messageBus, isolation, isolationManager ); const cluster = { - ...clusterData, + ...clusterContext, ledger, messageBus, agents, isolation, autoPr: clusterData.autoPr || false, + prOptions: clusterData.prOptions || null, + issue: clusterData.issue || null, }; - this.clusters.set(clusterId, cluster); - this._startSnapshotter(cluster); + Object.assign(clusterContext, cluster); + + this.clusters.set(clusterId, clusterContext); + this._startSnapshotter(clusterContext); this._log(`[Orchestrator] Loaded cluster: ${clusterId} with ${agents.length} agents`); - return cluster; + return clusterContext; } _restoreClusterIsolation(clusterId, clusterData) { @@ -348,12 +392,12 @@ class Orchestrator { return agentOptions; } - _instantiateAgent(agentConfig, messageBus, clusterId, agentOptions) { + _instantiateAgent(agentConfig, messageBus, clusterContext, agentOptions) { if (agentConfig.type === 'subcluster') { - return new SubClusterWrapper(agentConfig, messageBus, { id: clusterId }, agentOptions); + return new SubClusterWrapper(agentConfig, messageBus, clusterContext, agentOptions); } - return new AgentWrapper(agentConfig, messageBus, { id: clusterId }, agentOptions); + return new AgentWrapper(agentConfig, messageBus, clusterContext, agentOptions); } _restoreAgentState(agent, agentConfig, clusterData) { @@ -369,8 +413,10 @@ class Orchestrator { agent.processPid = savedState.processPid || null; } - _rebuildClusterAgents(clusterId, clusterData, messageBus, isolation, isolationManager) { + _rebuildClusterAgents(clusterContext, messageBus, isolation, isolationManager) { const agents = []; + const clusterId = clusterContext.id; + const clusterData = clusterContext; const agentCwd = this._resolveAgentCwd(clusterData); if (!clusterData.config?.agents) { @@ -393,7 +439,7 @@ class Orchestrator { isolation, isolationManager ); - const agent = this._instantiateAgent(agentConfig, messageBus, clusterId, agentOptions); + const agent = this._instantiateAgent(agentConfig, messageBus, clusterContext, agentOptions); this._restoreAgentState(agent, agentConfig, clusterData); agents.push(agent); } @@ -438,6 +484,51 @@ class Orchestrator { return clustersFile; } + /** + * Find active clusters for a given issue number + * Used to prevent duplicate runs on the same issue + * @param {number|string} issueNumber - Issue number to check + * @returns {Array<{id: string, state: string, createdAt: number}>} Active clusters for this issue + * @private + */ + _getActiveClustersForIssue(issueNumber, excludeClusterId = null) { + const activeClusters = []; + const issueNum = Number(issueNumber); + + for (const [clusterId, cluster] of this.clusters) { + if (excludeClusterId && clusterId === excludeClusterId) continue; + // Skip clusters without issue numbers + if (!cluster.issue) continue; + + // Check if same issue number + if (Number(cluster.issue) !== issueNum) continue; + + // Check if cluster is still active (not completed/failed/stopped) + const inactiveStates = ['completed', 'failed', 'stopped', 'corrupted']; + if (inactiveStates.includes(cluster.state)) continue; + + // Check if process is still running (zombie detection) + if (cluster.pid) { + try { + // process.kill with signal 0 checks if process exists + process.kill(cluster.pid, 0); + // Process exists - cluster is active + activeClusters.push({ + id: clusterId, + state: cluster.state, + createdAt: cluster.createdAt, + pid: cluster.pid, + }); + } catch { + // Process doesn't exist - cluster is a zombie (stale entry) + // Don't include in active list + } + } + } + + return activeClusters; + } + /** * Save clusters to persistent storage * Uses file locking to prevent race conditions with other processes @@ -513,8 +604,12 @@ class Orchestrator { failureInfo: cluster.failureInfo || null, // Persist PR mode for completion agent selection autoPr: cluster.autoPr || false, + // Persist PR options for resume + prOptions: cluster.prOptions || null, // Persist model override for consistent agent spawning on resume modelOverride: cluster.modelOverride || null, + // Persist issue number for heroshot/external tools + issue: cluster.issue || null, // Persist isolation info (excluding manager instance which can't be serialized) // CRITICAL: workDir is required for resume() to recreate container with same workspace isolation: cluster.isolation @@ -670,17 +765,20 @@ class Orchestrator { * @returns {Object} Cluster object */ start(config, input = {}, options = {}) { + const testMode = options.testMode || !!this.taskRunner; + const autoPr = options.autoPr ?? (testMode ? false : process.env.ZEROSHOT_PR === '1'); return this._startInternal(config, input, { - testMode: false, + testMode, cwd: options.cwd || process.cwd(), // Target working directory for agents isolation: options.isolation || false, isolationImage: options.isolationImage, worktree: options.worktree || false, - autoPr: options.autoPr || process.env.ZEROSHOT_PR === '1', + autoPr, modelOverride: options.modelOverride, // Model override for all agents clusterId: options.clusterId, // Explicit ID from CLI/daemon parent settings: options.settings, // User settings for issue provider detection forceProvider: options.forceProvider, // Force specific issue provider + force: options.force || false, // Skip duplicate issue check }); } @@ -733,6 +831,15 @@ class Orchestrator { initCompletePromise, _resolveInitComplete: resolveInitComplete, autoPr: options.autoPr || false, + // PR configuration options (persisted for resume) + prOptions: + options.prBase || options.mergeQueue || options.closeIssue + ? { + prBase: options.prBase || null, + mergeQueue: options.mergeQueue || false, + closeIssue: options.closeIssue || null, + } + : null, // Model override for all agents (applied to dynamically added agents) modelOverride: options.modelOverride || null, // Issue provider tracking (where issue was fetched from) @@ -766,6 +873,13 @@ class Orchestrator { this.clusters.set(clusterId, cluster); this._startSnapshotter(cluster); + // Persist the cluster immediately so detached runs can't create "invisible" initializing clusters. + // Without this, an early startup failure (before ISSUE_OPENED / TASK_STARTED) may never be saved, + // and external supervisors (e.g., heroshot) can't detect/cleanup the stuck state. + await this._saveClusters().catch((err) => { + console.warn(`[Orchestrator] Failed to persist initial cluster state for ${clusterId}:`, err); + }); + try { // Fetch input (issue from provider, file, or text) let inputData; @@ -786,6 +900,31 @@ class Orchestrator { // Store issue provider for logging/debugging and cross-provider workflows cluster.issueProvider = ProviderClass.id; + // Store issue number for heroshot/external tools (avoids log parsing) + cluster.issue = inputData.number || null; + + // Persist issue number early so supervisors can associate this cluster with the issue even if start fails. + await this._saveClusters().catch((err) => { + console.warn(`[Orchestrator] Failed to persist issue number for ${clusterId}:`, err); + }); + + // Check for duplicate active runs on same issue (unless --force) + if (cluster.issue && !options.force) { + const activeClusters = this._getActiveClustersForIssue(cluster.issue, clusterId); + if (activeClusters.length > 0) { + const existing = activeClusters[0]; + const age = Math.round((Date.now() - existing.createdAt) / 1000 / 60); + throw new Error( + `Issue #${cluster.issue} already has an active cluster:\n` + + ` Cluster: ${existing.id} (state: ${existing.state}, running for ${age}min, pid: ${existing.pid})\n\n` + + `Options:\n` + + ` 1. Kill existing: zeroshot kill ${existing.id}\n` + + ` 2. Override check: zeroshot run ${input.issue} --force\n` + + ` 3. View status: zeroshot status ${existing.id}` + ); + } + } + // Log clickable issue link if (inputData.url) { this._log(`[Orchestrator] Issue (${ProviderClass.displayName}): ${inputData.url}`); @@ -918,6 +1057,58 @@ class Orchestrator { if (cluster._resolveInitComplete) { cluster._resolveInitComplete(); } + // Persist the failure state (prevents "invisible" failures that supervisors can't detect/cleanup). + await this._saveClusters().catch((err) => { + console.warn( + `[Orchestrator] Failed to persist failed cluster state for ${clusterId}:`, + err + ); + }); + + // Best-effort cleanup of partially initialized resources (prevents orphaned worktrees/containers). + try { + if (cluster.snapshotter) { + cluster.snapshotter.stop(); + } + } catch { + // ignore + } + try { + for (const agent of cluster.agents || []) { + // Agent start may have partially succeeded. + // Stop is best-effort and must not mask the original error. + // eslint-disable-next-line no-await-in-loop + await agent.stop(); + } + } catch { + // ignore + } + try { + if (cluster.isolation?.manager) { + // Preserve workspace for inspection; callers can `zeroshot kill` for full cleanup. + // eslint-disable-next-line no-await-in-loop + await cluster.isolation.manager.cleanup(clusterId, { preserveWorkspace: true }); + } + } catch { + // ignore + } + try { + if (cluster.worktree?.manager) { + cluster.worktree.manager.cleanupWorktreeIsolation(clusterId, { preserveBranch: true }); + } + } catch { + // ignore + } + try { + cluster.messageBus?.close?.(); + } catch { + // ignore + } + try { + cluster.ledger?.close?.(); + } catch { + // ignore + } console.error(`Cluster ${clusterId} failed to start:`, error); throw error; } @@ -1019,16 +1210,27 @@ class Orchestrator { this._subscribeToClusterTopic(messageBus, clusterId, 'AGENT_ERROR', async (message) => { const agentRole = message.content?.data?.role; const attempts = message.content?.data?.attempts || 1; + const hookFailure = message.content?.data?.hookFailure === true; await this._saveClusters(); - if (agentRole === 'implementation' && attempts >= 3) { + const shouldStopForRole = + agentRole === 'implementation' || + agentRole === 'coordinator' || + message.sender === 'consensus-coordinator'; + const shouldStop = shouldStopForRole && (hookFailure || attempts >= 3); + + if (shouldStop) { this._log(`\n${'='.repeat(80)}`); - this._log(`āŒ WORKER AGENT FAILED: ${clusterId}`); + this._log(`āŒ CRITICAL AGENT FAILED: ${clusterId}`); this._log(`${'='.repeat(80)}`); - this._log(`Worker agent ${message.sender} failed after ${attempts} attempts`); + this._log( + `${message.sender} (${agentRole || 'unknown role'}) failed` + + (hookFailure ? ` (hookFailure=true)` : ``) + + ` after ${attempts} attempts` + ); this._log(`Error: ${message.content?.data?.error || 'unknown'}`); - this._log(`Stopping cluster - worker cannot continue`); + this._log(`Stopping cluster - critical agent cannot continue`); this._log(`${'='.repeat(80)}\n`); this.stop(clusterId).catch((err) => { @@ -1233,9 +1435,7 @@ class Orchestrator { this._log(`[Orchestrator] Starting cluster in isolation mode (image: ${image})`); const workDir = options.cwd || process.cwd(); - const providerName = normalizeProviderName( - config.forceProvider || config.defaultProvider || loadSettings().defaultProvider || 'claude' - ); + const providerName = this._resolveClusterProvider(config); containerId = await isolationManager.createContainer(clusterId, { workDir, image, @@ -1249,7 +1449,13 @@ class Orchestrator { const workDir = options.cwd || process.cwd(); isolationManager = new IsolationManager({}); - worktreeInfo = isolationManager.createWorktreeIsolation(clusterId, workDir); + // Use origin/${prBase} if prBase is set (ensures worktree is up-to-date with remote) + const worktreeOptions = {}; + if (options.prBase) { + worktreeOptions.baseRef = `origin/${options.prBase}`; + this._log(`[Orchestrator] Using remote base ref: origin/${options.prBase}`); + } + worktreeInfo = isolationManager.createWorktreeIsolation(clusterId, workDir, worktreeOptions); this._log(`[Orchestrator] Starting cluster in worktree isolation mode`); this._log(`[Orchestrator] Worktree: ${worktreeInfo.path}`); @@ -1310,7 +1516,11 @@ class Orchestrator { ); } - const gitPusherConfig = generateGitPusherAgent(platform); + const gitPusherConfig = generateGitPusherAgent(platform, { + prBase: options.prBase, + mergeQueue: options.mergeQueue, + closeIssue: options.closeIssue, + }); // Template replacement for issue context const issueRef = skipCloseIssue ? '' : `Closes #${inputData.number || 'unknown'}`; @@ -1398,6 +1608,114 @@ class Orchestrator { throw new Error('Failed to generate unique cluster ID after many attempts'); } + /** + * Wait for a process to exit + * @param {Number} pid - Process ID + * @param {Number} timeoutMs - Timeout in milliseconds + * @param {Number} intervalMs - Poll interval in milliseconds + * @returns {Promise} True if process exited + * @private + */ + async _waitForProcessExit(pid, timeoutMs, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!this._isProcessRunning(pid)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return !this._isProcessRunning(pid); + } + + /** + * Tear down Docker Compose services in a worktree directory to free host ports. + * Best-effort — silently ignores failures (compose may not have been started, Docker may not be running). + * @param {string} worktreePath - Path to the worktree directory + * @private + */ + _teardownWorktreeCompose(worktreePath) { + const { execSync } = require('./lib/safe-exec'); + const composePath = path.join(worktreePath, 'docker-compose.yml'); + if (!fs.existsSync(composePath)) return; + + try { + this._log(`[Orchestrator] Tearing down Docker Compose services in ${worktreePath}...`); + execSync('docker compose down --remove-orphans --volumes --timeout 10', { + cwd: worktreePath, + encoding: 'utf8', + stdio: 'pipe', + timeout: 30000, + }); + this._log(`[Orchestrator] Docker Compose services torn down`); + } catch { + // Best-effort: compose project may not have been started + } + } + + /** + * Signal a remote daemon that owns the cluster and wait for exit + * @param {Object} cluster - Cluster object + * @param {Object} options - { action, timeoutMs, killTimeoutMs, signal } + * @returns {Promise} { handled, remotePid, exited, forced } + * @private + */ + async _signalRemoteCluster(cluster, options) { + const remotePid = cluster.pid; + if (!remotePid || remotePid === process.pid) { + return { handled: false }; + } + + if (!this._isProcessRunning(remotePid)) { + return { handled: false, remotePid, alreadyExited: true }; + } + + const action = options?.action || 'stop'; + const timeoutMs = options?.timeoutMs ?? 10000; + const killTimeoutMs = options?.killTimeoutMs ?? 5000; + const signal = options?.signal || 'SIGTERM'; + + this._log(`[Orchestrator] Sending ${signal} to daemon PID ${remotePid} for ${cluster.id}...`); + try { + process.kill(remotePid, signal); + } catch (error) { + if (error.code === 'ESRCH') { + return { handled: true, remotePid, exited: true }; + } + throw error; + } + + const exited = await this._waitForProcessExit(remotePid, timeoutMs); + if (exited) { + return { handled: true, remotePid, exited: true }; + } + + if (action !== 'kill') { + throw new Error( + `Timed out waiting for daemon PID ${remotePid} to stop cluster ${cluster.id}` + ); + } + + this._log( + `[Orchestrator] Daemon PID ${remotePid} still running after ${timeoutMs}ms, sending SIGKILL...` + ); + try { + process.kill(remotePid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') { + throw error; + } + } + + const killed = await this._waitForProcessExit(remotePid, killTimeoutMs); + if (!killed) { + throw new Error( + `Failed to kill daemon PID ${remotePid} for cluster ${cluster.id} after SIGKILL` + ); + } + + return { handled: true, remotePid, exited: true, forced: true }; + } + /** * Stop a cluster * @param {String} clusterId - Cluster ID @@ -1408,6 +1726,8 @@ class Orchestrator { throw new Error(`Cluster ${clusterId} not found`); } + await this._signalRemoteCluster(cluster, { action: 'stop' }); + // CRITICAL: Wait for initialization to complete before stopping // This ensures ISSUE_OPENED is published, preventing 0-message clusters // Timeout after 30s to prevent infinite hang if init truly fails @@ -1450,10 +1770,16 @@ class Orchestrator { // Worktree cleanup on stop: preserve for resume capability // Branch stays, worktree stays - can resume work later + // BUT: tear down Docker Compose services to free host ports if (cluster.worktree?.manager) { this._log(`[Orchestrator] Worktree preserved at ${cluster.worktree.path} for resume`); this._log(`[Orchestrator] Branch: ${cluster.worktree.branch}`); - // Don't cleanup worktree - it will be reused on resume + // Tear down Docker Compose services in the worktree to free host ports. + // Without this, stopped worktrees hold ports (5433, 6379, etc.) indefinitely. + if (cluster.worktree.path) { + this._teardownWorktreeCompose(cluster.worktree.path); + } + // Don't cleanup worktree itself - it will be reused on resume } cluster.state = 'stopped'; @@ -1474,6 +1800,8 @@ class Orchestrator { throw new Error(`Cluster ${clusterId} not found`); } + await this._signalRemoteCluster(cluster, { action: 'kill' }); + cluster.state = 'stopping'; // Force stop all agents @@ -1608,6 +1936,197 @@ class Orchestrator { return this._resumeCleanCluster(clusterId, cluster, recentMessages, prompt); } + _validateGuidanceAgentArgs(clusterId, agentId, text) { + if (!clusterId) { + throw new Error('sendGuidanceToAgent: clusterId is required'); + } + if (!agentId) { + throw new Error('sendGuidanceToAgent: agentId is required'); + } + if (typeof text !== 'string' || !text.trim()) { + throw new Error('sendGuidanceToAgent: text must be a non-empty string'); + } + } + + _validateGuidanceClusterArgs(clusterId, text) { + if (!clusterId) { + throw new Error('sendGuidanceToCluster: clusterId is required'); + } + if (typeof text !== 'string' || !text.trim()) { + throw new Error('sendGuidanceToCluster: text must be a non-empty string'); + } + } + + _getClusterOrThrow(clusterId, caller = 'sendGuidanceToAgent') { + const cluster = this.clusters.get(clusterId); + if (!cluster) { + throw new Error(`${caller}: cluster not found: ${clusterId}`); + } + return cluster; + } + + _getAgentOrThrow(cluster, agentId) { + const agent = cluster.agents.find((candidate) => candidate.id === agentId); + if (!agent) { + throw new Error(`sendGuidanceToAgent: agent not found: ${agentId}`); + } + return agent; + } + + _buildDefaultDelivery(agent) { + return { + status: 'unsupported', + reason: 'unknown', + method: null, + taskId: agent.currentTaskId || null, + }; + } + + async _attemptGuidanceInjection(agent, text, timeoutMs) { + try { + const result = await agent.injectInput(text, { timeoutMs }); + return { + status: result.status === 'injected' ? 'injected' : 'unsupported', + reason: result.status === 'injected' ? null : result.reason || 'unsupported', + method: result.status === 'injected' ? result.method || 'pty' : result.method || null, + taskId: result.taskId || agent.currentTaskId || null, + }; + } catch (error) { + return { + status: 'unsupported', + reason: error.message, + method: null, + taskId: agent.currentTaskId || null, + }; + } + } + + _buildGuidanceMetadata(options, delivery) { + return { + ...(options.metadata || {}), + delivery: { + status: delivery.status, + reason: delivery.reason, + method: delivery.method, + taskId: delivery.taskId, + timestamp: Date.now(), + }, + }; + } + + _summarizeGuidanceDeliveries(deliveries) { + const agentIds = Object.keys(deliveries); + const summary = { + injected: 0, + queued: 0, + total: agentIds.length, + }; + + for (const agentId of agentIds) { + const delivery = deliveries[agentId]; + if (delivery?.status === 'injected') { + summary.injected += 1; + } else { + summary.queued += 1; + } + } + + return summary; + } + + _buildClusterGuidanceMetadata(options, deliveries) { + return { + ...(options.metadata || {}), + delivery: { + summary: this._summarizeGuidanceDeliveries(deliveries), + agents: deliveries, + timestamp: Date.now(), + }, + }; + } + + _publishGuidance(cluster, clusterId, agentId, text, metadata, sender) { + cluster.messageBus.publish({ + cluster_id: clusterId, + topic: USER_GUIDANCE_AGENT, + sender, + target_agent_id: agentId, + content: { text }, + metadata, + }); + } + + _publishClusterGuidance(cluster, clusterId, text, metadata, sender) { + cluster.messageBus.publish({ + cluster_id: clusterId, + topic: USER_GUIDANCE_CLUSTER, + sender, + content: { text }, + metadata, + }); + } + + /** + * Send guidance to a specific agent with optional live injection. + * Always persists USER_GUIDANCE_AGENT in the ledger with delivery metadata. + * @param {string} clusterId + * @param {string} agentId + * @param {string} text + * @param {object} [options] + * @param {string} [options.sender='user'] + * @param {object} [options.metadata] + * @param {number} [options.timeoutMs] + * @returns {Promise<{status: string, reason: string|null, method: string|null, taskId: string|null}>} + */ + async sendGuidanceToAgent(clusterId, agentId, text, options = {}) { + this._validateGuidanceAgentArgs(clusterId, agentId, text); + + const cluster = this._getClusterOrThrow(clusterId, 'sendGuidanceToAgent'); + const agent = this._getAgentOrThrow(cluster, agentId); + const defaultDelivery = this._buildDefaultDelivery(agent); + const delivery = + (await this._attemptGuidanceInjection(agent, text, options.timeoutMs)) || defaultDelivery; + const metadata = this._buildGuidanceMetadata(options, delivery); + + this._publishGuidance(cluster, clusterId, agentId, text, metadata, options.sender || 'user'); + + return delivery; + } + + /** + * Send guidance to every agent in a cluster with optional live injection. + * Always persists a single USER_GUIDANCE_CLUSTER in the ledger with delivery metadata. + * @param {string} clusterId + * @param {string} text + * @param {object} [options] + * @param {string} [options.sender='user'] + * @param {object} [options.metadata] + * @param {number} [options.timeoutMs] + * @returns {Promise<{summary: {injected: number, queued: number, total: number}, agents: Record, timestamp: number}>} + */ + async sendGuidanceToCluster(clusterId, text, options = {}) { + this._validateGuidanceClusterArgs(clusterId, text); + + const cluster = this._getClusterOrThrow(clusterId, 'sendGuidanceToCluster'); + const agents = Array.isArray(cluster.agents) ? cluster.agents : []; + const deliveries = {}; + + await Promise.all( + agents.map(async (agent) => { + const defaultDelivery = this._buildDefaultDelivery(agent); + const delivery = + (await this._attemptGuidanceInjection(agent, text, options.timeoutMs)) || defaultDelivery; + deliveries[agent.id] = delivery; + }) + ); + + const metadata = this._buildClusterGuidanceMetadata(options, deliveries); + + this._publishClusterGuidance(cluster, clusterId, text, metadata, options.sender || 'user'); + + return metadata.delivery; + } + _resolveFailureInfo(cluster, clusterId) { if (cluster.failureInfo) { return cluster.failureInfo; @@ -1671,12 +2190,7 @@ class Orchestrator { ); } - const providerName = normalizeProviderName( - cluster.config?.forceProvider || - cluster.config?.defaultProvider || - loadSettings().defaultProvider || - 'claude' - ); + const providerName = this._resolveClusterProvider(cluster.config); const newContainerId = await cluster.isolation.manager.createContainer(clusterId, { workDir, image: cluster.isolation.image, @@ -2092,6 +2606,14 @@ Continue from where you left off. Review your previous output to understand what proposedAgentConfigs.push(agentConfig); } } + } else if (op.action === 'load_config' && op.config) { + const loadedAgentConfigs = this._resolveLoadConfigAgents(op.config); + for (const agentConfig of loadedAgentConfigs) { + const existingIdx = proposedAgentConfigs.findIndex((a) => a.id === agentConfig.id); + if (existingIdx === -1) { + proposedAgentConfigs.push(agentConfig); + } + } } else if (op.action === 'remove_agents' && op.agentIds) { for (const agentId of op.agentIds) { const idx = proposedAgentConfigs.findIndex((a) => a.id === agentId); @@ -2110,6 +2632,40 @@ Continue from where you left off. Review your previous output to understand what return proposedAgentConfigs; } + _resolveLoadConfigAgents(config) { + if (!config) { + throw new Error('load_config operation missing config'); + } + + const templatesDir = path.join(__dirname, '..', 'cluster-templates'); + let loadedConfig; + + // Parameterized template - resolve with TemplateResolver + if (typeof config === 'object' && config.base) { + const { base, params } = config; + const resolver = new TemplateResolver(templatesDir); + loadedConfig = resolver.resolve(base, params || {}); + } else if (typeof config === 'string') { + // Static config - load directly from file + const configPath = path.join(templatesDir, `${config}.json`); + if (!fs.existsSync(configPath)) { + throw new Error(`Config not found: ${config} (looked in ${configPath})`); + } + const configContent = fs.readFileSync(configPath, 'utf8'); + loadedConfig = JSON.parse(configContent); + } else { + throw new Error( + `Invalid config format: expected string or {base, params}, got ${typeof config}` + ); + } + + if (!loadedConfig.agents || !Array.isArray(loadedConfig.agents)) { + throw new Error(`Config has no agents array`); + } + + return loadedConfig.agents; + } + _validateProposedConfig(clusterId, cluster, proposedAgentConfigs, operations) { const mockConfig = { agents: proposedAgentConfigs }; const validation = configValidator.validateConfig(mockConfig); @@ -2199,11 +2755,33 @@ Continue from where you left off. Review your previous output to understand what throw new Error('Agent config missing required field: id'); } - // Check for duplicate agent ID - const existingAgent = cluster.agents.find((a) => a.id === agentConfig.id); - if (existingAgent) { - this._log(` āš ļø Agent ${agentConfig.id} already exists, skipping`); - continue; + // Check for duplicate agent ID - REPLACE agent entirely + // Previous behavior merged triggers but kept old hooks, causing bugs when + // loading templates with same agent IDs but different hooks (e.g., quick-validation + // and heavy-validation both have consensus-coordinator with different onComplete hooks) + const existingAgentIndex = cluster.agents.findIndex((a) => a.id === agentConfig.id); + if (existingAgentIndex !== -1) { + const existingAgent = cluster.agents[existingAgentIndex]; + this._log( + ` šŸ”„ Replacing agent ${agentConfig.id} (old role: ${existingAgent.config.role})` + ); + + // Stop the existing agent (cluster.agents contains AgentWrapper instances directly) + if (existingAgent.stop) { + existingAgent.stop(); + } + + // Remove from cluster.agents array + cluster.agents.splice(existingAgentIndex, 1); + + // Remove from cluster.config.agents if present + if (cluster.config.agents) { + const configIndex = cluster.config.agents.findIndex((a) => a.id === agentConfig.id); + if (configIndex !== -1) { + cluster.config.agents.splice(configIndex, 1); + } + } + // Continue to add the new agent below } // Add to config agents array (for persistence) @@ -2410,7 +2988,7 @@ Continue from where you left off. Review your previous output to understand what return; } - const isPrMode = cluster.autoPr || process.env.ZEROSHOT_PR === '1'; + const isPrMode = cluster.autoPr ?? process.env.ZEROSHOT_PR === '1'; if (isPrMode) { // Detect platform from stored cluster metadata OR git context @@ -2438,7 +3016,8 @@ Continue from where you left off. Review your previous output to understand what ); } - const gitPusherConfig = generateGitPusherAgent(platform); + // Use persisted PR options from cluster state (or empty for repo settings fallback) + const gitPusherConfig = generateGitPusherAgent(platform, cluster.prOptions || {}); // Get issue context from ledger const issueMsg = cluster.messageBus.ledger.findLast({ topic: 'ISSUE_OPENED' }); @@ -2454,39 +3033,18 @@ Continue from where you left off. Review your previous output to understand what this._log(` [--pr mode] Injected ${platform}-git-pusher agent`); } else { // Default completion-detector + const { SHARED_TRIGGER_SCRIPT } = require('./agents/git-pusher-template'); const completionDetector = { id: 'completion-detector', role: 'orchestrator', - model: 'haiku', + modelLevel: this._resolveCompletionDetectorLevel(cluster.config), timeout: 0, triggers: [ { topic: 'VALIDATION_RESULT', logic: { engine: 'javascript', - script: `const validators = cluster.getAgentsByRole('validator'); -const lastPush = ledger.findLast({ topic: 'IMPLEMENTATION_READY' }); -if (!lastPush) return false; -if (validators.length === 0) return true; - -const validatorIds = new Set(validators.map((v) => v.id)); -const results = ledger.query({ topic: 'VALIDATION_RESULT', since: lastPush.timestamp }); - -const latestByValidator = new Map(); -for (const msg of results) { - if (!validatorIds.has(msg.sender)) continue; - latestByValidator.set(msg.sender, msg); -} - -if (latestByValidator.size < validators.length) return false; - -for (const validator of validators) { - const msg = latestByValidator.get(validator.id); - const approved = msg?.content?.data?.approved; - if (!(approved === true || approved === 'true')) return false; -} - -return true;`, + script: SHARED_TRIGGER_SCRIPT, }, action: 'stop_cluster', }, @@ -2558,7 +3116,15 @@ return true;`, pid: cluster.pid || null, createdAt: cluster.createdAt, agents: cluster.agents.map((a) => a.getState()), - messageCount: cluster.messageBus.count({ cluster_id: clusterId }), + messageCount: (() => { + try { + return cluster.messageBus.count({ cluster_id: clusterId }); + } catch { + // Cluster may have closed its ledger during startup failure cleanup. + // Status/list should remain safe to call for visibility + supervisor cleanup. + return 0; + } + })(), }; } @@ -2585,8 +3151,17 @@ return true;`, id: cluster.id, state: state, createdAt: cluster.createdAt, + issue: cluster.issue || null, agentCount: cluster.agents.length, - messageCount: cluster.messageBus.getAll(cluster.id).length, + messageCount: (() => { + try { + return cluster.messageBus.count({ cluster_id: cluster.id }); + } catch { + // Cluster may have closed its ledger during startup failure cleanup. + // List should remain safe to call for cleanup routines. + return 0; + } + })(), }; }); } diff --git a/src/preflight.js b/src/preflight.js index 3e2ad7ba..cd14da6a 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -76,28 +76,50 @@ function getClaudeVersion(claudeCommand = 'claude') { const command = parts[0]; const extraArgs = parts.slice(1); + // Determine CLI presence without depending on `claude --version` working. + // Some environments can have Claude installed but a broken/unwritable config dir, + // which should NOT block preflight. + if (!commandExists(command)) { + return { + installed: false, + version: null, + error: `Command '${command}' not installed`, + }; + } + try { const versionArgs = [...extraArgs, '--version']; const versionCmd = [command, ...versionArgs].join(' '); - const output = execSync(versionCmd, { encoding: 'utf8', stdio: 'pipe' }); + // Claude Code may try to write debug output under CLAUDE_CONFIG_DIR even for `--version`. + // Preflight should detect installation, not fail due to an unwritable/invalid config dir. + const preflightConfigDir = path.join(os.tmpdir(), 'zeroshot-claude-preflight'); + try { + fs.mkdirSync(preflightConfigDir, { recursive: true }); + } catch { + // If we can't create it, fall back to current env. + } + const output = execSync(versionCmd, { + encoding: 'utf8', + stdio: 'pipe', + env: { + ...process.env, + CLAUDE_CONFIG_DIR: preflightConfigDir, + }, + }); const match = output.match(/(\d+\.\d+\.\d+)/); return { installed: true, version: match ? match[1] : 'unknown', error: null, }; - } catch (err) { - if (err.message.includes('command not found') || err.message.includes('not found')) { - return { - installed: false, - version: null, - error: `Command '${command}' not installed`, - }; - } + } catch { + // The CLI exists, but the version command can still fail due to local environment + // (e.g. config-dir permissions). Treat this as installed with unknown version so + // preflight doesn't block on non-essential metadata. return { - installed: false, - version: null, - error: err.message, + installed: true, + version: 'unknown', + error: null, }; } } @@ -220,19 +242,41 @@ function checkGhAuth() { }; } - // Check auth status + // Check auth status with timeout to prevent hangs + // gh auth status: exit 0 = authenticated, exit 1 = not authenticated + // Note: gh outputs to stderr even on success + const AUTH_CHECK_TIMEOUT_MS = 10000; try { - execSync('gh auth status', { encoding: 'utf8', stdio: 'pipe' }); + execSync('gh auth status', { + encoding: 'utf8', + stdio: 'pipe', + timeout: AUTH_CHECK_TIMEOUT_MS, + }); + // Exit code 0 = authenticated return { installed: true, authenticated: true, error: null, }; } catch (err) { - // gh auth status returns non-zero if not authenticated - const stderr = err.stderr || err.message || ''; + const stderr = err.stderr || ''; - if (stderr.includes('not logged in')) { + // Check if killed due to timeout + if (err.killed || err.signal === 'SIGTERM') { + return { + installed: true, + authenticated: false, + error: 'gh auth status timed out - try running: gh auth login', + }; + } + + // gh auth status returns non-zero if not authenticated + // Check stderr for common "not logged in" patterns + if ( + stderr.includes('not logged in') || + stderr.includes('not logged into') || + stderr.includes('You are not logged') + ) { return { installed: true, authenticated: false, @@ -240,10 +284,21 @@ function checkGhAuth() { }; } + // But if stderr contains "Logged in", trust that (some edge cases) + if (stderr.includes('Logged in')) { + return { + installed: true, + authenticated: true, + error: null, + }; + } + + // Provide more helpful error - include actual stderr for debugging + const details = stderr.trim() || `Exit code ${err.status || 'unknown'}`; return { installed: true, authenticated: false, - error: stderr.trim() || 'Unknown gh auth error', + error: `gh auth check failed: ${details}`, }; } } diff --git a/src/providers/anthropic/index.js b/src/providers/anthropic/index.js index bd496ec7..1c336da1 100644 --- a/src/providers/anthropic/index.js +++ b/src/providers/anthropic/index.js @@ -20,6 +20,19 @@ class AnthropicProvider extends BaseProvider { this._cliFeatures = null; } + getRetryableErrorPatterns() { + return [ + ...super.getRetryableErrorPatterns(), + /no messages returned/i, + /\boverloaded\b/i, + /\brate[_ -]?limit\b/i, + ]; + } + + getPermanentErrorPatterns() { + return [...super.getPermanentErrorPatterns(), /invalid_request_error/i, /model_not_available/i]; + } + // SDK not implemented - uses CLI only // See BaseProvider for SDK extension point documentation diff --git a/src/providers/anthropic/models.js b/src/providers/anthropic/models.js index e2a0dc53..68327c8d 100644 --- a/src/providers/anthropic/models.js +++ b/src/providers/anthropic/models.js @@ -2,12 +2,13 @@ const MODEL_CATALOG = { haiku: { rank: 1 }, sonnet: { rank: 2 }, opus: { rank: 3 }, + 'opus-4.6': { rank: 3 }, }; const LEVEL_MAPPING = { level1: { rank: 1, model: 'haiku' }, level2: { rank: 2, model: 'sonnet' }, - level3: { rank: 3, model: 'opus' }, + level3: { rank: 3, model: 'opus-4.6' }, }; const DEFAULT_LEVEL = 'level2'; diff --git a/src/providers/anthropic/output-parser.js b/src/providers/anthropic/output-parser.js index 083274ba..8cd69572 100644 --- a/src/providers/anthropic/output-parser.js +++ b/src/providers/anthropic/output-parser.js @@ -18,7 +18,8 @@ function parseResultEvent(event) { duration: event.duration_ms, inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0, - cacheReadInputTokens: usage.cache_read_input_tokens || 0, + // Claude CLI uses 'cached_input_tokens'; Anthropic API uses 'cache_read_input_tokens' + cacheReadInputTokens: usage.cache_read_input_tokens || usage.cached_input_tokens || 0, cacheCreationInputTokens: usage.cache_creation_input_tokens || 0, modelUsage: event.modelUsage || null, }; diff --git a/src/providers/base-provider.js b/src/providers/base-provider.js index 3a57cc49..d60b2edc 100644 --- a/src/providers/base-provider.js +++ b/src/providers/base-provider.js @@ -14,6 +14,103 @@ class BaseProvider { this.cliCommand = options.cliCommand || null; } + // ============================================================================ + // ERROR CLASSIFICATION (Retry vs Permanent) + // ============================================================================ + // + // Providers currently execute via CLI, so errors are often unstructured strings. + // These helpers are used by the agent retry loop to decide whether to retry. + // + + /** + * Patterns that indicate the error is retryable/transient. + * Override in provider implementations to add provider-specific patterns. + * @returns {Array} Retryable error patterns + */ + getRetryableErrorPatterns() { + return [ + /rate.?limit/i, + /\b429\b/i, + /too many requests/i, + /overloaded/i, + /temporar(?:y|ily)/i, + /unavailable/i, + /try again/i, + /timeout/i, + /timed out/i, + /deadline exceeded/i, + /connection (?:reset|refused)/i, + /\b(econnreset|econnrefused|etimedout|eai_again)\b/i, + /network/i, + ]; + } + + /** + * Patterns that indicate the error is permanent and should not be retried. + * Override in provider implementations to add provider-specific patterns. + * @returns {Array} Permanent error patterns + */ + getPermanentErrorPatterns() { + return [ + /invalid[_ -]?api[_ -]?key/i, + /api[_ -]?key.*invalid/i, + /unauthorized/i, + /forbidden/i, + /authentication/i, + /permission denied/i, + /invalid argument/i, + /unknown option/i, + /\busage:\b/i, + /command not found/i, + /not recognized as an internal or external command/i, + /model not found/i, + /context length exceeded/i, + /insufficient quota/i, + ]; + } + + /** + * Classify whether an error is retryable. + * Default behavior is conservative: unknown errors are treated as retryable + * to prevent stuck clusters from single transient failures. + * @param {any} err - Error object (often Error with message) + * @returns {boolean} True if retryable + */ + isRetryableError(err) { + const status = + err?.status ?? err?.statusCode ?? err?.response?.status ?? err?.response?.statusCode ?? null; + + if (typeof status === 'number') { + if (status === 429 || status >= 500) return true; + if (status >= 400 && status < 500) return false; + } + + const code = err?.code || null; + if ( + typeof code === 'string' && + /\b(econnreset|econnrefused|etimedout|eai_again)\b/i.test(code) + ) { + return true; + } + + const message = (err?.message || String(err) || '').trim(); + if (!message) { + return true; + } + + const permanent = this.getPermanentErrorPatterns(); + if (Array.isArray(permanent) && permanent.some((p) => p.test(message))) { + return false; + } + + const retryable = this.getRetryableErrorPatterns(); + if (Array.isArray(retryable) && retryable.some((p) => p.test(message))) { + return true; + } + + return true; + } + // ============================================================================ // SDK SUPPORT (Future Extension Point) // ============================================================================ @@ -177,7 +274,7 @@ class BaseProvider { * Resolve a model name to its CLI-compatible identifier. * Override in provider implementations that need model ID transformation * (e.g., Anthropic Bedrock mapping). - * @param {string} model - Model name (e.g., 'opus', 'sonnet') + * @param {string} model - Provider model identifier * @param {Object} _authEnv - Authentication environment variables * @returns {string} CLI-compatible model identifier */ diff --git a/src/providers/google/index.js b/src/providers/google/index.js index a8c1c69c..95baa66e 100644 --- a/src/providers/google/index.js +++ b/src/providers/google/index.js @@ -20,6 +20,26 @@ class GoogleProvider extends BaseProvider { this._parserState = { lastToolId: null }; } + getRetryableErrorPatterns() { + return [ + ...super.getRetryableErrorPatterns(), + /\bRESOURCE_EXHAUSTED\b/i, + /\bUNAVAILABLE\b/i, + /\bDEADLINE_EXCEEDED\b/i, + /No capacity available/i, // Gemini rate limit + /quota.?exceeded/i, // Quota exhaustion + ]; + } + + getPermanentErrorPatterns() { + return [ + ...super.getPermanentErrorPatterns(), + /\bINVALID_ARGUMENT\b/i, + /\bPERMISSION_DENIED\b/i, + /\bNOT_FOUND\b/i, + ]; + } + // SDK not implemented - uses CLI only // See BaseProvider for SDK extension point documentation diff --git a/src/providers/openai/index.js b/src/providers/openai/index.js index 5ed716e5..d242d539 100644 --- a/src/providers/openai/index.js +++ b/src/providers/openai/index.js @@ -19,6 +19,24 @@ class OpenAIProvider extends BaseProvider { this._unknownEventCounts = new Map(); } + getRetryableErrorPatterns() { + return [ + ...super.getRetryableErrorPatterns(), + /rate_limit_exceeded/i, + /\bserver_error\b/i, + /\bservice_unavailable\b/i, + ]; + } + + getPermanentErrorPatterns() { + return [ + ...super.getPermanentErrorPatterns(), + /\binsufficient_quota\b/i, + /\bmodel_not_found\b/i, + /\bcontext_length_exceeded\b/i, + ]; + } + // SDK not implemented - uses CLI only // See BaseProvider for SDK extension point documentation diff --git a/src/providers/openai/models.js b/src/providers/openai/models.js index 140ea9f7..595daa76 100644 --- a/src/providers/openai/models.js +++ b/src/providers/openai/models.js @@ -1,11 +1,12 @@ -// Codex CLI - use null to let CLI pick its default model -// Levels vary by reasoning effort only -const MODEL_CATALOG = {}; +// Codex defaults to gpt-5.3-codex; levels vary by reasoning effort only. +const MODEL_CATALOG = { + 'gpt-5.3-codex': { rank: 2 }, +}; const LEVEL_MAPPING = { - level1: { rank: 1, model: null, reasoningEffort: 'medium' }, - level2: { rank: 2, model: null, reasoningEffort: 'high' }, - level3: { rank: 3, model: null, reasoningEffort: 'xhigh' }, + level1: { rank: 1, model: 'gpt-5.3-codex', reasoningEffort: 'medium' }, + level2: { rank: 2, model: 'gpt-5.3-codex', reasoningEffort: 'high' }, + level3: { rank: 3, model: 'gpt-5.3-codex', reasoningEffort: 'xhigh' }, }; const DEFAULT_LEVEL = 'level2'; diff --git a/src/providers/openai/output-parser.js b/src/providers/openai/output-parser.js index da3257e9..c7098524 100644 --- a/src/providers/openai/output-parser.js +++ b/src/providers/openai/output-parser.js @@ -47,8 +47,47 @@ function parseFunctionCallOutput(item) { }; } -function parseItem(item) { +function parseCommandExecutionItem(item, phase) { + // Codex CLI (newer) emits `command_execution` items for bash-like tool runs. + // Map them into the shared schema expected by the logs renderer. + const toolId = item.id; + const command = item.command || item.cmd || item.input?.command || item.input?.cmd; + + if (phase === 'started') { + return { + type: 'tool_call', + toolName: 'Bash', + toolId, + input: command ? { command } : {}, + }; + } + + const output = item.aggregated_output ?? item.output ?? item.result ?? ''; + const exitCode = + typeof item.exit_code === 'number' + ? item.exit_code + : typeof item.exitCode === 'number' + ? item.exitCode + : null; + + return { + type: 'tool_result', + toolId, + content: output, + isError: exitCode !== null ? exitCode !== 0 : !!item.error, + }; +} + +function parseReasoningItem(item) { + const text = item.text || item.content || ''; + if (!text) return null; + return { type: 'thinking', text }; +} + +function parseItem(item, eventType) { const events = []; + const phase = + eventType === 'item.started' ? 'started' : eventType === 'item.completed' ? 'completed' : null; // Handle assistant messages (Claude-style: type=message, role=assistant) if (item.type === 'message' && item.role === 'assistant') { @@ -60,6 +99,15 @@ function parseItem(item) { events.push({ type: 'text', text: item.text }); } + if (item.type === 'reasoning') { + const reasoning = parseReasoningItem(item); + if (reasoning) events.push(reasoning); + } + + if (item.type === 'command_execution' && phase) { + events.push(parseCommandExecutionItem(item, phase)); + } + if (item.type === 'function_call') { events.push(parseFunctionCall(item)); } @@ -82,13 +130,25 @@ function parseEvent(line, options = {}) { } switch (event.type) { + case 'error': + return { + type: 'result', + success: false, + error: event.error?.message || event.message || event.error || 'Error', + }; + case 'thread.started': case 'turn.started': return null; + case 'item.started': + if (!event.item || event.item.type !== 'command_execution') return null; + return parseItem(event.item, event.type); + case 'item.created': case 'item.completed': - return parseItem(event.item); + if (!event.item) return null; + return parseItem(event.item, event.type); case 'turn.completed': { const usage = event.usage || event.response?.usage || {}; diff --git a/src/state-snapshot.js b/src/state-snapshot.js index 418bcc91..11ecdd1a 100644 --- a/src/state-snapshot.js +++ b/src/state-snapshot.js @@ -12,7 +12,7 @@ const LIMITS = { const TEXT_LIMITS = { task: 2000, - plan: 2000, + plan: 2500, // Slightly increased for actionable plans with embedded patterns (was 2000) fixPlan: 1200, summary: 300, listItem: 200, diff --git a/src/sub-cluster-wrapper.js b/src/sub-cluster-wrapper.js index 261b2bd9..ced36853 100644 --- a/src/sub-cluster-wrapper.js +++ b/src/sub-cluster-wrapper.js @@ -15,6 +15,7 @@ const LogicEngine = require('./logic-engine'); const MessageBusBridge = require('./message-bus-bridge'); const { DEFAULT_MAX_ITERATIONS } = require('./agent/agent-config'); +const { bufferMessage, scheduleDrain, drainBufferedMessages } = require('./message-buffer'); function normalizeParentTopicConfig(entry) { if (typeof entry === 'string') { @@ -167,6 +168,10 @@ class SubClusterWrapper { * @private */ async _handleMessage(message) { + if (!this._bufferedMessages) { + this._bufferedMessages = []; + } + // Check if any trigger matches const matchingTrigger = this._findMatchingTrigger(message); if (!matchingTrigger) { @@ -179,8 +184,17 @@ class SubClusterWrapper { return; } if (this.state !== 'idle') { + bufferMessage(this, message); console.warn( - `[${this.id}] āš ļø DROPPING message (busy, state=${this.state}): ${message.topic}` + `[${this.id}] āøļø BUFFERING message (busy, state=${this.state}): ${message.topic}` + ); + scheduleDrain( + this, + () => + drainBufferedMessages(this, (next) => this._handleMessage(next), { + label: 'SubCluster', + }), + { label: 'SubCluster' } ); return; } @@ -462,6 +476,29 @@ class SubClusterWrapper { if (message.topic === 'CLUSTER_COMPLETE' && message.cluster_id === childId) { this._onChildComplete(message).catch((err) => { console.error(`Failed to handle child completion: ${err.message}`); + + // CRITICAL: Hook failure = cluster failure + this._publishLifecycle('HOOK_FAILED', { + error: err.message, + hook: 'onComplete', + }); + + this.messageBus.publish({ + cluster_id: this.parentCluster.id, + topic: 'CLUSTER_FAILED', + sender: this.id, + content: { + text: `Hook failed: ${err.message}`, + data: { + reason: 'onComplete hook failed', + error: err.message, + stack: err.stack, + }, + }, + }); + + this.state = 'failed'; + throw err; }); } }); @@ -471,6 +508,29 @@ class SubClusterWrapper { if (message.topic === 'CLUSTER_FAILED' && message.cluster_id === childId) { this._onChildFailed(message).catch((err) => { console.error(`Failed to handle child failure: ${err.message}`); + + // CRITICAL: Hook failure = cluster failure + this._publishLifecycle('HOOK_FAILED', { + error: err.message, + hook: 'onFailed', + }); + + this.messageBus.publish({ + cluster_id: this.parentCluster.id, + topic: 'CLUSTER_FAILED', + sender: this.id, + content: { + text: `Hook failed: ${err.message}`, + data: { + reason: 'onFailed hook failed', + error: err.message, + stack: err.stack, + }, + }, + }); + + this.state = 'failed'; + throw err; }); } }); @@ -488,13 +548,13 @@ class SubClusterWrapper { iteration: this.iteration, }); - // Execute onComplete hook + // Execute onComplete hook - will throw if verification fails await this._executeHook('onComplete', { result: message, triggeringMessage: null, }); - // Clean up child cluster + // Only clean up and transition to idle if hook succeeded await this._stopChildCluster(); this.state = 'idle'; diff --git a/src/template-validation/index.js b/src/template-validation/index.js new file mode 100644 index 00000000..f3f02ac3 --- /dev/null +++ b/src/template-validation/index.js @@ -0,0 +1,89 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { validateConfig } = require('../config-validator'); +const { simulateConsensusGates } = require('./simulate-consensus-gates'); +const { simulateTwoStageValidation } = require('./simulate-two-stage-validation'); + +function findJsonFiles(dir) { + const files = []; + if (!fs.existsSync(dir)) return files; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...findJsonFiles(fullPath)); + } else if (entry.name.endsWith('.json')) { + files.push(fullPath); + } + } + return files; +} + +function inferTemplateIdFromPath(filePath) { + const base = path.basename(filePath, '.json'); + return base || 'unknown'; +} + +async function validateTemplateConfig({ config, templateId, deep }) { + const result = validateConfig(config); + + if (result.valid) { + const simErrors = []; + simErrors.push(...simulateConsensusGates(config)); + if (deep) { + simErrors.push(...(await simulateTwoStageValidation({ templateId, config }))); + } + if (simErrors.length > 0) { + result.valid = false; + result.errors.push(...simErrors); + } + } + + return result; +} + +async function validateTemplates({ templatesDir, deep = false }) { + const templateFiles = [...findJsonFiles(templatesDir)]; + + let hasErrors = false; + let validated = 0; + let skipped = 0; + const results = []; + + for (const filePath of templateFiles) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const config = JSON.parse(content); + + // Skip non-cluster configs (like package.json) + if (!config.agents && !config.name) { + skipped++; + continue; + } + + const templateId = inferTemplateIdFromPath(filePath); + const result = await validateTemplateConfig({ config, templateId, deep }); + + results.push({ filePath, result }); + validated++; + if (!result.valid) hasErrors = true; + } catch (err) { + results.push({ filePath, result: { valid: false, errors: [err.message], warnings: [] } }); + validated++; + hasErrors = true; + } + } + + return { + valid: !hasErrors, + validated, + skipped, + results, + }; +} + +module.exports = { + validateTemplates, + validateTemplateConfig, +}; diff --git a/src/template-validation/simulate-consensus-gates.js b/src/template-validation/simulate-consensus-gates.js new file mode 100644 index 00000000..457085d6 --- /dev/null +++ b/src/template-validation/simulate-consensus-gates.js @@ -0,0 +1,159 @@ +const Ledger = require('../ledger'); +const MessageBus = require('../message-bus'); +const LogicEngine = require('../logic-engine'); + +function maybePublishStageStart({ messageBus, clusterId, logicScript }) { + const now = Date.now(); + + if (logicScript.includes('IMPLEMENTATION_READY')) { + messageBus.publish({ + cluster_id: clusterId, + topic: 'IMPLEMENTATION_READY', + sender: 'worker', + timestamp: now, + }); + } + + if (logicScript.includes('QUICK_VALIDATION_PASSED')) { + messageBus.publish({ + cluster_id: clusterId, + topic: 'QUICK_VALIDATION_PASSED', + sender: 'consensus-coordinator', + timestamp: now, + }); + } +} + +function collectTopicProducers(config) { + const producersByTopic = new Map(); + + for (const agent of config.agents || []) { + const hooks = agent?.hooks; + if (!hooks) continue; + + const onComplete = hooks.onComplete; + if (!onComplete) continue; + + if (onComplete.action === 'publish_message' && onComplete.config?.topic) { + const topic = String(onComplete.config.topic); + if (!producersByTopic.has(topic)) { + producersByTopic.set(topic, new Set()); + } + producersByTopic.get(topic).add(agent.id); + } + } + + return producersByTopic; +} + +/** + * Micro-sim: consensus-like trigger gates must not fire early due to duplicate publishes + * from the same producer (common in retries / double-publish bugs). + * + * Returns an array of error strings. + */ +function simulateConsensusGates(config) { + const agents = Array.isArray(config.agents) ? config.agents : []; + const producersByTopic = collectTopicProducers(config); + + const cluster = { + id: 'template-sim', + agents: agents.map((a) => ({ id: a.id, role: a.role })), + }; + + const failures = []; + + for (const agent of agents) { + const isConsensusLike = + agent?.role === 'coordinator' || + String(agent?.id || '').includes('consensus') || + String(agent?.id || '').includes('coordinator'); + + if (!isConsensusLike) continue; + + for (const trigger of agent.triggers || []) { + const topic = trigger?.topic; + const script = trigger?.logic?.script; + if (!topic || !script) continue; + + const producers = Array.from(producersByTopic.get(topic) || []); + if (producers.length < 2) continue; + + // Scenario A: Duplicate publishes from one producer MUST NOT satisfy the gate. + { + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const logicEngine = new LogicEngine(messageBus, cluster); + + maybePublishStageStart({ messageBus, clusterId: cluster.id, logicScript: script }); + + messageBus.publish({ + cluster_id: cluster.id, + topic, + sender: producers[0], + content: { data: { approved: true } }, + }); + messageBus.publish({ + cluster_id: cluster.id, + topic, + sender: producers[0], + content: { data: { approved: true } }, + }); + + const shouldTriggerEarly = logicEngine.evaluate( + script, + { id: agent.id, cluster_id: cluster.id }, + { topic } + ); + ledger.close(); + + if (shouldTriggerEarly) { + failures.push( + `Agent "${agent.id}" trigger on "${topic}" fires early on duplicate sender (${producers[0]}). ` + + `Gate must require distinct producers: ${producers.join(', ')}` + ); + continue; + } + } + + // Scenario B: One publish from each producer SHOULD satisfy the gate. + { + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const logicEngine = new LogicEngine(messageBus, cluster); + + maybePublishStageStart({ messageBus, clusterId: cluster.id, logicScript: script }); + + for (const producer of producers) { + messageBus.publish({ + cluster_id: cluster.id, + topic, + sender: producer, + content: { data: { approved: true } }, + }); + } + + const shouldTrigger = logicEngine.evaluate( + script, + { id: agent.id, cluster_id: cluster.id }, + { topic } + ); + ledger.close(); + + if (!shouldTrigger) { + failures.push( + `Agent "${agent.id}" trigger on "${topic}" did not fire after all producers published. ` + + `Expected producers: ${producers.join(', ')}` + ); + } + } + } + } + + return failures; +} + +module.exports = { + collectTopicProducers, + simulateConsensusGates, +}; diff --git a/src/template-validation/simulate-two-stage-validation.js b/src/template-validation/simulate-two-stage-validation.js new file mode 100644 index 00000000..b62f049b --- /dev/null +++ b/src/template-validation/simulate-two-stage-validation.js @@ -0,0 +1,270 @@ +const assert = require('node:assert'); + +const Ledger = require('../ledger'); +const MessageBus = require('../message-bus'); +const LogicEngine = require('../logic-engine'); +const { executeHook } = require('../agent/agent-hook-executor'); +const { parseResultOutput } = require('../agent/agent-task-executor'); + +function createSimAgent({ agentConfig, cluster, messageBus }) { + const simAgent = { + id: agentConfig.id, + role: agentConfig.role, + iteration: 1, + cluster, + messageBus, + config: agentConfig, + currentTaskId: 'sim-task', + workingDirectory: process.cwd(), + _log: () => {}, + _resolveProvider: () => 'claude', + _parseResultOutput: (output) => parseResultOutput(simAgent, output), + _publish: (message) => { + const receiver = message.receiver || 'broadcast'; + return messageBus.publish({ + ...message, + receiver, + cluster_id: cluster.id, + sender: simAgent.id, + }); + }, + }; + return simAgent; +} + +async function simulateQuickValidation({ config }) { + const cluster = { + id: 'quick-sim', + agents: config.agents.map((a) => ({ id: a.id, role: a.role })), + }; + + const coordinator = config.agents.find((a) => a.id === 'consensus-coordinator'); + assert.ok(coordinator, 'quick-validation: consensus-coordinator missing'); + + const trigger = coordinator.triggers.find((t) => t.topic === 'QUICK_VALIDATION_RESULT'); + assert.ok(trigger?.logic?.script, 'quick-validation: coordinator trigger logic missing'); + assert.ok(coordinator.hooks?.onComplete, 'quick-validation: coordinator onComplete missing'); + + const failures = []; + + const runScenario = async (allApproved) => { + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const logicEngine = new LogicEngine(messageBus, cluster); + + // Stage start + const now = Date.now(); + messageBus.publish({ + cluster_id: cluster.id, + topic: 'IMPLEMENTATION_READY', + sender: 'worker', + timestamp: now, + }); + + // Validator outputs (stage 1) + messageBus.publish({ + cluster_id: cluster.id, + topic: 'QUICK_VALIDATION_RESULT', + sender: 'validator-requirements', + timestamp: now + 10, + content: { data: { approved: true, errors: ['req-error'] } }, + }); + messageBus.publish({ + cluster_id: cluster.id, + topic: 'QUICK_VALIDATION_RESULT', + sender: 'validator-code', + timestamp: now + 20, + content: { data: { approved: true, errors: ['code-error'] } }, + }); + + const gateOk = logicEngine.evaluate( + trigger.logic.script, + { id: 'consensus-coordinator', cluster_id: cluster.id }, + { topic: 'QUICK_VALIDATION_RESULT' } + ); + if (!gateOk) { + ledger.close(); + return { ok: false, error: 'quick-validation: gate did not open after both validators' }; + } + + const simAgent = createSimAgent({ agentConfig: coordinator, cluster, messageBus }); + const triggeringMessage = messageBus.findLast({ + cluster_id: cluster.id, + topic: 'QUICK_VALIDATION_RESULT', + }); + + try { + await executeHook({ + hook: coordinator.hooks.onComplete, + agent: simAgent, + message: triggeringMessage, + result: { + output: JSON.stringify({ allApproved, summary: allApproved ? 'ok' : 'nope' }), + success: true, + taskId: 'sim-task', + }, + messageBus, + cluster, + }); + } catch (err) { + ledger.close(); + return { ok: false, error: `quick-validation: onComplete failed: ${err.message}` }; + } + + const passed = messageBus.findLast({ + cluster_id: cluster.id, + topic: 'QUICK_VALIDATION_PASSED', + }); + const validationResult = messageBus.findLast({ + cluster_id: cluster.id, + topic: 'VALIDATION_RESULT', + }); + + ledger.close(); + + if (allApproved) { + if (!passed) + return { ok: false, error: 'quick-validation: expected QUICK_VALIDATION_PASSED' }; + return { ok: true }; + } + + if (!validationResult) { + return { ok: false, error: 'quick-validation: expected VALIDATION_RESULT on rejection' }; + } + + const errors = validationResult.content?.data?.errors || []; + if (!errors.includes('req-error') || !errors.includes('code-error')) { + return { ok: false, error: 'quick-validation: rejection did not aggregate validator errors' }; + } + + return { ok: true }; + }; + + for (const allApproved of [true, false]) { + const res = await runScenario(allApproved); + if (!res.ok) failures.push(res.error); + } + + return failures; +} + +async function simulateHeavyValidation({ config }) { + const cluster = { + id: 'heavy-sim', + agents: config.agents.map((a) => ({ id: a.id, role: a.role })), + }; + + const coordinator = config.agents.find((a) => a.id === 'consensus-coordinator'); + assert.ok(coordinator, 'heavy-validation: consensus-coordinator missing'); + + const trigger = coordinator.triggers.find((t) => t.topic === 'HEAVY_VALIDATION_RESULT'); + assert.ok(trigger?.logic?.script, 'heavy-validation: coordinator trigger logic missing'); + assert.ok(coordinator.hooks?.onComplete, 'heavy-validation: coordinator onComplete missing'); + + const failures = []; + + const runScenario = async (allApproved) => { + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const logicEngine = new LogicEngine(messageBus, cluster); + + const now = Date.now(); + messageBus.publish({ + cluster_id: cluster.id, + topic: 'QUICK_VALIDATION_PASSED', + sender: 'consensus-coordinator', + timestamp: now, + }); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'HEAVY_VALIDATION_RESULT', + sender: 'validator-security', + timestamp: now + 10, + content: { data: { approved: true, errors: ['sec-error'] } }, + }); + messageBus.publish({ + cluster_id: cluster.id, + topic: 'HEAVY_VALIDATION_RESULT', + sender: 'validator-tester', + timestamp: now + 20, + content: { data: { approved: true, errors: ['test-error'] } }, + }); + + const gateOk = logicEngine.evaluate( + trigger.logic.script, + { id: 'consensus-coordinator', cluster_id: cluster.id }, + { topic: 'HEAVY_VALIDATION_RESULT' } + ); + if (!gateOk) { + ledger.close(); + return { ok: false, error: 'heavy-validation: gate did not open after both validators' }; + } + + const simAgent = createSimAgent({ agentConfig: coordinator, cluster, messageBus }); + const triggeringMessage = messageBus.findLast({ + cluster_id: cluster.id, + topic: 'HEAVY_VALIDATION_RESULT', + }); + + try { + await executeHook({ + hook: coordinator.hooks.onComplete, + agent: simAgent, + message: triggeringMessage, + result: { + output: JSON.stringify({ allApproved, summary: allApproved ? 'ok' : 'nope' }), + success: true, + taskId: 'sim-task', + }, + messageBus, + cluster, + }); + } catch (err) { + ledger.close(); + return { ok: false, error: `heavy-validation: onComplete failed: ${err.message}` }; + } + + const validationResult = messageBus.findLast({ + cluster_id: cluster.id, + topic: 'VALIDATION_RESULT', + }); + ledger.close(); + + if (!validationResult) { + return { ok: false, error: 'heavy-validation: expected VALIDATION_RESULT' }; + } + + const errors = validationResult.content?.data?.errors || []; + if (!errors.includes('sec-error') || !errors.includes('test-error')) { + return { ok: false, error: 'heavy-validation: did not aggregate validator errors' }; + } + + return { ok: true }; + }; + + for (const allApproved of [true, false]) { + const res = await runScenario(allApproved); + if (!res.ok) failures.push(res.error); + } + + return failures; +} + +/** + * Deep sim: run deterministic two-stage validation scenarios for base templates. + * Returns an array of error strings. + */ +function simulateTwoStageValidation({ templateId, config }) { + if (templateId === 'quick-validation') { + return simulateQuickValidation({ config }); + } + if (templateId === 'heavy-validation') { + return simulateHeavyValidation({ config }); + } + return Promise.resolve([]); +} + +module.exports = { + simulateTwoStageValidation, +}; diff --git a/src/tui-backend/index.ts b/src/tui-backend/index.ts new file mode 100644 index 00000000..5ca31305 --- /dev/null +++ b/src/tui-backend/index.ts @@ -0,0 +1,4 @@ +export * from './server'; +export * from './protocol'; +export * from './subscriptions'; +export * from './services'; diff --git a/src/tui-backend/protocol/constants.ts b/src/tui-backend/protocol/constants.ts new file mode 100644 index 00000000..db72c69a --- /dev/null +++ b/src/tui-backend/protocol/constants.ts @@ -0,0 +1,33 @@ +const PROTOCOL_VERSION = 1; +const MAX_FRAME_BYTES = 10 * 1024 * 1024; + +const RPC_ERROR_CODES = Object.freeze({ + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + PROTOCOL_VERSION_MISMATCH: -32000, + ORCHESTRATOR_UNAVAILABLE: -32001, + CLUSTER_NOT_FOUND: -32002, + UNSUPPORTED_CAPABILITY: -32003, +}); + +const RPC_ERROR_MESSAGES = Object.freeze({ + [RPC_ERROR_CODES.PARSE_ERROR]: 'Parse error', + [RPC_ERROR_CODES.INVALID_REQUEST]: 'Invalid request', + [RPC_ERROR_CODES.METHOD_NOT_FOUND]: 'Method not found', + [RPC_ERROR_CODES.INVALID_PARAMS]: 'Invalid params', + [RPC_ERROR_CODES.INTERNAL_ERROR]: 'Internal error', + [RPC_ERROR_CODES.PROTOCOL_VERSION_MISMATCH]: 'Protocol version mismatch', + [RPC_ERROR_CODES.ORCHESTRATOR_UNAVAILABLE]: 'Orchestrator unavailable', + [RPC_ERROR_CODES.CLUSTER_NOT_FOUND]: 'Cluster not found', + [RPC_ERROR_CODES.UNSUPPORTED_CAPABILITY]: 'Unsupported capability', +}); + +module.exports = { + PROTOCOL_VERSION, + MAX_FRAME_BYTES, + RPC_ERROR_CODES, + RPC_ERROR_MESSAGES, +}; diff --git a/src/tui-backend/protocol/dispatcher.ts b/src/tui-backend/protocol/dispatcher.ts new file mode 100644 index 00000000..6d8362e0 --- /dev/null +++ b/src/tui-backend/protocol/dispatcher.ts @@ -0,0 +1,88 @@ +const { RPC_ERROR_CODES, RPC_ERROR_MESSAGES } = require('./constants'); + +const buildError = (code: number, message: string, detail?: string) => { + const error: any = { code, message }; + if (detail) { + error.data = { detail }; + } + return error; +}; + +const isRpcError = (error: any) => + error && + typeof error === 'object' && + typeof error.code === 'number' && + typeof error.message === 'string'; + +const createDispatcher = (options: any = {}) => { + const serverInfo = options.serverInfo || { name: 'zeroshot', version: '0.0.0' }; + const protocolVersion = typeof options.protocolVersion === 'number' ? options.protocolVersion : 1; + const baseHandlers = { + initialize: async () => ({ + protocolVersion, + server: serverInfo, + capabilities: { + methods: [], + notifications: [], + }, + }), + ping: async () => ({ ok: true }), + }; + const extraHandlers = + options.handlers && typeof options.handlers === 'object' ? options.handlers : {}; + const handlers = { ...baseHandlers, ...extraHandlers }; + const methods = Array.from(new Set(Object.keys(handlers))); + const notifications = Array.isArray(options.notifications) ? options.notifications : []; + handlers.initialize = async () => ({ + protocolVersion, + server: serverInfo, + capabilities: { + methods, + notifications, + }, + }); + + const dispatchRequest = async (message) => { + const handler = handlers[message.method]; + if (!handler) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.METHOD_NOT_FOUND, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.METHOD_NOT_FOUND] + ), + }; + } + try { + const result = await handler(message.params ?? null, message); + return { ok: true, result }; + } catch (error) { + if (isRpcError(error)) { + const rpcError: any = { code: error.code, message: error.message }; + if (error.data) { + rpcError.data = error.data; + } + return { ok: false, error: rpcError }; + } + const detail = error instanceof Error ? error.message : 'Unhandled dispatcher error'; + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INTERNAL_ERROR, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INTERNAL_ERROR], + detail + ), + }; + } + }; + + return { + dispatchRequest, + methods, + notifications, + }; +}; + +module.exports = { + createDispatcher, +}; diff --git a/src/tui-backend/protocol/index.ts b/src/tui-backend/protocol/index.ts new file mode 100644 index 00000000..6b2f2023 --- /dev/null +++ b/src/tui-backend/protocol/index.ts @@ -0,0 +1,13 @@ +const constants = require('./constants'); +const validator = require('./validator'); +const dispatcher = require('./dispatcher'); +const framing = require('./stdio-framing'); + +export const PROTOCOL_VERSION = constants.PROTOCOL_VERSION; +export const MAX_FRAME_BYTES = constants.MAX_FRAME_BYTES; +export const RPC_ERROR_CODES = constants.RPC_ERROR_CODES; +export const RPC_ERROR_MESSAGES = constants.RPC_ERROR_MESSAGES; +export const createValidator = validator.createValidator; +export const createDispatcher = dispatcher.createDispatcher; +export const createFrameParser = framing.createFrameParser; +export const encodeFrame = framing.encodeFrame; diff --git a/src/tui-backend/protocol/schemas.ts b/src/tui-backend/protocol/schemas.ts new file mode 100644 index 00000000..e64b335a --- /dev/null +++ b/src/tui-backend/protocol/schemas.ts @@ -0,0 +1,599 @@ +const idSchema = { + anyOf: [{ type: 'string' }, { type: 'number' }], +}; + +const nullableString = { + anyOf: [{ type: 'string' }, { type: 'null' }], +}; + +const nullableNumber = { + anyOf: [{ type: 'number' }, { type: 'null' }], +}; + +const errorDataSchema = { + type: 'object', + additionalProperties: false, + properties: { + detail: { type: 'string' }, + fields: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + supportedVersions: { type: 'array', items: { type: 'number' } }, + }, +}; + +const errorSchema = { + type: 'object', + additionalProperties: false, + required: ['code', 'message'], + properties: { + code: { type: 'number' }, + message: { type: 'string' }, + data: errorDataSchema, + }, +}; + +const jsonRpcRequestBase = { + type: 'object', + additionalProperties: false, + required: ['jsonrpc', 'id', 'method'], + properties: { + jsonrpc: { const: '2.0' }, + id: idSchema, + method: { type: 'string' }, + params: { type: ['object', 'array', 'null'] }, + }, +}; + +const jsonRpcNotificationBase = { + type: 'object', + additionalProperties: false, + required: ['jsonrpc', 'method'], + properties: { + jsonrpc: { const: '2.0' }, + method: { type: 'string' }, + params: { type: ['object', 'array', 'null'] }, + }, + not: { required: ['id'] }, +}; + +const jsonRpcResponseBase = { + type: 'object', + additionalProperties: false, + required: ['jsonrpc', 'id'], + properties: { + jsonrpc: { const: '2.0' }, + id: idSchema, + // Allow result/error at base layer; method-specific schema handles shape. + result: {}, + error: {}, + }, +}; + +const emptyParamsSchema = { + anyOf: [{ type: 'null' }, { type: 'object', additionalProperties: false, maxProperties: 0 }], +}; + +const clusterSummarySchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'state', 'provider', 'createdAt', 'agentCount', 'messageCount', 'cwd'], + properties: { + id: { type: 'string' }, + state: { type: 'string' }, + provider: nullableString, + createdAt: { type: 'number' }, + agentCount: { type: 'number' }, + messageCount: { type: 'number' }, + cwd: nullableString, + }, +}; + +const clusterMetricsSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'supported', 'cpuPercent', 'memoryMB'], + properties: { + id: { type: 'string' }, + supported: { type: 'boolean' }, + cpuPercent: nullableNumber, + memoryMB: nullableNumber, + }, +}; + +const clusterLogLineSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'timestamp', 'text', 'agent', 'role', 'sender'], + properties: { + id: { type: 'string' }, + timestamp: { type: 'number' }, + text: { type: 'string' }, + agent: nullableString, + role: nullableString, + sender: nullableString, + }, +}; + +const timelineEventSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'timestamp', 'topic', 'label', 'approved', 'sender'], + properties: { + id: { type: 'string' }, + timestamp: { type: 'number' }, + topic: { type: 'string' }, + label: { type: 'string' }, + approved: { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, + sender: nullableString, + }, +}; + +const topologyAgentSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'role'], + properties: { + id: { type: 'string' }, + role: nullableString, + }, +}; + +const topologyEdgeSchema = { + type: 'object', + additionalProperties: false, + required: ['from', 'to', 'topic', 'kind'], + properties: { + from: { type: 'string' }, + to: { type: 'string' }, + topic: { type: 'string' }, + kind: { enum: ['trigger', 'publish', 'source'] }, + dynamic: { type: 'boolean' }, + }, +}; + +const clusterTopologySchema = { + type: 'object', + additionalProperties: false, + required: ['agents', 'edges', 'topics'], + properties: { + agents: { type: 'array', items: topologyAgentSchema }, + edges: { type: 'array', items: topologyEdgeSchema }, + topics: { type: 'array', items: { type: 'string' } }, + }, +}; + +const guidanceDeliveryResultSchema = { + type: 'object', + additionalProperties: false, + required: ['status', 'reason', 'method'], + properties: { + status: { type: 'string' }, + reason: nullableString, + method: nullableString, + taskId: nullableString, + }, +}; + +const clusterGuidanceSummarySchema = { + type: 'object', + additionalProperties: false, + required: ['injected', 'queued', 'total'], + properties: { + injected: { type: 'number' }, + queued: { type: 'number' }, + total: { type: 'number' }, + }, +}; + +const clusterGuidanceDeliverySchema = { + type: 'object', + additionalProperties: false, + required: ['summary', 'agents', 'timestamp'], + properties: { + summary: clusterGuidanceSummarySchema, + agents: { + type: 'object', + additionalProperties: guidanceDeliveryResultSchema, + }, + timestamp: { type: 'number' }, + }, +}; + +const initializeParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['protocolVersion', 'client'], + properties: { + protocolVersion: { type: 'number' }, + client: { + type: 'object', + additionalProperties: false, + required: ['name', 'version'], + properties: { + name: { type: 'string' }, + version: { type: 'string' }, + pid: { type: 'number' }, + }, + }, + capabilities: { + type: 'object', + additionalProperties: false, + properties: { + wantsMetrics: { type: 'boolean' }, + wantsTopology: { type: 'boolean' }, + }, + }, + }, +}; + +const initializeResultSchema = { + type: 'object', + additionalProperties: false, + required: ['protocolVersion', 'server', 'capabilities'], + properties: { + protocolVersion: { type: 'number' }, + server: { + type: 'object', + additionalProperties: false, + required: ['name', 'version'], + properties: { + name: { type: 'string' }, + version: { type: 'string' }, + }, + }, + capabilities: { + type: 'object', + additionalProperties: false, + required: ['methods', 'notifications'], + properties: { + methods: { type: 'array', items: { type: 'string' } }, + notifications: { type: 'array', items: { type: 'string' } }, + }, + }, + }, +}; + +const pingParamsSchema = emptyParamsSchema; + +const pingResultSchema = { + type: 'object', + additionalProperties: false, + required: ['ok'], + properties: { + ok: { const: true }, + }, +}; + +const listClustersResultSchema = { + type: 'object', + additionalProperties: false, + required: ['clusters'], + properties: { + clusters: { type: 'array', items: clusterSummarySchema }, + }, +}; + +const getClusterSummaryParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['clusterId'], + properties: { + clusterId: { type: 'string' }, + }, +}; + +const getClusterSummaryResultSchema = { + type: 'object', + additionalProperties: false, + required: ['summary'], + properties: { + summary: clusterSummarySchema, + }, +}; + +const listClusterMetricsParamsSchema = { + type: 'object', + additionalProperties: false, + properties: { + clusterIds: { type: 'array', items: { type: 'string' } }, + }, +}; + +const listClusterMetricsResultSchema = { + type: 'object', + additionalProperties: false, + required: ['metrics'], + properties: { + metrics: { type: 'array', items: clusterMetricsSchema }, + }, +}; + +const startClusterFromTextParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['text'], + properties: { + text: { type: 'string' }, + providerOverride: nullableString, + clusterId: { type: 'string' }, + }, +}; + +const startClusterFromIssueParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['ref'], + properties: { + ref: { type: 'string' }, + providerOverride: nullableString, + clusterId: { type: 'string' }, + }, +}; + +const startClusterResultSchema = { + type: 'object', + additionalProperties: false, + required: ['clusterId'], + properties: { + clusterId: { type: 'string' }, + }, +}; + +const sendGuidanceToAgentParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['clusterId', 'agentId', 'text'], + properties: { + clusterId: { type: 'string' }, + agentId: { type: 'string' }, + text: { type: 'string' }, + timeoutMs: { type: 'number' }, + }, +}; + +const sendGuidanceToClusterParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['clusterId', 'text'], + properties: { + clusterId: { type: 'string' }, + text: { type: 'string' }, + timeoutMs: { type: 'number' }, + }, +}; + +const sendGuidanceToAgentResultSchema = { + type: 'object', + additionalProperties: false, + required: ['result'], + properties: { + result: guidanceDeliveryResultSchema, + }, +}; + +const sendGuidanceToClusterResultSchema = { + type: 'object', + additionalProperties: false, + required: ['result'], + properties: { + result: clusterGuidanceDeliverySchema, + }, +}; + +const subscribeClusterLogsParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['clusterId'], + properties: { + clusterId: { type: 'string' }, + agentId: nullableString, + }, +}; + +const subscribeClusterTimelineParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['clusterId'], + properties: { + clusterId: { type: 'string' }, + }, +}; + +const subscribeResultSchema = { + type: 'object', + additionalProperties: false, + required: ['subscriptionId'], + properties: { + subscriptionId: { type: 'string' }, + }, +}; + +const unsubscribeParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['subscriptionId'], + properties: { + subscriptionId: { type: 'string' }, + }, +}; + +const unsubscribeResultSchema = { + type: 'object', + additionalProperties: false, + required: ['removed'], + properties: { + removed: { type: 'boolean' }, + }, +}; + +const getClusterTopologyParamsSchema = { + type: 'object', + additionalProperties: false, + required: ['clusterId'], + properties: { + clusterId: { type: 'string' }, + }, +}; + +const getClusterTopologyResultSchema = { + type: 'object', + additionalProperties: false, + required: ['topology'], + properties: { + topology: clusterTopologySchema, + }, +}; + +const clusterLogLinesNotificationSchema = { + type: 'object', + additionalProperties: false, + required: ['subscriptionId', 'clusterId', 'lines'], + properties: { + subscriptionId: { type: 'string' }, + clusterId: { type: 'string' }, + lines: { type: 'array', items: clusterLogLineSchema }, + droppedCount: { type: 'number' }, + }, +}; + +const clusterTimelineEventsNotificationSchema = { + type: 'object', + additionalProperties: false, + required: ['subscriptionId', 'clusterId', 'events'], + properties: { + subscriptionId: { type: 'string' }, + clusterId: { type: 'string' }, + events: { type: 'array', items: timelineEventSchema }, + droppedCount: { type: 'number' }, + }, +}; + +const buildRequestSchema = (method, paramsSchema, paramsRequired) => { + const schema = { + ...jsonRpcRequestBase, + properties: { + ...jsonRpcRequestBase.properties, + method: { const: method }, + }, + }; + if (paramsSchema) { + schema.properties.params = paramsSchema; + } + if (paramsRequired) { + schema.required = [...jsonRpcRequestBase.required, 'params']; + } + return schema; +}; + +const buildResponseSchema = (resultSchema) => ({ + ...jsonRpcResponseBase, + required: [...jsonRpcResponseBase.required, 'result'], + properties: { + ...jsonRpcResponseBase.properties, + result: resultSchema, + }, +}); + +const buildErrorResponseSchema = () => ({ + ...jsonRpcResponseBase, + required: [...jsonRpcResponseBase.required, 'error'], + properties: { + ...jsonRpcResponseBase.properties, + error: errorSchema, + }, +}); + +const buildNotificationSchema = (method, paramsSchema) => ({ + ...jsonRpcNotificationBase, + properties: { + ...jsonRpcNotificationBase.properties, + method: { const: method }, + params: paramsSchema, + }, + required: [...jsonRpcNotificationBase.required, 'params'], +}); + +const REQUEST_SCHEMAS = { + initialize: buildRequestSchema('initialize', initializeParamsSchema, true), + ping: buildRequestSchema('ping', pingParamsSchema, true), + listClusters: buildRequestSchema('listClusters', emptyParamsSchema, false), + getClusterSummary: buildRequestSchema('getClusterSummary', getClusterSummaryParamsSchema, true), + listClusterMetrics: buildRequestSchema( + 'listClusterMetrics', + listClusterMetricsParamsSchema, + false + ), + startClusterFromText: buildRequestSchema( + 'startClusterFromText', + startClusterFromTextParamsSchema, + true + ), + startClusterFromIssue: buildRequestSchema( + 'startClusterFromIssue', + startClusterFromIssueParamsSchema, + true + ), + sendGuidanceToAgent: buildRequestSchema( + 'sendGuidanceToAgent', + sendGuidanceToAgentParamsSchema, + true + ), + sendGuidanceToCluster: buildRequestSchema( + 'sendGuidanceToCluster', + sendGuidanceToClusterParamsSchema, + true + ), + subscribeClusterLogs: buildRequestSchema( + 'subscribeClusterLogs', + subscribeClusterLogsParamsSchema, + true + ), + subscribeClusterTimeline: buildRequestSchema( + 'subscribeClusterTimeline', + subscribeClusterTimelineParamsSchema, + true + ), + unsubscribe: buildRequestSchema('unsubscribe', unsubscribeParamsSchema, true), + getClusterTopology: buildRequestSchema( + 'getClusterTopology', + getClusterTopologyParamsSchema, + true + ), +}; + +const RESPONSE_SCHEMAS = { + initialize: buildResponseSchema(initializeResultSchema), + ping: buildResponseSchema(pingResultSchema), + listClusters: buildResponseSchema(listClustersResultSchema), + getClusterSummary: buildResponseSchema(getClusterSummaryResultSchema), + listClusterMetrics: buildResponseSchema(listClusterMetricsResultSchema), + startClusterFromText: buildResponseSchema(startClusterResultSchema), + startClusterFromIssue: buildResponseSchema(startClusterResultSchema), + sendGuidanceToAgent: buildResponseSchema(sendGuidanceToAgentResultSchema), + sendGuidanceToCluster: buildResponseSchema(sendGuidanceToClusterResultSchema), + subscribeClusterLogs: buildResponseSchema(subscribeResultSchema), + subscribeClusterTimeline: buildResponseSchema(subscribeResultSchema), + unsubscribe: buildResponseSchema(unsubscribeResultSchema), + getClusterTopology: buildResponseSchema(getClusterTopologyResultSchema), +}; + +const NOTIFICATION_SCHEMAS = { + clusterLogLines: buildNotificationSchema('clusterLogLines', clusterLogLinesNotificationSchema), + clusterTimelineEvents: buildNotificationSchema( + 'clusterTimelineEvents', + clusterTimelineEventsNotificationSchema + ), +}; + +module.exports = { + errorSchema, + jsonRpcRequestBase, + jsonRpcNotificationBase, + jsonRpcResponseBase, + buildErrorResponseSchema, + REQUEST_SCHEMAS, + RESPONSE_SCHEMAS, + NOTIFICATION_SCHEMAS, +}; diff --git a/src/tui-backend/protocol/stdio-framing.ts b/src/tui-backend/protocol/stdio-framing.ts new file mode 100644 index 00000000..275e01a0 --- /dev/null +++ b/src/tui-backend/protocol/stdio-framing.ts @@ -0,0 +1,83 @@ +const { MAX_FRAME_BYTES } = require('./constants'); + +const HEADER_DELIMITER = '\r\n\r\n'; + +const parseContentLength = (headerText) => { + const lines = headerText.split('\r\n'); + for (const line of lines) { + const sepIndex = line.indexOf(':'); + if (sepIndex === -1) { + continue; + } + const name = line.slice(0, sepIndex).trim().toLowerCase(); + if (name !== 'content-length') { + continue; + } + const value = line.slice(sepIndex + 1).trim(); + const length = Number.parseInt(value, 10); + if (!Number.isFinite(length) || length < 0) { + throw new Error('Invalid Content-Length header'); + } + return length; + } + throw new Error('Missing Content-Length header'); +}; + +const createFrameParser = (options: any = {}) => { + const maxFrameBytes = + typeof options.maxFrameBytes === 'number' ? options.maxFrameBytes : MAX_FRAME_BYTES; + let buffer = Buffer.alloc(0); + + const reset = () => { + buffer = Buffer.alloc(0); + }; + + const push = (chunk) => { + if (!chunk || chunk.length === 0) { + return []; + } + buffer = Buffer.concat([buffer, chunk]); + const frames = []; + + while (true) { + const headerIndex = buffer.indexOf(HEADER_DELIMITER); + if (headerIndex === -1) { + break; + } + const headerText = buffer.slice(0, headerIndex).toString('utf8'); + const contentLength = parseContentLength(headerText); + if (contentLength > maxFrameBytes) { + throw new Error('Frame exceeds maximum size'); + } + + const totalLength = headerIndex + HEADER_DELIMITER.length + contentLength; + if (buffer.length < totalLength) { + break; + } + + const payload = buffer.slice(headerIndex + HEADER_DELIMITER.length, totalLength); + frames.push(payload.toString('utf8')); + buffer = buffer.slice(totalLength); + } + + return frames; + }; + + return { push, reset }; +}; + +const encodeFrame = (payload) => { + const payloadBuffer = Buffer.isBuffer(payload) + ? payload + : Buffer.from(typeof payload === 'string' ? payload : JSON.stringify(payload), 'utf8'); + if (payloadBuffer.length > MAX_FRAME_BYTES) { + throw new Error('Frame exceeds maximum size'); + } + const header = `Content-Length: ${payloadBuffer.length}${HEADER_DELIMITER}`; + return Buffer.concat([Buffer.from(header, 'utf8'), payloadBuffer]); +}; + +module.exports = { + createFrameParser, + encodeFrame, +}; diff --git a/src/tui-backend/protocol/types.ts b/src/tui-backend/protocol/types.ts new file mode 100644 index 00000000..32792c2f --- /dev/null +++ b/src/tui-backend/protocol/types.ts @@ -0,0 +1,177 @@ +export type JsonRpcId = string | number; + +export type RpcErrorData = { + detail?: string; + fields?: Record; + supportedVersions?: number[]; +}; + +export type RpcError = { + code: number; + message: string; + data?: RpcErrorData; +}; + +export type JsonRpcRequest = { + jsonrpc: '2.0'; + id: JsonRpcId; + method: string; + params?: TParams | null; +}; + +export type JsonRpcNotification = { + jsonrpc: '2.0'; + method: string; + params?: TParams | null; +}; + +export type JsonRpcSuccessResponse = { + jsonrpc: '2.0'; + id: JsonRpcId; + result: TResult; +}; + +export type JsonRpcErrorResponse = { + jsonrpc: '2.0'; + id: JsonRpcId; + error: RpcError; +}; + +export type ClusterSummary = { + id: string; + state: string; + provider: string | null; + createdAt: number; + agentCount: number; + messageCount: number; + cwd: string | null; +}; + +export type ClusterMetrics = { + id: string; + supported: boolean; + cpuPercent: number | null; + memoryMB: number | null; +}; + +export type ClusterLogLine = { + id: string; + timestamp: number; + text: string; + agent: string | null; + role: string | null; + sender: string | null; +}; + +export type TimelineEvent = { + id: string; + timestamp: number; + topic: string; + label: string; + approved: boolean | null; + sender: string | null; +}; + +export type TopologyAgent = { + id: string; + role: string | null; +}; + +export type TopologyEdge = { + from: string; + to: string; + topic: string; + kind: 'trigger' | 'publish' | 'source'; + dynamic?: boolean; +}; + +export type ClusterTopology = { + agents: TopologyAgent[]; + edges: TopologyEdge[]; + topics: string[]; +}; + +export type GuidanceDeliveryResult = { + status: string; + reason: string | null; + method: string | null; + taskId?: string | null; +}; + +export type ClusterGuidanceSummary = { + injected: number; + queued: number; + total: number; +}; + +export type ClusterGuidanceDelivery = { + summary: ClusterGuidanceSummary; + agents: Record; + timestamp: number; +}; + +export type InitializeParams = { + protocolVersion: number; + client: { name: string; version: string; pid?: number }; + capabilities?: { wantsMetrics?: boolean; wantsTopology?: boolean }; +}; + +export type InitializeResult = { + protocolVersion: number; + server: { name: string; version: string }; + capabilities: { methods: string[]; notifications: string[] }; +}; + +export type PingParams = Record | null; +export type PingResult = { ok: true }; + +export type ListClustersResult = { clusters: ClusterSummary[] }; +export type GetClusterSummaryParams = { clusterId: string }; +export type GetClusterSummaryResult = { summary: ClusterSummary }; +export type ListClusterMetricsParams = { clusterIds?: string[] }; +export type ListClusterMetricsResult = { metrics: ClusterMetrics[] }; +export type StartClusterFromTextParams = { + text: string; + providerOverride?: string | null; + clusterId?: string; +}; +export type StartClusterFromIssueParams = { + ref: string; + providerOverride?: string | null; + clusterId?: string; +}; +export type StartClusterResult = { clusterId: string }; +export type SendGuidanceToAgentParams = { + clusterId: string; + agentId: string; + text: string; + timeoutMs?: number; +}; +export type SendGuidanceToClusterParams = { + clusterId: string; + text: string; + timeoutMs?: number; +}; +export type SendGuidanceToAgentResult = { result: GuidanceDeliveryResult }; +export type SendGuidanceToClusterResult = { result: ClusterGuidanceDelivery }; +export type SubscribeClusterLogsParams = { clusterId: string; agentId?: string | null }; +export type SubscribeClusterTimelineParams = { clusterId: string }; +export type SubscribeResult = { subscriptionId: string }; +export type UnsubscribeParams = { subscriptionId: string }; +export type UnsubscribeResult = { removed: boolean }; +export type GetClusterTopologyParams = { clusterId: string }; +export type GetClusterTopologyResult = { topology: ClusterTopology }; + +export type ClusterLogLinesNotification = { + subscriptionId: string; + clusterId: string; + lines: ClusterLogLine[]; + droppedCount?: number; +}; + +export type ClusterTimelineEventsNotification = { + subscriptionId: string; + clusterId: string; + events: TimelineEvent[]; + droppedCount?: number; +}; diff --git a/src/tui-backend/protocol/validator.ts b/src/tui-backend/protocol/validator.ts new file mode 100644 index 00000000..9cb823bc --- /dev/null +++ b/src/tui-backend/protocol/validator.ts @@ -0,0 +1,250 @@ +const Ajv = require('ajv'); +const { PROTOCOL_VERSION, RPC_ERROR_CODES, RPC_ERROR_MESSAGES } = require('./constants'); +const { + errorSchema, + jsonRpcRequestBase, + jsonRpcNotificationBase, + buildErrorResponseSchema, + REQUEST_SCHEMAS, + RESPONSE_SCHEMAS, + NOTIFICATION_SCHEMAS, +} = require('./schemas'); + +const buildError = (code, message, errors = []) => { + const data = Object.create(null); + if (errors && errors.length) { + const detail = errors + .map((err) => { + const path = err.instancePath || err.schemaPath || ''; + return path ? `${path} ${err.message}` : err.message; + }) + .join('; '); + if (detail) { + data.detail = detail; + } + const fields = Object.create(null); + for (const err of errors) { + const key = err.instancePath || err.schemaPath || ''; + if (key && !fields[key]) { + fields[key] = err.message || 'invalid'; + } + } + if (Object.keys(fields).length) { + data.fields = fields; + } + } + const error = Object.create(null); + error.code = code; + error.message = message; + if (Object.keys(data).length) { + error.data = data; + } + return error; +}; + +const compileSchemaMap = (ajv, schemas) => { + const validators = new Map(); + for (const [key, schema] of Object.entries(schemas)) { + validators.set(key, /** @type {any} */ ajv.compile(schema)); + } + return validators; +}; + +const createValidator = () => { + const ajv = new Ajv({ + allErrors: true, + strict: false, + coerceTypes: false, + removeAdditional: false, + }); + + const validateRequestBase = /** @type {any} */ ajv.compile(jsonRpcRequestBase); + const validateNotificationBase = /** @type {any} */ ajv.compile(jsonRpcNotificationBase); + const validateErrorObject = /** @type {any} */ ajv.compile(errorSchema); + const validateErrorResponse = /** @type {any} */ ajv.compile(buildErrorResponseSchema()); + + const requestValidators = compileSchemaMap(ajv, REQUEST_SCHEMAS); + const responseValidators = compileSchemaMap(ajv, RESPONSE_SCHEMAS); + const notificationValidators = compileSchemaMap(ajv, NOTIFICATION_SCHEMAS); + + const validateRequest = (message) => { + if (!validateRequestBase(message)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_REQUEST, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_REQUEST], + validateRequestBase.errors || [] + ), + }; + } + + const validator = requestValidators.get(message.method); + if (!validator) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.METHOD_NOT_FOUND, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.METHOD_NOT_FOUND] + ), + }; + } + + if (!validator(message)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_PARAMS, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_PARAMS], + validator.errors || [] + ), + }; + } + + if ( + message.method === 'initialize' && + message.params && + message.params.protocolVersion !== PROTOCOL_VERSION + ) { + const error = buildError( + RPC_ERROR_CODES.PROTOCOL_VERSION_MISMATCH, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.PROTOCOL_VERSION_MISMATCH] + ); + error.data = { + ...(error.data || {}), + supportedVersions: [PROTOCOL_VERSION], + }; + return { + ok: false, + error, + }; + } + + return { ok: true, value: message }; + }; + + const validateNotification = (message) => { + if (!validateNotificationBase(message)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_REQUEST, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_REQUEST], + validateNotificationBase.errors || [] + ), + }; + } + + const validator = notificationValidators.get(message.method); + if (!validator) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.METHOD_NOT_FOUND, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.METHOD_NOT_FOUND] + ), + }; + } + + if (!validator(message)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_PARAMS, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_PARAMS], + validator.errors || [] + ), + }; + } + + return { ok: true, value: message }; + }; + + const isValidId = (id) => typeof id === 'string' || typeof id === 'number'; + const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + + const validateResponse = (message, method) => { + if (!isObject(message) || message.jsonrpc !== '2.0' || !isValidId(message.id)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_REQUEST, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_REQUEST], + [] + ), + }; + } + + const hasError = Object.prototype.hasOwnProperty.call(message, 'error'); + const hasResult = Object.prototype.hasOwnProperty.call(message, 'result'); + if ((hasError && hasResult) || (!hasError && !hasResult)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_REQUEST, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_REQUEST] + ), + }; + } + + if (hasError) { + if (!validateErrorResponse(message)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_REQUEST, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_REQUEST], + validateErrorResponse.errors || [] + ), + }; + } + if (!validateErrorObject(message.error)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_REQUEST, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_REQUEST], + validateErrorObject.errors || [] + ), + }; + } + return { ok: true, value: message }; + } + + const validator = responseValidators.get(method); + if (!validator) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.METHOD_NOT_FOUND, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.METHOD_NOT_FOUND] + ), + }; + } + + if (!validator(message)) { + return { + ok: false, + error: buildError( + RPC_ERROR_CODES.INVALID_PARAMS, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_PARAMS], + validator.errors || [] + ), + }; + } + + return { ok: true, value: message }; + }; + + return { + validateRequest, + validateNotification, + validateResponse, + }; +}; + +module.exports = { + createValidator, + RPC_ERROR_CODES, + PROTOCOL_VERSION, +}; diff --git a/src/tui-backend/server.ts b/src/tui-backend/server.ts new file mode 100644 index 00000000..b4424ad8 --- /dev/null +++ b/src/tui-backend/server.ts @@ -0,0 +1,504 @@ +const MOCK_LAUNCH_ENV = 'ZEROSHOT_TUI_BACKEND_MOCK_LAUNCH'; +const MOCK_GUIDANCE_ENV = 'ZEROSHOT_TUI_BACKEND_MOCK_GUIDANCE'; +const METRICS_PLATFORM_ENV = 'ZEROSHOT_TUI_BACKEND_METRICS_PLATFORM'; + +const path = require('path'); +const { + createValidator, + createDispatcher, + createFrameParser, + encodeFrame, + RPC_ERROR_CODES, + RPC_ERROR_MESSAGES, + PROTOCOL_VERSION, +} = require('./protocol'); +const { + listClusters, + getClusterSummary, + listClusterMetrics, + ClusterNotFoundError, +} = require('./services/cluster-registry'); +const { getClusterTopology } = require('./services/cluster-topology'); +const { + launchClusterFromText, + launchClusterFromIssue, + InvalidIssueReferenceError, +} = require('./services/cluster-launcher'); +const { createClusterLogStream, MAX_LOG_LINES } = require('./services/cluster-logs'); +const { createClusterTimelineStream, MAX_TIMELINE_EVENTS } = require('./services/cluster-timeline'); +const { sendAgentGuidance, sendClusterGuidance } = require('./services/guidance-delivery'); +const { createSubscriptionRegistry } = require('./subscriptions'); + +const isValidId = (value) => typeof value === 'string' || typeof value === 'number'; + +const isMockLaunchEnabled = () => process.env[MOCK_LAUNCH_ENV] === '1'; +const isMockGuidanceEnabled = () => process.env[MOCK_GUIDANCE_ENV] === '1'; + +const createMockLauncherDeps = () => ({ + getOrchestrator: async () => ({}), + loadSettings: () => ({ defaultConfig: 'conductor-bootstrap', providerSettings: {} }), + resolveConfigPath: () => 'mock-config', + loadClusterConfig: () => ({}), + startClusterFromText: async () => {}, + startClusterFromIssue: async () => {}, +}); + +const createMockGuidanceDeps = () => ({ + getOrchestrator: async () => ({ + sendGuidanceToAgent: async (clusterId, agentId) => ({ + status: 'injected', + reason: null, + method: 'pty', + taskId: `task-${agentId}`, + }), + sendGuidanceToCluster: async () => ({ + summary: { injected: 1, queued: 1, total: 2 }, + agents: { + 'mock-agent-1': { + status: 'injected', + reason: null, + method: 'pty', + taskId: 'task-mock-agent-1', + }, + 'mock-agent-2': { + status: 'queued', + reason: 'queued', + method: null, + taskId: 'task-mock-agent-2', + }, + }, + timestamp: 1700000000000, + }), + }), +}); + +const loadPackageInfo = () => { + try { + const packagePath = path.resolve(__dirname, '..', '..', 'package.json'); + const pkg = require(packagePath); + return { + name: typeof pkg.name === 'string' ? pkg.name : 'zeroshot', + version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', + }; + } catch (error) { + return { name: 'zeroshot', version: '0.0.0' }; + } +}; + +const writeFrame = (payload) => { + const framed = encodeFrame(payload); + process.stdout.write(framed); +}; + +const writeError = (id, error) => { + writeFrame({ + jsonrpc: '2.0', + id, + error, + }); +}; + +const buildRpcError = (code, message, detail) => + detail ? { code, message, data: { detail } } : { code, message }; + +const logDiagnostic = (message, error) => { + const details = error instanceof Error ? `${message}: ${error.stack || error.message}` : message; + process.stderr.write(`${details}\n`); +}; + +const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0; + +const resolveMetricsPlatformOverride = () => { + const value = process.env[METRICS_PLATFORM_ENV]; + return isNonEmptyString(value) ? value : null; +}; + +const isRpcError = (error) => + error && + typeof error === 'object' && + typeof error.code === 'number' && + typeof error.message === 'string'; + +const isGuidanceInvalidParamsError = (message) => + message.includes('is required') || + message.includes('non-empty string') || + message.includes('agent not found'); + +const isGuidanceClusterNotFoundError = (message) => message.includes('cluster not found'); + +const isTopologyClusterNotFoundError = (error) => + error instanceof Error && /cluster/i.test(error.message) && /not found/i.test(error.message); + +const validateGuidanceText = (text) => { + if (!isNonEmptyString(text)) { + throw buildRpcError( + RPC_ERROR_CODES.INVALID_PARAMS, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_PARAMS], + 'text must be a non-empty string' + ); + } +}; + +const validateGuidanceId = (value, label) => { + if (!isNonEmptyString(value)) { + throw buildRpcError( + RPC_ERROR_CODES.INVALID_PARAMS, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_PARAMS], + `${label} must be a non-empty string` + ); + } +}; + +const resolveGuidanceError = (error) => { + if (isRpcError(error)) { + return error; + } + const message = error instanceof Error ? error.message : 'Guidance delivery error'; + if (isGuidanceClusterNotFoundError(message)) { + return buildRpcError( + RPC_ERROR_CODES.CLUSTER_NOT_FOUND, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.CLUSTER_NOT_FOUND], + message + ); + } + if (isGuidanceInvalidParamsError(message)) { + return buildRpcError( + RPC_ERROR_CODES.INVALID_PARAMS, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_PARAMS], + message + ); + } + return buildRpcError( + RPC_ERROR_CODES.INTERNAL_ERROR, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INTERNAL_ERROR], + message + ); +}; + +const capPayload = (items, maxItems) => { + if (!Array.isArray(items)) { + return { items: [], droppedCount: 0 }; + } + if (items.length <= maxItems) { + return { items, droppedCount: 0 }; + } + const trimmed = items.slice(items.length - maxItems); + return { items: trimmed, droppedCount: items.length - trimmed.length }; +}; + +const startServer = () => { + const registry = createSubscriptionRegistry(); + const notifications = ['clusterLogLines', 'clusterTimelineEvents']; + let shuttingDown = false; + const validator = createValidator(); + const dispatcher = createDispatcher({ + serverInfo: loadPackageInfo(), + protocolVersion: PROTOCOL_VERSION, + notifications, + handlers: { + listClusters: async () => ({ + clusters: await listClusters(), + }), + getClusterSummary: async (params) => { + try { + const summary = await getClusterSummary({ + clusterId: params.clusterId, + }); + return { summary }; + } catch (error) { + if (error instanceof ClusterNotFoundError) { + throw buildRpcError( + RPC_ERROR_CODES.CLUSTER_NOT_FOUND, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.CLUSTER_NOT_FOUND], + error.message + ); + } + throw error; + } + }, + getClusterTopology: async (params) => { + try { + const topology = await getClusterTopology(params.clusterId); + return { topology }; + } catch (error) { + if (isTopologyClusterNotFoundError(error)) { + throw buildRpcError( + RPC_ERROR_CODES.CLUSTER_NOT_FOUND, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.CLUSTER_NOT_FOUND], + error instanceof Error ? error.message : 'Cluster not found' + ); + } + throw error; + } + }, + listClusterMetrics: async (params) => { + const clusterIds = Array.isArray(params?.clusterIds) ? params.clusterIds : undefined; + const platformOverride = resolveMetricsPlatformOverride(); + const metricsById = await listClusterMetrics({ + clusterIds, + deps: platformOverride ? { platform: platformOverride } : undefined, + }); + const metrics = Array.isArray(clusterIds) + ? clusterIds.map((id) => metricsById[id]).filter(Boolean) + : Object.values(metricsById); + return { metrics }; + }, + startClusterFromText: async (params) => { + try { + const result = await launchClusterFromText({ + text: params.text, + providerOverride: params.providerOverride ?? null, + clusterId: params.clusterId, + deps: isMockLaunchEnabled() ? createMockLauncherDeps() : undefined, + }); + return result; + } catch (error) { + throw buildRpcError( + RPC_ERROR_CODES.INTERNAL_ERROR, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INTERNAL_ERROR], + error instanceof Error ? error.message : 'Launcher error' + ); + } + }, + startClusterFromIssue: async (params) => { + try { + const result = await launchClusterFromIssue({ + ref: params.ref, + providerOverride: params.providerOverride ?? null, + clusterId: params.clusterId, + deps: isMockLaunchEnabled() ? createMockLauncherDeps() : undefined, + }); + return result; + } catch (error) { + if (error instanceof InvalidIssueReferenceError) { + throw buildRpcError( + RPC_ERROR_CODES.INVALID_PARAMS, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_PARAMS], + error.message + ); + } + throw buildRpcError( + RPC_ERROR_CODES.INTERNAL_ERROR, + RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INTERNAL_ERROR], + error instanceof Error ? error.message : 'Launcher error' + ); + } + }, + sendGuidanceToAgent: async (params) => { + try { + validateGuidanceId(params?.clusterId, 'clusterId'); + validateGuidanceId(params?.agentId, 'agentId'); + validateGuidanceText(params?.text); + const result = await sendAgentGuidance({ + clusterId: params.clusterId, + agentId: params.agentId, + text: params.text, + timeoutMs: params.timeoutMs, + deps: isMockGuidanceEnabled() ? createMockGuidanceDeps() : undefined, + }); + return { result }; + } catch (error) { + throw resolveGuidanceError(error); + } + }, + sendGuidanceToCluster: async (params) => { + try { + validateGuidanceId(params?.clusterId, 'clusterId'); + validateGuidanceText(params?.text); + const result = await sendClusterGuidance({ + clusterId: params.clusterId, + text: params.text, + timeoutMs: params.timeoutMs, + deps: isMockGuidanceEnabled() ? createMockGuidanceDeps() : undefined, + }); + return { result }; + } catch (error) { + throw resolveGuidanceError(error); + } + }, + subscribeClusterLogs: async (params) => { + const clusterId = params.clusterId; + const agentId = params.agentId ?? null; + let subscriptionId = ''; + const stream = createClusterLogStream({ + clusterId, + agentId, + maxInitialLines: MAX_LOG_LINES * 5, + onLines: (lines) => { + if (!subscriptionId) return; + const { items, droppedCount } = capPayload(lines, MAX_LOG_LINES); + if (!items.length) return; + const payload = + droppedCount > 0 + ? { + subscriptionId, + clusterId, + lines: items, + droppedCount, + } + : { + subscriptionId, + clusterId, + lines: items, + }; + writeFrame({ + jsonrpc: '2.0', + method: 'clusterLogLines', + params: payload, + }); + }, + }); + subscriptionId = registry.add('clusterLogs', () => stream.close()); + stream.start(); + return { subscriptionId }; + }, + subscribeClusterTimeline: async (params) => { + const clusterId = params.clusterId; + let subscriptionId = ''; + const stream = createClusterTimelineStream({ + clusterId, + maxInitialEvents: MAX_TIMELINE_EVENTS * 5, + onEvents: (events) => { + if (!subscriptionId) return; + const { items, droppedCount } = capPayload(events, MAX_TIMELINE_EVENTS); + if (!items.length) return; + const payload = + droppedCount > 0 + ? { + subscriptionId, + clusterId, + events: items, + droppedCount, + } + : { + subscriptionId, + clusterId, + events: items, + }; + writeFrame({ + jsonrpc: '2.0', + method: 'clusterTimelineEvents', + params: payload, + }); + }, + }); + subscriptionId = registry.add('clusterTimeline', () => stream.close()); + stream.start(); + return { subscriptionId }; + }, + unsubscribe: async (params) => registry.unsubscribe(params.subscriptionId), + }, + }); + const parser = createFrameParser(); + + const shutdown = (code) => { + if (shuttingDown) return; + shuttingDown = true; + registry.closeAll(); + process.exit(code); + }; + + const handleFrame = async (payload) => { + let message; + try { + message = JSON.parse(payload); + } catch (error) { + writeError(null, { + code: RPC_ERROR_CODES.PARSE_ERROR, + message: RPC_ERROR_MESSAGES[RPC_ERROR_CODES.PARSE_ERROR], + }); + logDiagnostic('Invalid JSON payload', error); + return; + } + + if (!message || typeof message !== 'object') { + writeError(null, { + code: RPC_ERROR_CODES.INVALID_REQUEST, + message: RPC_ERROR_MESSAGES[RPC_ERROR_CODES.INVALID_REQUEST], + }); + return; + } + + const hasId = Object.prototype.hasOwnProperty.call(message, 'id'); + if (!hasId) { + const notification = validator.validateNotification(message); + if (!notification.ok) { + logDiagnostic('Invalid notification received', notification.error); + } + return; + } + + const requestValidation = validator.validateRequest(message); + if (!requestValidation.ok) { + const responseId = isValidId(message.id) ? message.id : null; + writeError(responseId, requestValidation.error); + return; + } + + const dispatchResult = await dispatcher.dispatchRequest(requestValidation.value); + if (!dispatchResult.ok) { + writeError(message.id, dispatchResult.error); + return; + } + + writeFrame({ + jsonrpc: '2.0', + id: message.id, + result: dispatchResult.result, + }); + }; + + const handleChunk = (chunk) => { + let frames = []; + try { + frames = parser.push(chunk); + } catch (error) { + parser.reset(); + writeError(null, { + code: RPC_ERROR_CODES.PARSE_ERROR, + message: RPC_ERROR_MESSAGES[RPC_ERROR_CODES.PARSE_ERROR], + data: { detail: error instanceof Error ? error.message : 'Parse error' }, + }); + logDiagnostic('Frame parsing failed', error); + return; + } + + for (const frame of frames) { + void handleFrame(frame); + } + }; + + process.stdin.on('data', handleChunk); + process.stdin.on('end', () => { + shutdown(0); + }); + process.stdin.on('error', (error) => { + logDiagnostic('Stdin error', error); + shutdown(1); + }); + + process.on('uncaughtException', (error) => { + logDiagnostic('Uncaught exception', error); + shutdown(1); + }); + process.on('unhandledRejection', (error) => { + logDiagnostic('Unhandled rejection', error); + shutdown(1); + }); + process.on('exit', () => { + if (!shuttingDown) { + shuttingDown = true; + registry.closeAll(); + } + }); + + process.stdin.resume(); +}; + +if (require.main === module) { + startServer(); +} + +module.exports = { + startServer, +}; diff --git a/src/tui-backend/services/cluster-launcher.ts b/src/tui-backend/services/cluster-launcher.ts new file mode 100644 index 00000000..217e123a --- /dev/null +++ b/src/tui-backend/services/cluster-launcher.ts @@ -0,0 +1,127 @@ +import { loadSettings } from '../../../lib/settings'; +import { + detectRunInput, + loadClusterConfig, + resolveConfigPath, + startClusterFromIssue, + startClusterFromText, +} from '../../../lib/start-cluster'; + +const { generateName } = require('../../../src/name-generator'); + +type ClusterLauncherDeps = { + getOrchestrator?: () => Promise; + loadSettings?: typeof loadSettings; + resolveConfigPath?: typeof resolveConfigPath; + loadClusterConfig?: typeof loadClusterConfig; + startClusterFromText?: typeof startClusterFromText; + startClusterFromIssue?: typeof startClusterFromIssue; + detectRunInput?: typeof detectRunInput; + generateClusterId?: () => string; +}; + +let orchestratorPromise: Promise | null = null; + +export class InvalidIssueReferenceError extends Error { + constructor(ref: string) { + super(`Invalid issue reference: ${ref}`); + this.name = 'InvalidIssueReferenceError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +async function getOrchestrator() { + if (!orchestratorPromise) { + const Orchestrator = require('../../../src/orchestrator'); + orchestratorPromise = Orchestrator.create({ quiet: true }); + } + return orchestratorPromise; +} + +export function generateClusterId(): string { + return generateName('cluster'); +} + +type LaunchClusterFromTextArgs = { + text: string; + providerOverride?: string | null; + clusterId?: string; + deps?: ClusterLauncherDeps; +}; + +export async function launchClusterFromText({ + text, + providerOverride = null, + clusterId, + deps = {}, +}: LaunchClusterFromTextArgs): Promise<{ clusterId: string }> { + const getOrchestratorImpl = deps.getOrchestrator ?? getOrchestrator; + const loadSettingsImpl = deps.loadSettings ?? loadSettings; + const resolveConfigPathImpl = deps.resolveConfigPath ?? resolveConfigPath; + const loadClusterConfigImpl = deps.loadClusterConfig ?? loadClusterConfig; + const startClusterFromTextImpl = deps.startClusterFromText ?? startClusterFromText; + const generateClusterIdImpl = deps.generateClusterId ?? generateClusterId; + + const orchestrator = await getOrchestratorImpl(); + const settings = loadSettingsImpl(); + const configName = settings.defaultConfig || 'conductor-bootstrap'; + const configPath = resolveConfigPathImpl(configName); + const config = loadClusterConfigImpl(orchestrator, configPath, settings, providerOverride); + const resolvedClusterId = clusterId || generateClusterIdImpl(); + + await startClusterFromTextImpl({ + orchestrator, + text, + config, + settings, + providerOverride, + clusterId: resolvedClusterId, + }); + + return { clusterId: resolvedClusterId }; +} + +type LaunchClusterFromIssueArgs = { + ref: string; + providerOverride?: string | null; + clusterId?: string; + deps?: ClusterLauncherDeps; +}; + +export async function launchClusterFromIssue({ + ref, + providerOverride = null, + clusterId, + deps = {}, +}: LaunchClusterFromIssueArgs): Promise<{ clusterId: string }> { + const getOrchestratorImpl = deps.getOrchestrator ?? getOrchestrator; + const loadSettingsImpl = deps.loadSettings ?? loadSettings; + const resolveConfigPathImpl = deps.resolveConfigPath ?? resolveConfigPath; + const loadClusterConfigImpl = deps.loadClusterConfig ?? loadClusterConfig; + const startClusterFromIssueImpl = deps.startClusterFromIssue ?? startClusterFromIssue; + const detectRunInputImpl = deps.detectRunInput ?? detectRunInput; + const generateClusterIdImpl = deps.generateClusterId ?? generateClusterId; + + const parsed = detectRunInputImpl(ref); + if (!parsed || typeof parsed !== 'object' || !('issue' in parsed)) { + throw new InvalidIssueReferenceError(ref); + } + + const orchestrator = await getOrchestratorImpl(); + const settings = loadSettingsImpl(); + const configName = settings.defaultConfig || 'conductor-bootstrap'; + const configPath = resolveConfigPathImpl(configName); + const config = loadClusterConfigImpl(orchestrator, configPath, settings, providerOverride); + const resolvedClusterId = clusterId || generateClusterIdImpl(); + + await startClusterFromIssueImpl({ + orchestrator, + issue: parsed.issue, + config, + settings, + providerOverride, + clusterId: resolvedClusterId, + }); + + return { clusterId: resolvedClusterId }; +} diff --git a/src/tui-backend/services/cluster-logs.ts b/src/tui-backend/services/cluster-logs.ts new file mode 100644 index 00000000..7143ee70 --- /dev/null +++ b/src/tui-backend/services/cluster-logs.ts @@ -0,0 +1,299 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const Ledger = require('../../../src/ledger'); + +export const MAX_LOG_LINES = 400; +export const LOG_POLL_INTERVAL_MS = 250; + +type ClusterLogState = 'idle' | 'waiting' | 'ready' | 'error'; + +export type ClusterLogStatus = { + state: ClusterLogState; + message?: string; +}; + +export type ClusterLogLine = { + id: string; + timestamp: number; + text: string; + agent: string | null; + role: string | null; + sender: string | null; +}; + +type ClusterLogStreamOptions = { + clusterId?: string | null; + agentId?: string | null; + onLines: (lines: ClusterLogLine[]) => void; + onStatus?: (status: ClusterLogStatus) => void; + pollIntervalMs?: number; + maxInitialLines?: number; +}; + +export function resolveClusterDbPath(clusterId: string): string { + const envHome = + (typeof process.env.HOME === 'string' && process.env.HOME.trim()) || + (typeof process.env.USERPROFILE === 'string' && process.env.USERPROFILE.trim()) || + (typeof process.env.HOMEDRIVE === 'string' && + typeof process.env.HOMEPATH === 'string' && + `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`.trim()) || + ''; + const homeDir = envHome || os.homedir(); + const storageDir = path.join(homeDir, '.zeroshot'); + const clustersFile = path.join(storageDir, 'clusters.json'); + + try { + if (fs.existsSync(clustersFile)) { + const raw = fs.readFileSync(clustersFile, 'utf8'); + try { + const data = JSON.parse(raw); + const entry = data && typeof data === 'object' ? data[clusterId] : null; + const dbPath = entry?.config?.dbPath; + + if (typeof dbPath === 'string' && dbPath.trim()) { + return dbPath; + } + } catch { + // clusters.json can be mid-write; fall back to default path + } + } + } catch { + // Ignore fs errors; fall back to default path + } + + return path.join(storageDir, `${clusterId}.db`); +} + +export function normalizeAgentOutput(message: any): ClusterLogLine | null { + if (!message || typeof message !== 'object') { + return null; + } + + const content = message.content || {}; + const data = content.data || {}; + const contentText = typeof content.text === 'string' ? content.text : ''; + const dataLine = typeof data.line === 'string' ? data.line : ''; + const text = contentText.trim() ? contentText : dataLine; + + if (!text || !text.trim()) { + return null; + } + + const timestamp = typeof message.timestamp === 'number' ? message.timestamp : Date.now(); + const sender = typeof message.sender === 'string' ? message.sender : null; + const agent = typeof data.agent === 'string' ? data.agent : sender; + const role = typeof data.role === 'string' ? data.role : null; + const id = + typeof message.id === 'string' + ? message.id + : `${timestamp}-${Math.random().toString(16).slice(2)}`; + + return { + id, + timestamp, + text, + agent, + role, + sender, + }; +} + +export function createClusterLogStream({ + clusterId, + agentId, + onLines, + onStatus, + pollIntervalMs = LOG_POLL_INTERVAL_MS, + maxInitialLines = MAX_LOG_LINES, +}: ClusterLogStreamOptions) { + let intervalId: NodeJS.Timeout | null = null; + let ledger: any | null = null; + let lastTimestamp = 0; + let initialized = false; + let closed = false; + let lastStatus: ClusterLogStatus | null = null; + const normalizedAgentId = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : null; + + const filterLines = (lines: ClusterLogLine[]) => { + if (!normalizedAgentId) { + return lines; + } + return lines.filter( + (line) => line.agent === normalizedAgentId || line.sender === normalizedAgentId + ); + }; + + const emitStatus = (status: ClusterLogStatus) => { + if (!onStatus) return; + if (lastStatus && lastStatus.state === status.state && lastStatus.message === status.message) { + return; + } + lastStatus = status; + onStatus(status); + }; + + const emitError = (err: unknown, context: string) => { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'; + emitStatus({ + state: 'error', + message: context ? `${context}: ${message}` : message, + }); + }; + + const resetLedger = (err: unknown, context: string) => { + emitError(err, context); + if (ledger) { + try { + ledger.close(); + } catch { + // ignore close errors + } + ledger = null; + } + }; + + const ensureLedger = () => { + if (!clusterId) { + emitStatus({ state: 'idle' }); + return false; + } + + if (ledger) { + return true; + } + + const dbPath = resolveClusterDbPath(clusterId); + if (!fs.existsSync(dbPath)) { + emitStatus({ state: 'waiting' }); + return false; + } + + try { + ledger = new Ledger(dbPath); + if (!initialized) { + lastTimestamp = 0; + } + emitStatus({ state: 'ready' }); + return true; + } catch (err) { + resetLedger(err, 'Failed to open log database'); + return false; + } + }; + + const loadInitial = () => { + if (!ledger || initialized || !clusterId) { + return; + } + + let rows: any[] = []; + try { + rows = ledger.query({ + cluster_id: clusterId, + topic: 'AGENT_OUTPUT', + order: 'desc', + limit: maxInitialLines, + }); + } catch (err) { + resetLedger(err, 'Failed to read initial logs'); + return; + } + + const messages = rows.slice().reverse(); + const lines = filterLines( + messages + .map((message: any) => normalizeAgentOutput(message)) + .filter(Boolean) as ClusterLogLine[] + ); + + if (lines.length > 0) { + onLines(lines); + } + + if (messages.length > 0) { + const last = messages[messages.length - 1]; + if (last && typeof last.timestamp === 'number') { + lastTimestamp = Math.max(lastTimestamp, last.timestamp); + } + } + + initialized = true; + }; + + const poll = () => { + if (closed) { + return; + } + + if (!clusterId) { + emitStatus({ state: 'idle' }); + return; + } + + if (!ensureLedger()) { + return; + } + + loadInitial(); + + let rows: any[] = []; + try { + rows = ledger.query({ + cluster_id: clusterId, + topic: 'AGENT_OUTPUT', + since: lastTimestamp + 1, + order: 'asc', + }); + } catch (err) { + resetLedger(err, 'Failed to read logs'); + return; + } + + if (!rows.length) { + return; + } + + const lines = filterLines( + rows.map((message: any) => normalizeAgentOutput(message)).filter(Boolean) as ClusterLogLine[] + ); + + if (lines.length > 0) { + onLines(lines); + } + + const last = rows[rows.length - 1]; + if (last && typeof last.timestamp === 'number') { + lastTimestamp = Math.max(lastTimestamp, last.timestamp); + } + }; + + const start = () => { + if (intervalId) { + return; + } + poll(); + intervalId = setInterval(poll, pollIntervalMs); + }; + + const stop = () => { + if (!intervalId) { + return; + } + clearInterval(intervalId); + intervalId = null; + }; + + const close = () => { + closed = true; + stop(); + if (ledger) { + ledger.close(); + ledger = null; + } + }; + + return { start, stop, close }; +} diff --git a/src/tui-backend/services/cluster-registry.ts b/src/tui-backend/services/cluster-registry.ts new file mode 100644 index 00000000..b8df1a6c --- /dev/null +++ b/src/tui-backend/services/cluster-registry.ts @@ -0,0 +1,295 @@ +import { loadSettings } from '../../../lib/settings'; +import { normalizeProviderName } from '../../../lib/provider-names'; + +const pidusage = require('pidusage'); + +type PidusageStats = Record; +type PidusageFn = (pids: number[]) => Promise; + +type ClusterRegistryDeps = { + getOrchestrator?: () => Promise; + pidusage?: PidusageFn; + platform?: string; + loadSettings?: typeof loadSettings; +}; + +export type ClusterSummary = { + id: string; + state: string; + provider: string | null; + createdAt: number; + agentCount: number; + messageCount: number; + cwd: string | null; +}; + +export type ClusterMetrics = { + id: string; + supported: boolean; + cpuPercent: number | null; + memoryMB: number | null; +}; + +let orchestratorPromise: Promise | null = null; + +async function getOrchestrator() { + if (!orchestratorPromise) { + const Orchestrator = require('../../../src/orchestrator'); + orchestratorPromise = Orchestrator.create({ quiet: true }); + } + return orchestratorPromise; +} + +function resolveClusterCwd(cluster: any): string | null { + if (!cluster || typeof cluster !== 'object') { + return null; + } + if (cluster.worktree?.path) { + return cluster.worktree.path; + } + if (cluster.isolation?.workDir) { + return cluster.isolation.workDir; + } + return null; +} + +function resolveClusterProvider(cluster: any, settings: any): string | null { + if (!cluster || typeof cluster !== 'object') { + const fallback = settings?.defaultProvider ?? null; + const normalizedFallback = normalizeProviderName(fallback); + return typeof normalizedFallback === 'string' ? normalizedFallback : null; + } + const forced = cluster.config?.forceProvider ?? null; + const defaultProvider = cluster.config?.defaultProvider ?? null; + const settingsProvider = settings?.defaultProvider ?? null; + const provider = + forced && typeof forced === 'string' + ? forced + : defaultProvider && typeof defaultProvider === 'string' + ? defaultProvider + : settingsProvider && typeof settingsProvider === 'string' + ? settingsProvider + : null; + const normalized = normalizeProviderName(provider); + return typeof normalized === 'string' ? normalized : null; +} + +function resolveAgentPid(agent: any): number | null { + if (!agent || typeof agent !== 'object') { + return null; + } + const pid = agent.processPid ?? agent.pid ?? null; + if (Number.isFinite(pid) && pid > 0) { + return pid; + } + if (typeof agent.getState === 'function') { + const state = agent.getState(); + const statePid = state?.pid ?? null; + if (Number.isFinite(statePid) && statePid > 0) { + return statePid; + } + } + return null; +} + +function collectAgentPids(cluster: any): number[] { + if (!cluster || typeof cluster !== 'object') { + return []; + } + const agents = Array.isArray(cluster.agents) ? cluster.agents : []; + const pids = new Set(); + for (const agent of agents) { + const pid = resolveAgentPid(agent); + if (pid) { + pids.add(pid); + } + } + return Array.from(pids); +} + +function normalizeSummary(summary: any, orchestrator: any, settings: any): ClusterSummary { + if (!summary || typeof summary !== 'object') { + throw new Error('Invalid cluster summary.'); + } + if (typeof summary.id !== 'string' || summary.id.length === 0) { + throw new Error('Invalid cluster id.'); + } + if (!Number.isFinite(summary.createdAt)) { + throw new Error(`Invalid createdAt for cluster ${summary.id}.`); + } + if (!Number.isFinite(summary.agentCount)) { + throw new Error(`Invalid agentCount for cluster ${summary.id}.`); + } + if (!Number.isFinite(summary.messageCount)) { + throw new Error(`Invalid messageCount for cluster ${summary.id}.`); + } + const cluster = orchestrator.getCluster(summary.id); + const cwd = resolveClusterCwd(cluster); + const provider = resolveClusterProvider(cluster, settings); + return { + id: summary.id, + state: String(summary.state ?? 'unknown'), + provider, + createdAt: summary.createdAt, + agentCount: summary.agentCount, + messageCount: summary.messageCount, + cwd, + }; +} + +export class ClusterNotFoundError extends Error { + clusterId: string; + + constructor(clusterId: string) { + super(`Cluster not found: ${clusterId}`); + this.name = 'ClusterNotFoundError'; + this.clusterId = clusterId; + } +} + +type ListClustersArgs = { + deps?: ClusterRegistryDeps; +}; + +type ListClusterMetricsArgs = { + clusterIds?: string[]; + deps?: ClusterRegistryDeps; +}; + +type GetClusterSummaryArgs = { + clusterId: string; + deps?: ClusterRegistryDeps; +}; + +const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux']); +const BYTES_PER_MB = 1024 * 1024; + +export async function listClusters({ deps = {} }: ListClustersArgs = {}): Promise< + ClusterSummary[] +> { + const getOrchestratorImpl = deps.getOrchestrator ?? getOrchestrator; + const loadSettingsImpl = deps.loadSettings ?? loadSettings; + const orchestrator = await getOrchestratorImpl(); + const settings = loadSettingsImpl(); + const summaries = orchestrator.listClusters(); + const results = summaries.map((summary: any) => + normalizeSummary(summary, orchestrator, settings) + ); + results.sort((left, right) => { + if (left.createdAt !== right.createdAt) { + return left.createdAt - right.createdAt; + } + return left.id.localeCompare(right.id); + }); + return results; +} + +export async function getClusterSummary({ + clusterId, + deps = {}, +}: GetClusterSummaryArgs): Promise { + const getOrchestratorImpl = deps.getOrchestrator ?? getOrchestrator; + const loadSettingsImpl = deps.loadSettings ?? loadSettings; + const orchestrator = await getOrchestratorImpl(); + const settings = loadSettingsImpl(); + const summaries = orchestrator.listClusters(); + const summary = summaries.find((entry: any) => entry.id === clusterId); + if (!summary) { + throw new ClusterNotFoundError(clusterId); + } + return normalizeSummary(summary, orchestrator, settings); +} + +export async function listClusterMetrics({ + clusterIds, + deps = {}, +}: ListClusterMetricsArgs = {}): Promise> { + const getOrchestratorImpl = deps.getOrchestrator ?? getOrchestrator; + const pidusageImpl = deps.pidusage ?? pidusage; + const platform = deps.platform ?? process.platform; + const orchestrator = await getOrchestratorImpl(); + const summaries = orchestrator.listClusters(); + const availableIds = summaries.map((summary: any) => summary.id); + const requestedIds = Array.isArray(clusterIds) + ? clusterIds.filter((id) => typeof id === 'string') + : null; + let resolvedIds = availableIds; + if (requestedIds) { + if (requestedIds.length === 0) { + return {}; + } + const availableSet = new Set(availableIds); + resolvedIds = requestedIds.filter((id) => availableSet.has(id)); + } + + if (resolvedIds.length === 0) { + return {}; + } + + if (!SUPPORTED_PLATFORMS.has(platform)) { + return Object.fromEntries( + resolvedIds.map((id) => [ + id, + { + id, + supported: false, + cpuPercent: null, + memoryMB: null, + }, + ]) + ); + } + + const pidsByCluster = new Map(); + const allPids = new Set(); + for (const clusterId of resolvedIds) { + const cluster = orchestrator.getCluster(clusterId); + const pids = collectAgentPids(cluster); + pidsByCluster.set(clusterId, pids); + for (const pid of pids) { + allPids.add(pid); + } + } + + let statsByPid: PidusageStats = {}; + if (allPids.size > 0) { + try { + statsByPid = await pidusageImpl(Array.from(allPids)); + } catch { + statsByPid = {}; + } + } + + const results: Record = {}; + for (const clusterId of resolvedIds) { + const pids = pidsByCluster.get(clusterId) ?? []; + let cpuTotal = 0; + let memoryTotalBytes = 0; + let hasCpu = false; + let hasMemory = false; + for (const pid of pids) { + const stats = statsByPid[String(pid)] ?? statsByPid[pid as any]; + if (!stats) { + continue; + } + const cpu = Number(stats.cpu); + const memory = Number(stats.memory); + if (Number.isFinite(cpu)) { + cpuTotal += cpu; + hasCpu = true; + } + if (Number.isFinite(memory)) { + memoryTotalBytes += memory; + hasMemory = true; + } + } + results[clusterId] = { + id: clusterId, + supported: true, + cpuPercent: hasCpu ? cpuTotal : null, + memoryMB: hasMemory ? memoryTotalBytes / BYTES_PER_MB : null, + }; + } + + return results; +} diff --git a/src/tui-backend/services/cluster-timeline.ts b/src/tui-backend/services/cluster-timeline.ts new file mode 100644 index 00000000..6b3a8659 --- /dev/null +++ b/src/tui-backend/services/cluster-timeline.ts @@ -0,0 +1,298 @@ +import fs from 'fs'; + +const Ledger = require('../../../src/ledger'); + +import { resolveClusterDbPath } from './cluster-logs'; + +export const MAX_TIMELINE_EVENTS = 40; +export const TIMELINE_POLL_INTERVAL_MS = 750; + +export const WORKFLOW_TRIGGERS = Object.freeze([ + 'ISSUE_OPENED', + 'PLAN_READY', + 'IMPLEMENTATION_READY', + 'VALIDATION_RESULT', + 'CONDUCTOR_ESCALATE', +]); + +type ClusterTimelineState = 'idle' | 'waiting' | 'ready' | 'error'; + +export type ClusterTimelineStatus = { + state: ClusterTimelineState; + message?: string; +}; + +export type TimelineEvent = { + id: string; + timestamp: number; + topic: string; + label: string; + approved: boolean | null; + sender: string | null; +}; + +type ClusterTimelineStreamOptions = { + clusterId?: string | null; + onEvents: (events: TimelineEvent[]) => void; + onStatus?: (status: ClusterTimelineStatus) => void; + pollIntervalMs?: number; + maxInitialEvents?: number; +}; + +function isWorkflowTopic(topic: string): boolean { + return WORKFLOW_TRIGGERS.includes(topic); +} + +function normalizeApproved(value: unknown): boolean | null { + if (value === true || value === 'true') { + return true; + } + if (value === false || value === 'false') { + return false; + } + return null; +} + +function labelForMessage(message: any, approved: boolean | null): string { + switch (message.topic) { + case 'ISSUE_OPENED': + return 'Issue opened'; + case 'PLAN_READY': + return 'Plan ready'; + case 'IMPLEMENTATION_READY': + return 'Implementation ready'; + case 'VALIDATION_RESULT': + if (approved === true) { + return 'Validation approved'; + } + if (approved === false) { + return 'Validation rejected'; + } + return 'Validation result'; + case 'CONDUCTOR_ESCALATE': + return 'Conductor escalated'; + default: + return message.topic || 'Workflow event'; + } +} + +export function normalizeTimelineMessage(message: any): TimelineEvent | null { + if (!message || typeof message !== 'object') { + return null; + } + + const topic = typeof message.topic === 'string' ? message.topic : ''; + if (!topic || !isWorkflowTopic(topic)) { + return null; + } + + const data = message.content?.data || {}; + const approved = normalizeApproved(data.approved); + const timestamp = typeof message.timestamp === 'number' ? message.timestamp : Date.now(); + const id = + typeof message.id === 'string' + ? message.id + : `${timestamp}-${Math.random().toString(16).slice(2)}`; + + return { + id, + timestamp, + topic, + label: labelForMessage(message, approved), + approved, + sender: typeof message.sender === 'string' ? message.sender : null, + }; +} + +export function createClusterTimelineStream({ + clusterId, + onEvents, + onStatus, + pollIntervalMs = TIMELINE_POLL_INTERVAL_MS, + maxInitialEvents = MAX_TIMELINE_EVENTS, +}: ClusterTimelineStreamOptions) { + let intervalId: NodeJS.Timeout | null = null; + let ledger: any | null = null; + let lastTimestamp = 0; + let initialized = false; + let closed = false; + let lastStatus: ClusterTimelineStatus | null = null; + + const emitStatus = (status: ClusterTimelineStatus) => { + if (!onStatus) return; + if (lastStatus && lastStatus.state === status.state && lastStatus.message === status.message) { + return; + } + lastStatus = status; + onStatus(status); + }; + + const emitError = (err: unknown, context: string) => { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'; + emitStatus({ + state: 'error', + message: context ? `${context}: ${message}` : message, + }); + }; + + const resetLedger = (err: unknown, context: string) => { + emitError(err, context); + if (ledger) { + try { + ledger.close(); + } catch { + // ignore close errors + } + ledger = null; + } + }; + + const ensureLedger = () => { + if (!clusterId) { + emitStatus({ state: 'idle' }); + return false; + } + + if (ledger) { + return true; + } + + const dbPath = resolveClusterDbPath(clusterId); + if (!fs.existsSync(dbPath)) { + emitStatus({ state: 'waiting' }); + return false; + } + + try { + ledger = new Ledger(dbPath); + if (!initialized) { + lastTimestamp = 0; + } + emitStatus({ state: 'ready' }); + return true; + } catch (err) { + resetLedger(err, 'Failed to open timeline database'); + return false; + } + }; + + const queryWorkflowMessages = (since?: number): any[] => { + if (!ledger || !clusterId) { + return []; + } + const messages: any[] = []; + const hasSince = typeof since === 'number' && Number.isFinite(since); + for (const topic of WORKFLOW_TRIGGERS) { + const criteria: any = { + cluster_id: clusterId, + topic, + order: 'asc', + }; + if (hasSince && since! > 0) { + criteria.since = since; + } + try { + const rows = ledger.query(criteria); + if (rows.length) { + messages.push(...rows); + } + } catch (err) { + resetLedger(err, 'Failed to read timeline entries'); + return []; + } + } + messages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); + return messages; + }; + + const loadInitial = () => { + if (!ledger || initialized || !clusterId) { + return; + } + + const messages = queryWorkflowMessages(); + if (messages.length) { + const events = messages + .map((message) => normalizeTimelineMessage(message)) + .filter(Boolean) as TimelineEvent[]; + if (events.length) { + const trimmed = + events.length > maxInitialEvents + ? events.slice(events.length - maxInitialEvents) + : events; + onEvents(trimmed); + } + + const last = messages[messages.length - 1]; + if (last && typeof last.timestamp === 'number') { + lastTimestamp = Math.max(lastTimestamp, last.timestamp); + } + } + + initialized = true; + }; + + const poll = () => { + if (closed) { + return; + } + + if (!clusterId) { + emitStatus({ state: 'idle' }); + return; + } + + if (!ensureLedger()) { + return; + } + + loadInitial(); + + const since = lastTimestamp > 0 ? lastTimestamp + 1 : 1; + const messages = queryWorkflowMessages(since); + if (!messages.length) { + return; + } + + const events = messages + .map((message) => normalizeTimelineMessage(message)) + .filter(Boolean) as TimelineEvent[]; + + if (events.length) { + onEvents(events); + } + + const last = messages[messages.length - 1]; + if (last && typeof last.timestamp === 'number') { + lastTimestamp = Math.max(lastTimestamp, last.timestamp); + } + }; + + const start = () => { + if (intervalId) { + return; + } + poll(); + intervalId = setInterval(poll, pollIntervalMs); + }; + + const stop = () => { + if (!intervalId) { + return; + } + clearInterval(intervalId); + intervalId = null; + }; + + const close = () => { + closed = true; + stop(); + if (ledger) { + ledger.close(); + ledger = null; + } + }; + + return { start, stop, close }; +} diff --git a/src/tui-backend/services/cluster-topology.ts b/src/tui-backend/services/cluster-topology.ts new file mode 100644 index 00000000..46035f0d --- /dev/null +++ b/src/tui-backend/services/cluster-topology.ts @@ -0,0 +1,145 @@ +type ClusterTopologyDeps = { + getOrchestrator?: () => Promise; +}; + +export type TopologyAgent = { + id: string; + role: string | null; +}; + +export type TopologyEdge = { + from: string; + to: string; + topic: string; + kind: 'trigger' | 'publish' | 'source'; + dynamic?: boolean; +}; + +export type ClusterTopology = { + agents: TopologyAgent[]; + edges: TopologyEdge[]; + topics: string[]; +}; + +let orchestratorPromise: Promise | null = null; + +async function getOrchestrator() { + if (!orchestratorPromise) { + const Orchestrator = require('../../../src/orchestrator'); + orchestratorPromise = Orchestrator.create({ quiet: true }); + } + return orchestratorPromise; +} + +function normalizeTopic(value: any): string | null { + if (typeof value !== 'string') { + return null; + } + const topic = value.trim(); + return topic ? topic : null; +} + +function extractTopicsFromScript(script: any): string[] { + if (typeof script !== 'string') { + return []; + } + const topics = new Set(); + const regex = /topic\s*:\s*['"`]([A-Za-z0-9_:-]+)['"`]/g; + let match: RegExpExecArray | null = null; + while ((match = regex.exec(script)) !== null) { + if (match[1]) { + topics.add(match[1]); + } + } + return Array.from(topics); +} + +export function buildTopologyModel(config: any): ClusterTopology { + const agents: TopologyAgent[] = []; + const edges: TopologyEdge[] = []; + const topics = new Set(); + const edgeKeys = new Set(); + + const addEdge = ( + from: string, + to: string, + topic: string, + kind: TopologyEdge['kind'], + dynamic?: boolean + ) => { + if (!from || !to || !topic) { + return; + } + const key = `${from}::${to}`; + if (edgeKeys.has(key)) { + return; + } + edgeKeys.add(key); + edges.push({ from, to, topic, kind, dynamic }); + }; + + topics.add('ISSUE_OPENED'); + addEdge('system', 'ISSUE_OPENED', 'ISSUE_OPENED', 'source'); + + const agentConfigs = Array.isArray(config?.agents) ? config.agents : []; + for (const agent of agentConfigs) { + const id = typeof agent?.id === 'string' ? agent.id : null; + if (!id) { + continue; + } + agents.push({ + id, + role: typeof agent.role === 'string' ? agent.role : null, + }); + + const triggers = Array.isArray(agent.triggers) ? agent.triggers : []; + for (const trigger of triggers) { + const topic = normalizeTopic(trigger?.topic); + if (!topic) { + continue; + } + topics.add(topic); + addEdge(topic, id, topic, 'trigger'); + } + + const outputTopic = normalizeTopic(agent?.hooks?.onComplete?.config?.topic); + if (outputTopic) { + topics.add(outputTopic); + addEdge(id, outputTopic, outputTopic, 'publish'); + } + + const hookLogicScript = agent?.hooks?.onComplete?.logic?.script; + for (const topic of extractTopicsFromScript(hookLogicScript)) { + topics.add(topic); + addEdge(id, topic, topic, 'publish', true); + } + + const hookTransformScript = agent?.hooks?.onComplete?.transform?.script; + for (const topic of extractTopicsFromScript(hookTransformScript)) { + topics.add(topic); + addEdge(id, topic, topic, 'publish', true); + } + } + + return { + agents, + edges, + topics: Array.from(topics), + }; +} + +export async function getClusterTopology( + clusterId: string | null | undefined, + { deps = {} }: { deps?: ClusterTopologyDeps } = {} +): Promise { + if (!clusterId) { + return { agents: [], edges: [], topics: [] }; + } + const getOrchestratorImpl = deps.getOrchestrator ?? getOrchestrator; + const orchestrator = await getOrchestratorImpl(); + const cluster = orchestrator.getCluster(clusterId); + if (!cluster?.config) { + throw new Error(`Cluster ${clusterId} not found.`); + } + return buildTopologyModel(cluster.config); +} diff --git a/src/tui-backend/services/guidance-delivery.ts b/src/tui-backend/services/guidance-delivery.ts new file mode 100644 index 00000000..17602594 --- /dev/null +++ b/src/tui-backend/services/guidance-delivery.ts @@ -0,0 +1,74 @@ +type GuidanceDeliveryResult = { + status: string; + reason: string | null; + method: string | null; + taskId?: string | null; +}; + +type ClusterGuidanceSummary = { + injected: number; + queued: number; + total: number; +}; + +type ClusterGuidanceDelivery = { + summary: ClusterGuidanceSummary; + agents: Record; + timestamp: number; +}; + +type GuidanceDeliveryDeps = { + getOrchestrator?: () => Promise; +}; + +let orchestratorPromise: Promise | null = null; + +async function getOrchestrator() { + if (!orchestratorPromise) { + const Orchestrator = require('../../../src/orchestrator'); + orchestratorPromise = Orchestrator.create({ quiet: true }); + } + return orchestratorPromise; +} + +type SendAgentGuidanceArgs = { + clusterId: string; + agentId: string; + text: string; + timeoutMs?: number; + deps?: GuidanceDeliveryDeps; +}; + +export async function sendAgentGuidance({ + clusterId, + agentId, + text, + timeoutMs, + deps = {}, +}: SendAgentGuidanceArgs): Promise { + const getOrchestratorImpl = deps.getOrchestrator ?? getOrchestrator; + const orchestrator = await getOrchestratorImpl(); + return await orchestrator.sendGuidanceToAgent(clusterId, agentId, text, { + timeoutMs, + }); +} + +type SendClusterGuidanceArgs = { + clusterId: string; + text: string; + timeoutMs?: number; + deps?: GuidanceDeliveryDeps; +}; + +export async function sendClusterGuidance({ + clusterId, + text, + timeoutMs, + deps = {}, +}: SendClusterGuidanceArgs): Promise { + const getOrchestratorImpl = deps.getOrchestrator ?? getOrchestrator; + const orchestrator = await getOrchestratorImpl(); + return await orchestrator.sendGuidanceToCluster(clusterId, text, { + timeoutMs, + }); +} diff --git a/src/tui-backend/services/index.ts b/src/tui-backend/services/index.ts new file mode 100644 index 00000000..e537d853 --- /dev/null +++ b/src/tui-backend/services/index.ts @@ -0,0 +1,7 @@ +export * from './cluster-launcher'; +export * from './cluster-registry'; +export * from './cluster-logs'; +export * from './cluster-timeline'; +export * from './cluster-topology'; +export * from './guidance-delivery'; +export * from './start-cluster'; diff --git a/src/tui-backend/services/start-cluster.ts b/src/tui-backend/services/start-cluster.ts new file mode 100644 index 00000000..c4ec87f6 --- /dev/null +++ b/src/tui-backend/services/start-cluster.ts @@ -0,0 +1 @@ +export { startClusterFromText, startClusterFromIssue } from '../../../lib/start-cluster'; diff --git a/src/tui-backend/subscriptions/index.ts b/src/tui-backend/subscriptions/index.ts new file mode 100644 index 00000000..79a59ad4 --- /dev/null +++ b/src/tui-backend/subscriptions/index.ts @@ -0,0 +1,69 @@ +import { randomUUID } from 'crypto'; + +export type SubscriptionKind = string; + +export type SubscriptionEntry = { + id: string; + kind: SubscriptionKind; + close: () => void; + closed: boolean; +}; + +export type SubscriptionRegistry = { + add: (kind: SubscriptionKind, close: () => void) => string; + unsubscribe: (id: string) => { removed: boolean }; + closeAll: () => number; + size: () => number; +}; + +export function createSubscriptionRegistry(): SubscriptionRegistry { + const entries = new Map(); + + const add = (kind: SubscriptionKind, close: () => void) => { + if (typeof close !== 'function') { + throw new TypeError('Subscription close must be a function.'); + } + const id = randomUUID(); + entries.set(id, { + id, + kind, + close, + closed: false, + }); + return id; + }; + + const unsubscribe = (id: string) => { + const entry = entries.get(id); + if (!entry) { + return { removed: false }; + } + entries.delete(id); + if (!entry.closed) { + entry.closed = true; + entry.close(); + } + return { removed: true }; + }; + + const closeAll = () => { + const values = Array.from(entries.values()); + entries.clear(); + for (const entry of values) { + if (!entry.closed) { + entry.closed = true; + entry.close(); + } + } + return values.length; + }; + + const size = () => entries.size; + + return { + add, + unsubscribe, + closeAll, + size, + }; +} diff --git a/src/tui/CHANGES.txt b/src/tui/CHANGES.txt deleted file mode 100644 index 1e8e4ac1..00000000 --- a/src/tui/CHANGES.txt +++ /dev/null @@ -1,133 +0,0 @@ -TUI Performance & UX Improvements -================================== - -## New Features - -### 4. Two-Level Navigation šŸŽÆ -**Feature:** Completely separate layouts for overview vs detail -**Implementation:** -- Overview mode: ONLY shows clusters table + stats (agents/logs hidden) -- Detail mode: ONLY shows agents + logs (clusters/stats hidden) -- Enter key to drill into detail, Escape to return -- Help text updates dynamically based on current view -- Widgets physically shown/hidden (not just empty data) - -## Fixed Issues - -### 1. Slow Startup ⚔ -**Problem:** TUI took 5-10 seconds to start due to synchronous cluster loading -**Solution:** -- Deferred initial polls by 50-100ms to let UI render first -- Shows "Loading..." message immediately -- Lazy-loads cluster ledgers only when needed -- Startup now instant (<100ms) - -### 2. Default Filter šŸŽÆ -**Problem:** Showed all clusters (including stopped) by default -**Solution:** -- Changed default filter from "all" to "running" -- User only sees active clusters -- Can still use `--filter all` to see everything - -### 3. Cluster Selection šŸ“ -**Problem:** Agents and logs weren't properly filtered by selected cluster -**Solution:** -- Renderer now tracks selectedClusterId -- Agents shown are for the selected cluster only -- Logs filtered to show only messages from selected cluster -- Messages cleared when switching between clusters -- Navigate with ↑↓ or jk keys - -## Performance Improvements - -**Before:** -- Startup: 5-10 seconds -- All clusters loaded synchronously -- All ledgers opened on startup -- Unfiltered logs from all clusters - -**After:** -- Startup: <100ms instant -- Clusters loaded async after UI renders -- Ledgers lazy-loaded when needed -- Logs filtered to selected cluster only - -## Usage - -```bash -# Shows only running clusters (default) -vibe watch - -# Show all clusters (including stopped) -vibe watch --filter all - -# Show only stopped clusters -vibe watch --filter stopped -``` - -## Keyboard Navigation - -### Two-Level Navigation -- **Overview Mode** (default): ONLY clusters + stats visible - - Large clusters table (16 rows) with system stats sidebar - - No agents or logs shown - - `↑` / `k` - Select previous cluster - - `↓` / `j` - Select next cluster - - `Enter` - Switch to detail view for selected cluster - -- **Detail Mode**: ONLY agents + logs visible - - Full-width agents table (9 rows) - - Full-width live logs (9 rows) - - Clusters table and stats hidden - - `Escape` - Switch back to overview mode - -Agents and logs auto-update in real-time when in detail view - -## Technical Changes - -### data-poller.js -- Line 45-53: Deferred initial polls with setTimeout -- Line 196-208: Added lazy loading for cluster ledgers -- Line 201-203: Check if ledger DB exists before loading - -### index.js (TUI) -- Line 21: Changed default filter to 'running' -- Line 34-36: Added viewMode state ('overview' or 'detail') and detailClusterId -- Line 48-49: Show "Loading..." message on startup -- Line 107-119: Conditional rendering - agents only shown in detail view -- Line 130-137: Conditional rendering for resource_stats case - -### keybindings.js -- Line 14-37: Enter key handler to switch to detail view -- Line 39-59: Escape key handler to switch to overview view -- Line 22, 45: Clear messages when switching views -- Line 29-34: Detail mode - hide clusters/stats, show agents/logs -- Line 52-57: Overview mode - show clusters/stats, hide agents/logs -- Line 24-27, 47-50: Update help text based on view mode - -### layout.js -- Line 36: Expanded clusters table to 16 rows (from 6) -- Line 66: Expanded stats box to 16 rows (from 6) -- Line 85: Repositioned agent table to row 0, 9 rows, full width -- Line 119: Repositioned logs to row 9, 9 rows, full width -- Line 165-167: Initially hide agent table and logs (overview mode default) - -### cli/index.js -- Line 1161: Changed default filter to 'running' in CLI option - -## Testing - -Run integration tests: -```bash -node tests/tui-integration.test.js # Basic TUI startup and data loading -node tests/tui-navigation-test.js # Two-level navigation functionality -``` - -Expected: All tests pass, TUI starts instantly - -## Notes - -- Messages are cluster-scoped (only show for selected cluster) -- Selection persists across refreshes -- Empty clusters (no agents) still show in list -- Logs clear when switching clusters to avoid confusion diff --git a/src/tui/LAYOUT.md b/src/tui/LAYOUT.md deleted file mode 100644 index 53cb23b3..00000000 --- a/src/tui/LAYOUT.md +++ /dev/null @@ -1,261 +0,0 @@ -# TUI Dashboard Layout Module - -Dashboard layout builder for real-time cluster monitoring with blessed-contrib. - -## Overview - -The layout module creates a responsive terminal UI with a 20x12 grid layout containing: - -- **Clusters Table** (top-left): View all active clusters, status, agent count, and uptime -- **System Stats** (top-right): CPU, memory, and cluster statistics -- **Agents Table** (middle): List all agents with role, status, iteration, and resource usage -- **Live Logs** (lower): Real-time event stream with color-coded severity levels -- **Help Bar** (bottom): Keyboard shortcut reference - -## Grid Layout - -``` -ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” -│ Clusters Table (6 rows x 8 cols) │ System Stats Box │ -│ │ (6 rows x 4 cols) │ -ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ -│ Agents Table (6 rows x 12 cols) │ -ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ -│ Live Logs (6 rows x 12 cols) │ -ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ -│ Help Bar (2 rows x 12 cols) │ -ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ -``` - -## Usage - -### Basic Setup - -```javascript -const blessed = require('blessed'); -const { createLayout } = require('./layout'); - -// Create screen -const screen = blessed.screen({ mouse: true, title: 'Cluster Dashboard' }); - -// Create layout -const layout = createLayout(screen); - -// Render -screen.render(); - -// Exit handler -screen.key(['q', 'C-c'], () => process.exit(0)); -``` - -### Updating Tables - -```javascript -const { updateClustersTable, updateAgentsTable, updateStatsBox, addLogEntry } = require('./layout'); - -// Update clusters table -updateClustersTable(layout.clustersTable, [ - { - id: 'cluster-swift-falcon', - status: 'running', - agentCount: 5, - config: 'default', - uptime: '2h 30m', - }, -]); - -// Update agents table -updateAgentsTable(layout.agentTable, [ - { - clusterId: 'cluster-swift-falcon', - id: 'worker-1', - role: 'worker', - status: 'running', - iteration: 3, - cpu: '12.5%', - memory: '245 MB', - }, -]); - -// Update system stats -updateStatsBox(layout.statsBox, { - activeClusters: 2, - totalAgents: 5, - usedMemory: '512 MB', - totalMemory: '8 GB', - totalCPU: '26.2%', -}); - -// Add log entry -addLogEntry(layout.logsBox, 'Cluster started successfully', 'info'); -addLogEntry(layout.logsBox, 'Warning: High CPU usage', 'warn'); -addLogEntry(layout.logsBox, 'Error: Agent crashed', 'error'); -``` - -## API Reference - -### createLayout(screen) - -Creates the dashboard layout with all widgets. - -**Parameters:** - -- `screen` (blessed.screen): Blessed screen instance - -**Returns:** - -```javascript -{ - (screen, // Blessed screen - grid, // blessed-contrib grid - clustersTable, // Clusters table widget - agentTable, // Agents table widget - statsBox, // System stats box widget - logsBox, // Live logs widget - helpBar, // Help bar widget - widgets, // Array of interactive widgets [clustersTable, agentTable, logsBox] - focus(index), // Function to focus widget by index - getCurrentFocus()); // Function to get current focus index -} -``` - -### updateClustersTable(clustersTable, clusters) - -Updates the clusters table with current data. - -**Parameters:** - -- `clustersTable`: Clusters table widget -- `clusters` (array): Array of cluster objects with properties: - - `id` (string): Cluster identifier - - `status` (string): running | stopped | initializing | stopping | failed | killed - - `agentCount` (number): Number of agents - - `config` (string): Configuration name - - `uptime` (string): Formatted uptime (e.g., "2h 30m") - -### updateAgentsTable(agentTable, agents) - -Updates the agents table with current data. - -**Parameters:** - -- `agentTable`: Agents table widget -- `agents` (array): Array of agent objects with properties: - - `clusterId` (string): Parent cluster ID - - `id` (string): Agent identifier - - `role` (string): worker | validator | orchestrator - - `status` (string): running | idle | failed - - `iteration` (number): Current iteration count - - `cpu` (string): CPU percentage (e.g., "12.5%") - - `memory` (string): Memory usage (e.g., "245 MB") - -### updateStatsBox(statsBox, stats) - -Updates the system stats box. - -**Parameters:** - -- `statsBox`: Stats box widget -- `stats` (object): - - `activeClusters` (number): Count of active clusters - - `totalAgents` (number): Total agent count - - `usedMemory` (string): Formatted memory usage - - `totalMemory` (string): Formatted total memory - - `totalCPU` (string): Total CPU percentage - -### addLogEntry(logsBox, message, level) - -Adds a timestamped log entry. - -**Parameters:** - -- `logsBox`: Logs box widget -- `message` (string): Log message -- `level` (string): info | warn | error | debug (default: info) - -### clearLogs(logsBox) - -Clears all log entries. - -**Parameters:** - -- `logsBox`: Logs box widget - -## Keyboard Navigation - -| Key | Action | -| --------- | -------------------------- | -| Tab | Next widget | -| Shift+Tab | Previous widget | -| ↑/↓ | Navigate in focused widget | -| Enter | Select/activate | -| q | Quit | - -## Color Scheme - -- **Borders**: Cyan -- **Headers**: Cyan (bold) -- **Text**: White -- **Selection**: Black text on cyan background -- **Log levels**: - - info: White - - warn: Yellow - - error: Red - - debug: Gray - -## Demo - -Run the included demo: - -```bash -node src/tui/demo.js -``` - -Keyboard shortcuts in demo: - -- [r] - Refresh data -- [c] - Simulate cluster start -- [k] - Simulate cluster kill -- [s] - Simulate warning -- [q] - Quit - -## Testing - -Run tests: - -```bash -npm test -- tests/tui-layout.test.js -``` - -Tests verify: - -- Layout creation and widget initialization -- Data update functions -- Focus navigation -- Log entry handling -- Edge cases (empty data, missing properties) - -## Styling Customization - -Widgets can be customized by modifying the configuration objects in `createLayout()`: - -```javascript -const clustersTable = grid.set(0, 0, 6, 8, contrib.table, { - fg: 'white', // Foreground color - selectedFg: 'black', // Selected foreground - selectedBg: 'cyan', // Selected background - border: { type: 'line', fg: 'cyan' }, - style: { - header: { fg: 'cyan', bold: true }, - cell: { selected: { fg: 'black', bg: 'cyan' } }, - }, -}); -``` - -## Related Modules - -- `formatters.js` - Value formatting utilities (timestamps, bytes, CPU) -- `renderer.js` - Additional rendering helpers -- `keybindings.js` - Keyboard event handlers -- `data-poller.js` - Real-time data collection -- `index.js` - Main dashboard integration diff --git a/src/tui/README.txt b/src/tui/README.txt deleted file mode 100644 index d246f5a1..00000000 --- a/src/tui/README.txt +++ /dev/null @@ -1,192 +0,0 @@ -VIBE WATCH - Interactive TUI Dashboard -======================================= - -Launch with: vibe watch - -OVERVIEW --------- -The vibe watch command provides a real-time, htop/k9s-style dashboard for monitoring all active vibe clusters. - -FEATURES --------- -āœ“ Real-time cluster state monitoring (1s refresh) -āœ“ CPU and memory tracking per agent (via pidusage) -āœ“ Live message streaming from cluster ledgers -āœ“ Interactive keyboard controls (kill, stop, export) -āœ“ Automatic detection of new clusters -āœ“ System-wide statistics (active clusters, agents, avg resources) - -LAYOUT ------- -ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” -│ VIBE CLUSTER WATCH [q] Quit │ -ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ -│ ā”Œā”€ Clusters ─────────┐ ā”Œā”€ System Stats ──────────────┐ │ -│ │ ID State Time │ │ Active: 2 CPU: 12% │ │ -│ │ ā— a-38 RUN 5m │ │ Agents: 7 Mem: 245 MB │ │ -│ │ ā— s-62 RUN 2m │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ -│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ -│ ā”Œā”€ Agents ───────────────────────────────────────────┐ │ -│ │ Agent Role State Iter CPU% Mem(MB) │ │ -│ │ worker impl exec 3 8.5 67 │ │ -│ │ validator val idle 1 0.1 42 │ │ -│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ -│ ā”Œā”€ Live Logs ────────────────────────────────────────┐ │ -│ │ [09:45:23] worker: TASK_STARTED (iteration 3) │ │ -│ │ [09:45:24] worker: Implementing feature X... │ │ -│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ -│ ā”Œā”€ Help ─────────────────────────────────────────────┐ │ -│ │ [↑/↓] Nav [K] Kill [s] Stop [e] Export [q] Quit│ │ -│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ -ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - -KEYBOARD SHORTCUTS ------------------- -Navigation: - ↑ / k Move selection up - ↓ / j Move selection down - -Actions (on selected cluster): - K Kill cluster (force, with confirmation) - s Stop cluster (graceful, with confirmation) - e Export cluster conversation (markdown) - l Open full logs in new terminal window - -View Controls: - r Force refresh all data - f Toggle filter (running/stopped/all) - ? / h Show help dialog - -Exit: - q / Ctrl-C Quit (with confirmation if clusters active) - -USAGE ------ -# Launch with defaults -vibe watch - -# Filter to only running clusters -vibe watch --filter running - -# Faster refresh (500ms instead of 1s) -vibe watch --refresh-rate 500 - -# Show only stopped clusters -vibe watch --filter stopped - -ARCHITECTURE ------------- -The TUI is composed of 6 modular components: - -1. index.js - Main coordinator, initializes screen/layout/poller -2. layout.js - Creates blessed-contrib grid with 5 widgets -3. renderer.js - Transforms data into widget updates -4. data-poller.js - Polls orchestrator at 4 different intervals -5. keybindings.js - Keyboard event handlers and confirmations -6. formatters.js - Utility functions (time, bytes, CPU, icons) - -Data Flow: - Orchestrator → DataPoller → TUI.onUpdate → Renderer → Widgets → Screen - -Polling Strategy: - - Cluster states: 1s (main refresh rate) - - Resource stats: 2s (expensive pidusage calls) - - New clusters: 2s (rare event) - - Log messages: 500ms per cluster (real-time feel) - -DEPENDENCIES ------------- -- blessed@0.1.81 Terminal UI framework -- blessed-contrib@4.11.0 Dashboard widgets (grid, table, log) -- pidusage@4.0.1 Cross-platform CPU/memory monitoring - -TESTING -------- -# Run integration test -node tests/tui-integration.test.js - -# Run layout demo (interactive) -node src/tui/demo.js - -# Run unit tests -npm test - -TROUBLESHOOTING ---------------- -Q: TUI shows "No clusters found" -A: Start a cluster first: vibe run "test task" or vibe task run "test" - -Q: CPU/Memory shows 0% -A: Process may have died. Check cluster state. - -Q: Logs not streaming -A: Ledger database may be locked. Wait a few seconds. - -Q: Terminal garbled on exit -A: Try running: reset - -Q: Keyboard shortcuts not working -A: Make sure terminal supports key events (most modern terminals do) - -DEMO MODE ---------- -To see the TUI with mock data: - node src/tui/demo.js - -This starts a live dashboard with simulated clusters that auto-updates. -Press [r] to refresh, [c] to add cluster, [k] to kill, [q] to quit. - -FILES ------ -src/tui/ -ā”œā”€ā”€ index.js Main TUI class (6.0K) -ā”œā”€ā”€ layout.js Widget creation (8.1K) -ā”œā”€ā”€ renderer.js Data → widgets (5.6K) -ā”œā”€ā”€ data-poller.js Data collection (8.1K) -ā”œā”€ā”€ keybindings.js User input (8.9K) -ā”œā”€ā”€ formatters.js Utilities (3.4K) -ā”œā”€ā”€ demo.js Interactive demo (5.0K) -└── LAYOUT.md API documentation (7.4K) - -tests/ -└── tui-integration.test.js Integration test - -MODIFICATIONS TO EXISTING FILES -------------------------------- -src/agent-wrapper.js - - Added: this.processPid tracking (line 42) - - Added: PID capture on spawn (line 605-607) - - Added: PROCESS_SPAWNED lifecycle event - - Added: TASK_ID_ASSIGNED lifecycle event - - Added: pid field in getState() (line 1350) - -cli/index.js - - Added: vibe watch command with options - -lib/completion.js - - Added: Shell completion for watch command - -package.json - - Added: blessed, blessed-contrib, pidusage dependencies - -FUTURE ENHANCEMENTS -------------------- -Potential improvements: -- [ ] Sorting clusters by various fields -- [ ] Filtering by cluster config name -- [ ] Graph view for CPU/memory over time -- [ ] Search/filter logs by keyword -- [ ] Export selected logs to file -- [ ] Cluster health indicators -- [ ] Alert notifications for failures -- [ ] Docker container stats (for isolation mode) -- [ ] Network I/O stats -- [ ] Agent communication graph visualization - -CREDITS -------- -Implementation: 6 parallel agents (Dec 2024) -Architecture: blessed-contrib grid system -Inspiration: htop, k9s, lazydocker - -For issues or feature requests, see vibe/cluster GitHub repo. diff --git a/src/tui/TWO-LEVEL-NAVIGATION.md b/src/tui/TWO-LEVEL-NAVIGATION.md deleted file mode 100644 index eeaaf562..00000000 --- a/src/tui/TWO-LEVEL-NAVIGATION.md +++ /dev/null @@ -1,186 +0,0 @@ -# Two-Level Navigation - Implementation Summary - -## Overview - -Completely redesigned TUI layout with separate views for overview and detail modes: - -1. **Overview mode** (default): ONLY clusters + stats - clean, focused view -2. Press Enter → **Detail mode**: ONLY agents + logs for selected cluster -3. Press Escape → Return to overview - -## User Experience - -### Overview Mode (Default) - -- **ONLY visible:** Large clusters table (16 rows) + system stats sidebar -- **Hidden:** Agent table and logs (completely invisible) -- Clean, spacious layout focusing on cluster selection -- Help text: `[Enter] View [↑/↓] Navigate [k] Kill [s] Stop [l] Logs [r] Refresh [q] Quit` - -### Detail Mode (After pressing Enter) - -- **ONLY visible:** Full-width agents table (9 rows) + full-width logs (9 rows) -- **Hidden:** Clusters table and stats box (completely invisible) -- Dedicated space for monitoring single cluster in depth -- Help text: `[Esc] Back [k] Kill [s] Stop [e] Export [l] Logs [r] Refresh [q] Quit` - -### Navigation Flow - -``` -Overview (ONLY clusters + stats) - ↓ Enter -Detail (ONLY agents + logs) - ↓ Escape -Overview (ONLY clusters + stats) -``` - -## Implementation Details - -### Layout Design - -**Overview mode layout** (`src/tui/layout.js`): - -- Clusters table: rows 0-16 (16 rows), cols 0-8 -- Stats box: rows 0-16 (16 rows), cols 8-12 -- Help bar: rows 18-20 -- Agents/logs: **hidden** (`.hide()` called on initialization) - -**Detail mode layout** (`src/tui/layout.js`): - -- Agents table: rows 0-9 (9 rows), cols 0-12 (full width) -- Logs box: rows 9-18 (9 rows), cols 0-12 (full width) -- Help bar: rows 18-20 -- Clusters/stats: **hidden** (`.hide()` called on mode switch) - -### State Management - -**New state in TUI class (`src/tui/index.js`):** - -```javascript -this.viewMode = 'overview'; // or 'detail' -this.detailClusterId = null; // cluster ID when in detail mode -``` - -### Keybindings - -**Enter key** (`src/tui/keybindings.js` lines 14-37): - -- Checks if in overview mode with clusters available -- Sets `viewMode = 'detail'` and `detailClusterId` -- **Hides** clusters table and stats box (`.hide()`) -- **Shows** agents table and logs box (`.show()`) -- Updates help text -- Clears old messages - -**Escape key** (`src/tui/keybindings.js` lines 39-59): - -- Checks if in detail mode -- Sets `viewMode = 'overview'` and `detailClusterId = null` -- **Shows** clusters table and stats box (`.show()`) -- **Hides** agents table and logs box (`.hide()`) -- Updates help text -- Clears messages - -### Conditional Rendering - -**Cluster state updates** (`src/tui/index.js` lines 107-119): - -```javascript -if (this.viewMode === 'detail' && this.detailClusterId) { - // Show agents for detail cluster - const status = this.orchestrator.getStatus(this.detailClusterId); - this.renderer.renderAgentTable(status.agents, this.resourceStats); -} else if (this.viewMode === 'overview') { - // Don't show agents in overview - this.renderer.renderAgentTable([], this.resourceStats); -} -``` - -**Resource stats updates** (`src/tui/index.js` lines 130-137): - -- Same conditional logic as above -- Only renders agents in detail mode - -## Testing - -### Automated Tests - -**Test 1: `tests/tui-integration.test.js`** - -- Basic TUI startup -- Data loading -- Module integration -- āœ… PASSING - -**Test 2: `tests/tui-navigation-test.js`** - -- Initial state verification -- Enter detail view -- Verify agents shown -- Return to overview -- Conditional rendering logic -- āœ… PASSING - -### Manual Testing - -**Run the manual test:** - -```bash -chmod +x tests/tui-keybindings-manual-test.js -node tests/tui-keybindings-manual-test.js -``` - -**Instructions:** - -1. Press ↑/↓ or j/k to navigate clusters -2. Press Enter to drill into detail view → clusters/stats hide, agents/logs appear -3. Press Escape to return to overview → clusters/stats reappear, agents/logs hide -4. Verify help text updates correctly - -## Files Modified - -| File | Changes | -| ------------------------ | ------------------------------------------------------- | -| `src/tui/index.js` | Added viewMode state, conditional rendering | -| `src/tui/keybindings.js` | Added Enter/Escape handlers, widget visibility toggling | -| `src/tui/layout.js` | Updated help text to show Enter key | -| `src/tui/CHANGES.txt` | Documented feature and technical changes | - -## Files Created - -| File | Purpose | -| -------------------------------------- | --------------------------------------- | -| `tests/tui-navigation-test.js` | Automated test for two-level navigation | -| `tests/tui-keybindings-manual-test.js` | Interactive manual test | -| `src/tui/TWO-LEVEL-NAVIGATION.md` | This document | - -## Performance Impact - -- **Startup:** No impact (viewMode check is O(1)) -- **Rendering:** Slight improvement in overview mode (no agent data fetching) -- **Memory:** Minimal increase (2 new state variables) - -## Known Limitations - -None. Feature is complete and tested. - -## Usage - -```bash -# Start TUI (shows overview by default) -zeroshot watch - -# In overview: -# - Use ↑/↓ or j/k to select cluster -# - Press Enter to drill into detail view - -# In detail: -# - View agents and logs for selected cluster -# - Press Escape to return to overview -``` - -## Future Enhancements (Optional) - -- Add breadcrumb showing current cluster in detail mode -- Add keybinding to jump directly to a cluster by ID -- Add "pinning" to keep detail view on specific cluster even when new clusters spawn diff --git a/src/tui/data-poller.js b/src/tui/data-poller.js deleted file mode 100644 index b777467d..00000000 --- a/src/tui/data-poller.js +++ /dev/null @@ -1,349 +0,0 @@ -/** - * DataPoller - Aggregates cluster data for TUI display - * - * Polls all data sources at appropriate intervals: - * - Cluster states (1s) - * - Resource stats via pidusage (2s) - * - New cluster detection (2s) - * - Ledger message streaming (500ms per cluster) - */ - -const pidusage = require('pidusage'); -const Ledger = require('../ledger'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -class DataPoller { - constructor(orchestrator, options = {}) { - this.orchestrator = orchestrator; - this.intervals = []; - this.ledgers = new Map(); // clusterId -> Ledger instance - this.ledgerStopFns = new Map(); // clusterId -> stop function for pollForMessages - this.onUpdate = options.onUpdate || (() => {}); // Callback for updates - this.watchForNewClustersStopFn = null; - } - - /** - * Start all polling intervals - */ - start() { - // Poll cluster states (1s) - const clusterStateInterval = setInterval(() => { - this._pollClusterStates(); - }, 1000); - this.intervals.push(clusterStateInterval); - - // Poll resource stats (2s) - const resourceStatsInterval = setInterval(() => { - this._pollResourceStats(); - }, 2000); - this.intervals.push(resourceStatsInterval); - - // Watch for new clusters (2s) - this._watchForNewClusters(); - - // Defer initial polls to avoid blocking UI startup - // Run in background after 50ms to let UI render first - setTimeout(() => { - this._pollClusterStates(); - }, 50); - - setTimeout(() => { - this._pollResourceStats(); - }, 100); - } - - /** - * Stop all polling intervals and clean up resources - */ - stop() { - // Clear all intervals - for (const intervalId of this.intervals) { - clearInterval(intervalId); - } - this.intervals = []; - - // Stop watching for new clusters - if (this.watchForNewClustersStopFn) { - this.watchForNewClustersStopFn(); - this.watchForNewClustersStopFn = null; - } - - // Stop all ledger polling - for (const stopFn of this.ledgerStopFns.values()) { - stopFn(); - } - this.ledgerStopFns.clear(); - - // Close all ledger connections - for (const ledger of this.ledgers.values()) { - try { - ledger.close(); - } catch { - // Ignore errors during cleanup - } - } - this.ledgers.clear(); - } - - /** - * Poll cluster states (1s interval) - * Gets all clusters and their agent states from orchestrator - * @private - */ - _pollClusterStates() { - try { - const clusters = this.orchestrator.listClusters(); - - // Get detailed status for each cluster - const clustersWithStatus = clusters.map((cluster) => { - try { - const status = this.orchestrator.getStatus(cluster.id); - // Add agentCount for stats calculation - return { - ...status, - agentCount: status.agents ? status.agents.length : 0, - }; - } catch (error) { - console.error( - `[DataPoller] Failed to get status for cluster ${cluster.id}:`, - error.message - ); - return { - id: cluster.id, - state: 'unknown', - createdAt: cluster.createdAt, - agents: [], - agentCount: 0, - messageCount: 0, - }; - } - }); - - this.onUpdate({ - type: 'cluster_state', - clusters: clustersWithStatus, - }); - } catch (error) { - console.error('[DataPoller] _pollClusterStates error:', error.message); - } - } - - /** - * Poll resource stats (2s interval) - * Uses pidusage to get CPU and memory for all agent processes - * @private - */ - async _pollResourceStats() { - try { - const clusters = this.orchestrator.listClusters(); - const stats = {}; - const pids = this._collectClusterPids(clusters); - - if (pids.length > 0) { - const pidStats = await this._safePidUsage(pids); - this._populatePidStats(stats, pids, pidStats); - } - - this.onUpdate({ - type: 'resource_stats', - stats, - }); - } catch (error) { - console.error('[DataPoller] _pollResourceStats error:', error.message); - } - } - - /** - * Watch for new clusters (2s interval) - * Uses orchestrator.watchForNewClusters to detect new clusters - * and start streaming their ledger messages - * @private - */ - _watchForNewClusters() { - this.watchForNewClustersStopFn = this.orchestrator.watchForNewClusters((cluster) => { - // Lazy load ledger only when we need to stream messages - // This avoids loading all ledgers on startup - const ledger = this._loadLedgerForCluster(cluster, { - label: 'cluster', - requireExisting: true, - }); - - if (!ledger) { - return; - } - - // Start streaming messages - this._streamLedgerMessages(cluster.id); - - // Emit update about new cluster - this.onUpdate({ - type: 'new_cluster', - cluster, - }); - }, 2000); - - // Also load ledgers for all existing clusters - const existingClusters = this.orchestrator.listClusters(); - for (const cluster of existingClusters) { - const ledger = this._loadLedgerForCluster(cluster, { label: 'existing cluster' }); - if (!ledger) { - continue; - } - - this._streamLedgerMessages(cluster.id); - } - } - - /** - * Stream ledger messages for a cluster (500ms interval) - * Uses ledger.pollForMessages to get new messages - * @param {string} clusterId - Cluster ID to stream messages from - * @private - */ - _streamLedgerMessages(clusterId) { - const ledger = this.ledgers.get(clusterId); - if (!ledger) { - console.error(`[DataPoller] No ledger found for cluster ${clusterId}`); - return; - } - - // Stop existing polling if any - const existingStopFn = this.ledgerStopFns.get(clusterId); - if (existingStopFn) { - existingStopFn(); - } - - // Start polling for messages - const stopFn = ledger.pollForMessages( - clusterId, - (message) => { - this.onUpdate({ - type: 'new_message', - clusterId, - message, - }); - }, - 500, // Poll every 500ms - 50 // Show last 50 messages initially - ); - - this.ledgerStopFns.set(clusterId, stopFn); - } - - /** - * Collect resource stats for all agent PIDs - * @returns {Object} Map of pid -> { cpu, memory } - * @private - */ - async _collectResourceStats() { - const stats = {}; - const clusters = this.orchestrator.listClusters(); - - for (const cluster of clusters) { - const pids = this._getClusterAgentPids(cluster); - for (const pid of pids) { - stats[pid] = await this._getSinglePidStat(pid); - } - } - - return stats; - } - - _collectClusterPids(clusters) { - const pids = []; - - for (const cluster of clusters) { - pids.push(...this._getClusterAgentPids(cluster)); - } - - return pids; - } - - _getClusterAgentPids(cluster) { - try { - const status = this.orchestrator.getStatus(cluster.id); - const pids = []; - - for (const agent of status.agents || []) { - if (agent.pid) { - pids.push(agent.pid); - } - } - - return pids; - } catch { - // Skip clusters that error - return []; - } - } - - async _getSinglePidStat(pid) { - try { - const pidStat = await pidusage(pid); - return { - cpu: pidStat.cpu || 0, - memory: pidStat.memory || 0, - }; - } catch { - // Process died - set to zero - return { cpu: 0, memory: 0 }; - } - } - - async _safePidUsage(pids) { - try { - return await pidusage(pids); - } catch { - return null; - } - } - - _populatePidStats(stats, pids, pidStats) { - for (const pid of pids) { - const entry = pidStats?.[pid]; - stats[pid] = { - cpu: entry?.cpu || 0, - memory: entry?.memory || 0, - }; - } - } - - _loadLedgerForCluster(cluster, options) { - const { label, requireExisting = false } = options; - - try { - return this._ensureLedger(cluster.id, { requireExisting }); - } catch (error) { - console.error( - `[DataPoller] Failed to load ledger for ${label} ${cluster.id}:`, - error.message - ); - return null; - } - } - - _ensureLedger(clusterId, { requireExisting = false } = {}) { - const existing = this.ledgers.get(clusterId); - if (existing) { - return existing; - } - - const dbPath = this._getLedgerPath(clusterId); - if (requireExisting && !fs.existsSync(dbPath)) { - return null; - } - - const ledger = new Ledger(dbPath); - this.ledgers.set(clusterId, ledger); - return ledger; - } - - _getLedgerPath(clusterId) { - const storageDir = this.orchestrator.storageDir || path.join(os.homedir(), '.zeroshot'); - return path.join(storageDir, `${clusterId}.db`); - } -} - -module.exports = DataPoller; diff --git a/src/tui/demo.js b/src/tui/demo.js deleted file mode 100644 index 6d5bbf2b..00000000 --- a/src/tui/demo.js +++ /dev/null @@ -1,208 +0,0 @@ -/** - * TUI Dashboard Demo - * Simple demonstration of the dashboard layout with mock data - * - * Run: node src/tui/demo.js - * Press: [q] to quit - */ - -const blessed = require('blessed'); -const { - createLayout, - updateClustersTable, - updateAgentsTable, - updateStatsBox, - addLogEntry, -} = require('./layout'); -const { formatTimestamp } = require('./formatters'); - -// Create main screen -const screen = blessed.screen({ - mouse: true, - title: 'Cluster Dashboard - Demo', - smartCSR: true, -}); - -// Create layout -const layout = createLayout(screen); - -// Mock data generators -const mockClusters = [ - { - id: 'cluster-swift-falcon', - status: 'running', - agentCount: 5, - config: 'default', - uptime: formatTimestamp(2 * 60 * 60 * 1000 + 30 * 60 * 1000), // 2h 30m - }, - { - id: 'cluster-bold-panther', - status: 'running', - agentCount: 3, - config: 'simple', - uptime: formatTimestamp(45 * 60 * 1000), // 45m - }, - { - id: 'cluster-quick-eagle', - status: 'stopped', - agentCount: 0, - config: 'default', - uptime: '0s', - }, -]; - -const mockAgents = [ - { - clusterId: 'cluster-swift-falcon', - id: 'worker-1', - role: 'worker', - status: 'running', - iteration: 3, - cpu: '12.5%', - memory: '245 MB', - }, - { - clusterId: 'cluster-swift-falcon', - id: 'validator-req', - role: 'validator', - status: 'idle', - iteration: 0, - cpu: '0.1%', - memory: '128 MB', - }, - { - clusterId: 'cluster-swift-falcon', - id: 'validator-sec', - role: 'validator', - status: 'idle', - iteration: 0, - cpu: '0.2%', - memory: '135 MB', - }, - { - clusterId: 'cluster-bold-panther', - id: 'worker-2', - role: 'worker', - status: 'running', - iteration: 1, - cpu: '8.3%', - memory: '189 MB', - }, - { - clusterId: 'cluster-bold-panther', - id: 'validator-qa', - role: 'validator', - status: 'running', - iteration: 1, - cpu: '5.1%', - memory: '156 MB', - }, -]; - -const mockStats = { - activeClusters: 2, - totalAgents: 5, - usedMemory: '853 MB', - totalMemory: '8 GB', - totalCPU: '26.2%', -}; - -// Keyboard shortcuts -screen.key(['q', 'C-c'], () => { - return process.exit(0); -}); - -screen.key(['r'], () => { - updateClustersTable(layout.clustersTable, mockClusters); - updateAgentsTable(layout.agentTable, mockAgents); - updateStatsBox(layout.statsBox, mockStats); - addLogEntry(layout.logsBox, 'Dashboard refreshed', 'info'); - screen.render(); -}); - -screen.key(['c'], () => { - addLogEntry(layout.logsBox, 'Cluster started: cluster-wandering-wolf', 'info'); - screen.render(); -}); - -screen.key(['k'], () => { - addLogEntry(layout.logsBox, 'Cluster killed: cluster-quick-eagle', 'warn'); - screen.render(); -}); - -screen.key(['s'], () => { - addLogEntry(layout.logsBox, 'Warning: High memory usage on cluster-swift-falcon', 'warn'); - screen.render(); -}); - -// Initialize with mock data -updateClustersTable(layout.clustersTable, mockClusters); -updateAgentsTable(layout.agentTable, mockAgents); -updateStatsBox(layout.statsBox, mockStats); - -// Add initial log entries -addLogEntry(layout.logsBox, 'Dashboard initialized', 'info'); -addLogEntry(layout.logsBox, 'Monitoring 2 active clusters', 'info'); -addLogEntry(layout.logsBox, 'System CPU: 26.2% | Memory: 853 MB / 8 GB', 'info'); - -// Simulate live updates -const updateInterval = setInterval(() => { - // Update uptime for running clusters - mockClusters.forEach((cluster) => { - if (cluster.status === 'running') { - const uptimeMs = Math.random() * 3 * 60 * 60 * 1000; // Random uptime - cluster.uptime = formatTimestamp(uptimeMs); - } - }); - - // Simulate CPU/Memory changes - mockAgents.forEach((agent) => { - if (agent.status === 'running') { - agent.cpu = (Math.random() * 20).toFixed(1) + '%'; - agent.memory = Math.floor(Math.random() * 200 + 100) + ' MB'; - } - }); - - mockStats.totalCPU = (Math.random() * 50).toFixed(1) + '%'; - - updateClustersTable(layout.clustersTable, mockClusters); - updateAgentsTable(layout.agentTable, mockAgents); - updateStatsBox(layout.statsBox, mockStats); - - screen.render(); -}, 3000); - -// Display help on startup -setTimeout(() => { - addLogEntry( - layout.logsBox, - 'Press [r] to refresh | [c] to add cluster | [k] to kill | [s] for warning | [q] to quit', - 'info' - ); - screen.render(); -}, 500); - -// Cleanup on exit -process.on('exit', () => { - clearInterval(updateInterval); -}); - -// Render initial screen -screen.render(); - -console.log( - '\n' + - '===============================================\n' + - ' Cluster Dashboard - Demo Mode\n' + - '===============================================\n' + - 'Keyboard shortcuts:\n' + - ' [↑/↓] Navigate between widgets\n' + - ' [Tab] Next widget\n' + - ' [Shift+Tab] Previous widget\n' + - ' [r] Refresh data\n' + - ' [c] Simulate cluster start\n' + - ' [k] Simulate cluster kill\n' + - ' [s] Simulate warning\n' + - ' [q] Quit\n' + - '===============================================\n\n' -); diff --git a/src/tui/formatters.js b/src/tui/formatters.js deleted file mode 100644 index 6ad9c585..00000000 --- a/src/tui/formatters.js +++ /dev/null @@ -1,129 +0,0 @@ -/** - * TUI Display Formatters - * Converts raw values to human-readable formats for terminal display - */ - -/** - * Convert milliseconds to human-readable uptime string - * @param {number} ms - Milliseconds - * @returns {string} Formatted uptime (e.g., "5m 23s", "2h 15m", "3d 4h") - */ -const formatTimestamp = (ms) => { - if (!ms || ms < 0) return '0s'; - - const seconds = Math.floor(ms / 1000); - - if (seconds < 60) { - return `${seconds}s`; - } - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) { - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; - } - - const hours = Math.floor(minutes / 60); - if (hours < 24) { - const remainingMinutes = minutes % 60; - return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; - } - - const days = Math.floor(hours / 24); - const remainingHours = hours % 24; - return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`; -}; - -/** - * Convert bytes to human-readable size string - * @param {number} bytes - Number of bytes - * @returns {string} Formatted size (e.g., "245 MB", "1.2 GB", "512 KB") - */ -const formatBytes = (bytes) => { - if (!bytes || bytes < 0) return '0 B'; - - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let size = bytes; - let unitIndex = 0; - - while (size >= 1000 && unitIndex < units.length - 1) { - size /= 1000; - unitIndex++; - } - - const formatted = size < 10 ? size.toFixed(1) : Math.round(size); - return `${formatted} ${units[unitIndex]}`; -}; - -/** - * Format CPU percentage with consistent precision - * @param {number} percent - CPU percentage (0-100) - * @returns {string} Formatted percentage (e.g., "12.3%", "0.1%") - */ -const formatCPU = (percent) => { - if (typeof percent !== 'number' || percent < 0) return '0.0%'; - - // Assert normalized range (should never exceed 100% after per-core normalization) - let normalizedPercent = percent; - if (normalizedPercent > 100) { - console.warn(`[formatCPU] CPU percent ${percent}% exceeds 100% - normalization bug?`); - normalizedPercent = 100; - } - - return `${normalizedPercent.toFixed(1)}%`; -}; - -/** - * Map cluster state to unicode icon - * @param {string} state - Cluster state (running, stopped, initializing, stopping, failed, killed) - * @returns {string} Unicode icon representing state - */ -const stateIcon = (state) => { - const icons = { - running: 'ā—', // filled circle (green) - stopped: 'ā—‹', // hollow circle - initializing: '◐', // half circle - stopping: 'ā—‘', // half circle other way - failed: '⚠', // warning - killed: '⚠', // warning - }; - - return icons[state] || '?'; -}; - -/** - * Truncate string with ellipsis if exceeds max length - * @param {string} str - String to truncate - * @param {number} maxLen - Maximum length - * @returns {string} Truncated string with "..." if needed - */ -const truncate = (str, maxLen) => { - if (!str || typeof str !== 'string') return ''; - if (str.length <= maxLen) return str; - - return str.substring(0, maxLen - 3) + '...'; -}; - -/** - * Format duration between two timestamps - * @param {number} startMs - Start timestamp in milliseconds - * @param {number} endMs - End timestamp in milliseconds (null = now) - * @returns {string} Formatted duration (e.g., "5m 23s", "2h 15m") - */ -const formatDuration = (startMs, endMs) => { - if (!startMs || startMs < 0) return '0s'; - - const end = endMs && endMs > 0 ? endMs : Date.now(); - const duration = Math.max(0, end - startMs); - - return formatTimestamp(duration); -}; - -module.exports = { - formatTimestamp, - formatBytes, - formatCPU, - stateIcon, - truncate, - formatDuration, -}; diff --git a/src/tui/index.js b/src/tui/index.js deleted file mode 100644 index b11e9667..00000000 --- a/src/tui/index.js +++ /dev/null @@ -1,193 +0,0 @@ -/** - * TUI - Main interactive dashboard - * - * Coordinates: - * - Screen and layout - * - Data polling - * - Rendering - * - Keybindings - * - State management - */ - -const blessed = require('blessed'); -const { createLayout } = require('./layout'); -const DataPoller = require('./data-poller'); -const Renderer = require('./renderer'); -const { setupKeybindings } = require('./keybindings'); - -class TUI { - constructor(options) { - this.orchestrator = options.orchestrator; - this.filter = options.filter || 'running'; - this.refreshRate = options.refreshRate || 1000; - - // State - this.clusters = []; - this.resourceStats = new Map(); - this.messages = []; - this.selectedIndex = 0; - this.poller = null; - this.renderer = null; - this.widgets = null; - this.screen = null; - - // View mode: 'overview' or 'detail' - this.viewMode = 'overview'; - this.detailClusterId = null; - } - - start() { - // Create screen - this.screen = blessed.screen({ - smartCSR: true, - title: 'Vibe Cluster Watch', - dockBorders: true, - fullUnicode: true, - }); - - // Create layout - this.widgets = createLayout(this.screen); - - // Show immediate loading message - this.widgets.statsBox.setContent('{center}{bold}Loading...{/bold}{/center}'); - this.screen.render(); - - // Create renderer - this.renderer = new Renderer(this.widgets, this.screen); - - // Setup keybindings (pass TUI instance for state management) - setupKeybindings(this.screen, this.widgets, this, this.orchestrator); - - // Create data poller - this.poller = new DataPoller(this.orchestrator, { - refreshRate: this.refreshRate, - onUpdate: (update) => this._handleUpdate(update), - }); - - // Initial message - this.messages.push({ - timestamp: new Date().toISOString(), - text: 'TUI started. Press ? for help.', - level: 'info', - }); - this.renderer.renderLogs(this.messages.slice(-20)); - - // Start polling - this.poller.start(); - - // Initial render - this.screen.render(); - } - - _handleUpdate(update) { - // Update state based on update.type - switch (update.type) { - case 'cluster_state': - // Update cluster list - this.clusters = update.clusters; - - // Apply filter - let filteredClusters = this.clusters; - if (this.filter === 'running') { - // For "running" filter, only show truly active (running) clusters - // Exclude initializing, stopped, failed, etc. - filteredClusters = this.clusters.filter((c) => c.state === 'running'); - } else if (this.filter !== 'all') { - // For other specific filters, match exact state - filteredClusters = this.clusters.filter((c) => c.state === this.filter); - } - - // Ensure selectedIndex is valid - if (this.selectedIndex >= filteredClusters.length) { - this.selectedIndex = Math.max(0, filteredClusters.length - 1); - } - - // Render clusters table - this.renderer.renderClustersTable(filteredClusters, this.selectedIndex); - - // Render system stats - this.renderer.renderSystemStats(this.clusters, this.resourceStats); - - // Update agent table for selected cluster (ONLY in detail view) - if (this.viewMode === 'detail' && this.detailClusterId) { - // In detail view, show agents for the detail cluster - try { - const status = this.orchestrator.getStatus(this.detailClusterId); - this.renderer.renderAgentTable(status.agents, this.resourceStats); - } catch { - // Cluster might have been stopped/killed - this.renderer.renderAgentTable([], this.resourceStats); - } - } else if (this.viewMode === 'overview') { - // In overview view, don't show agents (or show empty) - this.renderer.renderAgentTable([], this.resourceStats); - } - break; - - case 'resource_stats': - // Update resource stats - this.resourceStats = update.stats; - - // Re-render system stats - this.renderer.renderSystemStats(this.clusters, this.resourceStats); - - // Update agent table with new resource stats (ONLY in detail view) - if (this.viewMode === 'detail' && this.detailClusterId) { - try { - const status = this.orchestrator.getStatus(this.detailClusterId); - this.renderer.renderAgentTable(status.agents, this.resourceStats); - } catch { - this.renderer.renderAgentTable([], this.resourceStats); - } - } - break; - - case 'new_message': - // Only add messages from the selected cluster - const selectedClusterId = this.renderer.selectedClusterId; - if (selectedClusterId && update.clusterId === selectedClusterId) { - // Add new message to log - this.messages.push(update.message); - - // Keep only last 100 messages in memory - if (this.messages.length > 100) { - this.messages = this.messages.slice(-100); - } - - // Render last 20 messages - this.renderer.renderLogs(this.messages.slice(-20)); - } - break; - - case 'error': - // Add error to log - this.messages.push({ - timestamp: new Date().toISOString(), - text: `āœ— ${update.error}`, - level: 'error', - }); - - if (this.messages.length > 100) { - this.messages = this.messages.slice(-100); - } - - this.renderer.renderLogs(this.messages.slice(-20)); - break; - } - - // Render screen - this.screen.render(); - } - - exit() { - if (this.poller) { - this.poller.stop(); - } - if (this.screen) { - this.screen.destroy(); - } - process.exit(0); - } -} - -module.exports = TUI; diff --git a/src/tui/keybindings.js b/src/tui/keybindings.js deleted file mode 100644 index 43084594..00000000 --- a/src/tui/keybindings.js +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Keybindings for TUI - * - * Handles: - * - Navigation (up/down, j/k) - * - Actions (kill, stop, export, logs) - * - Confirmations for destructive actions - */ - -const blessed = require('blessed'); -const fs = require('fs'); -const { spawn } = require('child_process'); -const { execSync } = require('../lib/safe-exec'); // Enforces timeouts - -const HELP_TEXT_DETAIL = - '{cyan-fg}[Esc]{/} Back {cyan-fg}[k]{/} Kill {cyan-fg}[s]{/} Stop {cyan-fg}[e]{/} Export {cyan-fg}[l]{/} Logs {cyan-fg}[r]{/} Refresh {cyan-fg}[q]{/} Quit'; -const HELP_TEXT_OVERVIEW = - '{cyan-fg}[Enter]{/} View {cyan-fg}[↑/↓]{/} Navigate {cyan-fg}[k]{/} Kill {cyan-fg}[s]{/} Stop {cyan-fg}[l]{/} Logs {cyan-fg}[r]{/} Refresh {cyan-fg}[q]{/} Quit'; - -function getSelectedCluster(tui) { - if (tui.clusters.length === 0) { - return null; - } - - return tui.clusters[tui.selectedIndex] || null; -} - -function pushLogMessage(tui, text, level) { - tui.messages.push({ - timestamp: new Date().toISOString(), - text, - level, - }); - tui.renderer.renderLogs(tui.messages.slice(-20)); -} - -function enterDetailView(screen, widgets, tui) { - if (tui.viewMode !== 'overview') { - return; - } - - const selectedCluster = getSelectedCluster(tui); - if (!selectedCluster) { - return; - } - - tui.viewMode = 'detail'; - tui.detailClusterId = selectedCluster.id; - tui.renderer.setSelectedCluster(selectedCluster.id); - tui.messages = []; - - widgets.helpBar.setContent(HELP_TEXT_DETAIL); - widgets.clustersTable.hide(); - widgets.statsBox.hide(); - widgets.agentTable.show(); - widgets.logsBox.show(); - screen.render(); -} - -function exitDetailView(screen, widgets, tui) { - if (tui.viewMode !== 'detail') { - return; - } - - tui.viewMode = 'overview'; - tui.detailClusterId = null; - tui.renderer.setSelectedCluster(null); - tui.messages = []; - - widgets.helpBar.setContent(HELP_TEXT_OVERVIEW); - widgets.clustersTable.show(); - widgets.statsBox.show(); - widgets.agentTable.hide(); - widgets.logsBox.hide(); - screen.render(); -} - -function moveSelection(screen, tui, orchestrator, delta) { - if (tui.clusters.length === 0) { - return; - } - - tui.selectedIndex = Math.min(tui.clusters.length - 1, Math.max(0, tui.selectedIndex + delta)); - tui.renderer.renderClustersTable(tui.clusters, tui.selectedIndex); - - const selectedCluster = tui.clusters[tui.selectedIndex]; - if (selectedCluster) { - tui.renderer.setSelectedCluster(selectedCluster.id); - tui.messages = []; - - const status = orchestrator.getStatus(selectedCluster.id); - tui.renderer.renderAgentTable(status.agents, tui.resourceStats); - } - - screen.render(); -} - -function createConfirmationDialog(screen, label, color) { - const labelText = color - ? ` {bold}{${color}-fg}${label}{/${color}-fg}{/bold} ` - : ` {bold}${label}{/bold} `; - - return blessed.question({ - parent: screen, - border: 'line', - height: 'shrink', - width: 'half', - top: 'center', - left: 'center', - label: labelText, - tags: true, - keys: true, - vi: true, - }); -} - -function confirmClusterAction(options) { - const { screen, tui, selectedCluster, label, color, prompt, action, successText, failureText } = - options; - const question = createConfirmationDialog(screen, label, color); - - question.ask(prompt, async (err, value) => { - if (err || !value) { - return; - } - - try { - await action(selectedCluster); - pushLogMessage(tui, successText(selectedCluster), 'success'); - } catch (error) { - pushLogMessage(tui, failureText(error), 'error'); - } - - screen.render(); - }); -} - -function handleKillCluster(screen, tui, orchestrator) { - const selectedCluster = getSelectedCluster(tui); - if (!selectedCluster) { - return; - } - - confirmClusterAction({ - screen, - tui, - selectedCluster, - label: 'Confirm Kill', - color: 'red', - prompt: `Kill cluster ${selectedCluster.id}?\n\n(This will force-stop all agents)`, - action: (cluster) => orchestrator.kill(cluster.id), - successText: (cluster) => `āœ“ Killed cluster ${cluster.id}`, - failureText: (error) => `āœ— Failed to kill cluster: ${error.message}`, - }); -} - -function handleStopCluster(screen, tui, orchestrator) { - const selectedCluster = getSelectedCluster(tui); - if (!selectedCluster) { - return; - } - - confirmClusterAction({ - screen, - tui, - selectedCluster, - label: 'Confirm Stop', - color: 'yellow', - prompt: `Stop cluster ${selectedCluster.id}?\n\n(This will gracefully stop all agents)`, - action: (cluster) => orchestrator.stop(cluster.id), - successText: (cluster) => `āœ“ Stopped cluster ${cluster.id}`, - failureText: (error) => `āœ— Failed to stop cluster: ${error.message}`, - }); -} - -function handleExportCluster(screen, tui, orchestrator) { - const selectedCluster = getSelectedCluster(tui); - if (!selectedCluster) { - return; - } - - try { - const markdown = orchestrator.export(selectedCluster.id, 'markdown'); - const filename = `${selectedCluster.id}-export.md`; - fs.writeFileSync(filename, markdown); - pushLogMessage(tui, `āœ“ Exported cluster to ${filename}`, 'success'); - } catch (error) { - pushLogMessage(tui, `āœ— Failed to export cluster: ${error.message}`, 'error'); - } - - screen.render(); -} - -function findTerminalCommand() { - const terminals = ['gnome-terminal', 'konsole', 'xterm', 'urxvt', 'alacritty', 'kitty']; - - for (const terminal of terminals) { - try { - execSync(`which ${terminal}`, { stdio: 'ignore' }); - return terminal; - } catch { - // Ignore missing terminal - } - } - - return 'xterm'; -} - -function buildLogCommand(clusterId) { - const term = - process.env.TERM_PROGRAM || (process.env.COLORTERM ? 'gnome-terminal' : null) || 'xterm'; - - if (term === 'iTerm.app' || term === 'Apple_Terminal') { - return { - cmd: 'osascript', - args: ['-e', `tell application "Terminal" to do script "zeroshot logs ${clusterId} -f"`], - }; - } - - const cmd = findTerminalCommand(); - const logCommand = `zeroshot logs ${clusterId} -f; read -p "Press enter to close..."`; - - if (cmd === 'gnome-terminal' || cmd === 'konsole') { - return { cmd, args: ['--', 'bash', '-c', logCommand] }; - } - - return { cmd, args: ['-e', 'bash', '-c', logCommand] }; -} - -function handleOpenLogs(screen, tui) { - const selectedCluster = getSelectedCluster(tui); - if (!selectedCluster) { - return; - } - - try { - const { cmd, args } = buildLogCommand(selectedCluster.id); - spawn(cmd, args, { detached: true, stdio: 'ignore' }); - pushLogMessage(tui, `āœ“ Opened logs for ${selectedCluster.id} in new terminal`, 'success'); - } catch (error) { - pushLogMessage(tui, `āœ— Failed to open logs: ${error.message}`, 'error'); - } - - screen.render(); -} - -function handleRefresh(screen, tui) { - pushLogMessage(tui, '↻ Refreshing...', 'info'); - screen.render(); - - if (tui.poller) { - tui.poller.poll(); - } -} - -function handleExit(screen, tui) { - const question = createConfirmationDialog(screen, 'Confirm Exit'); - - question.ask('Exit TUI?\n\n(Clusters will continue running)', (err, value) => { - if (err || !value) { - return; - } - - tui.exit(); - }); -} - -function handleHelp(screen) { - const helpBox = blessed.box({ - parent: screen, - border: 'line', - height: '80%', - width: '60%', - top: 'center', - left: 'center', - label: ' {bold}Keybindings{/bold} ', - tags: true, - keys: true, - vi: true, - scrollable: true, - alwaysScroll: true, - content: ` -{bold}Navigation:{/bold} - ↑/k Move selection up - ↓/j Move selection down - -{bold}Actions:{/bold} - K Kill selected cluster (force stop) - s Stop selected cluster (graceful) - e Export selected cluster to markdown - l Show full logs in new terminal - r Force refresh - -{bold}Other:{/bold} - ?/h Show this help - q/Ctrl-C Exit TUI - -Press any key to close... - `.trim(), - }); - - helpBox.key(['escape', 'q', 'enter', 'space'], () => { - helpBox.destroy(); - screen.render(); - }); - - screen.render(); -} - -function setupKeybindings(screen, widgets, tui, orchestrator) { - screen.key(['enter'], () => enterDetailView(screen, widgets, tui)); - screen.key(['escape'], () => exitDetailView(screen, widgets, tui)); - screen.key(['up', 'k'], () => moveSelection(screen, tui, orchestrator, -1)); - screen.key(['down', 'j'], () => moveSelection(screen, tui, orchestrator, 1)); - screen.key(['K'], () => handleKillCluster(screen, tui, orchestrator)); - screen.key(['s'], () => handleStopCluster(screen, tui, orchestrator)); - screen.key(['e'], () => handleExportCluster(screen, tui, orchestrator)); - screen.key(['l'], () => handleOpenLogs(screen, tui)); - screen.key(['r'], () => handleRefresh(screen, tui)); - screen.key(['q', 'C-c'], () => handleExit(screen, tui)); - screen.key(['?', 'h'], () => handleHelp(screen)); -} - -module.exports = { setupKeybindings }; diff --git a/src/tui/renderer.js b/src/tui/renderer.js deleted file mode 100644 index f714cf04..00000000 --- a/src/tui/renderer.js +++ /dev/null @@ -1,184 +0,0 @@ -/** - * TUI Screen Renderer - * Transforms polled data into widget updates using formatters and layout widgets - */ - -const { formatTimestamp, formatBytes, formatCPU, stateIcon, truncate } = require('./formatters'); - -class Renderer { - /** - * Create renderer instance - * @param {object} widgets - Widget objects from layout.js - * @param {object} screen - Blessed screen instance - */ - constructor(widgets, screen) { - if (!widgets) { - throw new Error('Renderer requires widgets object from layout'); - } - if (!screen) { - throw new Error('Renderer requires screen instance'); - } - - this.widgets = widgets; - this.screen = screen; - this.selectedClusterId = null; - } - - /** - * Set the currently selected cluster ID - * @param {string|null} id - Cluster ID to select - */ - setSelectedCluster(id) { - this.selectedClusterId = id; - } - - /** - * Render clusters table with state icons and uptime - * @param {Array} clusters - Array of cluster objects - */ - renderClustersTable(clusters) { - const clusterList = !clusters || !Array.isArray(clusters) ? [] : clusters; - - const data = clusterList.map((c) => { - if (!c) return ['', '', '', '']; - - const icon = stateIcon(c.state || 'unknown'); - const uptime = - c.state === 'running' && c.createdAt ? formatTimestamp(Date.now() - c.createdAt) : '-'; - const clusterId = truncate(c.id || '', 18); - const state = (c.state || 'unknown').toUpperCase(); - const agentCount = `${c.agentCount || 0} agents`; - - return [`${icon} ${clusterId}`, state, agentCount, uptime]; - }); - - if (this.widgets.clustersTable && this.widgets.clustersTable.setData) { - this.widgets.clustersTable.setData({ - headers: ['ID', 'State', 'Agents', 'Uptime'], - data, - }); - } - } - - /** - * Render system statistics box with aggregate metrics - * @param {Array} clusters - Array of cluster objects - * @param {Map} resourceStats - Map of PID -> {cpu, memory} - */ - renderSystemStats(clusters, resourceStats) { - const clusterList = !clusters || !Array.isArray(clusters) ? [] : clusters; - const statsMap = !resourceStats || !(resourceStats instanceof Map) ? new Map() : resourceStats; - - // Calculate aggregate stats - const activeClusters = clusterList.filter((c) => c && c.state === 'running').length; - const totalAgents = clusterList.reduce((sum, c) => sum + (c?.agentCount || 0), 0); - - // Calculate average CPU and memory from resource stats - let totalCpu = 0; - let totalMemory = 0; - let statCount = 0; - - statsMap.forEach((stat) => { - if (stat && typeof stat.cpu === 'number' && typeof stat.memory === 'number') { - totalCpu += stat.cpu; - totalMemory += stat.memory; - statCount++; - } - }); - - const avgCpu = statCount > 0 ? totalCpu / statCount : 0; - const avgMemory = statCount > 0 ? totalMemory / statCount : 0; - - // Format output with blessed color tags - const statsText = [ - '{cyan-fg}Active Clusters:{/} ' + activeClusters, - '{cyan-fg}Total Agents:{/} ' + totalAgents, - '{cyan-fg}Avg CPU:{/} ' + formatCPU(avgCpu), - '{cyan-fg}Avg Memory:{/} ' + formatBytes(avgMemory), - ].join('\n'); - - if (this.widgets.statsBox && this.widgets.statsBox.setContent) { - this.widgets.statsBox.setContent(statsText); - } - } - - /** - * Render agent table for selected cluster - * @param {Array} agents - Array of agent objects - * @param {Map} resourceStats - Map of PID -> {cpu, memory} - */ - renderAgentTable(agents, resourceStats) { - if (!this.selectedClusterId) { - // No cluster selected, show empty table - if (this.widgets.agentTable && this.widgets.agentTable.setData) { - this.widgets.agentTable.setData({ - headers: ['Agent', 'Role', 'State', 'Iter', 'CPU%', 'Mem'], - data: [], - }); - } - return; - } - - const agentList = !agents || !Array.isArray(agents) ? [] : agents; - const statsMap = !resourceStats || !(resourceStats instanceof Map) ? new Map() : resourceStats; - - const data = agentList.map((a) => { - if (!a) return ['', '', '', '', '', '']; - - const pid = a.pid; - const stats = statsMap.get(pid) || { cpu: 0, memory: 0 }; - - const agentId = truncate(a.id || '', 12); - const role = truncate(a.role || '', 12); - const state = a.state || 'unknown'; - const iteration = `${a.iteration || 0}/${a.maxIterations || 0}`; - const cpu = formatCPU(stats.cpu); - const memory = formatBytes(stats.memory); - - return [agentId, role, state, iteration, cpu, memory]; - }); - - if (this.widgets.agentTable && this.widgets.agentTable.setData) { - this.widgets.agentTable.setData({ - headers: ['Agent', 'Role', 'State', 'Iter', 'CPU%', 'Mem'], - data, - }); - } - } - - /** - * Render log messages to log widget - * @param {Array} messages - Array of message objects - */ - renderLogs(messages) { - if (!messages || !Array.isArray(messages)) { - return; - } - - if (!this.widgets.logsBox || !this.widgets.logsBox.log) { - return; - } - - messages.forEach((msg) => { - if (!msg) return; - - const timestamp = msg.timestamp || Date.now(); - const time = new Date(timestamp).toLocaleTimeString(); - const sender = truncate(msg.sender || 'unknown', 15); - const text = truncate(msg.content?.text || '', 60); - - this.widgets.logsBox.log(`[${time}] ${sender}: ${text}`); - }); - } - - /** - * Trigger screen render to update display - */ - render() { - if (this.screen && this.screen.render) { - this.screen.render(); - } - } -} - -module.exports = Renderer; diff --git a/task-lib/config.js b/task-lib/config.js index f3c1a0d7..47a9f449 100644 --- a/task-lib/config.js +++ b/task-lib/config.js @@ -1,7 +1,10 @@ -import { homedir } from 'os'; import { join } from 'path'; +import { homedir } from 'os'; + +const HOME_DIR = + process.env.ZEROSHOT_HOME || process.env.HOME || process.env.USERPROFILE || homedir(); -export const TASKS_DIR = join(homedir(), '.claude-zeroshot'); +export const TASKS_DIR = join(HOME_DIR, '.claude-zeroshot'); export const LOGS_DIR = join(TASKS_DIR, 'logs'); export const SCHEDULER_PID_FILE = join(TASKS_DIR, 'scheduler.pid'); export const SCHEDULER_LOG = join(TASKS_DIR, 'scheduler.log'); diff --git a/tests/add-agents-trigger-merge.test.js b/tests/add-agents-trigger-merge.test.js new file mode 100644 index 00000000..e0bf3d34 --- /dev/null +++ b/tests/add-agents-trigger-merge.test.js @@ -0,0 +1,257 @@ +/** + * Test: add_agents should REPLACE agents with duplicate IDs entirely + * + * HISTORY: + * - Original behavior: Merged triggers but kept old hooks → BUG + * - Bug manifestation: heavy-validation consensus-coordinator used quick-validation's + * hooks, publishing QUICK_VALIDATION_PASSED instead of VALIDATION_RESULT → infinite loop + * - Fix: Replace agent entirely when same ID encountered + * + * DESIGN DECISION: Same ID = same agent = full replacement + * If you need different behavior, use different agent IDs. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const Orchestrator = require('../src/orchestrator.js'); +const MockTaskRunner = require('./helpers/mock-task-runner.js'); + +// Isolate tests from user settings +const testSettingsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-test-settings-')); +const testSettingsFile = path.join(testSettingsDir, 'settings.json'); +fs.writeFileSync(testSettingsFile, JSON.stringify({ maxModel: 'opus', minModel: null })); +process.env.ZEROSHOT_SETTINGS_FILE = testSettingsFile; + +function createTempDir() { + const tmpBase = path.join(os.tmpdir(), 'zeroshot-test'); + if (!fs.existsSync(tmpBase)) { + fs.mkdirSync(tmpBase, { recursive: true }); + } + return fs.mkdtempSync(path.join(tmpBase, 'agent-replace-')); +} + +function cleanupTempDir(tmpDir) { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe('add_agents duplicate ID handling', function () { + this.timeout(10000); + + let tmpDir; + let orchestrator; + let mockRunner; + + beforeEach(function () { + tmpDir = createTempDir(); + mockRunner = new MockTaskRunner(); + }); + + afterEach(function () { + if (orchestrator) { + orchestrator.close(); + orchestrator = null; + } + cleanupTempDir(tmpDir); + }); + + it('should REPLACE agent entirely when adding agent with duplicate ID', async function () { + orchestrator = new Orchestrator({ + dataDir: tmpDir, + taskRunner: mockRunner, + quiet: true, + }); + + // Initial agent with QUICK trigger and quick-specific hooks + const initialConfig = { + agents: [ + { + id: 'consensus-coordinator', + role: 'coordinator', + modelLevel: 'level2', + triggers: [{ topic: 'QUICK_VALIDATION_RESULT', action: 'execute_task' }], + prompt: 'Quick validation coordinator.', + hooks: { + onComplete: { + action: 'publish_message', + config: { topic: 'QUICK_VALIDATION_PASSED', content: { text: 'Stage 1 passed' } }, + }, + }, + }, + ], + }; + + const result = await orchestrator.start(initialConfig, { text: 'Test task' }); + const clusterId = result.id; + const cluster = orchestrator.getCluster(clusterId); + + // Verify initial state + const agentBefore = cluster.agents.find((a) => a.id === 'consensus-coordinator'); + assert.ok(agentBefore, 'consensus-coordinator should exist'); + assert.strictEqual(agentBefore.config.triggers.length, 1); + assert.strictEqual(agentBefore.config.hooks.onComplete.config.topic, 'QUICK_VALIDATION_PASSED'); + + // Add agent with SAME ID but DIFFERENT triggers and hooks (simulating heavy-validation) + await orchestrator._opAddAgents( + cluster, + { + agents: [ + { + id: 'consensus-coordinator', // Same ID! + role: 'coordinator', + modelLevel: 'level2', + triggers: [{ topic: 'HEAVY_VALIDATION_RESULT', action: 'execute_task' }], + prompt: 'Heavy validation coordinator.', + hooks: { + onComplete: { + action: 'publish_message', + config: { topic: 'VALIDATION_RESULT', content: { text: 'All validations passed' } }, + }, + }, + }, + ], + }, + {} + ); + + // Verify REPLACEMENT occurred (not merge) + const clusterAfter = orchestrator.getCluster(clusterId); + const agentAfter = clusterAfter.agents.find((a) => a.id === 'consensus-coordinator'); + + assert.ok(agentAfter, 'consensus-coordinator should still exist'); + + // CRITICAL: Should have ONLY the new trigger (not merged) + assert.strictEqual( + agentAfter.config.triggers.length, + 1, + 'Should have 1 trigger (replaced, not merged)' + ); + assert.strictEqual( + agentAfter.config.triggers[0].topic, + 'HEAVY_VALIDATION_RESULT', + 'Should have the NEW trigger' + ); + + // CRITICAL: Should have NEW hooks (the bug was keeping old hooks) + assert.strictEqual( + agentAfter.config.hooks.onComplete.config.topic, + 'VALIDATION_RESULT', + 'Should have NEW hooks (not old QUICK_VALIDATION_PASSED)' + ); + + // Verify prompt was also replaced + assert.strictEqual(agentAfter.config.prompt, 'Heavy validation coordinator.'); + + await orchestrator.stop(clusterId); + }); + + it('should allow adding agents with different IDs', async function () { + orchestrator = new Orchestrator({ + dataDir: tmpDir, + taskRunner: mockRunner, + quiet: true, + }); + + const initialConfig = { + agents: [ + { + id: 'quick-consensus', + role: 'coordinator', + triggers: [{ topic: 'QUICK_RESULT', action: 'execute_task' }], + prompt: 'Quick coordinator.', + }, + ], + }; + + const result = await orchestrator.start(initialConfig, { text: 'Test task' }); + const clusterId = result.id; + const cluster = orchestrator.getCluster(clusterId); + + // Add a DIFFERENT agent (different ID) + await orchestrator._opAddAgents( + cluster, + { + agents: [ + { + id: 'heavy-consensus', // Different ID + role: 'coordinator', + triggers: [{ topic: 'HEAVY_RESULT', action: 'execute_task' }], + prompt: 'Heavy coordinator.', + }, + ], + }, + {} + ); + + const clusterAfter = orchestrator.getCluster(clusterId); + + // Should have BOTH agents + assert.strictEqual(clusterAfter.agents.length, 2, 'Should have 2 agents'); + + const quickAgent = clusterAfter.agents.find((a) => a.id === 'quick-consensus'); + const heavyAgent = clusterAfter.agents.find((a) => a.id === 'heavy-consensus'); + + assert.ok(quickAgent, 'quick-consensus should exist'); + assert.ok(heavyAgent, 'heavy-consensus should exist'); + + await orchestrator.stop(clusterId); + }); + + it('should replace agent instance entirely when same ID added', async function () { + orchestrator = new Orchestrator({ + dataDir: tmpDir, + taskRunner: mockRunner, + quiet: true, + }); + + const initialConfig = { + agents: [ + { + id: 'test-agent', + role: 'validator', + triggers: [{ topic: 'TEST', action: 'execute_task' }], + prompt: 'Test agent.', + }, + ], + }; + + const result = await orchestrator.start(initialConfig, { text: 'Test task' }); + const clusterId = result.id; + const cluster = orchestrator.getCluster(clusterId); + + // cluster.agents contains AgentWrapper instances directly + const agentBefore = cluster.agents.find((a) => a.id === 'test-agent'); + assert.ok(agentBefore, 'Agent should exist before replacement'); + + // Replace the agent + await orchestrator._opAddAgents( + cluster, + { + agents: [ + { + id: 'test-agent', + role: 'validator', + triggers: [{ topic: 'TEST2', action: 'execute_task' }], + prompt: 'Replaced agent.', + }, + ], + }, + {} + ); + + const clusterAfter = orchestrator.getCluster(clusterId); + const agentAfter = clusterAfter.agents.find((a) => a.id === 'test-agent'); + + // Should be a DIFFERENT AgentWrapper instance (not the same object) + assert.notStrictEqual(agentAfter, agentBefore, 'Should be new AgentWrapper instance'); + + // Verify the new config was applied + assert.strictEqual(agentAfter.config.triggers[0].topic, 'TEST2'); + assert.strictEqual(agentAfter.config.prompt, 'Replaced agent.'); + + await orchestrator.stop(clusterId); + }); +}); diff --git a/tests/agent-task-not-found.test.js b/tests/agent-task-not-found.test.js new file mode 100644 index 00000000..d253ea61 --- /dev/null +++ b/tests/agent-task-not-found.test.js @@ -0,0 +1,159 @@ +/** + * TEST: Agent Task Not Found - Fail-Safe Restart Behavior + * + * Verifies that when zeroshot status returns "ID not found", the agent + * immediately fails with restart error instead of polling 30 times. + * + * POSTMORTEM (2026-01-29): Agent worker polling failed 30 times when task + * completed and was cleaned up before worker could detect it. Worker treated + * "not found" as retryable error, wasting 30+ seconds before giving up. + * + * FIX: Detect "ID not found" immediately → return error → trigger restart (fail-safe) + * + * This test verifies the fix at the code level by reading the implementation. + * Integration test would require complex mocking of child processes and timers. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +describe('Agent Task Not Found - Fail-Safe Restart', () => { + const sourceFile = path.join(__dirname, '..', 'src', 'agent', 'agent-task-executor.js'); + let sourceCode; + + before(() => { + sourceCode = fs.readFileSync(sourceFile, 'utf8'); + }); + + it('should have handleStatusExecError function that detects "ID not found"', () => { + // Verify the function exists + assert.ok( + sourceCode.includes('function handleStatusExecError'), + 'handleStatusExecError function should exist' + ); + + // Verify it checks for "ID not found" pattern + assert.ok( + sourceCode.includes('ID not found') || sourceCode.includes('Not found in tasks'), + 'Should check for "ID not found" or "Not found in tasks" patterns' + ); + }); + + it('should check both error.message and stderr for "not found" patterns', () => { + // Extract the handleStatusExecError function + const functionMatch = sourceCode.match( + /function handleStatusExecError\([^)]*\)\s*{[\s\S]*?^}/m + ); + + assert.ok(functionMatch, 'Should find handleStatusExecError function'); + + const functionBody = functionMatch[0]; + + // Verify it checks error message + assert.ok( + functionBody.includes('error.message') || functionBody.includes('errorMessage'), + 'Should check error.message for patterns' + ); + + // Verify it checks stderr + assert.ok( + functionBody.includes('stderr') || functionBody.includes('stderrMessage'), + 'Should check stderr for patterns' + ); + + // Verify it looks for both "ID not found" and "Not found in tasks" + const hasIdNotFound = functionBody.includes('ID not found'); + const hasNotFoundInTasks = functionBody.includes('Not found in tasks'); + + assert.ok( + hasIdNotFound && hasNotFoundInTasks, + 'Should check for both "ID not found" and "Not found in tasks" patterns' + ); + }); + + it('should return error immediately when task not found (not retry)', () => { + const functionMatch = sourceCode.match( + /function handleStatusExecError\([^)]*\)\s*{[\s\S]*?^}/m + ); + + assert.ok(functionMatch, 'Should find handleStatusExecError function'); + + const functionBody = functionMatch[0]; + + // Verify it has a dedicated "not found" check BEFORE the retry counter + const notFoundCheckIndex = functionBody.indexOf('ID not found'); + const retryCounterIndex = functionBody.indexOf('consecutiveExecFailures++'); + + assert.ok( + notFoundCheckIndex > 0 && notFoundCheckIndex < retryCounterIndex, + 'Should check for "not found" BEFORE incrementing retry counter' + ); + + // Verify it resolves immediately when not found + const notFoundSection = functionBody.substring(notFoundCheckIndex, retryCounterIndex); + + assert.ok( + notFoundSection.includes('resolve('), + 'Should call resolve() immediately when task not found' + ); + + assert.ok( + notFoundSection.includes('success: false'), + 'Should resolve with success: false when task not found' + ); + + assert.ok( + notFoundSection.includes('restarting') || notFoundSection.includes('restart'), + 'Error message should mention restarting' + ); + }); + + it('should publish AGENT_ERROR event when task not found', () => { + const functionMatch = sourceCode.match( + /function handleStatusExecError\([^)]*\)\s*{[\s\S]*?^}/m + ); + + const functionBody = functionMatch[0]; + + // Find the "not found" section + const notFoundStart = functionBody.indexOf('ID not found'); + const retryCounterIndex = functionBody.indexOf('consecutiveExecFailures++'); + const notFoundSection = functionBody.substring(notFoundStart, retryCounterIndex); + + // Verify it publishes an error event + assert.ok( + notFoundSection.includes('_publish') && notFoundSection.includes('AGENT_ERROR'), + 'Should publish AGENT_ERROR event when task not found' + ); + + // Verify error type is appropriate + assert.ok( + notFoundSection.includes('task_not_found') || notFoundSection.includes('not_found'), + 'Error event should have appropriate error type' + ); + }); + + it('should have improved warning message', () => { + const functionMatch = sourceCode.match( + /function handleStatusExecError\([^)]*\)\s*{[\s\S]*?^}/m + ); + + const functionBody = functionMatch[0]; + + // Verify warning message is helpful + const notFoundStart = functionBody.indexOf('ID not found'); + const retryCounterIndex = functionBody.indexOf('consecutiveExecFailures++'); + const notFoundSection = functionBody.substring(notFoundStart, retryCounterIndex); + + assert.ok( + notFoundSection.includes('will restart') || notFoundSection.includes('restarting'), + 'Warning message should explain that task will be restarted' + ); + + assert.ok( + notFoundSection.includes('ensure completion') || notFoundSection.includes('safety'), + 'Warning message should explain fail-safe reasoning' + ); + }); +}); diff --git a/tests/cluster-operations.test.js b/tests/cluster-operations.test.js index e90a2e92..ccdc5b42 100644 --- a/tests/cluster-operations.test.js +++ b/tests/cluster-operations.test.js @@ -116,6 +116,34 @@ describe('CLUSTER_OPERATIONS', function () { 'Should have error about missing ISSUE_OPENED trigger' ); }); + + it('should include agents from load_config when building proposed config (parameterized)', function () { + const existing = [ + { + id: 'worker', + role: 'implementation', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + }, + ]; + + const ops = [ + { + action: 'load_config', + config: { base: 'quick-validation', params: {} }, + }, + ]; + + const proposed = orchestrator._buildProposedAgentConfigs(existing, ops); + + assert( + proposed.some((a) => a.id === 'validator-requirements'), + 'Expected load_config to add validator-requirements' + ); + assert( + proposed.some((a) => a.id === 'validator-code'), + 'Expected load_config to add validator-code' + ); + }); }); describe('VALID_OPERATIONS constant', function () { diff --git a/tests/conductor-republish-metadata.test.js b/tests/conductor-republish-metadata.test.js index 88cc0446..586cd773 100644 --- a/tests/conductor-republish-metadata.test.js +++ b/tests/conductor-republish-metadata.test.js @@ -31,7 +31,9 @@ describe('Conductor Republish Metadata', function () { // Parse and validate logic script const script = issueTrigger.logic.script; expect(script, 'Logic script should check sender=system').to.include('message.sender === '); - expect(script, 'Logic script should exclude _republished').to.include('!message.metadata?._republished'); + expect(script, 'Logic script should exclude _republished').to.include( + '!message.metadata?._republished' + ); }); it('junior conductor transform should set _republished metadata', function () { diff --git a/tests/config-validator.test.js b/tests/config-validator.test.js index 11c07e02..d84b6aba 100644 --- a/tests/config-validator.test.js +++ b/tests/config-validator.test.js @@ -137,6 +137,7 @@ describe('modelRules validation', function () { ], }); assert.ok(result.errors.some((e) => e.includes('Add catch-all rule'))); + assert.ok(result.errors.some((e) => e.includes('"modelLevel": "level2"'))); }); it('should accept modelRules with "all" catch-all', function () { @@ -184,7 +185,7 @@ describe('modelRules validation', function () { }, ], }); - assert.ok(result.warnings.some((w) => w.includes('model "gpt4"') && w.includes('claude'))); + assert.ok(result.warnings.some((w) => w.includes('model "gpt4"') && w.includes('not valid'))); }); }); diff --git a/tests/fixtures/detached-daemon.js b/tests/fixtures/detached-daemon.js new file mode 100644 index 00000000..524be6e7 --- /dev/null +++ b/tests/fixtures/detached-daemon.js @@ -0,0 +1,55 @@ +const Orchestrator = require('../../src/orchestrator'); +const MockTaskRunner = require('../helpers/mock-task-runner'); + +const storageDir = process.env.ZEROSHOT_TEST_STORAGE; +const clusterId = process.env.ZEROSHOT_TEST_CLUSTER_ID; + +if (!storageDir || !clusterId) { + console.error('Missing ZEROSHOT_TEST_STORAGE or ZEROSHOT_TEST_CLUSTER_ID'); + process.exit(1); +} + +const mockRunner = new MockTaskRunner(); +const orchestrator = new Orchestrator({ + quiet: true, + storageDir, + taskRunner: mockRunner, +}); + +const config = { + agents: [ + { + id: 'worker', + role: 'implementation', + timeout: 0, + triggers: [{ topic: 'NEVER', action: 'execute_task' }], + prompt: 'Idle agent for detached stop test', + }, + ], +}; + +async function startCluster() { + await orchestrator.start(config, { text: 'Detached stop test' }, { clusterId }); + console.log('READY'); +} + +async function shutdown(signal) { + try { + await orchestrator.stop(clusterId); + console.log(`[DAEMON] Stopped cluster ${clusterId} from ${signal}`); + } catch (error) { + console.error(`[DAEMON] Failed to stop cluster ${clusterId}: ${error.message}`); + } + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +startCluster().catch((error) => { + console.error(`[DAEMON] Failed to start cluster: ${error.message}`); + console.error(error.stack); + process.exit(1); +}); + +setInterval(() => {}, 1000); diff --git a/tests/fixtures/tui-v2/protocol/invalid.params.getClusterSummary.json b/tests/fixtures/tui-v2/protocol/invalid.params.getClusterSummary.json new file mode 100644 index 00000000..3ce90532 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/invalid.params.getClusterSummary.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 100, + "method": "getClusterSummary", + "params": { + "clusterId": 123 + } +} diff --git a/tests/fixtures/tui-v2/protocol/invalid.request.missing-method.json b/tests/fixtures/tui-v2/protocol/invalid.request.missing-method.json new file mode 100644 index 00000000..f2816670 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/invalid.request.missing-method.json @@ -0,0 +1,4 @@ +{ + "jsonrpc": "2.0", + "id": 99 +} diff --git a/tests/fixtures/tui-v2/protocol/notification.clusterLogLines.json b/tests/fixtures/tui-v2/protocol/notification.clusterLogLines.json new file mode 100644 index 00000000..c3ab9036 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/notification.clusterLogLines.json @@ -0,0 +1,19 @@ +{ + "jsonrpc": "2.0", + "method": "clusterLogLines", + "params": { + "subscriptionId": "sub-logs-1", + "clusterId": "cluster-123", + "lines": [ + { + "id": "log-1", + "timestamp": 1769811111000, + "text": "Agent output", + "agent": "worker", + "role": "implementation", + "sender": "worker" + } + ], + "droppedCount": 0 + } +} diff --git a/tests/fixtures/tui-v2/protocol/notification.clusterTimelineEvents.json b/tests/fixtures/tui-v2/protocol/notification.clusterTimelineEvents.json new file mode 100644 index 00000000..9cb74ae3 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/notification.clusterTimelineEvents.json @@ -0,0 +1,19 @@ +{ + "jsonrpc": "2.0", + "method": "clusterTimelineEvents", + "params": { + "subscriptionId": "sub-timeline-1", + "clusterId": "cluster-123", + "events": [ + { + "id": "evt-1", + "timestamp": 1769811111000, + "topic": "PLAN_READY", + "label": "Plan ready", + "approved": null, + "sender": "planner" + } + ], + "droppedCount": 0 + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.getClusterSummary.json b/tests/fixtures/tui-v2/protocol/request.getClusterSummary.json new file mode 100644 index 00000000..e5e9bd26 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.getClusterSummary.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "method": "getClusterSummary", + "params": { + "clusterId": "cluster-123" + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.getClusterTopology.json b/tests/fixtures/tui-v2/protocol/request.getClusterTopology.json new file mode 100644 index 00000000..042c26f5 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.getClusterTopology.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 11, + "method": "getClusterTopology", + "params": { + "clusterId": "cluster-123" + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.initialize.json b/tests/fixtures/tui-v2/protocol/request.initialize.json new file mode 100644 index 00000000..5f004610 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.initialize.json @@ -0,0 +1,17 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "client": { + "name": "zeroshot-tui", + "version": "0.1.0", + "pid": 4242 + }, + "capabilities": { + "wantsMetrics": true, + "wantsTopology": false + } + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.listClusterMetrics.json b/tests/fixtures/tui-v2/protocol/request.listClusterMetrics.json new file mode 100644 index 00000000..bed45323 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.listClusterMetrics.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 4, + "method": "listClusterMetrics", + "params": { + "clusterIds": ["cluster-123", "cluster-456"] + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.listClusters.json b/tests/fixtures/tui-v2/protocol/request.listClusters.json new file mode 100644 index 00000000..9371b943 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.listClusters.json @@ -0,0 +1,5 @@ +{ + "jsonrpc": "2.0", + "id": 2, + "method": "listClusters" +} diff --git a/tests/fixtures/tui-v2/protocol/request.sendGuidanceToAgent.json b/tests/fixtures/tui-v2/protocol/request.sendGuidanceToAgent.json new file mode 100644 index 00000000..f2314e9e --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.sendGuidanceToAgent.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "id": 7, + "method": "sendGuidanceToAgent", + "params": { + "clusterId": "cluster-123", + "agentId": "worker", + "text": "Focus on tests", + "timeoutMs": 5000 + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.sendGuidanceToCluster.json b/tests/fixtures/tui-v2/protocol/request.sendGuidanceToCluster.json new file mode 100644 index 00000000..bc635dfd --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.sendGuidanceToCluster.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "id": 8, + "method": "sendGuidanceToCluster", + "params": { + "clusterId": "cluster-123", + "text": "Ship it", + "timeoutMs": 5000 + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.startClusterFromIssue.json b/tests/fixtures/tui-v2/protocol/request.startClusterFromIssue.json new file mode 100644 index 00000000..6d7201d8 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.startClusterFromIssue.json @@ -0,0 +1,9 @@ +{ + "jsonrpc": "2.0", + "id": 6, + "method": "startClusterFromIssue", + "params": { + "ref": "covibes/zeroshot#240", + "providerOverride": null + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.startClusterFromText.json b/tests/fixtures/tui-v2/protocol/request.startClusterFromText.json new file mode 100644 index 00000000..d142b669 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.startClusterFromText.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "id": 5, + "method": "startClusterFromText", + "params": { + "text": "Implement the requested feature", + "providerOverride": "codex", + "clusterId": "cluster-789" + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.subscribeClusterLogs.json b/tests/fixtures/tui-v2/protocol/request.subscribeClusterLogs.json new file mode 100644 index 00000000..2f6fde6d --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.subscribeClusterLogs.json @@ -0,0 +1,9 @@ +{ + "jsonrpc": "2.0", + "id": 9, + "method": "subscribeClusterLogs", + "params": { + "clusterId": "cluster-123", + "agentId": "worker" + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.subscribeClusterTimeline.json b/tests/fixtures/tui-v2/protocol/request.subscribeClusterTimeline.json new file mode 100644 index 00000000..60fe45e5 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.subscribeClusterTimeline.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 10, + "method": "subscribeClusterTimeline", + "params": { + "clusterId": "cluster-123" + } +} diff --git a/tests/fixtures/tui-v2/protocol/request.unsubscribe.json b/tests/fixtures/tui-v2/protocol/request.unsubscribe.json new file mode 100644 index 00000000..f1369030 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/request.unsubscribe.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 21, + "method": "unsubscribe", + "params": { + "subscriptionId": "sub-123" + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.getClusterSummary.json b/tests/fixtures/tui-v2/protocol/response.getClusterSummary.json new file mode 100644 index 00000000..511e43dc --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.getClusterSummary.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "summary": { + "id": "cluster-123", + "state": "running", + "provider": "codex", + "createdAt": 1769810000000, + "agentCount": 3, + "messageCount": 120, + "cwd": "/Users/tom/.zeroshot/worktrees/prime-falcon-67" + } + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.getClusterTopology.json b/tests/fixtures/tui-v2/protocol/response.getClusterTopology.json new file mode 100644 index 00000000..1ffd22db --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.getClusterTopology.json @@ -0,0 +1,33 @@ +{ + "jsonrpc": "2.0", + "id": 11, + "result": { + "topology": { + "agents": [ + { "id": "planner", "role": "planning" }, + { "id": "worker", "role": "implementation" } + ], + "edges": [ + { + "from": "system", + "to": "ISSUE_OPENED", + "topic": "ISSUE_OPENED", + "kind": "source" + }, + { + "from": "ISSUE_OPENED", + "to": "planner", + "topic": "ISSUE_OPENED", + "kind": "trigger" + }, + { + "from": "planner", + "to": "PLAN_READY", + "topic": "PLAN_READY", + "kind": "publish" + } + ], + "topics": ["ISSUE_OPENED", "PLAN_READY"] + } + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.initialize.json b/tests/fixtures/tui-v2/protocol/response.initialize.json new file mode 100644 index 00000000..cf371728 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.initialize.json @@ -0,0 +1,28 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "server": { + "name": "zeroshot-backend", + "version": "5.4.0" + }, + "capabilities": { + "methods": [ + "initialize", + "listClusters", + "getClusterSummary", + "listClusterMetrics", + "startClusterFromText", + "startClusterFromIssue", + "sendGuidanceToAgent", + "sendGuidanceToCluster", + "subscribeClusterLogs", + "subscribeClusterTimeline", + "unsubscribe", + "getClusterTopology" + ], + "notifications": ["clusterLogLines", "clusterTimelineEvents"] + } + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.listClusterMetrics.json b/tests/fixtures/tui-v2/protocol/response.listClusterMetrics.json new file mode 100644 index 00000000..19b90ff8 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.listClusterMetrics.json @@ -0,0 +1,20 @@ +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "metrics": [ + { + "id": "cluster-123", + "supported": true, + "cpuPercent": 12.5, + "memoryMB": 256.4 + }, + { + "id": "cluster-456", + "supported": false, + "cpuPercent": null, + "memoryMB": null + } + ] + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.listClusters.json b/tests/fixtures/tui-v2/protocol/response.listClusters.json new file mode 100644 index 00000000..f047025f --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.listClusters.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "clusters": [ + { + "id": "cluster-123", + "state": "running", + "provider": "codex", + "createdAt": 1769810000000, + "agentCount": 3, + "messageCount": 120, + "cwd": "/Users/tom/.zeroshot/worktrees/prime-falcon-67" + }, + { + "id": "cluster-456", + "state": "idle", + "provider": null, + "createdAt": 1769810500000, + "agentCount": 0, + "messageCount": 0, + "cwd": null + } + ] + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.sendGuidanceToAgent.json b/tests/fixtures/tui-v2/protocol/response.sendGuidanceToAgent.json new file mode 100644 index 00000000..39b538d6 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.sendGuidanceToAgent.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": 7, + "result": { + "result": { + "status": "injected", + "reason": null, + "method": "pty", + "taskId": "task-1" + } + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.sendGuidanceToCluster.json b/tests/fixtures/tui-v2/protocol/response.sendGuidanceToCluster.json new file mode 100644 index 00000000..0cdadaca --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.sendGuidanceToCluster.json @@ -0,0 +1,28 @@ +{ + "jsonrpc": "2.0", + "id": 8, + "result": { + "result": { + "summary": { + "injected": 1, + "queued": 0, + "total": 1 + }, + "agents": { + "worker": { + "status": "injected", + "reason": null, + "method": "pty", + "taskId": "task-1" + }, + "validator": { + "status": "queued", + "reason": "busy", + "method": null, + "taskId": null + } + }, + "timestamp": 1769811111000 + } + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.startClusterFromIssue.json b/tests/fixtures/tui-v2/protocol/response.startClusterFromIssue.json new file mode 100644 index 00000000..3c3fd9ac --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.startClusterFromIssue.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "clusterId": "cluster-456" + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.startClusterFromText.json b/tests/fixtures/tui-v2/protocol/response.startClusterFromText.json new file mode 100644 index 00000000..17986870 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.startClusterFromText.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "id": 5, + "result": { + "clusterId": "cluster-789" + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.subscribeClusterLogs.json b/tests/fixtures/tui-v2/protocol/response.subscribeClusterLogs.json new file mode 100644 index 00000000..f0a0a54d --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.subscribeClusterLogs.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "id": 9, + "result": { + "subscriptionId": "sub-logs-1" + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.subscribeClusterTimeline.json b/tests/fixtures/tui-v2/protocol/response.subscribeClusterTimeline.json new file mode 100644 index 00000000..5dbdb505 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.subscribeClusterTimeline.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "id": 10, + "result": { + "subscriptionId": "sub-timeline-1" + } +} diff --git a/tests/fixtures/tui-v2/protocol/response.unsubscribe.json b/tests/fixtures/tui-v2/protocol/response.unsubscribe.json new file mode 100644 index 00000000..8d42ff14 --- /dev/null +++ b/tests/fixtures/tui-v2/protocol/response.unsubscribe.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "id": 21, + "result": { + "removed": true + } +} diff --git a/tests/integration/detached-stop.test.js b/tests/integration/detached-stop.test.js new file mode 100644 index 00000000..a57ab3ae --- /dev/null +++ b/tests/integration/detached-stop.test.js @@ -0,0 +1,135 @@ +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawn } = require('node:child_process'); + +const Orchestrator = require('../../src/orchestrator'); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForClusterRecord(storageDir, clusterId, expectedPid, timeoutMs = 10000) { + const clustersFile = path.join(storageDir, 'clusters.json'); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (fs.existsSync(clustersFile)) { + try { + const data = JSON.parse(fs.readFileSync(clustersFile, 'utf8')); + const record = data[clusterId]; + if (record && record.state === 'running' && record.pid) { + if (expectedPid) { + assert.strictEqual( + record.pid, + expectedPid, + `Expected cluster pid ${expectedPid}, got ${record.pid}` + ); + } + return record; + } + } catch { + // Ignore transient parse errors while file is being written. + } + } + await sleep(100); + } + + throw new Error(`Timed out waiting for cluster ${clusterId} in ${clustersFile}`); +} + +function waitForChildExit(child, timeoutMs = 10000) { + if (child.exitCode !== null) { + return child.exitCode; + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timed out waiting for child process ${child.pid} to exit`)); + }, timeoutMs); + + child.once('exit', (code) => { + clearTimeout(timer); + resolve(code); + }); + + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +describe('Detached daemon stop', function () { + this.timeout(20000); + + let tempDir; + let child; + + afterEach(function () { + if (child && child.exitCode === null) { + try { + process.kill(child.pid, 'SIGKILL'); + } catch { + // Ignore cleanup errors + } + } + + if (tempDir && fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + }); + + it('signals remote daemon pid and halts ledger activity', async function () { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zs-detached-stop-')); + const clusterId = `detached-stop-${Date.now()}`; + const fixturePath = path.join(__dirname, '..', 'fixtures', 'detached-daemon.js'); + + child = spawn(process.execPath, [fixturePath], { + env: { + ...process.env, + ZEROSHOT_TEST_STORAGE: tempDir, + ZEROSHOT_TEST_CLUSTER_ID: clusterId, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + await waitForClusterRecord(tempDir, clusterId, child.pid); + await sleep(200); + + const orchestrator = await Orchestrator.create({ + quiet: true, + storageDir: tempDir, + }); + + const cluster = orchestrator.getCluster(clusterId); + assert(cluster, 'Cluster should be loaded from storage'); + + const beforeCount = cluster.messageBus.count({ cluster_id: clusterId }); + + await orchestrator.stop(clusterId); + await waitForChildExit(child, 10000); + + const afterStopCount = cluster.messageBus.count({ cluster_id: clusterId }); + await sleep(250); + const afterWaitCount = cluster.messageBus.count({ cluster_id: clusterId }); + + assert.strictEqual( + afterStopCount, + afterWaitCount, + 'No new ledger messages should appear after stop' + ); + + const status = orchestrator.getStatus(clusterId); + assert.strictEqual(status.state, 'stopped'); + assert.strictEqual(status.pid, null); + assert.strictEqual(beforeCount, afterStopCount); + + orchestrator.close(); + }); +}); diff --git a/tests/integration/guidance-queue-context.test.js b/tests/integration/guidance-queue-context.test.js new file mode 100644 index 00000000..35e62876 --- /dev/null +++ b/tests/integration/guidance-queue-context.test.js @@ -0,0 +1,83 @@ +const assert = require('assert'); + +const AgentWrapper = require('../../src/agent-wrapper'); +const MessageBus = require('../../src/message-bus'); +const Ledger = require('../../src/ledger'); +const MockTaskRunner = require('../helpers/mock-task-runner'); +const { USER_GUIDANCE_AGENT } = require('../../src/guidance-topics'); + +describe('Guidance queue integration', function () { + it('injects queued guidance into next prompt only', async function () { + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const mockRunner = new MockTaskRunner(); + const clusterId = 'guidance-queue-integration'; + const clusterCreatedAt = Date.now() - 5000; + + const cluster = { + id: clusterId, + createdAt: clusterCreatedAt, + agents: [], + }; + + const workerConfig = { + id: 'worker', + role: 'implementation', + modelLevel: 'level2', + timeout: 0, + maxIterations: 5, + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + contextStrategy: { + sources: [{ topic: 'ISSUE_OPENED', since: 'cluster_start', limit: 1 }], + }, + }; + + const worker = new AgentWrapper(workerConfig, messageBus, cluster, { + testMode: true, + taskRunner: mockRunner, + }); + cluster.agents.push(worker); + + const trigger = { + cluster_id: clusterId, + topic: 'ISSUE_OPENED', + sender: 'tester', + content: { text: 'Implement feature X' }, + }; + + mockRunner.when('worker').returns({ ok: true }); + + worker.start(); + + // First run: no guidance queued + await worker._executeTask(trigger); + mockRunner.assertCalled('worker', 1); + const firstContext = mockRunner.calls[0].context; + assert(!firstContext.includes('## Guidance (Queued)'), 'no guidance block in first run'); + + // Queue guidance after first execution + messageBus.publish({ + cluster_id: clusterId, + topic: USER_GUIDANCE_AGENT, + sender: 'user', + target_agent_id: 'worker', + content: { text: 'Use approach B' }, + timestamp: Date.now() + 10, + }); + + await worker._executeTask(trigger); + mockRunner.assertCalled('worker', 2); + const secondContext = mockRunner.calls[1].context; + assert(secondContext.includes('## Guidance (Queued)'), 'guidance block appears on next run'); + assert(secondContext.includes('Use approach B'), 'guidance text is included'); + + // Third run: no new guidance + await worker._executeTask(trigger); + mockRunner.assertCalled('worker', 3); + const thirdContext = mockRunner.calls[2].context; + assert(!thirdContext.includes('## Guidance (Queued)'), 'guidance block not repeated'); + + await worker.stop(); + ledger.close(); + }); +}); diff --git a/tests/integration/orchestrator-flow.test.js b/tests/integration/orchestrator-flow.test.js index b8e56e9d..a9a26394 100644 --- a/tests/integration/orchestrator-flow.test.js +++ b/tests/integration/orchestrator-flow.test.js @@ -1,3 +1,6 @@ +// Skip gh CLI verification in integration tests (we mock the task runner, not the CLI) +process.env.ZEROSHOT_SKIP_GH_VERIFY = '1'; + /** * Integration tests for complete cluster lifecycle * @@ -334,7 +337,12 @@ function definePrModeFlowTests() { it('should stop after git-pusher completes in autoPr mode', async () => { mockRunner.when('worker').returns({ summary: 'No changes', result: 'noop' }); mockRunner.when('validator').returns({ approved: true }); - mockRunner.when('git-pusher').returns({ summary: 'PR done', result: 'Merged' }); + mockRunner.when('git-pusher').returns({ + summary: 'PR done', + result: 'Merged', + pr_number: 12345, + pr_url: 'https://github.com/test/test/pull/12345', + }); createOrchestrator(); @@ -576,6 +584,7 @@ function defineErrorHandlingTests() { role: 'implementation', timeout: 0, maxIterations: 2, + maxRetries: 1, // Fast failure for error handling test (default is now 3) triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], hooks: { onError: { diff --git a/tests/integration/orchestrator-worktree.test.js b/tests/integration/orchestrator-worktree.test.js index 2d43971d..99a17db8 100644 --- a/tests/integration/orchestrator-worktree.test.js +++ b/tests/integration/orchestrator-worktree.test.js @@ -52,6 +52,26 @@ const simpleConfig = { ], }; +async function rmDirWithRetries(target, attempts = 5) { + if (!target || !fs.existsSync(target)) { + return; + } + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + fs.rmSync(target, { recursive: true, force: true }); + return; + } catch (error) { + const retriable = + error && (error.code === 'ENOTEMPTY' || error.code === 'EBUSY' || error.code === 'EPERM'); + if (!retriable || attempt === attempts - 1) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } +} + function registerWorktreeHooks() { beforeEach(function () { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zs-worktree-test-')); @@ -81,16 +101,14 @@ function registerWorktreeHooks() { } } - if (tempDir && fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + await rmDirWithRetries(tempDir); if (testRepoDir && fs.existsSync(testRepoDir)) { try { execSync('git worktree prune', { cwd: testRepoDir, stdio: 'pipe' }); } catch { // Ignore } - fs.rmSync(testRepoDir, { recursive: true, force: true }); + await rmDirWithRetries(testRepoDir); } }); } @@ -128,7 +146,7 @@ function registerWorktreePathTest() { `Worktree path should exist: ${cluster.worktree.path}` ); - const expectedRoot = fs.realpathSync(path.join(os.tmpdir(), 'zeroshot-worktrees')); + const expectedRoot = fs.realpathSync(path.join(os.homedir(), '.zeroshot', 'worktrees')); const worktreePath = fs.realpathSync(cluster.worktree.path); assert( worktreePath.startsWith(expectedRoot + path.sep), diff --git a/tests/integration/trigger-evaluation.test.js b/tests/integration/trigger-evaluation.test.js index a6e7c3dc..4bf9f15f 100644 --- a/tests/integration/trigger-evaluation.test.js +++ b/tests/integration/trigger-evaluation.test.js @@ -13,6 +13,7 @@ const os = require('os'); const LogicEngine = require('../../src/logic-engine'); const MessageBus = require('../../src/message-bus'); const Ledger = require('../../src/ledger'); +const { SHARED_TRIGGER_SCRIPT } = require('../../src/agents/git-pusher-template'); let tempDir; let ledger; @@ -61,6 +62,7 @@ describe('Trigger Evaluation Integration', function () { defineErrorHandlingTests(); defineScriptValidationTests(); defineComplexConsensusTests(); + defineGitPusherTriggerTests(); }); function defineBasicLedgerQueryTests() { @@ -476,3 +478,122 @@ function defineComplexConsensusTests() { }); }); } + +function defineGitPusherTriggerTests() { + describe('Git-pusher Trigger Evidence', () => { + it('should allow approvals with CANNOT_VALIDATE and empty output evidence', () => { + messageBus.publish({ + cluster_id: cluster.id, + topic: 'IMPLEMENTATION_READY', + sender: 'worker', + timestamp: Date.now(), + }); + + const implTime = Date.now(); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'VALIDATION_RESULT', + sender: 'validator-1', + timestamp: implTime + 100, + content: { + data: { + approved: true, + criteriaResults: [ + { + id: 'AC1', + status: 'PASS', + evidence: { command: 'npm test', exitCode: 0, output: '' }, + }, + { + id: 'AC2', + status: 'CANNOT_VALIDATE', + reason: 'Docker not available', + }, + ], + }, + }, + }); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'VALIDATION_RESULT', + sender: 'validator-2', + timestamp: implTime + 200, + content: { data: { approved: true } }, + }); + + const result = logicEngine.evaluate( + SHARED_TRIGGER_SCRIPT, + { id: 'git-pusher', cluster_id: cluster.id }, + { topic: 'VALIDATION_RESULT' } + ); + + assert.strictEqual(result, true); + }); + + it('should accept consensus-only VALIDATION_RESULT when validators do not publish directly', () => { + // Simulate staged validation (quick/heavy): validators publish stage-specific topics, + // and only a coordinator publishes a single consolidated VALIDATION_RESULT. + cluster.agents.push( + { id: 'validator-3', role: 'validator' }, + { id: 'validator-4', role: 'validator' } + ); + + const implTime = Date.now(); + messageBus.publish({ + cluster_id: cluster.id, + topic: 'IMPLEMENTATION_READY', + sender: 'worker', + timestamp: implTime, + }); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'VALIDATION_RESULT', + sender: 'consensus-coordinator', + timestamp: implTime + 100, + content: { data: { approved: true, stage: 'heavy' } }, + }); + + const result = logicEngine.evaluate( + SHARED_TRIGGER_SCRIPT, + { id: 'git-pusher', cluster_id: cluster.id }, + { topic: 'VALIDATION_RESULT' } + ); + + assert.strictEqual(result, true); + }); + + it('should not accept consensus-only VALIDATION_RESULT when rejected', () => { + cluster.agents.push( + { id: 'validator-3', role: 'validator' }, + { id: 'validator-4', role: 'validator' } + ); + + const implTime = Date.now(); + messageBus.publish({ + cluster_id: cluster.id, + topic: 'IMPLEMENTATION_READY', + sender: 'worker', + timestamp: implTime, + }); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'VALIDATION_RESULT', + sender: 'consensus-coordinator', + timestamp: implTime + 100, + content: { data: { approved: false, stage: 'quick' } }, + }); + + const result = logicEngine.evaluate( + SHARED_TRIGGER_SCRIPT, + { id: 'git-pusher', cluster_id: cluster.id }, + { topic: 'VALIDATION_RESULT' } + ); + + assert.strictEqual(result, false); + }); + }); +} diff --git a/tests/integration/worktree-isolation.test.js b/tests/integration/worktree-isolation.test.js index de95d4ee..e6281f89 100644 --- a/tests/integration/worktree-isolation.test.js +++ b/tests/integration/worktree-isolation.test.js @@ -2,7 +2,7 @@ * Test: Worktree Isolation - Lightweight git-based isolation * * Tests the worktree isolation mode that provides: - * - Git worktree creation at {os.tmpdir()}/zeroshot-worktrees/{clusterId} + * - Git worktree creation at ~/.zeroshot/worktrees/{clusterId} * - Separate branch (zeroshot/{clusterId}) without copying files * - Fast setup (<1s vs 30-60s for Docker) * - No Docker dependency @@ -73,7 +73,7 @@ function registerWorktreePathTest() { const info = manager.createWorktreeIsolation(testClusterId, testRepoDir); assert(info.path, 'Should return worktree path'); - const expectedRoot = fs.realpathSync(path.join(os.tmpdir(), 'zeroshot-worktrees')); + const expectedRoot = fs.realpathSync(path.join(os.homedir(), '.zeroshot', 'worktrees')); const worktreePath = fs.realpathSync(info.path); assert( worktreePath.startsWith(expectedRoot + path.sep), diff --git a/tests/max-model.test.js b/tests/max-model.test.js index 956453a5..0d7c90c9 100644 --- a/tests/max-model.test.js +++ b/tests/max-model.test.js @@ -219,7 +219,7 @@ function registerDynamicModelRulesTests() { function registerDefaultModelTests() { describe('Default model when unspecified', function () { it('should use maxModel ceiling when it constrains default', function () { - saveTestSettings({ maxModel: 'haiku' }); + saveTestSettings({ maxModel: 'haiku', defaultProvider: 'claude' }); const agentConfig = { id: 'default-model-agent', timeout: 0 }; @@ -256,7 +256,7 @@ function registerDefaultModelTests() { }); it('should use provider default level even when maxModel allows higher', function () { - saveTestSettings({ maxModel: 'opus' }); + saveTestSettings({ maxModel: 'opus', defaultProvider: 'claude' }); const agentConfig = { id: 'premium-default-agent', timeout: 0 }; diff --git a/tests/message-buffering-while-busy.test.js b/tests/message-buffering-while-busy.test.js new file mode 100644 index 00000000..25788c4d --- /dev/null +++ b/tests/message-buffering-while-busy.test.js @@ -0,0 +1,153 @@ +/** + * Regression test: never drop trigger-matching messages while an agent is busy. + * + * BUG: + * Agents dropped trigger-matching messages whenever state !== 'idle'. + * In real clusters this can drop VALIDATION_RESULT / QUICK_VALIDATION_RESULT + * while the worker or coordinator is executing a task, wedging the cluster. + * + * FIX: + * Buffer trigger-matching messages while busy and drain once idle. + */ + +const { expect } = require('chai'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Orchestrator = require('../src/orchestrator'); +const MockTaskRunner = require('./helpers/mock-task-runner'); + +describe('Agent message buffering while busy', function () { + this.timeout(15000); + + let orchestrator; + let mockRunner; + let testDir; + let clusterId; + + beforeEach(() => { + testDir = path.join( + os.tmpdir(), + `zeroshot-buffering-test-${crypto.randomBytes(8).toString('hex')}` + ); + fs.mkdirSync(testDir, { recursive: true }); + + const settingsPath = path.join(testDir, 'settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + firstRunComplete: true, + defaultProvider: 'claude', + autoCheckUpdates: false, + }, + null, + 2 + ) + ); + + mockRunner = new MockTaskRunner(); + orchestrator = new Orchestrator({ + quiet: true, + storageDir: testDir, + skipLoad: true, + taskRunner: mockRunner, + }); + }); + + afterEach(async () => { + if (clusterId) { + try { + await orchestrator.kill(clusterId); + } catch { + // Cluster may already be stopped + } + } + + try { + orchestrator.close(); + } catch { + /* ignore */ + } + + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('executes worker twice when VALIDATION_RESULT arrives during an in-flight task', async () => { + mockRunner.when('worker').delays(250, 'done'); + + const config = { + agents: [ + { + id: 'worker', + role: 'implementation', + modelLevel: 'level2', + prompt: 'do work', + triggers: [ + { topic: 'ISSUE_OPENED', action: 'execute_task' }, + { topic: 'VALIDATION_RESULT', action: 'execute_task' }, + ], + hooks: { + onComplete: { + action: 'publish_message', + config: { topic: 'WORK_DONE' }, + }, + }, + }, + { + id: 'completion-detector', + role: 'completion-detector', + modelLevel: 'level2', + prompt: 'stop after 2 work cycles', + triggers: [ + { + topic: 'WORK_DONE', + action: 'stop_cluster', + logic: { script: "return ledger.count({ topic: 'WORK_DONE' }) >= 2;" }, + }, + ], + }, + ], + completion_detector: { + type: 'topic', + config: { topic: 'CLUSTER_COMPLETE' }, + }, + }; + + const result = await orchestrator.start(config, { text: 'test' }, { cwd: process.cwd() }); + clusterId = result.id; + + const cluster = orchestrator.getCluster(clusterId); + expect(cluster).to.exist; + + // Publish a trigger-matching message while the worker is still busy with the first task. + await new Promise((resolve) => setTimeout(resolve, 50)); + cluster.messageBus.publish({ + cluster_id: clusterId, + topic: 'VALIDATION_RESULT', + sender: 'consensus-coordinator', + receiver: 'broadcast', + timestamp: Date.now(), + content: { text: 'stage 1 rejected' }, + }); + + // Wait for the cluster to stop (completion-detector publishes CLUSTER_COMPLETE after 2 WORK_DONE). + const start = Date.now(); + while (Date.now() - start < 10000) { + const current = orchestrator.getCluster(clusterId); + if (current.state === 'stopped') { + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + const finalCluster = orchestrator.getCluster(clusterId); + expect(finalCluster.state).to.equal('stopped'); + + mockRunner.assertCalled('worker', 2); + }); +}); diff --git a/tests/model-selection.test.js b/tests/model-selection.test.js index fdd0c78e..1cf6098d 100644 --- a/tests/model-selection.test.js +++ b/tests/model-selection.test.js @@ -26,6 +26,7 @@ function registerModelSelectionHooks() { } const testSettings = { maxModel: 'opus', + defaultProvider: 'claude', defaultConfig: 'conductor-bootstrap', defaultDocker: false, strictSchema: true, diff --git a/tests/openai-output-parser.test.js b/tests/openai-output-parser.test.js index bb508ef8..4e7ed1b7 100644 --- a/tests/openai-output-parser.test.js +++ b/tests/openai-output-parser.test.js @@ -58,6 +58,11 @@ describe('Codex output parser', () => { assert.strictEqual(result, null); }); + it('ignores item.started events', () => { + const result = parseEvent('{"type":"item.started","item_id":"item_123"}'); + assert.strictEqual(result, null); + }); + it('parses item.created events', () => { const line = JSON.stringify({ type: 'item.created', diff --git a/tests/orchestrator-completion-detector.test.js b/tests/orchestrator-completion-detector.test.js new file mode 100644 index 00000000..c933afaf --- /dev/null +++ b/tests/orchestrator-completion-detector.test.js @@ -0,0 +1,87 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Orchestrator = require('../src/orchestrator'); + +describe('Orchestrator completion-detector injection', function () { + const originalSettingsFile = process.env.ZEROSHOT_SETTINGS_FILE; + let tempDir; + + afterEach(function () { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + + if (originalSettingsFile) { + process.env.ZEROSHOT_SETTINGS_FILE = originalSettingsFile; + } else { + delete process.env.ZEROSHOT_SETTINGS_FILE; + } + }); + + function writeSettings(settings) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-completion-detector-')); + const settingsFile = path.join(tempDir, 'settings.json'); + fs.writeFileSync(settingsFile, JSON.stringify(settings), 'utf8'); + process.env.ZEROSHOT_SETTINGS_FILE = settingsFile; + } + + it('uses modelLevel derived from claude minModel bounds', async function () { + writeSettings({ + maxModel: 'opus', + minModel: 'sonnet', + }); + + const orchestrator = new Orchestrator({ quiet: true, skipLoad: true }); + let injectedAgent = null; + orchestrator._opAddAgents = (_cluster, operation) => { + injectedAgent = operation.agents[0]; + }; + + await orchestrator._injectCompletionAgent( + { + agents: [], + config: { defaultProvider: 'claude' }, + autoPr: false, + }, + {} + ); + + assert.ok(injectedAgent, 'Expected completion-detector to be injected'); + assert.strictEqual(injectedAgent.id, 'completion-detector'); + assert.strictEqual(injectedAgent.modelLevel, 'level2'); + assert.strictEqual(injectedAgent.model, undefined); + }); + + it('uses provider-specific minLevel when forcing non-claude provider', async function () { + writeSettings({ + defaultProvider: 'codex', + providerSettings: { + codex: { + minLevel: 'level3', + }, + }, + }); + + const orchestrator = new Orchestrator({ quiet: true, skipLoad: true }); + let injectedAgent = null; + orchestrator._opAddAgents = (_cluster, operation) => { + injectedAgent = operation.agents[0]; + }; + + await orchestrator._injectCompletionAgent( + { + agents: [], + config: { forceProvider: 'codex' }, + autoPr: false, + }, + {} + ); + + assert.ok(injectedAgent, 'Expected completion-detector to be injected'); + assert.strictEqual(injectedAgent.modelLevel, 'level3'); + assert.strictEqual(injectedAgent.model, undefined); + }); +}); diff --git a/tests/orchestrator.test.js b/tests/orchestrator.test.js index f48de86f..0905a9c9 100644 --- a/tests/orchestrator.test.js +++ b/tests/orchestrator.test.js @@ -245,6 +245,50 @@ function defineLifecycleStartTests() { assert.strictEqual(callCount, 2, 'Expected SIGTERM failure to trigger one retry'); }); + // eslint-disable-next-line sonarjs/no-skipped-tests -- AGENT_RESTART_ATTEMPT feature not yet implemented (see AGENTS.md) + it.skip('should restart implementation agent after retries exhausted', async function () { + const config = { + agents: [ + { + id: 'worker', + role: 'implementation', + modelLevel: 'level2', + outputFormat: 'text', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + hooks: { + onComplete: { + action: 'publish_message', + config: { + topic: 'CLUSTER_COMPLETE', + content: { + data: { reason: 'restart-after-exhausted-retries-test' }, + }, + }, + }, + }, + }, + ], + }; + + let callCount = 0; + lifecycleMockRunner.when('worker').calls(() => { + callCount += 1; + if (callCount <= 3) { + return { success: false, output: '', error: 'Request timed out' }; + } + return { success: true, output: 'ok' }; + }); + + const result = await lifecycleOrchestrator.start(config, { text: 'Fix bug' }); + await waitForClusterState(lifecycleOrchestrator, result.id, 'stopped', 10000); + + const cluster = lifecycleOrchestrator.getCluster(result.id); + const ledger = new LedgerAssertions(cluster.ledger, result.id); + ledger.assertCount('AGENT_RESTART_ATTEMPT', 1); + + assert.ok(callCount >= 4, `Expected worker to be invoked at least 4 times, got ${callCount}`); + }); + it('should inject worktree cwd when worktree enabled', function () { // This test requires a real git repo - skip in test environment // The functionality is tested in integration/worktree tests @@ -290,6 +334,16 @@ function defineLifecycleStartTests() { /issue.*or text/i, 'Should reject missing input' ); + + // Regression: startup failures must be persisted for supervisor visibility (no "invisible" clusters). + const clustersFile = path.join(lifecycleStorageDir, 'clusters.json'); + assert.ok(fs.existsSync(clustersFile), 'clusters.json should exist after failed start'); + const persisted = JSON.parse(fs.readFileSync(clustersFile, 'utf8')); + const ids = Object.keys(persisted); + assert.equal(ids.length, 1, 'Expected exactly one persisted cluster entry'); + const c = persisted[ids[0]]; + assert.equal(c.state, 'failed', 'Failed start should persist state=failed'); + assert.equal(c.pid, null, 'Failed start should persist pid=null'); }); it('should auto-generate unique cluster IDs', async function () { diff --git a/tests/output-extraction.test.js b/tests/output-extraction.test.js index 93b1ae74..75401cf3 100644 --- a/tests/output-extraction.test.js +++ b/tests/output-extraction.test.js @@ -9,6 +9,7 @@ const assert = require('assert'); const { extractJsonFromOutput, + extractCliError, extractFromResultWrapper, extractFromTextEvents, extractFromMarkdown, @@ -22,6 +23,7 @@ describe('Output Extraction Module', function () { defineTextEventExtractionTests(); defineMarkdownExtractionTests(); defineDirectJsonExtractionTests(); + defineCliErrorExtractionTests(); defineFullPipelineTests(); defineRegressionTests(); }); @@ -274,6 +276,168 @@ function defineDirectJsonExtractionTests() { assert.strictEqual(extractDirectJson(' '), null); assert.strictEqual(extractDirectJson(null), null); }); + + // CLI metadata rejection tests - prevent schema validation against wrong structure + it('should reject type:result objects (CLI wrapper)', function () { + const text = '{"type":"result","subtype":"success","duration_ms":1234}'; + const result = extractDirectJson(text); + assert.strictEqual(result, null); + }); + + it('should reject CLI metadata with duration_ms and session_id', function () { + const text = + '{"duration_ms":5000,"session_id":"abc123","total_cost_usd":0.05,"usage":{"input_tokens":100}}'; + const result = extractDirectJson(text); + assert.strictEqual(result, null); + }); + + it('should reject CLI metadata with multiple metadata fields', function () { + const text = + '{"type":"result","subtype":"error","is_error":true,"duration_ms":123,"num_turns":5,"total_cost_usd":0.1,"permission_denials":[],"errors":["some error"]}'; + const result = extractDirectJson(text); + assert.strictEqual(result, null); + }); + + it('should accept normal agent output (not CLI metadata)', function () { + const text = '{"summary":"Task completed","completionStatus":{"canValidate":true}}'; + const result = extractDirectJson(text); + assert.deepStrictEqual(result, { + summary: 'Task completed', + completionStatus: { canValidate: true }, + }); + }); + + it('should accept agent output that has one CLI-like field by coincidence', function () { + // If agent happens to output a field named "errors", that's fine (< 2 CLI fields) + const text = '{"summary":"Fixed bugs","errors":[]}'; + const result = extractDirectJson(text); + assert.deepStrictEqual(result, { summary: 'Fixed bugs', errors: [] }); + }); + }); +} + +function defineCliErrorExtractionTests() { + // ============================================================================ + // CLI ERROR EXTRACTION (ALL PROVIDERS) + // ============================================================================ + describe('extractCliError', function () { + // Claude errors + it('should extract Claude error with is_error:true', function () { + const output = '{"type":"result","is_error":true,"errors":["Permission denied for tool X"]}'; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'Permission denied for tool X', + provider: 'claude', + }); + }); + + it('should extract Claude error with multiple errors', function () { + const output = '{"type":"result","is_error":true,"errors":["Error 1","Error 2"]}'; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'Error 1; Error 2', + provider: 'claude', + }); + }); + + it('should extract Claude error with subtype:error', function () { + const output = '{"type":"result","subtype":"error","error":"Token limit exceeded"}'; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'Token limit exceeded', + provider: 'claude', + }); + }); + + // Codex errors + it('should extract Codex turn.failed error', function () { + const output = '{"type":"turn.failed","error":{"message":"API rate limit exceeded"}}'; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'API rate limit exceeded', + provider: 'codex', + }); + }); + + it('should extract Codex turn.failed with string error', function () { + const output = '{"type":"turn.failed","error":"Something went wrong"}'; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'Something went wrong', + provider: 'codex', + }); + }); + + // Gemini errors + it('should extract Gemini error with success:false', function () { + const output = '{"type":"result","success":false,"error":"Model unavailable"}'; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'Model unavailable', + provider: 'gemini', + }); + }); + + // Opencode errors + it('should extract Opencode session.error', function () { + const output = '{"type":"session.error","error":{"message":"Connection timeout"}}'; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'Connection timeout', + provider: 'opencode', + }); + }); + + it('should extract Opencode session.error with nested data', function () { + const output = + '{"type":"session.error","error":{"data":{"message":"Auth failed"},"name":"AuthError"}}'; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'Auth failed', + provider: 'opencode', + }); + }); + + // No error cases + it('should return null for successful Claude output', function () { + const output = '{"type":"result","subtype":"success","result":{"foo":"bar"}}'; + const result = extractCliError(output); + assert.strictEqual(result, null); + }); + + it('should return null for successful Codex output', function () { + const output = '{"type":"turn.completed","usage":{"input_tokens":100}}'; + const result = extractCliError(output); + assert.strictEqual(result, null); + }); + + it('should return null for successful Gemini output', function () { + const output = '{"type":"result","success":true}'; + const result = extractCliError(output); + assert.strictEqual(result, null); + }); + + it('should return null for empty output', function () { + assert.strictEqual(extractCliError(''), null); + assert.strictEqual(extractCliError(null), null); + }); + + it('should return null for non-error JSON', function () { + const output = '{"foo":"bar","baz":123}'; + const result = extractCliError(output); + assert.strictEqual(result, null); + }); + + it('should find error in multi-line NDJSON output', function () { + const output = `{"type":"system","subtype":"init"} +{"type":"assistant","message":{}} +{"type":"result","is_error":true,"errors":["Task failed"]}`; + const result = extractCliError(output); + assert.deepStrictEqual(result, { + error: 'Task failed', + provider: 'claude', + }); + }); }); } @@ -493,5 +657,15 @@ Done.`; assert.strictEqual(result.complexity, 'TRIVIAL'); assert.strictEqual(result.taskType, 'TASK'); }); + + it('REGRESSION: Claude CLI error result without actual output', function () { + // When Claude has an error, it returns CLI metadata without result field. + // This MUST return null (not the CLI metadata object), so schema validation + // doesn't run against wrong structure ({duration_ms, session_id} vs {summary, completionStatus}) + const output = + '{"type":"result","subtype":"error","is_error":true,"duration_ms":1234,"duration_api_ms":1200,"num_turns":0,"session_id":"abc123","total_cost_usd":0.0,"usage":{},"modelUsage":null,"permission_denials":[],"uuid":"xyz","errors":["Permission denied"]}'; + const result = extractJsonFromOutput(output, 'claude'); + assert.strictEqual(result, null); + }); }); } diff --git a/tests/prompt-selection.test.js b/tests/prompt-selection.test.js index 32bde32c..e7f0b1e1 100644 --- a/tests/prompt-selection.test.js +++ b/tests/prompt-selection.test.js @@ -29,12 +29,10 @@ describe('Prompt Selection - Static (backward compat)', function () { }); it('should return null if no prompt configured', function () { - const agent = new AgentWrapper( - { id: 'test', timeout: 0 }, - mockMessageBus, - mockCluster, - { testMode: true, mockSpawnFn: () => {} } - ); + const agent = new AgentWrapper({ id: 'test', timeout: 0 }, mockMessageBus, mockCluster, { + testMode: true, + mockSpawnFn: () => {}, + }); assert.strictEqual(agent._selectPrompt(), null); }); diff --git a/tests/settings-providers.test.js b/tests/settings-providers.test.js index cdeb0cad..99762811 100644 --- a/tests/settings-providers.test.js +++ b/tests/settings-providers.test.js @@ -4,6 +4,7 @@ const path = require('path'); const os = require('os'); const { loadSettings, validateSetting } = require('../lib/settings'); const { validateProviderSettings, validateProviderLevel } = require('../src/config-validator'); +const { getProvider } = require('../src/providers'); describe('Provider settings', function () { const testDir = path.join(os.tmpdir(), `zeroshot-provider-settings-${Date.now()}`); @@ -83,4 +84,16 @@ describe('Provider settings', function () { assert.strictEqual(settings.providerSettings.claude.maxLevel, 'level1'); assert.strictEqual(settings.providerSettings.claude.defaultLevel, 'level1'); }); + + it('uses gpt-5.3-codex as the default codex model', function () { + const codex = getProvider('codex'); + const modelSpec = codex.resolveModelSpec(codex.getDefaultLevel(), {}); + assert.strictEqual(modelSpec.model, 'gpt-5.3-codex'); + }); + + it('maps claude level3 to opus-4.6', function () { + const claude = getProvider('claude'); + const modelSpec = claude.resolveModelSpec('level3', {}); + assert.strictEqual(modelSpec.model, 'opus-4.6'); + }); }); diff --git a/tests/structuredOutput-mapping.test.js b/tests/structuredOutput-mapping.test.js new file mode 100644 index 00000000..1439b58d --- /dev/null +++ b/tests/structuredOutput-mapping.test.js @@ -0,0 +1,108 @@ +/** + * Regression test: structuredOutput → jsonSchema mapping + * + * ROOT CAUSE (discovered 2026-02-08): + * git-pusher-template.js uses `structuredOutput` as the config key, + * but agent-config.js only recognizes `jsonSchema`. + * The structuredOutput key was silently ignored → default schema applied → + * agent never told to output pr_number → verify_github_pr hook fails with + * "VERIFICATION FAILED: git-pusher must provide pr_number in structured output" + * + * The PR was actually created and merged, but the hook couldn't extract + * pr_number because the CLI was given the wrong schema. + */ + +const assert = require('assert'); +const { generateGitPusherAgent } = require('../src/agents/git-pusher-template'); +const { validateAgentConfig } = require('../src/agent/agent-config'); + +describe('structuredOutput → jsonSchema mapping', function () { + it('should use structuredOutput as jsonSchema when both are not set', function () { + // SETUP: Generate git-pusher config (uses structuredOutput key) + const agentConfig = generateGitPusherAgent('github'); + + // VERIFY: structuredOutput is defined in the raw config + assert.ok(agentConfig.structuredOutput, 'git-pusher template must define structuredOutput'); + assert.ok( + agentConfig.structuredOutput.properties.pr_number, + 'structuredOutput must have pr_number property' + ); + + // ACTION: Pass through validateAgentConfig (this is where mapping should happen) + const normalized = validateAgentConfig({ ...agentConfig }); + + // ASSERTION: jsonSchema must be the structuredOutput schema, NOT the default + assert.ok(normalized.jsonSchema, 'jsonSchema must be set after validation'); + assert.ok( + normalized.jsonSchema.properties.pr_number, + 'jsonSchema must contain pr_number from structuredOutput (not default summary/result schema)' + ); + assert.strictEqual( + normalized.jsonSchema.properties.pr_number.type, + 'number', + 'pr_number must be type number' + ); + + // Verify it does NOT have the default schema fields + assert.strictEqual( + normalized.jsonSchema.properties.summary, + undefined, + 'jsonSchema must NOT have default "summary" field when structuredOutput is provided' + ); + }); + + it('should preserve explicit jsonSchema over structuredOutput', function () { + // If someone sets BOTH jsonSchema and structuredOutput, jsonSchema wins + const agentConfig = generateGitPusherAgent('github'); + const customSchema = { + type: 'object', + properties: { + custom_field: { type: 'string' }, + }, + required: ['custom_field'], + }; + + const normalized = validateAgentConfig({ + ...agentConfig, + jsonSchema: customSchema, + }); + + assert.strictEqual( + normalized.jsonSchema.properties.custom_field.type, + 'string', + 'explicit jsonSchema must take precedence over structuredOutput' + ); + }); + + it('should apply default schema when neither jsonSchema nor structuredOutput is set', function () { + const normalized = validateAgentConfig({ + id: 'test-agent', + role: 'test', + triggers: [], + prompt: 'test prompt', + }); + + assert.ok( + normalized.jsonSchema.properties.summary, + 'default schema must have summary when no schema provided' + ); + assert.ok( + normalized.jsonSchema.properties.result, + 'default schema must have result when no schema provided' + ); + }); + + it('should work for all git-pusher platforms', function () { + const platforms = ['github', 'gitlab', 'azure-devops']; + + for (const platform of platforms) { + const agentConfig = generateGitPusherAgent(platform); + const normalized = validateAgentConfig({ ...agentConfig }); + + assert.ok( + normalized.jsonSchema.properties.pr_number || normalized.jsonSchema.properties.mr_number, + `${platform}: jsonSchema must contain pr_number or mr_number from structuredOutput` + ); + } + }); +}); diff --git a/tests/transform-sandbox-ledger.test.js b/tests/transform-sandbox-ledger.test.js new file mode 100644 index 00000000..a2bdb6fc --- /dev/null +++ b/tests/transform-sandbox-ledger.test.js @@ -0,0 +1,296 @@ +/** + * Regression test for transform sandbox ledger API + * + * ROOT CAUSE (2026-02-03): + * buildTransformSandbox() did NOT provide `ledger` API to transform scripts, + * but template transforms (e.g., heavy-validation.json:247) used `ledger.query()`. + * Result: "ledger is not defined" → hook failed → cluster deadlocked. + * + * FIX: Added ledger, cluster, and helpers APIs to buildTransformSandbox(), + * mirroring logic-engine.js _buildContext(). + */ + +const { expect } = require('chai'); +const vm = require('vm'); + +// Mock message bus +function createMockMessageBus() { + const messages = []; + return { + publish: (msg) => messages.push({ ...msg, timestamp: Date.now() }), + query: ({ topic, cluster_id, since }) => { + return messages.filter( + (m) => + m.cluster_id === cluster_id && + (!topic || m.topic === topic) && + (!since || m.timestamp > since) + ); + }, + findLast: ({ topic, cluster_id }) => { + const matching = messages.filter( + (m) => m.cluster_id === cluster_id && (!topic || m.topic === topic) + ); + return matching[matching.length - 1] || null; + }, + count: ({ topic, cluster_id, since }) => { + return messages.filter( + (m) => + m.cluster_id === cluster_id && + (!topic || m.topic === topic) && + (!since || m.timestamp > since) + ).length; + }, + since: ({ cluster_id, timestamp }) => { + return messages.filter((m) => m.cluster_id === cluster_id && m.timestamp > timestamp); + }, + _messages: messages, + }; +} + +// Mock agent +function createMockAgent(messageBus, clusterId = 'test-cluster') { + return { + id: 'test-agent', + cluster_id: clusterId, + messageBus, + cluster: { + id: clusterId, + agents: [ + { id: 'validator-1', role: 'validator' }, + { id: 'validator-2', role: 'validator' }, + { id: 'worker', role: 'implementation' }, + ], + }, + _log: () => {}, + }; +} + +describe('Transform sandbox ledger API', () => { + let messageBus; + let agent; + + beforeEach(() => { + messageBus = createMockMessageBus(); + agent = createMockAgent(messageBus); + }); + + /** + * REGRESSION TEST: Transform script can access ledger.query() + * + * This is the exact pattern that failed in heavy-validation.json:247 + */ + it('transform script can use ledger.query()', () => { + // Seed some messages + messageBus.publish({ + cluster_id: 'test-cluster', + topic: 'HEAVY_VALIDATION_RESULT', + sender: 'validator-1', + content: { data: { errors: ['error1'] } }, + }); + messageBus.publish({ + cluster_id: 'test-cluster', + topic: 'HEAVY_VALIDATION_RESULT', + sender: 'validator-2', + content: { data: { errors: ['error2'] } }, + }); + + // Build sandbox like agent-hook-executor.js does + const sandbox = buildTestSandbox(agent, { allApproved: true, summary: 'All good' }); + + // This is the EXACT script from heavy-validation.json:247 that was failing + const script = ` + return { + topic: 'VALIDATION_RESULT', + content: { + text: result.allApproved ? 'All validations passed' : 'Stage 2 rejected', + data: { + approved: result.allApproved, + stage: 'heavy', + summary: result.summary, + errors: ledger.query({ topic: 'HEAVY_VALIDATION_RESULT' }) + .flatMap(r => r.content?.data?.errors || []) + } + } + }; + `; + + const vmContext = vm.createContext(sandbox); + const wrappedScript = `(function() { ${script} })()`; + const result = vm.runInContext(wrappedScript, vmContext); + + expect(result.topic).to.equal('VALIDATION_RESULT'); + expect(result.content.data.approved).to.equal(true); + expect(result.content.data.errors).to.deep.equal(['error1', 'error2']); + }); + + /** + * REGRESSION TEST: Transform script can access ledger.findLast() + */ + it('transform script can use ledger.findLast()', () => { + messageBus.publish({ + cluster_id: 'test-cluster', + topic: 'IMPLEMENTATION_READY', + sender: 'worker', + content: { text: 'Done' }, + }); + + const sandbox = buildTestSandbox(agent, {}); + + const script = ` + const lastPush = ledger.findLast({ topic: 'IMPLEMENTATION_READY' }); + return { + topic: 'TEST_RESULT', + content: { found: !!lastPush, sender: lastPush?.sender } + }; + `; + + const vmContext = vm.createContext(sandbox); + const result = vm.runInContext(`(function() { ${script} })()`, vmContext); + + expect(result.content.found).to.equal(true); + expect(result.content.sender).to.equal('worker'); + }); + + /** + * REGRESSION TEST: Transform script can access cluster.getAgentsByRole() + * + * Pattern from full-workflow.json:255 + */ + it('transform script can use cluster.getAgentsByRole()', () => { + const sandbox = buildTestSandbox(agent, {}); + + const script = ` + const validators = cluster.getAgentsByRole('validator'); + return { + topic: 'TEST_RESULT', + content: { validatorCount: validators.length, ids: validators.map(v => v.id) } + }; + `; + + const vmContext = vm.createContext(sandbox); + const result = vm.runInContext(`(function() { ${script} })()`, vmContext); + + expect(result.content.validatorCount).to.equal(2); + expect(result.content.ids).to.deep.equal(['validator-1', 'validator-2']); + }); + + /** + * REGRESSION TEST: Transform script can use helpers.allResponded() + */ + it('transform script can use helpers.allResponded()', () => { + // Both validators responded + messageBus.publish({ + cluster_id: 'test-cluster', + topic: 'VALIDATION_RESULT', + sender: 'validator-1', + content: { data: { approved: true } }, + }); + messageBus.publish({ + cluster_id: 'test-cluster', + topic: 'VALIDATION_RESULT', + sender: 'validator-2', + content: { data: { approved: true } }, + }); + + const sandbox = buildTestSandbox(agent, {}); + + const script = ` + const validators = cluster.getAgentsByRole('validator'); + const allDone = helpers.allResponded(validators, 'VALIDATION_RESULT', 0); + return { + topic: 'TEST_RESULT', + content: { allResponded: allDone } + }; + `; + + const vmContext = vm.createContext(sandbox); + const result = vm.runInContext(`(function() { ${script} })()`, vmContext); + + expect(result.content.allResponded).to.equal(true); + }); + + /** + * REGRESSION TEST: Sandbox provides Set for validators pattern + */ + it('transform script can use Set builtin', () => { + const sandbox = buildTestSandbox(agent, {}); + + const script = ` + const ids = new Set(['a', 'b', 'a']); + return { + topic: 'TEST_RESULT', + content: { size: ids.size } + }; + `; + + const vmContext = vm.createContext(sandbox); + const result = vm.runInContext(`(function() { ${script} })()`, vmContext); + + expect(result.content.size).to.equal(2); + }); +}); + +/** + * Build sandbox matching agent-hook-executor.js buildTransformSandbox() + * This is a copy for testing - the real one is in agent-hook-executor.js + */ +function buildTestSandbox(agent, resultData) { + const clusterId = agent.cluster_id; + const messageBus = agent.messageBus; + const cluster = agent.cluster; + + const ledgerAPI = { + query: (criteria) => { + return messageBus.query({ ...criteria, cluster_id: clusterId }); + }, + findLast: (criteria) => { + return messageBus.findLast({ ...criteria, cluster_id: clusterId }); + }, + count: (criteria) => { + return messageBus.count({ ...criteria, cluster_id: clusterId }); + }, + since: (timestamp) => { + return messageBus.since({ cluster_id: clusterId, timestamp }); + }, + }; + + const clusterAPI = { + id: clusterId, + getAgents: () => (cluster ? cluster.agents || [] : []), + getAgentsByRole: (role) => + cluster ? (cluster.agents || []).filter((a) => a.role === role) : [], + getAgent: (id) => (cluster ? (cluster.agents || []).find((a) => a.id === id) : null), + }; + + const helpers = { + getConfig: () => ({}), + allResponded: (agents, topic, since) => { + const responses = ledgerAPI.query({ topic, since }); + const responders = new Set(responses.map((r) => r.sender)); + return agents.every((a) => responders.has(a.id || a)); + }, + hasConsensus: (topic, since) => { + const responses = ledgerAPI.query({ topic, since }); + if (responses.length === 0) return false; + return responses.every((r) => r.content?.data?.approved === true); + }, + }; + + return { + result: resultData, + triggeringMessage: null, + ledger: ledgerAPI, + cluster: clusterAPI, + helpers, + JSON, + Set, + Map, + Array, + Object, + console: { + log: () => {}, + error: () => {}, + warn: () => {}, + }, + }; +} diff --git a/tests/tui-layout.test.js b/tests/tui-layout.test.js deleted file mode 100644 index debc85b9..00000000 --- a/tests/tui-layout.test.js +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Test suite for TUI layout - * Verifies layout creation and widget updates work correctly - */ - -const { expect } = require('chai'); -const blessed = require('blessed'); -const { - createLayout, - updateClustersTable, - updateAgentsTable, - updateStatsBox, - addLogEntry, - clearLogs, -} = require('../src/tui/layout'); - -let screen; -let layout; - -describe('TUI Layout', () => { - beforeEach(() => { - // Create a mock screen for testing - screen = blessed.screen({ mouse: true, title: 'Test Dashboard' }); - }); - - afterEach(() => { - if (screen) { - screen.destroy(); - } - }); - - defineCreateLayoutTests(); - defineUpdateClustersTableTests(); - defineUpdateAgentsTableTests(); - defineUpdateStatsBoxTests(); - defineAddLogEntryTests(); - defineClearLogsTests(); - defineFocusNavigationTests(); -}); - -function defineCreateLayoutTests() { - describe('createLayout', () => { - it('should create layout with all widgets', () => { - layout = createLayout(screen); - - expect(layout).to.exist; - expect(layout.screen).to.equal(screen); - expect(layout.grid).to.exist; - expect(layout.clustersTable).to.exist; - expect(layout.agentTable).to.exist; - expect(layout.statsBox).to.exist; - expect(layout.logsBox).to.exist; - expect(layout.helpBar).to.exist; - }); - - it('should have widgets array with 3 items', () => { - layout = createLayout(screen); - - expect(layout.widgets).to.be.an('array'); - expect(layout.widgets).to.have.lengthOf(3); - }); - - it('should initialize clusters table with empty data', () => { - layout = createLayout(screen); - - // Table should have headers and empty data initially - expect(layout.clustersTable).to.exist; - }); - - it('should provide focus control methods', () => { - layout = createLayout(screen); - - expect(layout.focus).to.be.a('function'); - expect(layout.getCurrentFocus).to.be.a('function'); - }); - - it('should set focus to clusters table initially', () => { - layout = createLayout(screen); - - expect(layout.getCurrentFocus()).to.equal(0); - }); - }); -} - -function defineUpdateClustersTableTests() { - describe('updateClustersTable', () => { - beforeEach(() => { - layout = createLayout(screen); - }); - - it('should update table with cluster data', () => { - const clusters = [ - { - id: 'cluster-1', - status: 'running', - agentCount: 5, - config: 'default', - uptime: '2h 30m', - }, - { - id: 'cluster-2', - status: 'stopped', - agentCount: 0, - config: 'simple', - uptime: '0s', - }, - ]; - - updateClustersTable(layout.clustersTable, clusters); - - // Verify the method completes without error - expect(layout.clustersTable).to.exist; - }); - - it('should handle empty cluster array', () => { - updateClustersTable(layout.clustersTable, []); - - expect(layout.clustersTable).to.exist; - }); - - it('should handle clusters with missing properties', () => { - const clusters = [ - { - id: 'cluster-1', - // missing other properties - }, - ]; - - updateClustersTable(layout.clustersTable, clusters); - - expect(layout.clustersTable).to.exist; - }); - }); -} - -function defineUpdateAgentsTableTests() { - describe('updateAgentsTable', () => { - beforeEach(() => { - layout = createLayout(screen); - }); - - it('should update table with agent data', () => { - const agents = [ - { - clusterId: 'cluster-1', - id: 'worker-1', - role: 'worker', - status: 'running', - iteration: 3, - cpu: '12.5%', - memory: '245 MB', - }, - { - clusterId: 'cluster-1', - id: 'validator-1', - role: 'validator', - status: 'idle', - iteration: 0, - cpu: '0.1%', - memory: '128 MB', - }, - ]; - - updateAgentsTable(layout.agentTable, agents); - - expect(layout.agentTable).to.exist; - }); - - it('should handle empty agent array', () => { - updateAgentsTable(layout.agentTable, []); - - expect(layout.agentTable).to.exist; - }); - - it('should handle agents with missing properties', () => { - const agents = [ - { - id: 'agent-1', - // missing other properties - }, - ]; - - updateAgentsTable(layout.agentTable, agents); - - expect(layout.agentTable).to.exist; - }); - }); -} - -function defineUpdateStatsBoxTests() { - describe('updateStatsBox', () => { - beforeEach(() => { - layout = createLayout(screen); - }); - - it('should update stats box with system metrics', () => { - const stats = { - activeClusters: 3, - totalAgents: 15, - usedMemory: '512 MB', - totalMemory: '2 GB', - totalCPU: '25.5%', - }; - - updateStatsBox(layout.statsBox, stats); - - expect(layout.statsBox).to.exist; - }); - - it('should handle missing stats properties', () => { - const stats = { - activeClusters: 2, - // missing other properties - }; - - updateStatsBox(layout.statsBox, stats); - - expect(layout.statsBox).to.exist; - }); - - it('should handle empty stats object', () => { - updateStatsBox(layout.statsBox, {}); - - expect(layout.statsBox).to.exist; - }); - }); -} - -function defineAddLogEntryTests() { - describe('addLogEntry', () => { - beforeEach(() => { - layout = createLayout(screen); - }); - - it('should add info level log entry', () => { - addLogEntry(layout.logsBox, 'Test info message', 'info'); - - expect(layout.logsBox).to.exist; - }); - - it('should add warn level log entry', () => { - addLogEntry(layout.logsBox, 'Test warning message', 'warn'); - - expect(layout.logsBox).to.exist; - }); - - it('should add error level log entry', () => { - addLogEntry(layout.logsBox, 'Test error message', 'error'); - - expect(layout.logsBox).to.exist; - }); - - it('should add debug level log entry', () => { - addLogEntry(layout.logsBox, 'Test debug message', 'debug'); - - expect(layout.logsBox).to.exist; - }); - - it('should default to info level if not specified', () => { - addLogEntry(layout.logsBox, 'Test message without level'); - - expect(layout.logsBox).to.exist; - }); - - it('should handle unknown log level', () => { - addLogEntry(layout.logsBox, 'Test unknown level', 'unknown'); - - expect(layout.logsBox).to.exist; - }); - }); -} - -function defineClearLogsTests() { - describe('clearLogs', () => { - beforeEach(() => { - layout = createLayout(screen); - }); - - it('should clear logs without error', () => { - addLogEntry(layout.logsBox, 'Test message 1'); - addLogEntry(layout.logsBox, 'Test message 2'); - - clearLogs(layout.logsBox); - - expect(layout.logsBox).to.exist; - }); - }); -} - -function defineFocusNavigationTests() { - describe('focus navigation', () => { - beforeEach(() => { - layout = createLayout(screen); - }); - - it('should cycle focus through widgets', () => { - expect(layout.getCurrentFocus()).to.equal(0); // clusters table - - layout.focus(1); - expect(layout.getCurrentFocus()).to.equal(1); // agents table - - layout.focus(2); - expect(layout.getCurrentFocus()).to.equal(2); // logs - - layout.focus(0); - expect(layout.getCurrentFocus()).to.equal(0); // back to clusters - }); - - it('should not focus on invalid indices', () => { - const initialFocus = layout.getCurrentFocus(); - - layout.focus(-1); - expect(layout.getCurrentFocus()).to.equal(initialFocus); - - layout.focus(999); - expect(layout.getCurrentFocus()).to.equal(initialFocus); - }); - }); -} diff --git a/tests/two-stage-validation.test.js b/tests/two-stage-validation.test.js index e55e226b..b37c8810 100644 --- a/tests/two-stage-validation.test.js +++ b/tests/two-stage-validation.test.js @@ -5,6 +5,10 @@ const assert = require('assert'); const path = require('path'); const TemplateResolver = require('../src/template-resolver'); +const { validateConfig } = require('../src/config-validator'); +const LogicEngine = require('../src/logic-engine'); +const MessageBus = require('../src/message-bus'); +const Ledger = require('../src/ledger'); describe('Two-Stage Validation Pipeline', function () { let resolver; @@ -115,6 +119,118 @@ describe('Two-Stage Validation Pipeline', function () { assert.ok(contextSource, 'validator-security should have QUICK_VALIDATION_PASSED context'); assert.strictEqual(contextSource.priority, 'required'); }); + + it('should not retrigger consensus on a late single-validator update after heavy result is published', function () { + const resolved = resolver.resolve('heavy-validation', {}); + const coordinator = resolved.agents.find((a) => a.id === 'consensus-coordinator'); + const triggerScript = coordinator?.triggers?.find( + (t) => t.topic === 'HEAVY_VALIDATION_RESULT' + )?.logic?.script; + assert.ok(triggerScript, 'heavy consensus trigger script should exist'); + + const cluster = { + id: 'heavy-regression', + agents: [ + { id: 'validator-security', role: 'validator' }, + { id: 'validator-tester', role: 'validator' }, + { id: 'consensus-coordinator', role: 'coordinator' }, + ], + }; + + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const logicEngine = new LogicEngine(messageBus, cluster); + + try { + let ts = Date.now(); + const nextTs = () => ++ts; + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'QUICK_VALIDATION_PASSED', + sender: 'consensus-coordinator', + timestamp: nextTs(), + }); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'HEAVY_VALIDATION_RESULT', + sender: 'validator-security', + timestamp: nextTs(), + content: { data: { approved: true } }, + }); + + let shouldTrigger = logicEngine.evaluate( + triggerScript, + { id: 'consensus-coordinator', cluster_id: cluster.id }, + { topic: 'HEAVY_VALIDATION_RESULT' } + ); + assert.strictEqual(shouldTrigger, false, 'must wait for both validators'); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'HEAVY_VALIDATION_RESULT', + sender: 'validator-tester', + timestamp: nextTs(), + content: { data: { approved: true } }, + }); + + shouldTrigger = logicEngine.evaluate( + triggerScript, + { id: 'consensus-coordinator', cluster_id: cluster.id }, + { topic: 'HEAVY_VALIDATION_RESULT' } + ); + assert.strictEqual(shouldTrigger, true, 'should trigger once both validators respond'); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'VALIDATION_RESULT', + sender: 'consensus-coordinator', + timestamp: nextTs(), + content: { data: { approved: true, stage: 'heavy' } }, + }); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'HEAVY_VALIDATION_RESULT', + sender: 'validator-security', + timestamp: nextTs(), + content: { data: { approved: false } }, + }); + + shouldTrigger = logicEngine.evaluate( + triggerScript, + { id: 'consensus-coordinator', cluster_id: cluster.id }, + { topic: 'HEAVY_VALIDATION_RESULT' } + ); + assert.strictEqual( + shouldTrigger, + false, + 'late update from one validator must not retrigger heavy consensus' + ); + + messageBus.publish({ + cluster_id: cluster.id, + topic: 'HEAVY_VALIDATION_RESULT', + sender: 'validator-tester', + timestamp: nextTs(), + content: { data: { approved: false } }, + }); + + shouldTrigger = logicEngine.evaluate( + triggerScript, + { id: 'consensus-coordinator', cluster_id: cluster.id }, + { topic: 'HEAVY_VALIDATION_RESULT' } + ); + assert.strictEqual( + shouldTrigger, + true, + 'should trigger again only after both validators publish a fresh cycle' + ); + } finally { + ledger.close(); + } + }); }); describe('full-workflow integration', function () { @@ -136,6 +252,51 @@ describe('Two-Stage Validation Pipeline', function () { // Inline validators should be filtered out const validators = resolved.agents.filter((a) => a.role === 'validator'); assert.strictEqual(validators.length, 0, 'No inline validators for CRITICAL tasks'); + + // Regression: config-validator should NOT raise Gap 15 role-reference errors when validators are absent. + const validation = validateConfig(resolved); + const roleErrors = validation.errors.filter( + (e) => + e.includes('[Gap 15]') || + e.includes("Logic references role 'validator'") || + e.includes('Logic references role "validator"') + ); + assert.strictEqual(roleErrors.length, 0, `Unexpected role reference errors: ${roleErrors}`); + }); + + it('meta-coordinator should republish trigger topic after load_config (prevents validator deadlock)', function () { + const resolved = resolver.resolve('full-workflow', { + task_type: 'TASK', + complexity: 'CRITICAL', + max_tokens: 150000, + max_iterations: 25, + planner_level: 'level3', + worker_level: 'level2', + validator_level: 'level2', + validator_count: 0, + }); + + const metaCoordinator = resolved.agents.find((a) => a.id === 'meta-coordinator'); + assert.ok(metaCoordinator, 'meta-coordinator should be present for CRITICAL tasks'); + + const hookTransformScript = metaCoordinator.hooks?.onComplete?.transform?.script || ''; + assert.ok( + hookTransformScript.includes("action: 'publish'") || + hookTransformScript.includes('action: "publish"'), + 'meta-coordinator should publish a republished trigger topic after load_config' + ); + assert.ok( + hookTransformScript.includes('_republished'), + 'meta-coordinator republish should include _republished metadata' + ); + + const implTrigger = metaCoordinator.triggers?.find((t) => t.topic === 'IMPLEMENTATION_READY'); + assert.ok(implTrigger?.logic?.script?.includes('_republished')); + + const stage2Trigger = metaCoordinator.triggers?.find( + (t) => t.topic === 'QUICK_VALIDATION_PASSED' + ); + assert.ok(stage2Trigger?.logic?.script?.includes('_republished')); }); it('should NOT load meta-coordinator for STANDARD tasks', function () { diff --git a/tests/unit/.test-storage/clusters.json b/tests/unit/.test-storage/clusters.json new file mode 100644 index 00000000..7b8f330a --- /dev/null +++ b/tests/unit/.test-storage/clusters.json @@ -0,0 +1,147 @@ +{ + "emerald-prism-4": { + "id": "emerald-prism-4", + "config": { + "agents": [ + { + "id": "test-conductor", + "role": "conductor", + "modelLevel": "level1", + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "complexity": { + "type": "string", + "enum": ["TRIVIAL", "SIMPLE", "STANDARD", "CRITICAL"] + }, + "reasoning": { + "type": "string" + } + }, + "required": ["complexity", "reasoning"] + }, + "prompt": "Classify this task: {{ISSUE_OPENED.content.text}}. Return JSON with complexity and reasoning.", + "triggers": [ + { + "topic": "ISSUE_OPENED", + "action": "execute_task" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "CLASSIFICATION_DONE", + "content": { + "text": "Classification complete", + "data": { + "result": "{{result}}" + } + } + } + } + }, + "cwd": "/home/ubuntu/.zeroshot/worktrees/frozen-equinox-32", + "strictSchema": true, + "timeout": 0 + }, + { + "id": "git-pusher", + "role": "completion-detector", + "modelLevel": "level2", + "triggers": [ + { + "topic": "VALIDATION_RESULT", + "logic": { + "engine": "javascript", + "script": "const validators = cluster.getAgentsByRole('validator');\nconst lastPush = ledger.findLast({ topic: 'IMPLEMENTATION_READY' });\nif (!lastPush) return false;\nif (validators.length === 0) return true;\nconst results = ledger.query({ topic: 'VALIDATION_RESULT', since: lastPush.timestamp });\nif (results.length < validators.length) return false;\nconst allApproved = results.every(r => r.content?.data?.approved === 'true' || r.content?.data?.approved === true);\nif (!allApproved) return false;\nconst hasSufficientEvidence = results.every(r => {\n const criteria = r.content?.data?.criteriaResults;\n if (!Array.isArray(criteria) || criteria.length === 0) return true;\n return criteria.every(c => {\n const status = String(c.status || '').toUpperCase();\n if (status === 'CANNOT_VALIDATE') return true;\n if (status === 'SKIPPED') return true;\n if (status === 'CANNOT_VALIDATE_YET') return false;\n const evidence = c.evidence || {};\n const hasCommand = typeof evidence.command === 'string' && evidence.command.trim().length > 0;\n const exitCode = evidence.exitCode;\n const hasExitCode =\n typeof exitCode === 'number' ||\n (typeof exitCode === 'string' && exitCode.trim() !== '' && Number.isFinite(Number(exitCode)));\n const hasOutput = evidence.output === undefined || typeof evidence.output === 'string';\n return hasCommand && hasExitCode && hasOutput;\n });\n});\nreturn hasSufficientEvidence;" + }, + "action": "execute_task" + } + ], + "prompt": "🚨 CRITICAL: ALL VALIDATORS APPROVED. YOU MUST CREATE A PR AND GET IT MERGED. DO NOT STOP UNTIL THE PR IS MERGED. 🚨\n\n## MANDATORY STEPS - EXECUTE EACH ONE IN ORDER - DO NOT SKIP ANY STEP\n\n### STEP 1: Stage ALL changes (MANDATORY)\n```bash\ngit add -A\n```\nRun this command. Do not skip it.\n\n### STEP 2: Check what's staged\n```bash\ngit status\n```\nRun this. If nothing to commit, output JSON with pr_url: null and stop.\n\n### STEP 3: Commit the changes (MANDATORY if there are changes)\n```bash\ngit commit -m \"feat: implement #unknown - Manual Input\"\n```\nRun this command. Do not skip it.\n\n### STEP 4: Push to origin (MANDATORY)\n```bash\ngit push -u origin HEAD\n```\nRun this. If it fails, check the error and fix it.\n\nāš ļø AFTER PUSH YOU ARE NOT DONE! CONTINUE TO STEP 5! āš ļø\n\n### STEP 5: CREATE THE PR (MANDATORY - YOU MUST RUN THIS COMMAND)\n```bash\ngh pr create --base dev --title \"feat: Manual Input\" --body \"Closes #unknown\"\n```\n🚨 YOU MUST RUN `gh pr create`! Outputting a link is NOT creating a PR! 🚨\nThe push output shows a \"Create a pull request\" link - IGNORE IT.\nYou MUST run the `gh pr create` command above. Save the actual PR URL from the output.\n\nāš ļø AFTER PR CREATION YOU ARE NOT DONE! CONTINUE TO STEP 6! āš ļø\n\n### STEP 6: MERGE THE PR (MANDATORY - THIS IS NOT OPTIONAL)\n```bash\ngh pr merge --merge --auto\n```\nThis sets auto-merge. If it fails (e.g., no auto-merge enabled), try:\n```bash\ngh pr merge --merge\n```\n\n🚨 IF MERGE FAILS DUE TO CONFLICTS - YOU MUST RESOLVE THEM:\na) Pull latest dev and rebase:\n ```bash\n git fetch origin dev\n git rebase origin/dev\n ```\nb) If conflicts appear - RESOLVE THEM IMMEDIATELY:\n - Read the conflicting files\n - Make intelligent decisions about what code to keep\n - Edit the files to resolve conflicts\n - `git add `\n - `git rebase --continue`\nc) Force push the resolved branch:\n ```bash\n git push --force-with-lease\n ```\nd) Retry merge:\n ```bash\ngh pr merge --merge\n```\n\nREPEAT UNTIL MERGED. DO NOT GIVE UP. DO NOT SKIP. THE PR MUST BE MERGED.\nIf merge is blocked by CI, wait and retry. If blocked by reviews, set auto-merge.\n\n\n\n## CRITICAL RULES\n- Execute EVERY step in order (1, 2, 3, 4, 5, 6)\n- Do NOT skip git add -A\n- Do NOT skip git commit\n- Do NOT skip gh pr create - THE TASK IS NOT DONE UNTIL PR EXISTS\n- Do NOT skip gh pr merge --merge - THE TASK IS NOT DONE UNTIL PR IS MERGED\n- If push fails, debug and fix it\n- If PR creation fails, debug and fix it\n- If merge fails, debug and fix it\n- DO NOT OUTPUT JSON UNTIL PR IS MERGED\n- A link from git push is NOT a PR - you must run gh pr create\n\n## Final Output\nONLY after the PR is MERGED, output:\n```json\n{\"pr_url\": \"https://github.com/owner/repo/pull/123\", \"pr_number\": 123, \"merged\": true}\n```\n\nIf truly no changes exist, output:\n```json\n{\"pr_url\": null, \"pr_number\": null, \"merged\": false}\n```", + "hooks": { + "onComplete": { + "action": "verify_github_pr" + } + }, + "output": { + "topic": "PR_CREATED", + "publishAfter": "CLUSTER_COMPLETE" + }, + "structuredOutput": { + "type": "object", + "properties": { + "pr_number": { + "type": "number", + "description": "MUST extract from gh pr create output - NOT from git push link" + }, + "pr_url": { + "type": "string" + }, + "merged": { + "type": "boolean" + }, + "merge_commit_sha": { + "type": "string", + "description": "MUST extract from gh pr merge output" + } + }, + "required": ["pr_number", "pr_url", "merged", "merge_commit_sha"] + }, + "cwd": "/home/ubuntu/.zeroshot/worktrees/frozen-equinox-32", + "outputFormat": "json", + "jsonSchema": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Brief summary of what was done" + }, + "result": { + "type": "string", + "description": "Detailed result or output" + } + }, + "required": ["summary", "result"] + }, + "strictSchema": true, + "timeout": 0 + } + ] + }, + "state": "running", + "createdAt": 1770023875452, + "pid": 3049126, + "failureInfo": null, + "autoPr": true, + "prOptions": null, + "modelOverride": null, + "issue": null, + "isolation": { + "enabled": true, + "containerId": "e900d72bbf07", + "image": "zeroshot-cluster-base", + "workDir": "/home/ubuntu/.zeroshot/worktrees/frozen-equinox-32" + }, + "agentStates": [ + { + "id": "test-conductor", + "state": "executing_task", + "iteration": 3, + "currentTask": false, + "currentTaskId": null, + "processPid": 3051855 + }, + { + "id": "git-pusher", + "state": "idle", + "iteration": 0, + "currentTask": false, + "currentTaskId": null, + "processPid": null + } + ] + } +} diff --git a/tests/unit/.test-storage/emerald-prism-4.db b/tests/unit/.test-storage/emerald-prism-4.db new file mode 100644 index 0000000000000000000000000000000000000000..d53f7626f1a13c594f22718b9ac31fc1e191d61d GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYFvV$qUjhK(-m98b?E5 nGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nC=3ArlePz! literal 0 HcmV?d00001 diff --git a/tests/unit/.test-storage/emerald-prism-4.db-shm b/tests/unit/.test-storage/emerald-prism-4.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..3641d16a2212903a6788e4833c39b00bf3d76a56 GIT binary patch literal 32768 zcmeI)xlUF=6u|L6ARwD;il2xI0)h()h`6I{vb%r^0tzCbq4WuS2H(Obu=NpaDXr|Z zw$T}LH54R<;*v@HPck`QzB%`tbAPv=!j~T6Y6!as>NZ2H4Ew|O+kwwr%R}!zer@_b z_x{_?r{S@NzO5gHeddQ!zgMjLbJxoL{u@GXio-4mJN`bD#nLR6WwAVqheQ06A9G_~ z%!%j!!`W`Hz23hfIK=N5J@-2Q*I9VXrGNqoD4>7>3Mim}0tzUgfC36Apnw7jD4>7> z3Mim}0tzUgfC36Apnw7jD4>7>3Mim}0tzUgfC36Apnw7jD4>7>3Mim}0tzUgfC35} zfIv~W8=)-R7g0$ya|h7R77C;zP|OjI(Ly^H=;H?Wc)-JS#&Z${aug_`mL^Wo!A1JH z$$chx#3HY9>}hEQ(hw-6j^muBlS^FX7Q;;Pm?hTI7|$sb$Wfr2dJ3H396bzhn-QjX z!VA`O>}hEQ(h#T!w>_Sql`bxGjXR7o%{n_|-jr literal 0 HcmV?d00001 diff --git a/tests/unit/.test-storage/emerald-prism-4.db-wal b/tests/unit/.test-storage/emerald-prism-4.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..8c3531361897fd5969d6ccc0f74d568573b880f7 GIT binary patch literal 412032 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 LoWfH&r||y+i6>Pk literal 0 HcmV?d00001 diff --git a/tests/unit/attach-stdin.test.js b/tests/unit/attach-stdin.test.js new file mode 100644 index 00000000..912190cd --- /dev/null +++ b/tests/unit/attach-stdin.test.js @@ -0,0 +1,84 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { AttachServer } = require('../../src/attach'); +const { sendInput } = require('../../src/attach/send-input'); + +describe('Attach stdin', function () { + this.timeout(10000); + + it('sendInput writes to a live PTY', async function () { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-attach-')); + const socketPath = path.join(tmpDir, 'attach.sock'); + + const server = new AttachServer({ + id: 'attach-stdin-test', + socketPath, + command: 'cat', + args: [], + cwd: process.cwd(), + env: process.env, + cols: 80, + rows: 24, + }); + + let output = ''; + let resolved = false; + + const outputPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error('Timed out waiting for PTY output')); + } + }, 2000); + + const onOutput = (data) => { + output += data.toString(); + if (output.includes('hello-attach')) { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + server.off('output', onOutput); + resolve(); + } + }; + + server.on('output', onOutput); + }); + + let testError; + let stopError; + try { + await server.start(); + const result = await sendInput({ + socketPath, + data: 'hello-attach\n', + timeoutMs: 1000, + }); + + assert.strictEqual(result.ok, true); + await outputPromise; + assert.ok(output.includes('hello-attach')); + } catch (error) { + testError = error; + } finally { + try { + await server.stop('SIGTERM'); + } catch (error) { + console.warn('AttachServer.stop failed in attach-stdin test', error); + stopError = error; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + + if (stopError && !testError) { + throw stopError; + } + if (testError) { + throw testError; + } + }); +}); diff --git a/tests/unit/cli-default-entry.test.js b/tests/unit/cli-default-entry.test.js new file mode 100644 index 00000000..8d09d764 --- /dev/null +++ b/tests/unit/cli-default-entry.test.js @@ -0,0 +1,46 @@ +/** + * Test: CLI Default Entry Behavior (no args) + * + * Verifies zeroshot with no args launches TUI on interactive TTY + * and prints help on non-interactive input. + */ + +const assert = require('assert'); + +function resolveDefaultEntry(args, { isInteractiveTty }) { + let workingArgs = [...args]; + let shouldOutputHelp = false; + + if (workingArgs.length === 0) { + if (isInteractiveTty) { + workingArgs = ['tui']; + } else { + shouldOutputHelp = true; + } + } + + return { args: workingArgs, shouldOutputHelp }; +} + +describe('CLI Default Entry (no args)', function () { + it('routes to tui when no args and interactive TTY', function () { + const result = resolveDefaultEntry([], { isInteractiveTty: true }); + + assert.deepStrictEqual(result.args, ['tui']); + assert.strictEqual(result.shouldOutputHelp, false); + }); + + it('prints help when no args and non-interactive', function () { + const result = resolveDefaultEntry([], { isInteractiveTty: false }); + + assert.deepStrictEqual(result.args, []); + assert.strictEqual(result.shouldOutputHelp, true); + }); + + it('does not change args when already provided', function () { + const result = resolveDefaultEntry(['list'], { isInteractiveTty: true }); + + assert.deepStrictEqual(result.args, ['list']); + assert.strictEqual(result.shouldOutputHelp, false); + }); +}); diff --git a/tests/unit/cli-invalid-command.test.js b/tests/unit/cli-invalid-command.test.js index 5a38f213..fa9d8260 100644 --- a/tests/unit/cli-invalid-command.test.js +++ b/tests/unit/cli-invalid-command.test.js @@ -37,6 +37,11 @@ describe('CLI Invalid Command Handling', function () { 'purge', 'export', 'watch', + 'tui', + 'codex', + 'claude', + 'gemini', + 'opencode', 'attach', 'agents', 'config', @@ -101,6 +106,22 @@ describe('CLI Invalid Command Handling', function () { it('should not prepend run for "settings" command', function () { assert.strictEqual(shouldPrependRun(['settings']), false); }); + + it('should not prepend run for "codex" command', function () { + assert.strictEqual(shouldPrependRun(['codex']), false); + }); + + it('should not prepend run for "claude" command', function () { + assert.strictEqual(shouldPrependRun(['claude']), false); + }); + + it('should not prepend run for "gemini" command', function () { + assert.strictEqual(shouldPrependRun(['gemini']), false); + }); + + it('should not prepend run for "opencode" command', function () { + assert.strictEqual(shouldPrependRun(['opencode']), false); + }); }); describe('Flags should not trigger run prepending', function () { diff --git a/tests/unit/cli-pr-base-env.test.js b/tests/unit/cli-pr-base-env.test.js new file mode 100644 index 00000000..4adcdd9a --- /dev/null +++ b/tests/unit/cli-pr-base-env.test.js @@ -0,0 +1,137 @@ +/** + * Test: PR config env fallback + * + * Ensures detached/daemon runs can read PR config from env vars. + */ + +const assert = require('assert'); +const { buildStartOptions } = require('../../lib/start-cluster'); + +const ENV_VARS = [ + 'ZEROSHOT_CWD', + 'ZEROSHOT_RUN_OPTIONS', + 'ZEROSHOT_PR_BASE', + 'ZEROSHOT_MERGE_QUEUE', + 'ZEROSHOT_CLOSE_ISSUE', +]; + +const originalEnv = ENV_VARS.reduce((acc, key) => { + acc[key] = process.env[key]; + return acc; +}, {}); + +function restoreEnv() { + for (const key of ENV_VARS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } +} + +describe('CLI PR config env fallback', function () { + afterEach(function () { + restoreEnv(); + }); + + it('uses ZEROSHOT_PR_BASE when options.prBase is missing', function () { + process.env.ZEROSHOT_CWD = '/tmp/zeroshot-test'; + process.env.ZEROSHOT_PR_BASE = 'dev'; + + const result = buildStartOptions({ clusterId: 'test', options: {}, settings: {} }); + + assert.strictEqual(result.prBase, 'dev'); + }); + + it('uses ZEROSHOT_RUN_OPTIONS when options are missing', function () { + process.env.ZEROSHOT_CWD = '/tmp/zeroshot-test'; + process.env.ZEROSHOT_RUN_OPTIONS = JSON.stringify({ + prBase: 'dev', + mergeQueue: true, + closeIssue: 'always', + }); + + const result = buildStartOptions({ clusterId: 'test', options: {}, settings: {} }); + + assert.strictEqual(result.prBase, 'dev'); + assert.strictEqual(result.mergeQueue, true); + assert.strictEqual(result.closeIssue, 'always'); + }); + + it('prefers explicit options over ZEROSHOT_RUN_OPTIONS', function () { + process.env.ZEROSHOT_CWD = '/tmp/zeroshot-test'; + process.env.ZEROSHOT_RUN_OPTIONS = JSON.stringify({ + prBase: 'dev', + mergeQueue: false, + closeIssue: 'always', + }); + + const result = buildStartOptions({ + clusterId: 'test', + options: { prBase: 'main', mergeQueue: true, closeIssue: 'never' }, + settings: {}, + }); + + assert.strictEqual(result.prBase, 'main'); + assert.strictEqual(result.mergeQueue, true); + assert.strictEqual(result.closeIssue, 'never'); + }); + + it('prefers options.prBase over ZEROSHOT_PR_BASE', function () { + process.env.ZEROSHOT_CWD = '/tmp/zeroshot-test'; + process.env.ZEROSHOT_PR_BASE = 'dev'; + + const result = buildStartOptions({ + clusterId: 'test', + options: { prBase: 'main' }, + settings: {}, + }); + + assert.strictEqual(result.prBase, 'main'); + }); + + it('uses ZEROSHOT_MERGE_QUEUE when options.mergeQueue is missing', function () { + process.env.ZEROSHOT_CWD = '/tmp/zeroshot-test'; + process.env.ZEROSHOT_MERGE_QUEUE = '1'; + + const result = buildStartOptions({ clusterId: 'test', options: {}, settings: {} }); + + assert.strictEqual(result.mergeQueue, true); + }); + + it('prefers options.mergeQueue over ZEROSHOT_MERGE_QUEUE', function () { + process.env.ZEROSHOT_CWD = '/tmp/zeroshot-test'; + process.env.ZEROSHOT_MERGE_QUEUE = '0'; + + const result = buildStartOptions({ + clusterId: 'test', + options: { mergeQueue: true }, + settings: {}, + }); + + assert.strictEqual(result.mergeQueue, true); + }); + + it('uses ZEROSHOT_CLOSE_ISSUE when options.closeIssue is missing', function () { + process.env.ZEROSHOT_CWD = '/tmp/zeroshot-test'; + process.env.ZEROSHOT_CLOSE_ISSUE = 'always'; + + const result = buildStartOptions({ clusterId: 'test', options: {}, settings: {} }); + + assert.strictEqual(result.closeIssue, 'always'); + }); + + it('prefers options.closeIssue over ZEROSHOT_CLOSE_ISSUE', function () { + process.env.ZEROSHOT_CWD = '/tmp/zeroshot-test'; + process.env.ZEROSHOT_CLOSE_ISSUE = 'always'; + + const result = buildStartOptions({ + clusterId: 'test', + options: { closeIssue: 'never' }, + settings: {}, + }); + + assert.strictEqual(result.closeIssue, 'never'); + }); +}); diff --git a/tests/unit/cli-tui-binary-resolution.test.js b/tests/unit/cli-tui-binary-resolution.test.js new file mode 100644 index 00000000..9baeb0f3 --- /dev/null +++ b/tests/unit/cli-tui-binary-resolution.test.js @@ -0,0 +1,103 @@ +/** + * Test: CLI TUI Binary Resolution + * + * Verifies local Rust builds are preferred over installed libexec binaries. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { resolveRustTuiBinary } = require('../../lib/tui-launcher'); + +const DEFAULT_RUST_BIN_NAME = process.platform === 'win32' ? 'zeroshot-tui.exe' : 'zeroshot-tui'; +const DEBUG_SUFFIX = path.join('tui-rs', 'target', 'debug', DEFAULT_RUST_BIN_NAME); +const RELEASE_SUFFIX = path.join('tui-rs', 'target', 'release', DEFAULT_RUST_BIN_NAME); +const LIBEXEC_SUFFIX = path.join('libexec', DEFAULT_RUST_BIN_NAME); + +describe('CLI TUI binary resolution', function () { + function withPatchedExistsSync(mock, callback) { + const originalExistsSync = fs.existsSync; + fs.existsSync = mock; + try { + callback(); + } finally { + fs.existsSync = originalExistsSync; + } + } + + function withCleanBinaryEnv(callback) { + const keys = ['ZEROSHOT_TUI_BINARY_PATH', 'ZEROSHOT_TUI_PATH', 'ZEROSHOT_TUI_BIN']; + const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + + for (const key of keys) { + delete process.env[key]; + } + + try { + callback(); + } finally { + for (const key of keys) { + if (previous[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous[key]; + } + } + } + } + + it('prefers local debug build over release and installed libexec binaries', function () { + withCleanBinaryEnv(() => { + withPatchedExistsSync( + (candidate) => { + if (candidate.endsWith(DEBUG_SUFFIX)) { + return true; + } + if (candidate.endsWith(RELEASE_SUFFIX)) { + return true; + } + if (candidate.endsWith(LIBEXEC_SUFFIX)) { + return true; + } + return false; + }, + () => { + const resolved = resolveRustTuiBinary(); + assert(resolved.endsWith(DEBUG_SUFFIX)); + } + ); + }); + }); + + it('falls back to local release build when debug build is unavailable', function () { + withCleanBinaryEnv(() => { + withPatchedExistsSync( + (candidate) => { + if (candidate.endsWith(RELEASE_SUFFIX)) { + return true; + } + if (candidate.endsWith(LIBEXEC_SUFFIX)) { + return true; + } + return false; + }, + () => { + const resolved = resolveRustTuiBinary(); + assert(resolved.endsWith(RELEASE_SUFFIX)); + } + ); + }); + }); + + it('falls back to installed libexec binary when local build is unavailable', function () { + withCleanBinaryEnv(() => { + withPatchedExistsSync( + (candidate) => candidate.endsWith(LIBEXEC_SUFFIX), + () => { + const resolved = resolveRustTuiBinary(); + assert(resolved.endsWith(LIBEXEC_SUFFIX)); + } + ); + }); + }); +}); diff --git a/tests/unit/cli-tui-entrypoints.test.js b/tests/unit/cli-tui-entrypoints.test.js new file mode 100644 index 00000000..a8b5227c --- /dev/null +++ b/tests/unit/cli-tui-entrypoints.test.js @@ -0,0 +1,23 @@ +/** + * Test: CLI TUI Entrypoints + * + * Verifies provider-specific entrypoints map to TUI provider override. + */ + +const assert = require('assert'); +const { resolveTuiProviderOverride } = require('../../lib/tui-launcher'); + +function buildEntrypointOptions(providerName) { + return { providerOverride: resolveTuiProviderOverride({ provider: providerName }) }; +} + +describe('CLI TUI Entrypoints', function () { + const entrypoints = ['codex', 'claude', 'gemini', 'opencode']; + + for (const provider of entrypoints) { + it(`sets providerOverride for ${provider}`, function () { + const result = buildEntrypointOptions(provider); + assert.strictEqual(result.providerOverride, provider); + }); + } +}); diff --git a/tests/unit/cli-tui-launcher.test.js b/tests/unit/cli-tui-launcher.test.js new file mode 100644 index 00000000..7ee9a2e7 --- /dev/null +++ b/tests/unit/cli-tui-launcher.test.js @@ -0,0 +1,39 @@ +/** + * Test: CLI TUI Launcher + * + * Verifies Rust TUI spawn is default. + */ + +const assert = require('assert'); +const { launchTuiSession } = require('../../lib/tui-launcher'); + +describe('CLI TUI Launcher', function () { + it('spawns Rust TUI by default with initial screen + provider override', function () { + const spawnCalls = []; + const spawnStub = (command, args, options) => { + spawnCalls.push({ command, args, options }); + return { on: () => {} }; + }; + + launchTuiSession({ + initialView: 'monitor', + provider: 'codex', + spawn: spawnStub, + binaryPath: '/tmp/zeroshot-tui', + cwd: '/tmp', + }); + + assert.strictEqual(spawnCalls.length, 1); + assert.strictEqual(spawnCalls[0].command, '/tmp/zeroshot-tui'); + assert.deepStrictEqual(spawnCalls[0].args, [ + '--initial-screen', + 'monitor', + '--provider-override', + 'codex', + ]); + assert.strictEqual(spawnCalls[0].options.cwd, '/tmp'); + assert.strictEqual(spawnCalls[0].options.stdio, 'inherit'); + assert.strictEqual(spawnCalls[0].options.env.ZEROSHOT_TUI_INITIAL_SCREEN, 'monitor'); + assert.strictEqual(spawnCalls[0].options.env.ZEROSHOT_TUI_PROVIDER_OVERRIDE, 'codex'); + }); +}); diff --git a/tests/unit/cli-tui-provider-override.test.js b/tests/unit/cli-tui-provider-override.test.js new file mode 100644 index 00000000..9b66db36 --- /dev/null +++ b/tests/unit/cli-tui-provider-override.test.js @@ -0,0 +1,31 @@ +/** + * Test: CLI TUI Provider Override + * + * Verifies that --provider parsing for `zeroshot tui` normalizes and validates + * the provider override passed to the TUI bootstrap. + */ + +const assert = require('assert'); +const { buildRustTuiCommand, resolveTuiProviderOverride } = require('../../lib/tui-launcher'); + +describe('CLI TUI Provider Override', function () { + it('returns null when no override is set', function () { + const result = resolveTuiProviderOverride({}); + assert.strictEqual(result, null); + }); + + it('passes provider override into Rust TUI command', function () { + const result = buildRustTuiCommand({ provider: 'codex', binaryPath: '/tmp/zeroshot-tui' }); + assert.deepStrictEqual(result.args, ['--provider-override', 'codex']); + assert.strictEqual(result.env.ZEROSHOT_TUI_PROVIDER_OVERRIDE, 'codex'); + }); + + it('normalizes provider aliases', function () { + const result = resolveTuiProviderOverride({ provider: 'OpenAI' }); + assert.strictEqual(result, 'codex'); + }); + + it('throws on unknown provider', function () { + assert.throws(() => resolveTuiProviderOverride({ provider: 'invalid' }), /Unknown provider:/); + }); +}); diff --git a/tests/unit/cli-tui-ui-variant.test.js b/tests/unit/cli-tui-ui-variant.test.js new file mode 100644 index 00000000..017f35a7 --- /dev/null +++ b/tests/unit/cli-tui-ui-variant.test.js @@ -0,0 +1,25 @@ +/** + * Test: CLI TUI UI Variant + * + * Verifies UI variant parsing and Rust TUI args/env plumbing. + */ + +const assert = require('assert'); +const { buildRustTuiCommand, resolveUiVariant } = require('../../lib/tui-launcher'); + +describe('CLI TUI UI Variant', function () { + it('returns null when no variant is set', function () { + const result = resolveUiVariant({}); + assert.strictEqual(result, null); + }); + + it('passes normalized ui variant into Rust TUI command', function () { + const result = buildRustTuiCommand({ ui: 'Disruptive', binaryPath: '/tmp/zeroshot-tui' }); + assert.deepStrictEqual(result.args, ['--ui', 'disruptive']); + assert.strictEqual(result.env.ZEROSHOT_TUI_UI, 'disruptive'); + }); + + it('throws on unknown ui variant', function () { + assert.throws(() => resolveUiVariant({ ui: 'weird' }), /Unknown UI variant/); + }); +}); diff --git a/tests/unit/github-provider-parse-identifier.test.js b/tests/unit/github-provider-parse-identifier.test.js new file mode 100644 index 00000000..b4746f1f --- /dev/null +++ b/tests/unit/github-provider-parse-identifier.test.js @@ -0,0 +1,67 @@ +/** + * Regression test: GitHub provider must ALWAYS extract repo from identifier + * + * Bug: _extractIssueNumber discarded repo, causing `gh issue view` to guess + * from CWD git remote, which failed when CWD was in a different repo. + */ + +const { expect } = require('chai'); +const GitHubProvider = require('../../src/issue-providers/github-provider'); + +describe('GitHubProvider._parseIdentifier', () => { + let provider; + + beforeEach(() => { + provider = new GitHubProvider(); + }); + + describe('extracts repo from identifier', () => { + it('org/repo#123 format returns both repo and number', () => { + const result = provider._parseIdentifier('covibes/covibes#1172'); + expect(result).to.deep.equal({ repo: 'covibes/covibes', number: '1172' }); + }); + + it('org-with-dash/repo-with-dash#123 format', () => { + const result = provider._parseIdentifier('my-org/my-repo#456'); + expect(result).to.deep.equal({ repo: 'my-org/my-repo', number: '456' }); + }); + + it('org.with.dots/repo.with.dots#123 format', () => { + const result = provider._parseIdentifier('my.org/my.repo#789'); + expect(result).to.deep.equal({ repo: 'my.org/my.repo', number: '789' }); + }); + + it('GitHub URL extracts repo and number', () => { + const result = provider._parseIdentifier('https://github.com/covibes/covibes/issues/1172'); + expect(result).to.deep.equal({ repo: 'covibes/covibes', number: '1172' }); + }); + + it('bare number with gitContext uses context repo', () => { + const gitContext = { owner: 'covibes', repo: 'covibes' }; + const result = provider._parseIdentifier('1172', gitContext); + expect(result).to.deep.equal({ repo: 'covibes/covibes', number: '1172' }); + }); + + it('bare number without gitContext returns null repo', () => { + const result = provider._parseIdentifier('1172', null); + expect(result).to.deep.equal({ repo: null, number: '1172' }); + }); + }); + + describe('never loses repo information', () => { + it('explicit repo takes precedence over gitContext', () => { + const gitContext = { owner: 'other', repo: 'repo' }; + const result = provider._parseIdentifier('covibes/covibes#1172', gitContext); + expect(result.repo).to.equal('covibes/covibes'); + }); + + it('URL repo takes precedence over gitContext', () => { + const gitContext = { owner: 'other', repo: 'repo' }; + const result = provider._parseIdentifier( + 'https://github.com/covibes/covibes/issues/1172', + gitContext + ); + expect(result.repo).to.equal('covibes/covibes'); + }); + }); +}); diff --git a/tests/unit/guidance-delivery.test.js b/tests/unit/guidance-delivery.test.js new file mode 100644 index 00000000..f9fc5d67 --- /dev/null +++ b/tests/unit/guidance-delivery.test.js @@ -0,0 +1,276 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-guidance-')); +process.env.ZEROSHOT_HOME = tempHome; + +const assert = require('assert'); + +const Orchestrator = require('../../src/orchestrator'); +const AgentWrapper = require('../../src/agent-wrapper'); +const MessageBus = require('../../src/message-bus'); +const Ledger = require('../../src/ledger'); +const { USER_GUIDANCE_AGENT, USER_GUIDANCE_CLUSTER } = require('../../src/guidance-topics'); +const { AttachServer } = require('../../src/attach'); + +describe('Guidance delivery', function () { + this.timeout(10000); + + let orchestrator; + let cluster; + let agent; + let ledger; + + beforeEach(() => { + ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + cluster = { + id: 'guidance-cluster', + messageBus, + ledger, + agents: [], + config: {}, + }; + + orchestrator = new Orchestrator({ + quiet: true, + skipLoad: true, + storageDir: path.join(tempHome, 'clusters'), + }); + + agent = new AgentWrapper( + { + id: 'agent-1', + role: 'implementation', + modelLevel: 'level1', + prompt: 'noop', + triggers: [], + }, + messageBus, + cluster, + { testMode: true } + ); + + cluster.agents.push(agent); + orchestrator.clusters.set(cluster.id, cluster); + }); + + afterEach(() => { + ledger.close(); + orchestrator.close(); + orchestrator.clusters.clear(); + }); + + after(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('publishes unsupported delivery metadata when no live socket is available', async () => { + const result = await orchestrator.sendGuidanceToAgent(cluster.id, agent.id, 'Use approach A'); + + assert.strictEqual(result.status, 'unsupported'); + + const messages = cluster.messageBus.query({ + cluster_id: cluster.id, + topic: USER_GUIDANCE_AGENT, + }); + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].metadata.delivery.status, 'unsupported'); + }); + + it('publishes injected delivery metadata when attach socket is live', async () => { + const { addTask, ensureDirs, removeTask } = await import('../../task-lib/store.js'); + ensureDirs(); + + const taskId = 'task-guidance-1'; + const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-guidance-sock-')); + const socketPath = path.join(socketDir, 'attach.sock'); + + const server = new AttachServer({ + id: taskId, + socketPath, + command: 'cat', + args: [], + cwd: process.cwd(), + env: process.env, + cols: 80, + rows: 24, + }); + + let testError; + let stopError; + try { + await server.start(); + + addTask({ + id: taskId, + prompt: 'test', + fullPrompt: 'test', + cwd: process.cwd(), + status: 'running', + pid: server.pid, + sessionId: null, + logFile: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + exitCode: null, + error: null, + provider: 'claude', + model: null, + scheduleId: null, + socketPath, + attachable: true, + }); + + agent.currentTaskId = taskId; + + const result = await orchestrator.sendGuidanceToAgent( + cluster.id, + agent.id, + 'Injected guidance' + ); + + assert.strictEqual(result.status, 'injected'); + + const messages = cluster.messageBus.query({ + cluster_id: cluster.id, + topic: USER_GUIDANCE_AGENT, + }); + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].metadata.delivery.status, 'injected'); + assert.strictEqual(messages[0].metadata.delivery.method, 'pty'); + assert.strictEqual(messages[0].metadata.delivery.taskId, taskId); + } catch (error) { + testError = error; + } finally { + removeTask(taskId); + try { + await server.stop('SIGTERM'); + } catch (error) { + console.warn('AttachServer.stop failed in guidance-delivery test', error); + stopError = error; + } + fs.rmSync(socketDir, { recursive: true, force: true }); + } + + if (stopError && !testError) { + throw stopError; + } + if (testError) { + throw testError; + } + }); + + it('broadcasts cluster guidance with per-agent delivery results', async () => { + const { addTask, ensureDirs, removeTask } = await import('../../task-lib/store.js'); + ensureDirs(); + + const taskId = 'task-guidance-cluster-1'; + const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-guidance-cluster-sock-')); + const socketPath = path.join(socketDir, 'attach.sock'); + + const server = new AttachServer({ + id: taskId, + socketPath, + command: 'cat', + args: [], + cwd: process.cwd(), + env: process.env, + cols: 80, + rows: 24, + }); + + const agentTwo = new AgentWrapper( + { + id: 'agent-2', + role: 'implementation', + modelLevel: 'level1', + prompt: 'noop', + triggers: [], + }, + cluster.messageBus, + cluster, + { testMode: true } + ); + + cluster.agents.push(agentTwo); + + let testError; + let stopError; + try { + await server.start(); + + addTask({ + id: taskId, + prompt: 'test', + fullPrompt: 'test', + cwd: process.cwd(), + status: 'running', + pid: server.pid, + sessionId: null, + logFile: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + exitCode: null, + error: null, + provider: 'claude', + model: null, + scheduleId: null, + socketPath, + attachable: true, + }); + + agent.currentTaskId = taskId; + + const result = await orchestrator.sendGuidanceToCluster( + cluster.id, + 'Cluster guidance message' + ); + + assert.deepStrictEqual(result.summary, { injected: 1, queued: 1, total: 2 }); + assert.strictEqual(result.agents[agent.id].status, 'injected'); + assert.strictEqual(result.agents[agent.id].method, 'pty'); + assert.strictEqual(result.agents[agent.id].taskId, taskId); + assert.strictEqual(result.agents[agentTwo.id].status, 'unsupported'); + assert.ok(result.agents[agentTwo.id].reason); + + const clusterMessages = cluster.messageBus.query({ + cluster_id: cluster.id, + topic: USER_GUIDANCE_CLUSTER, + }); + const agentMessages = cluster.messageBus.query({ + cluster_id: cluster.id, + topic: USER_GUIDANCE_AGENT, + }); + + assert.strictEqual(clusterMessages.length, 1); + assert.strictEqual(agentMessages.length, 0); + + const deliveryMeta = clusterMessages[0].metadata.delivery; + assert.deepStrictEqual(deliveryMeta.summary, { injected: 1, queued: 1, total: 2 }); + assert.strictEqual(deliveryMeta.agents[agent.id].status, 'injected'); + assert.strictEqual(deliveryMeta.agents[agentTwo.id].status, 'unsupported'); + } catch (error) { + testError = error; + } finally { + removeTask(taskId); + try { + await server.stop('SIGTERM'); + } catch (error) { + console.warn('AttachServer.stop failed in guidance-delivery test', error); + stopError = error; + } + fs.rmSync(socketDir, { recursive: true, force: true }); + } + + if (stopError && !testError) { + throw stopError; + } + if (testError) { + throw testError; + } + }); +}); diff --git a/tests/unit/guidance-mailbox.test.js b/tests/unit/guidance-mailbox.test.js new file mode 100644 index 00000000..367fff28 --- /dev/null +++ b/tests/unit/guidance-mailbox.test.js @@ -0,0 +1,132 @@ +const assert = require('assert'); + +const Ledger = require('../../src/ledger'); +const { USER_GUIDANCE_AGENT, USER_GUIDANCE_CLUSTER } = require('../../src/guidance-topics'); + +describe('Guidance mailbox', function () { + it('persists guidance messages with target_agent_id mapped to receiver', function () { + const ledger = new Ledger(':memory:'); + const clusterId = 'guidance-mailbox-1'; + + const published = ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_AGENT, + sender: 'user', + target_agent_id: 'agent-1', + content: { text: 'Use approach A' }, + }); + + assert.strictEqual(published.receiver, 'agent-1'); + + const rows = ledger.query({ cluster_id: clusterId, topic: USER_GUIDANCE_AGENT }); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].receiver, 'agent-1'); + + ledger.close(); + }); + + it('returns cluster + agent guidance since last delivered in deterministic order', function () { + const ledger = new Ledger(':memory:'); + const clusterId = 'guidance-mailbox-2'; + const now = Date.now(); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_CLUSTER, + sender: 'user', + content: { text: 'Old cluster guidance' }, + timestamp: now + 10, + }); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_AGENT, + sender: 'user', + target_agent_id: 'agent-a', + content: { text: 'Old agent guidance' }, + timestamp: now + 20, + }); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_CLUSTER, + sender: 'user', + content: { text: 'Cluster guidance 1' }, + timestamp: now + 200, + }); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_AGENT, + sender: 'user', + target_agent_id: 'agent-a', + content: { text: 'Agent guidance' }, + timestamp: now + 210, + }); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_CLUSTER, + sender: 'user', + content: { text: 'Cluster guidance 2' }, + timestamp: now + 220, + }); + + const mailbox = ledger.queryGuidanceMailbox({ + cluster_id: clusterId, + target_agent_id: 'agent-a', + lastDeliveredAt: now + 100, + }); + + const topics = mailbox.map((message) => message.topic); + assert.deepStrictEqual(topics, [ + USER_GUIDANCE_CLUSTER, + USER_GUIDANCE_AGENT, + USER_GUIDANCE_CLUSTER, + ]); + + const timestamps = mailbox.map((message) => message.timestamp); + const sorted = [...timestamps].sort((a, b) => a - b); + assert.deepStrictEqual(timestamps, sorted); + + const texts = mailbox.map((message) => message.content?.text); + assert.deepStrictEqual(texts, ['Cluster guidance 1', 'Agent guidance', 'Cluster guidance 2']); + + ledger.close(); + }); + + it('excludes guidance for other agents', function () { + const ledger = new Ledger(':memory:'); + const clusterId = 'guidance-mailbox-3'; + const now = Date.now(); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_AGENT, + sender: 'user', + target_agent_id: 'agent-a', + content: { text: 'Target agent' }, + timestamp: now + 10, + }); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_AGENT, + sender: 'user', + target_agent_id: 'agent-b', + content: { text: 'Other agent' }, + timestamp: now + 20, + }); + + const mailbox = ledger.queryGuidanceMailbox({ + cluster_id: clusterId, + target_agent_id: 'agent-a', + lastDeliveredAt: now - 1, + }); + + assert.strictEqual(mailbox.length, 1); + assert.strictEqual(mailbox[0].content.text, 'Target agent'); + + ledger.close(); + }); +}); diff --git a/tests/unit/guidance-queue.test.js b/tests/unit/guidance-queue.test.js new file mode 100644 index 00000000..9dfcf7be --- /dev/null +++ b/tests/unit/guidance-queue.test.js @@ -0,0 +1,193 @@ +const assert = require('assert'); + +const MessageBus = require('../../src/message-bus'); +const Ledger = require('../../src/ledger'); +const { + GUIDANCE_BLOCK_START, + GUIDANCE_BLOCK_END, + formatGuidanceBlock, + collectQueuedGuidance, +} = require('../../src/agent/guidance-queue'); +const { USER_GUIDANCE_AGENT, USER_GUIDANCE_CLUSTER } = require('../../src/guidance-topics'); + +describe('Guidance queue formatting', () => { + it('formats a single delimited guidance block in timestamp order', () => { + const now = Date.now(); + const messages = [ + { + topic: USER_GUIDANCE_CLUSTER, + sender: 'user', + timestamp: now + 20, + content: { text: 'Second' }, + }, + { + topic: USER_GUIDANCE_AGENT, + sender: 'user', + timestamp: now + 10, + content: { text: 'First' }, + }, + ]; + + const block = formatGuidanceBlock(messages); + + assert(block.includes('## Guidance (Queued)'), 'block includes header'); + assert(block.includes(GUIDANCE_BLOCK_START), 'block includes start marker'); + assert(block.includes(GUIDANCE_BLOCK_END), 'block includes end marker'); + + const firstIndex = block.indexOf('First'); + const secondIndex = block.indexOf('Second'); + assert(firstIndex > -1 && secondIndex > -1, 'block includes both messages'); + assert(firstIndex < secondIndex, 'messages are ordered by timestamp'); + }); + + it('returns empty string when no guidance exists', () => { + assert.strictEqual(formatGuidanceBlock([]), ''); + assert.strictEqual(formatGuidanceBlock(null), ''); + }); +}); + +describe('Guidance queue collection', () => { + it('deduplicates across iterations via lastDeliveredAt cursor', () => { + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const clusterId = 'guidance-queue-1'; + const now = Date.now(); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_CLUSTER, + sender: 'user', + content: { text: 'Initial' }, + timestamp: now + 5, + }); + + const first = collectQueuedGuidance({ + messageBus, + clusterId, + agentId: 'agent-a', + lastDeliveredAt: null, + }); + + assert.strictEqual(first.messages.length, 1); + assert.strictEqual(first.latestTimestamp, now + 5); + assert(first.guidanceBlock.includes('Initial')); + + const second = collectQueuedGuidance({ + messageBus, + clusterId, + agentId: 'agent-a', + lastDeliveredAt: first.latestTimestamp, + }); + + assert.strictEqual(second.messages.length, 0); + assert.strictEqual(second.guidanceBlock, ''); + + ledger.close(); + }); + + it('collects cluster and agent guidance in order', () => { + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const clusterId = 'guidance-queue-2'; + const now = Date.now(); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_CLUSTER, + sender: 'user', + content: { text: 'Cluster 1' }, + timestamp: now + 10, + }); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_AGENT, + sender: 'user', + target_agent_id: 'agent-a', + content: { text: 'Agent 1' }, + timestamp: now + 20, + }); + + ledger.append({ + cluster_id: clusterId, + topic: USER_GUIDANCE_CLUSTER, + sender: 'user', + content: { text: 'Cluster 2' }, + timestamp: now + 30, + }); + + const result = collectQueuedGuidance({ + messageBus, + clusterId, + agentId: 'agent-a', + lastDeliveredAt: now, + }); + + assert.deepStrictEqual( + result.messages.map((message) => message.content.text), + ['Cluster 1', 'Agent 1', 'Cluster 2'] + ); + + ledger.close(); + }); +}); + +describe('Guidance queue placement in context', () => { + it('injects guidance between instructions and JSON output schema', () => { + const ledger = new Ledger(':memory:'); + const messageBus = new MessageBus(ledger); + const clusterId = 'guidance-queue-context'; + const cluster = { id: clusterId, createdAt: Date.now() - 1000 }; + + const config = { + id: 'worker', + role: 'implementation', + outputFormat: 'json', + jsonSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + }, + contextStrategy: { sources: [] }, + prompt: 'Do the work.', + }; + + const { buildContext } = require('../../src/agent/agent-context-builder'); + const guidanceBlock = formatGuidanceBlock([ + { + topic: USER_GUIDANCE_CLUSTER, + sender: 'user', + timestamp: Date.now(), + content: { text: 'Queued guidance' }, + }, + ]); + + const context = buildContext({ + id: 'worker', + role: 'implementation', + iteration: 1, + config, + messageBus, + cluster, + triggeringMessage: { + cluster_id: clusterId, + topic: 'ISSUE_OPENED', + sender: 'tester', + content: { text: 'Task' }, + }, + queuedGuidance: guidanceBlock, + }); + + const instructionsIndex = context.indexOf('## Instructions'); + const guidanceIndex = context.indexOf('## Guidance (Queued)'); + const outputSchemaIndex = context.indexOf('## šŸ”“ OUTPUT FORMAT - JSON ONLY'); + + assert(instructionsIndex !== -1, 'instructions present'); + assert(guidanceIndex !== -1, 'guidance present'); + assert(outputSchemaIndex !== -1, 'json schema present'); + assert(instructionsIndex < guidanceIndex, 'guidance after instructions'); + assert(guidanceIndex < outputSchemaIndex, 'guidance before json schema'); + + ledger.close(); + }); +}); diff --git a/tests/unit/orchestrator-agent-error-stop.test.js b/tests/unit/orchestrator-agent-error-stop.test.js new file mode 100644 index 00000000..115245ea --- /dev/null +++ b/tests/unit/orchestrator-agent-error-stop.test.js @@ -0,0 +1,85 @@ +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const sinon = require('sinon'); + +const Ledger = require('../../src/ledger'); +const MessageBus = require('../../src/message-bus'); +const Orchestrator = require('../../src/orchestrator'); + +describe('Orchestrator critical agent error handling', function () { + this.timeout(10_000); + + let tempDir; + let ledger; + let messageBus; + let orchestrator; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-orchestrator-agent-error-')); + ledger = new Ledger(path.join(tempDir, 'test.db')); + messageBus = new MessageBus(ledger); + + orchestrator = new Orchestrator({ quiet: true, skipLoad: true, storageDir: tempDir }); + sinon.stub(orchestrator, '_saveClusters').resolves(); + }); + + afterEach(() => { + sinon.restore(); + if (ledger) ledger.close(); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('stops cluster when coordinator fails after retries', async () => { + const stopSpy = sinon.stub(orchestrator, 'stop').resolves(); + orchestrator._registerAgentErrorHandler(messageBus, 'c1'); + + messageBus.publish({ + cluster_id: 'c1', + topic: 'AGENT_ERROR', + sender: 'consensus-coordinator', + content: { data: { role: 'coordinator', attempts: 3, error: 'boom' } }, + }); + + await new Promise((r) => setTimeout(r, 10)); + assert.equal(stopSpy.calledOnce, true); + assert.equal(stopSpy.firstCall.args[0], 'c1'); + }); + + it('stops cluster immediately when hookFailure is true (even with attempts=1)', async () => { + const stopSpy = sinon.stub(orchestrator, 'stop').resolves(); + orchestrator._registerAgentErrorHandler(messageBus, 'c2'); + + messageBus.publish({ + cluster_id: 'c2', + topic: 'AGENT_ERROR', + sender: 'consensus-coordinator', + content: { + data: { role: 'coordinator', attempts: 1, hookFailure: true, error: 'hook died' }, + }, + }); + + await new Promise((r) => setTimeout(r, 10)); + assert.equal(stopSpy.calledOnce, true); + assert.equal(stopSpy.firstCall.args[0], 'c2'); + }); + + it('does not stop cluster for validator errors by default', async () => { + const stopSpy = sinon.stub(orchestrator, 'stop').resolves(); + orchestrator._registerAgentErrorHandler(messageBus, 'c3'); + + messageBus.publish({ + cluster_id: 'c3', + topic: 'AGENT_ERROR', + sender: 'validator-1', + content: { data: { role: 'validator', attempts: 3, error: 'nope' } }, + }); + + await new Promise((r) => setTimeout(r, 10)); + assert.equal(stopSpy.called, false); + }); +}); diff --git a/tests/unit/orchestrator-issue-duplicate-check.test.js b/tests/unit/orchestrator-issue-duplicate-check.test.js new file mode 100644 index 00000000..00b2301d --- /dev/null +++ b/tests/unit/orchestrator-issue-duplicate-check.test.js @@ -0,0 +1,38 @@ +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const Orchestrator = require('../../src/orchestrator'); + +describe('Orchestrator duplicate issue check', function () { + this.timeout(5000); + + let tempDir; + let orchestrator; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-orchestrator-dup-')); + orchestrator = new Orchestrator({ quiet: true, skipLoad: true, storageDir: tempDir }); + }); + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not treat the current cluster as a duplicate of itself', () => { + const clusterId = 'self'; + orchestrator.clusters.set(clusterId, { + id: clusterId, + issue: 1172, + state: 'initializing', + pid: process.pid, + createdAt: Date.now(), + }); + + const active = orchestrator._getActiveClustersForIssue(1172, clusterId); + assert.deepEqual(active, []); + }); +}); diff --git a/tests/unit/orchestrator-provider-reload.test.js b/tests/unit/orchestrator-provider-reload.test.js new file mode 100644 index 00000000..0e215797 --- /dev/null +++ b/tests/unit/orchestrator-provider-reload.test.js @@ -0,0 +1,100 @@ +/** + * Orchestrator Provider Reload Test + * + * Regression test for provider resolution when reloading clusters from storage. + * Ensures forceProvider is honored in status output after reload. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const Orchestrator = require('../../src/orchestrator.js'); +const Ledger = require('../../src/ledger.js'); + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanupDir(dirPath) { + if (dirPath && fs.existsSync(dirPath)) { + fs.rmSync(dirPath, { recursive: true, force: true }); + } +} + +describe('Orchestrator provider reload', function () { + const originalSettingsFile = process.env.ZEROSHOT_SETTINGS_FILE; + + afterEach(function () { + if (originalSettingsFile === undefined) { + delete process.env.ZEROSHOT_SETTINGS_FILE; + } else { + process.env.ZEROSHOT_SETTINGS_FILE = originalSettingsFile; + } + }); + + it('uses config.forceProvider after reload', async function () { + const storageDir = createTempDir('zeroshot-provider-reload-'); + const settingsDir = createTempDir('zeroshot-provider-settings-'); + const settingsFile = path.join(settingsDir, 'settings.json'); + + fs.writeFileSync(settingsFile, JSON.stringify({ defaultProvider: 'claude' })); + process.env.ZEROSHOT_SETTINGS_FILE = settingsFile; + + const clusterId = 'provider-reload-test'; + const clusterData = { + id: clusterId, + config: { + forceProvider: 'codex', + agents: [ + { + id: 'worker', + role: 'implementation', + modelLevel: 'level2', + outputFormat: 'text', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + prompt: 'Worker', + }, + ], + }, + state: 'stopped', + createdAt: Date.now(), + autoPr: false, + prOptions: null, + issue: null, + isolation: null, + worktree: null, + agentStates: null, + }; + + const clustersFile = path.join(storageDir, 'clusters.json'); + fs.writeFileSync(clustersFile, JSON.stringify({ [clusterId]: clusterData }, null, 2)); + + const dbPath = path.join(storageDir, `${clusterId}.db`); + const ledger = new Ledger(dbPath); + ledger.append({ + topic: 'TEST', + sender: 'tester', + receiver: 'tester', + content_text: 'ok', + cluster_id: clusterId, + }); + ledger.close(); + + let orchestrator; + try { + orchestrator = await Orchestrator.create({ storageDir, quiet: true }); + const status = orchestrator.getStatus(clusterId); + const providers = status.agents.map((agent) => agent.provider); + for (const provider of providers) { + assert.strictEqual(provider, 'codex'); + } + } finally { + if (orchestrator) { + orchestrator.close(); + } + cleanupDir(storageDir); + cleanupDir(settingsDir); + } + }); +}); diff --git a/tests/unit/provider-retryable-errors.test.js b/tests/unit/provider-retryable-errors.test.js new file mode 100644 index 00000000..52c83a82 --- /dev/null +++ b/tests/unit/provider-retryable-errors.test.js @@ -0,0 +1,35 @@ +const assert = require('assert'); +const { getProvider } = require('../../src/providers'); + +describe('Provider error retry classification', () => { + it('retries on rate limit (all providers)', () => { + const err = new Error('Rate limit exceeded. Retry after 60 seconds.'); + for (const name of ['claude', 'codex', 'gemini']) { + const provider = getProvider(name); + assert.strictEqual(provider.isRetryableError(err), true, `${name} should retry rate limit`); + } + }); + + it('does not retry on invalid_api_key (all providers)', () => { + const err = new Error('invalid_api_key: key revoked'); + for (const name of ['claude', 'codex', 'gemini']) { + const provider = getProvider(name); + assert.strictEqual(provider.isRetryableError(err), false, `${name} should not retry auth`); + } + }); + + it('Claude: retries on "No messages returned"', () => { + const provider = getProvider('claude'); + assert.strictEqual(provider.isRetryableError(new Error('No messages returned')), true); + }); + + it('Codex: retries on "server_error"', () => { + const provider = getProvider('codex'); + assert.strictEqual(provider.isRetryableError(new Error('server_error')), true); + }); + + it('Gemini: retries on "RESOURCE_EXHAUSTED"', () => { + const provider = getProvider('gemini'); + assert.strictEqual(provider.isRetryableError(new Error('RESOURCE_EXHAUSTED')), true); + }); +}); diff --git a/tests/unit/rate-limit-backoff.test.js b/tests/unit/rate-limit-backoff.test.js new file mode 100644 index 00000000..b285399d --- /dev/null +++ b/tests/unit/rate-limit-backoff.test.js @@ -0,0 +1,103 @@ +const assert = require('assert'); +const { + calculateRateLimitDelay, + isRateLimitError, + parseRetryAfter, +} = require('../../src/agent/rate-limit-backoff'); + +describe('rate-limit-backoff', () => { + describe('isRateLimitError', () => { + it('detects HTTP 429 errors', () => { + assert.strictEqual(isRateLimitError(new Error('HTTP 429: Rate limit exceeded')), true); + assert.strictEqual(isRateLimitError(new Error('Error 429 - Too many requests')), true); + }); + + it('detects "rate limit" text', () => { + assert.strictEqual(isRateLimitError(new Error('Rate limit exceeded')), true); + assert.strictEqual(isRateLimitError(new Error('rate-limit error')), true); + }); + + it('detects Gemini "No capacity available" errors', () => { + assert.strictEqual( + isRateLimitError(new Error('No capacity available for model gemini-3-pro')), + true + ); + }); + + it('detects quota exceeded errors', () => { + assert.strictEqual(isRateLimitError(new Error('Quota exceeded for this project')), true); + }); + + it('detects resource exhausted errors', () => { + assert.strictEqual(isRateLimitError(new Error('RESOURCE_EXHAUSTED: No capacity')), true); + }); + + it('returns false for non-rate-limit errors', () => { + assert.strictEqual(isRateLimitError(new Error('Network timeout')), false); + assert.strictEqual(isRateLimitError(new Error('No messages returned')), false); + assert.strictEqual(isRateLimitError(new Error('SIGTERM')), false); + }); + + it('handles null/undefined', () => { + assert.strictEqual(isRateLimitError(null), false); + assert.strictEqual(isRateLimitError(undefined), false); + }); + }); + + describe('parseRetryAfter', () => { + it('parses "Retry-After: N" header format', () => { + assert.strictEqual(parseRetryAfter(new Error('Rate limit. Retry-After: 120')), 120); + assert.strictEqual(parseRetryAfter(new Error('Retry-After:60')), 60); + }); + + it('returns null when not found', () => { + assert.strictEqual(parseRetryAfter(new Error('Rate limit exceeded')), null); + assert.strictEqual(parseRetryAfter(null), null); + }); + }); + + describe('calculateRateLimitDelay', () => { + let originalRandom; + beforeEach(() => { + originalRandom = Math.random; + Math.random = () => 0.5; // Neutralizes jitter + }); + afterEach(() => { + Math.random = originalRandom; + }); + + it('uses 30s base for rate limit errors', () => { + const error = new Error('HTTP 429: Rate limit'); + const delay = calculateRateLimitDelay(error, 1, {}); + assert.strictEqual(delay, 30000); + }); + + it('uses exponential backoff for rate limit errors', () => { + const error = new Error('HTTP 429: Rate limit'); + assert.strictEqual(calculateRateLimitDelay(error, 1, {}), 30000); + assert.strictEqual(calculateRateLimitDelay(error, 2, {}), 60000); + assert.strictEqual(calculateRateLimitDelay(error, 3, {}), 120000); + }); + + it('caps rate limit delays at 5 minutes', () => { + const error = new Error('HTTP 429: Rate limit'); + assert.strictEqual(calculateRateLimitDelay(error, 5, {}), 300000); + }); + + it('uses 2s base for non-rate-limit errors', () => { + const error = new Error('No messages returned'); + const delay = calculateRateLimitDelay(error, 1, {}); + assert.strictEqual(delay, 2000); + }); + + it('honors Retry-After header', () => { + const error = new Error('Rate limit. Retry-After: 120'); + assert.strictEqual(calculateRateLimitDelay(error, 1, {}), 120000); + }); + + it('caps Retry-After at 5 minutes', () => { + const error = new Error('Rate limit. Retry-After: 600'); + assert.strictEqual(calculateRateLimitDelay(error, 1, {}), 300000); + }); + }); +}); diff --git a/tests/unit/stream-json-parser-codex.test.js b/tests/unit/stream-json-parser-codex.test.js new file mode 100644 index 00000000..3bcea16d --- /dev/null +++ b/tests/unit/stream-json-parser-codex.test.js @@ -0,0 +1,51 @@ +const assert = require('assert'); +const { parseChunk } = require('../../lib/stream-json-parser'); + +describe('stream-json parser (Codex)', () => { + it('maps command_execution start/completed into tool_call/tool_result', () => { + const chunk = [ + JSON.stringify({ + type: 'item.started', + item: { id: 'item_1', type: 'command_execution', command: 'ls -la' }, + }), + JSON.stringify({ + type: 'item.completed', + item: { + id: 'item_1', + type: 'command_execution', + aggregated_output: 'file1.txt\nfile2.txt\n', + exit_code: 0, + }, + }), + ].join('\n'); + + const events = parseChunk(chunk); + assert.deepStrictEqual(events[0], { + type: 'tool_call', + toolName: 'Bash', + toolId: 'item_1', + input: { command: 'ls -la' }, + }); + assert.deepStrictEqual(events[1], { + type: 'tool_result', + toolId: 'item_1', + content: 'file1.txt\nfile2.txt\n', + isError: false, + }); + }); + + it('maps reasoning items into thinking', () => { + const chunk = JSON.stringify({ + type: 'item.completed', + item: { id: 'r1', type: 'reasoning', text: 'thinking...' }, + }); + const events = parseChunk(chunk); + assert.deepStrictEqual(events, [{ type: 'thinking', text: 'thinking...' }]); + }); + + it('maps top-level errors into result errors', () => { + const chunk = JSON.stringify({ type: 'error', error: { message: 'boom' } }); + const events = parseChunk(chunk); + assert.deepStrictEqual(events, [{ type: 'result', success: false, error: 'boom' }]); + }); +}); diff --git a/tests/unit/template-simulation.test.js b/tests/unit/template-simulation.test.js new file mode 100644 index 00000000..aa0dd358 --- /dev/null +++ b/tests/unit/template-simulation.test.js @@ -0,0 +1,84 @@ +const assert = require('node:assert'); + +const { + simulateConsensusGates, +} = require('../../src/template-validation/simulate-consensus-gates'); + +describe('Template micro-simulation (consensus gates)', function () { + it('flags consensus gates that fire on duplicate sender', function () { + const config = { + name: 'Bad consensus template', + agents: [ + { + id: 'validator-a', + role: 'validator', + triggers: [{ topic: 'START', action: 'execute_task' }], + hooks: { onComplete: { action: 'publish_message', config: { topic: 'X' } } }, + }, + { + id: 'validator-b', + role: 'validator', + triggers: [{ topic: 'START', action: 'execute_task' }], + hooks: { onComplete: { action: 'publish_message', config: { topic: 'X' } } }, + }, + { + id: 'consensus-coordinator', + role: 'coordinator', + triggers: [ + { + topic: 'X', + logic: { + engine: 'javascript', + // BUGGY: counts messages, doesn't require distinct senders. + script: + "const results = ledger.query({ topic: 'X' }); return results.length === 2;", + }, + action: 'execute_task', + }, + ], + }, + ], + }; + + const failures = simulateConsensusGates(config); + assert.ok(failures.length >= 1); + assert.ok(failures.some((f) => f.includes('fires early'))); + }); + + it('accepts consensus gates that require distinct producers via helpers.allResponded', function () { + const config = { + name: 'Good consensus template', + agents: [ + { + id: 'validator-a', + role: 'validator', + triggers: [{ topic: 'START', action: 'execute_task' }], + hooks: { onComplete: { action: 'publish_message', config: { topic: 'X' } } }, + }, + { + id: 'validator-b', + role: 'validator', + triggers: [{ topic: 'START', action: 'execute_task' }], + hooks: { onComplete: { action: 'publish_message', config: { topic: 'X' } } }, + }, + { + id: 'consensus-coordinator', + role: 'coordinator', + triggers: [ + { + topic: 'X', + logic: { + engine: 'javascript', + script: "return helpers.allResponded(['validator-a','validator-b'], 'X', 0);", + }, + action: 'execute_task', + }, + ], + }, + ], + }; + + const failures = simulateConsensusGates(config); + assert.deepStrictEqual(failures, []); + }); +}); diff --git a/tests/unit/template-validation-deep.test.js b/tests/unit/template-validation-deep.test.js new file mode 100644 index 00000000..30a095ba --- /dev/null +++ b/tests/unit/template-validation-deep.test.js @@ -0,0 +1,14 @@ +const assert = require('node:assert'); +const path = require('node:path'); + +const { validateTemplates } = require('../../src/template-validation'); + +describe('Template validation (deep)', function () { + this.timeout(10000); + + it('passes deep sim for base templates', async function () { + const templatesDir = path.join(__dirname, '..', '..', 'cluster-templates', 'base-templates'); + const report = await validateTemplates({ templatesDir, deep: true }); + assert.strictEqual(report.valid, true); + }); +}); diff --git a/tests/unit/tui-backend-cluster-launcher.test.js b/tests/unit/tui-backend-cluster-launcher.test.js new file mode 100644 index 00000000..78c14a59 --- /dev/null +++ b/tests/unit/tui-backend-cluster-launcher.test.js @@ -0,0 +1,91 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const buildOutput = path.join( + __dirname, + '..', + '..', + 'lib', + 'tui-backend', + 'services', + 'cluster-launcher.js' +); +const sourcePath = path.join( + __dirname, + '..', + '..', + 'src', + 'tui-backend', + 'services', + 'cluster-launcher.ts' +); + +function ensureBackendBuild() { + if (!fs.existsSync(buildOutput)) { + execSync('npm run build:tui-backend', { stdio: 'inherit' }); + return; + } + if (fs.existsSync(sourcePath)) { + const buildMtime = fs.statSync(buildOutput).mtimeMs; + const sourceMtime = fs.statSync(sourcePath).mtimeMs; + if (sourceMtime > buildMtime) { + execSync('npm run build:tui-backend', { stdio: 'inherit' }); + } + } +} + +ensureBackendBuild(); + +const { + launchClusterFromIssue, + InvalidIssueReferenceError, +} = require('../../lib/tui-backend/services/cluster-launcher'); + +describe('tui-backend cluster launcher', function () { + it('throws InvalidIssueReferenceError for invalid issue refs', async function () { + await assert.rejects( + () => + launchClusterFromIssue({ + ref: 'not-an-issue', + deps: { + detectRunInput: () => ({ text: 'not-an-issue' }), + }, + }), + (error) => { + assert.ok(error instanceof InvalidIssueReferenceError); + assert.ok(error.message.includes('Invalid issue reference: not-an-issue')); + return true; + } + ); + }); + + it('forwards providerOverride and clusterId to startClusterFromIssue', async function () { + const calls = []; + const deps = { + getOrchestrator: () => ({ id: 'orch' }), + loadSettings: () => ({ defaultConfig: 'conductor-bootstrap', providerSettings: {} }), + resolveConfigPath: () => '/tmp/config.json', + loadClusterConfig: () => ({ name: 'config' }), + detectRunInput: () => ({ issue: '123' }), + startClusterFromIssue: (args) => { + calls.push(args); + }, + generateClusterId: () => 'generated', + }; + + const result = await launchClusterFromIssue({ + ref: '123', + providerOverride: 'codex', + clusterId: 'cluster-789', + deps, + }); + + assert.deepStrictEqual(result, { clusterId: 'cluster-789' }); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].issue, '123'); + assert.strictEqual(calls[0].providerOverride, 'codex'); + assert.strictEqual(calls[0].clusterId, 'cluster-789'); + }); +}); diff --git a/tests/unit/tui-backend-cluster-registry.test.js b/tests/unit/tui-backend-cluster-registry.test.js new file mode 100644 index 00000000..87b6f1f6 --- /dev/null +++ b/tests/unit/tui-backend-cluster-registry.test.js @@ -0,0 +1,113 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const buildOutput = path.join( + __dirname, + '..', + '..', + 'lib', + 'tui-backend', + 'services', + 'cluster-registry.js' +); +const sourcePath = path.join( + __dirname, + '..', + '..', + 'src', + 'tui-backend', + 'services', + 'cluster-registry.ts' +); + +function ensureBackendBuild() { + if (!fs.existsSync(buildOutput)) { + execSync('npm run build:tui-backend', { stdio: 'inherit' }); + return; + } + if (fs.existsSync(sourcePath)) { + const buildMtime = fs.statSync(buildOutput).mtimeMs; + const sourceMtime = fs.statSync(sourcePath).mtimeMs; + if (sourceMtime > buildMtime) { + execSync('npm run build:tui-backend', { stdio: 'inherit' }); + } + } +} + +ensureBackendBuild(); + +const { + listClusters, + getClusterSummary, + ClusterNotFoundError, +} = require('../../lib/tui-backend/services/cluster-registry'); + +function createOrchestrator(summaries, clustersById) { + return { + listClusters() { + return summaries; + }, + getCluster(id) { + return clustersById[id]; + }, + }; +} + +describe('tui-backend cluster registry', function () { + it('resolves provider with forceProvider -> defaultProvider -> settings default', async function () { + const summaries = [ + { id: 'cluster-force', state: 'running', createdAt: 1, agentCount: 1, messageCount: 1 }, + { id: 'cluster-default', state: 'running', createdAt: 2, agentCount: 1, messageCount: 1 }, + { id: 'cluster-settings', state: 'running', createdAt: 3, agentCount: 1, messageCount: 1 }, + { id: 'cluster-empty', state: 'running', createdAt: 4, agentCount: 1, messageCount: 1 }, + ]; + const clustersById = { + 'cluster-force': { config: { forceProvider: 'openai', defaultProvider: 'claude' } }, + 'cluster-default': { config: { defaultProvider: 'google' } }, + 'cluster-settings': { config: {} }, + 'cluster-empty': {}, + }; + const orchestrator = createOrchestrator(summaries, clustersById); + + const result = await listClusters({ + deps: { + getOrchestrator: () => Promise.resolve(orchestrator), + loadSettings: () => ({ defaultProvider: 'opencode' }), + }, + }); + + const providerById = Object.fromEntries( + result.map((cluster) => [cluster.id, cluster.provider]) + ); + assert.strictEqual(providerById['cluster-force'], 'codex'); + assert.strictEqual(providerById['cluster-default'], 'gemini'); + assert.strictEqual(providerById['cluster-settings'], 'opencode'); + assert.strictEqual(providerById['cluster-empty'], 'opencode'); + }); + + it('throws ClusterNotFoundError for missing cluster id', async function () { + const summaries = [ + { id: 'cluster-1', state: 'running', createdAt: 1, agentCount: 1, messageCount: 1 }, + ]; + const clustersById = { + 'cluster-1': { config: { defaultProvider: 'claude' } }, + }; + const orchestrator = createOrchestrator(summaries, clustersById); + + try { + await getClusterSummary({ + clusterId: 'missing-cluster', + deps: { + getOrchestrator: () => Promise.resolve(orchestrator), + loadSettings: () => ({ defaultProvider: 'claude' }), + }, + }); + assert.fail('Expected ClusterNotFoundError'); + } catch (error) { + assert.ok(error instanceof ClusterNotFoundError); + assert.strictEqual(error.clusterId, 'missing-cluster'); + } + }); +}); diff --git a/tests/unit/tui-backend-smoke.test.js b/tests/unit/tui-backend-smoke.test.js new file mode 100644 index 00000000..5e90615f --- /dev/null +++ b/tests/unit/tui-backend-smoke.test.js @@ -0,0 +1,49 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const buildOutput = path.join( + __dirname, + '..', + '..', + 'lib', + 'tui-backend', + 'services', + 'cluster-registry.js' +); +const sourcePath = path.join( + __dirname, + '..', + '..', + 'src', + 'tui-backend', + 'services', + 'cluster-registry.ts' +); + +function ensureBackendBuild() { + if (!fs.existsSync(buildOutput)) { + execSync('npm run build:tui-backend', { stdio: 'inherit' }); + return; + } + if (fs.existsSync(sourcePath)) { + const buildMtime = fs.statSync(buildOutput).mtimeMs; + const sourceMtime = fs.statSync(sourcePath).mtimeMs; + if (sourceMtime > buildMtime) { + execSync('npm run build:tui-backend', { stdio: 'inherit' }); + } + } +} + +ensureBackendBuild(); + +describe('TUI backend build', function () { + it('exposes cluster registry services', function () { + const registry = require('../../lib/tui-backend/services/cluster-registry'); + assert.ok(registry); + assert.strictEqual(typeof registry.listClusters, 'function'); + assert.strictEqual(typeof registry.getClusterSummary, 'function'); + assert.strictEqual(typeof registry.listClusterMetrics, 'function'); + }); +}); diff --git a/tests/unit/tui-backend-stdio.test.js b/tests/unit/tui-backend-stdio.test.js new file mode 100644 index 00000000..ce75f573 --- /dev/null +++ b/tests/unit/tui-backend-stdio.test.js @@ -0,0 +1,707 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync, spawn } = require('child_process'); +const Ledger = require('../../src/ledger'); + +const PROJECT_ROOT = path.resolve(__dirname, '../..'); +const SERVER_PATH = path.join(PROJECT_ROOT, 'lib', 'tui-backend', 'server.js'); +const SERVER_SOURCE_PATH = path.join(PROJECT_ROOT, 'src', 'tui-backend', 'server.ts'); +const CLUSTER_LOGS_SOURCE_PATH = path.join( + PROJECT_ROOT, + 'src', + 'tui-backend', + 'services', + 'cluster-logs.ts' +); +const CLUSTER_LOGS_BUILD_PATH = path.join( + PROJECT_ROOT, + 'lib', + 'tui-backend', + 'services', + 'cluster-logs.js' +); + +function isBuildStale(sourcePath, buildPath) { + if (!fs.existsSync(buildPath)) { + return true; + } + if (!fs.existsSync(sourcePath)) { + return false; + } + return fs.statSync(sourcePath).mtimeMs > fs.statSync(buildPath).mtimeMs; +} + +const encodeFrame = (payload) => { + const body = Buffer.from(typeof payload === 'string' ? payload : JSON.stringify(payload), 'utf8'); + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf8'); + return Buffer.concat([header, body]); +}; + +const createFrameCollector = () => { + let buffer = Buffer.alloc(0); + return (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + const frames = []; + while (true) { + const headerIndex = buffer.indexOf('\r\n\r\n'); + if (headerIndex === -1) { + break; + } + const headerText = buffer.slice(0, headerIndex).toString('utf8'); + const match = headerText.match(/Content-Length:\s*(\d+)/i); + if (!match) { + throw new Error('Missing Content-Length header in response'); + } + const length = Number.parseInt(match[1], 10); + const totalLength = headerIndex + 4 + length; + if (buffer.length < totalLength) { + break; + } + const payload = buffer.slice(headerIndex + 4, totalLength).toString('utf8'); + frames.push(payload); + buffer = buffer.slice(totalLength); + } + return frames; + }; +}; + +const createMessageQueue = () => { + const queue = []; + const waiters = []; + return { + push(message) { + if (waiters.length) { + const waiter = waiters.shift(); + clearTimeout(waiter.timer); + waiter.resolve(message); + return; + } + queue.push(message); + }, + next(timeoutMs = 2000) { + if (queue.length) { + return Promise.resolve(queue.shift()); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const index = waiters.findIndex((waiter) => waiter.resolve === resolve); + if (index !== -1) { + waiters.splice(index, 1); + } + reject(new Error('Timed out waiting for response')); + }, timeoutMs); + waiters.push({ resolve, reject, timer }); + }); + }, + }; +}; + +describe('tui-backend stdio JSON-RPC', function () { + this.timeout(15000); + + let server; + let queue; + const originalHome = process.env.HOME; + let tempHome; + let MAX_LOG_LINES; + const topologyClusterId = 'cluster-stdio-topology'; + const metricsClusterId = 'cluster-stdio-metrics'; + + const waitForMessage = async (predicate, timeoutMs = 2000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const remaining = Math.max(deadline - Date.now(), 10); + try { + const message = await queue.next(remaining); + if (predicate(message)) { + return message; + } + } catch (error) { + if (Date.now() >= deadline) { + throw error; + } + } + } + throw new Error('Timed out waiting for matching response'); + }; + + const expectNoMessage = async (predicate, timeoutMs = 300) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const remaining = Math.max(deadline - Date.now(), 10); + try { + const message = await queue.next(remaining); + if (predicate(message)) { + assert.fail(`Unexpected message: ${JSON.stringify(message)}`); + } + } catch (error) { + if (error && String(error.message).includes('Timed out waiting for response')) { + return; + } + throw error; + } + } + }; + + before(function () { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-tui-backend-')); + process.env.HOME = tempHome; + const zeroshotDir = path.join(tempHome, '.zeroshot'); + fs.mkdirSync(zeroshotDir, { recursive: true }); + + const clustersFile = path.join(zeroshotDir, 'clusters.json'); + const now = Date.now(); + const baseConfig = { + agents: [ + { + id: 'worker', + role: 'implementation', + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + hooks: { onComplete: { config: { topic: 'IMPLEMENTATION_READY' } } }, + }, + { + id: 'validator', + role: 'validator', + triggers: [{ topic: 'IMPLEMENTATION_READY', action: 'execute_task' }], + }, + ], + }; + const clustersData = { + [topologyClusterId]: { + id: topologyClusterId, + config: baseConfig, + state: 'stopped', + createdAt: now - 1000, + pid: null, + }, + [metricsClusterId]: { + id: metricsClusterId, + config: baseConfig, + state: 'stopped', + createdAt: now, + pid: null, + }, + }; + fs.writeFileSync(clustersFile, JSON.stringify(clustersData, null, 2)); + + for (const clusterId of [topologyClusterId, metricsClusterId]) { + const dbPath = path.join(zeroshotDir, `${clusterId}.db`); + const ledger = new Ledger(dbPath); + ledger.append({ + cluster_id: clusterId, + topic: 'SYSTEM', + sender: 'test', + content: { text: `seed ${clusterId}`, data: { line: `seed ${clusterId}` } }, + }); + ledger.close(); + } + + if ( + isBuildStale(SERVER_SOURCE_PATH, SERVER_PATH) || + isBuildStale(CLUSTER_LOGS_SOURCE_PATH, CLUSTER_LOGS_BUILD_PATH) + ) { + execSync('npm run build:tui-backend', { cwd: PROJECT_ROOT, stdio: 'inherit' }); + } + + ({ MAX_LOG_LINES } = require('../../lib/tui-backend/services/cluster-logs')); + + server = spawn('node', [SERVER_PATH], { + cwd: PROJECT_ROOT, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + ZEROSHOT_TUI_BACKEND_MOCK_LAUNCH: '1', + ZEROSHOT_TUI_BACKEND_MOCK_GUIDANCE: '1', + ZEROSHOT_TUI_BACKEND_METRICS_PLATFORM: 'sunos', + HOME: tempHome, + }, + }); + + queue = createMessageQueue(); + const collectFrames = createFrameCollector(); + + server.stdout.on('data', (chunk) => { + const frames = collectFrames(chunk); + for (const frame of frames) { + queue.push(JSON.parse(frame)); + } + }); + + server.stderr.on('data', () => {}); + }); + + after(async function () { + if (!server) return; + server.stdin.end(); + await new Promise((resolve) => server.on('exit', resolve)); + process.env.HOME = originalHome; + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('responds to initialize with capabilities', async function () { + const request = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 1, + client: { name: 'test-client', version: '0.1.0' }, + }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 1); + assert.strictEqual(response.jsonrpc, '2.0'); + assert.strictEqual(response.result.protocolVersion, 1); + assert.ok(response.result.server.name); + assert.ok(response.result.server.version); + assert.ok(response.result.capabilities.methods.includes('initialize')); + assert.ok(response.result.capabilities.methods.includes('ping')); + assert.ok(response.result.capabilities.methods.includes('listClusters')); + assert.ok(response.result.capabilities.methods.includes('getClusterSummary')); + assert.ok(response.result.capabilities.methods.includes('listClusterMetrics')); + assert.ok(response.result.capabilities.methods.includes('getClusterTopology')); + assert.ok(response.result.capabilities.methods.includes('unsubscribe')); + assert.deepStrictEqual(response.result.capabilities.notifications, [ + 'clusterLogLines', + 'clusterTimelineEvents', + ]); + }); + + it('responds to ping', async function () { + const request = { + jsonrpc: '2.0', + id: 2, + method: 'ping', + params: {}, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 2); + assert.deepStrictEqual(response.result, { ok: true }); + }); + + it('responds to listClusters with provider and cwd fields', async function () { + const request = { + jsonrpc: '2.0', + id: 7, + method: 'listClusters', + params: {}, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 7); + assert.strictEqual(response.jsonrpc, '2.0'); + assert.ok(response.result); + assert.ok(Array.isArray(response.result.clusters)); + for (const cluster of response.result.clusters) { + assert.ok(Object.prototype.hasOwnProperty.call(cluster, 'provider')); + assert.ok(Object.prototype.hasOwnProperty.call(cluster, 'cwd')); + } + }); + + it('returns cluster not found for unknown cluster id', async function () { + const request = { + jsonrpc: '2.0', + id: 8, + method: 'getClusterSummary', + params: { clusterId: 'missing-cluster-stdio' }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 8); + assert.strictEqual(response.error.code, -32002); + }); + + it('responds to getClusterSummary for a known cluster when available', async function () { + const listRequest = { + jsonrpc: '2.0', + id: 9, + method: 'listClusters', + params: {}, + }; + + server.stdin.write(encodeFrame(listRequest)); + const listResponse = await queue.next(); + const clusters = listResponse.result?.clusters ?? []; + if (clusters.length === 0) { + return; + } + + const request = { + jsonrpc: '2.0', + id: 10, + method: 'getClusterSummary', + params: { clusterId: clusters[0].id }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 10); + assert.strictEqual(response.result.summary.id, clusters[0].id); + assert.ok(Object.prototype.hasOwnProperty.call(response.result.summary, 'provider')); + assert.ok(Object.prototype.hasOwnProperty.call(response.result.summary, 'cwd')); + }); + + it('responds to getClusterTopology for a seeded cluster', async function () { + const request = { + jsonrpc: '2.0', + id: 17, + method: 'getClusterTopology', + params: { clusterId: topologyClusterId }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 17); + assert.ok(response.result.topology); + assert.ok(Array.isArray(response.result.topology.agents)); + assert.ok(Array.isArray(response.result.topology.edges)); + assert.ok(Array.isArray(response.result.topology.topics)); + assert.ok(response.result.topology.topics.includes('ISSUE_OPENED')); + assert.ok(response.result.topology.topics.includes('IMPLEMENTATION_READY')); + assert.ok( + response.result.topology.edges.some( + (edge) => edge.from === 'system' && edge.to === 'ISSUE_OPENED' && edge.kind === 'source' + ) + ); + assert.ok( + response.result.topology.edges.some( + (edge) => edge.from === 'ISSUE_OPENED' && edge.to === 'worker' + ) + ); + assert.ok( + response.result.topology.edges.some( + (edge) => edge.from === 'worker' && edge.to === 'IMPLEMENTATION_READY' + ) + ); + }); + + it('responds to listClusterMetrics with filtered cluster ids', async function () { + const request = { + jsonrpc: '2.0', + id: 18, + method: 'listClusterMetrics', + params: { + clusterIds: [metricsClusterId, 'missing-metrics', topologyClusterId], + }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 18); + assert.ok(Array.isArray(response.result.metrics)); + assert.strictEqual(response.result.metrics.length, 2); + assert.strictEqual(response.result.metrics[0].id, metricsClusterId); + assert.strictEqual(response.result.metrics[1].id, topologyClusterId); + for (const metric of response.result.metrics) { + assert.strictEqual(metric.supported, false); + assert.strictEqual(metric.cpuPercent, null); + assert.strictEqual(metric.memoryMB, null); + } + }); + + it('responds to startClusterFromText with a cluster id', async function () { + const request = { + jsonrpc: '2.0', + id: 11, + method: 'startClusterFromText', + params: { text: 'Launch from text', clusterId: 'cluster-stdio' }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 11); + assert.deepStrictEqual(response.result, { clusterId: 'cluster-stdio' }); + }); + + it('returns invalid params for invalid issue ref', async function () { + const request = { + jsonrpc: '2.0', + id: 12, + method: 'startClusterFromIssue', + params: { ref: 'not-an-issue' }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 12); + assert.strictEqual(response.error.code, -32602); + assert.ok(response.error.data.detail.includes('Invalid issue reference:')); + }); + + it('responds to sendGuidanceToAgent with delivery details', async function () { + const request = { + jsonrpc: '2.0', + id: 13, + method: 'sendGuidanceToAgent', + params: { + clusterId: 'cluster-guidance', + agentId: 'agent-1', + text: 'Use approach A', + timeoutMs: 250, + }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 13); + assert.strictEqual(response.result.result.status, 'injected'); + assert.strictEqual(response.result.result.reason, null); + assert.strictEqual(response.result.result.method, 'pty'); + assert.strictEqual(response.result.result.taskId, 'task-agent-1'); + }); + + it('responds to sendGuidanceToCluster with summary and agents', async function () { + const request = { + jsonrpc: '2.0', + id: 14, + method: 'sendGuidanceToCluster', + params: { + clusterId: 'cluster-guidance', + text: 'Use approach B', + timeoutMs: 500, + }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 14); + assert.strictEqual(response.result.result.summary.total, 2); + assert.strictEqual(response.result.result.summary.injected, 1); + assert.strictEqual(response.result.result.summary.queued, 1); + assert.strictEqual(response.result.result.agents['mock-agent-1'].status, 'injected'); + assert.strictEqual(response.result.result.agents['mock-agent-2'].status, 'queued'); + assert.strictEqual(response.result.result.timestamp, 1700000000000); + }); + + it('returns invalid params for empty agent guidance text', async function () { + const request = { + jsonrpc: '2.0', + id: 15, + method: 'sendGuidanceToAgent', + params: { + clusterId: 'cluster-guidance', + agentId: 'agent-1', + text: ' ', + }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 15); + assert.strictEqual(response.error.code, -32602); + }); + + it('returns invalid params for empty cluster guidance text', async function () { + const request = { + jsonrpc: '2.0', + id: 16, + method: 'sendGuidanceToCluster', + params: { + clusterId: 'cluster-guidance', + text: '', + }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 16); + assert.strictEqual(response.error.code, -32602); + }); + + it('returns parse error for invalid JSON', async function () { + server.stdin.write(encodeFrame('{ not-json')); + const response = await queue.next(); + + assert.strictEqual(response.id, null); + assert.strictEqual(response.error.code, -32700); + }); + + it('returns method not found for unknown method', async function () { + const request = { + jsonrpc: '2.0', + id: 3, + method: 'nope', + params: {}, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 3); + assert.strictEqual(response.error.code, -32601); + }); + + it('returns invalid params for malformed initialize', async function () { + const request = { + jsonrpc: '2.0', + id: 4, + method: 'initialize', + params: { protocolVersion: 1 }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 4); + assert.strictEqual(response.error.code, -32602); + }); + + it('returns protocol mismatch with supported versions', async function () { + const request = { + jsonrpc: '2.0', + id: 5, + method: 'initialize', + params: { + protocolVersion: 999, + client: { name: 'test-client', version: '0.1.0' }, + }, + }; + + server.stdin.write(encodeFrame(request)); + const response = await queue.next(); + + assert.strictEqual(response.id, 5); + assert.strictEqual(response.error.code, -32000); + assert.deepStrictEqual(response.error.data.supportedVersions, [1]); + }); + + it('reassembles partial frames across chunks', async function () { + const request = { + jsonrpc: '2.0', + id: 6, + method: 'ping', + params: {}, + }; + const frame = encodeFrame(request); + const splitIndex = Math.floor(frame.length / 2); + server.stdin.write(frame.slice(0, splitIndex)); + server.stdin.write(frame.slice(splitIndex)); + + const response = await queue.next(); + assert.strictEqual(response.id, 6); + assert.deepStrictEqual(response.result, { ok: true }); + }); + + it('streams cluster logs and stops after unsubscribe', async function () { + const clusterId = 'cluster-stdio-logs'; + const zeroshotDir = path.join(tempHome, '.zeroshot'); + fs.mkdirSync(zeroshotDir, { recursive: true }); + + const dbPath = path.join(zeroshotDir, `${clusterId}.db`); + const clustersFile = path.join(zeroshotDir, 'clusters.json'); + fs.writeFileSync( + clustersFile, + JSON.stringify( + { + [clusterId]: { + config: { + dbPath, + }, + }, + }, + null, + 2 + ) + ); + + const seedLedger = new Ledger(dbPath); + seedLedger.close(); + + const subscribeRequest = { + jsonrpc: '2.0', + id: 20, + method: 'subscribeClusterLogs', + params: { clusterId }, + }; + + server.stdin.write(encodeFrame(subscribeRequest)); + const subscribeResponse = await waitForMessage((msg) => msg.id === 20); + const subscriptionId = subscribeResponse.result.subscriptionId; + + const writer = new Ledger(dbPath); + const payloadCount = MAX_LOG_LINES + 5; + const messages = Array.from({ length: payloadCount }, (_, index) => ({ + cluster_id: clusterId, + topic: 'AGENT_OUTPUT', + sender: 'worker', + content: { + text: `line ${index}`, + data: { + agent: 'worker', + role: 'implementation', + line: `line ${index}`, + }, + }, + })); + writer.batchAppend(messages); + writer.close(); + + const notification = await waitForMessage( + (msg) => + msg.method === 'clusterLogLines' && + msg.params && + msg.params.subscriptionId === subscriptionId, + 4000 + ); + + assert.strictEqual(notification.params.clusterId, clusterId); + assert.strictEqual(notification.params.lines.length, MAX_LOG_LINES); + assert.strictEqual(notification.params.droppedCount, 5); + + const unsubscribeRequest = { + jsonrpc: '2.0', + id: 21, + method: 'unsubscribe', + params: { subscriptionId }, + }; + + server.stdin.write(encodeFrame(unsubscribeRequest)); + const unsubscribeResponse = await waitForMessage((msg) => msg.id === 21); + assert.deepStrictEqual(unsubscribeResponse.result, { removed: true }); + + const writer2 = new Ledger(dbPath); + writer2.append({ + cluster_id: clusterId, + topic: 'AGENT_OUTPUT', + sender: 'worker', + content: { + text: 'after unsubscribe', + data: { + agent: 'worker', + role: 'implementation', + line: 'after unsubscribe', + }, + }, + }); + writer2.close(); + + await expectNoMessage( + (msg) => + msg.method === 'clusterLogLines' && + msg.params && + msg.params.subscriptionId === subscriptionId, + 600 + ); + }); +}); diff --git a/tests/unit/tui-backend-subscriptions.test.js b/tests/unit/tui-backend-subscriptions.test.js new file mode 100644 index 00000000..69a12f5d --- /dev/null +++ b/tests/unit/tui-backend-subscriptions.test.js @@ -0,0 +1,64 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const PROJECT_ROOT = path.resolve(__dirname, '../..'); +const SOURCE_PATH = path.join(PROJECT_ROOT, 'src', 'tui-backend', 'subscriptions', 'index.ts'); +const BUILD_PATH = path.join(PROJECT_ROOT, 'lib', 'tui-backend', 'subscriptions', 'index.js'); + +function isBuildStale(sourcePath, buildPath) { + if (!fs.existsSync(buildPath)) { + return true; + } + if (!fs.existsSync(sourcePath)) { + return false; + } + return fs.statSync(sourcePath).mtimeMs > fs.statSync(buildPath).mtimeMs; +} + +if (isBuildStale(SOURCE_PATH, BUILD_PATH)) { + execSync('npm run build:tui-backend', { cwd: PROJECT_ROOT, stdio: 'inherit' }); +} + +const { createSubscriptionRegistry } = require('../../lib/tui-backend/subscriptions'); + +describe('tui-backend subscription registry', function () { + it('closes subscriptions exactly once', function () { + const registry = createSubscriptionRegistry(); + let closes = 0; + + const id = registry.add('logs', () => { + closes += 1; + }); + + const first = registry.unsubscribe(id); + assert.deepStrictEqual(first, { removed: true }); + const second = registry.unsubscribe(id); + assert.deepStrictEqual(second, { removed: false }); + + assert.strictEqual(closes, 1); + assert.strictEqual(registry.size(), 0); + }); + + it('closeAll closes remaining subscriptions and clears registry', function () { + const registry = createSubscriptionRegistry(); + let closes = 0; + + registry.add('logs', () => { + closes += 1; + }); + registry.add('timeline', () => { + closes += 1; + }); + + const count = registry.closeAll(); + assert.strictEqual(count, 2); + assert.strictEqual(closes, 2); + assert.strictEqual(registry.size(), 0); + + const again = registry.closeAll(); + assert.strictEqual(again, 0); + assert.strictEqual(closes, 2); + }); +}); diff --git a/tests/unit/tui-backend/protocol-validation.test.js b/tests/unit/tui-backend/protocol-validation.test.js new file mode 100644 index 00000000..efac1f0b --- /dev/null +++ b/tests/unit/tui-backend/protocol-validation.test.js @@ -0,0 +1,83 @@ +const fs = require('fs'); +const path = require('path'); +const { expect } = require('chai'); + +require.extensions['.ts'] = require.extensions['.js']; + +const { + createValidator, + RPC_ERROR_CODES, +} = require('../../../src/tui-backend/protocol/validator.ts'); + +const fixturesDir = path.join(__dirname, '..', '..', 'fixtures', 'tui-v2', 'protocol'); + +const readFixture = (file) => { + const raw = fs.readFileSync(path.join(fixturesDir, file), 'utf8'); + return JSON.parse(raw); +}; + +const listFixtures = () => + fs + .readdirSync(fixturesDir) + .filter((file) => file.endsWith('.json')) + .sort(); + +const extractMethod = (file) => { + const parts = file.split('.'); + if (parts.length < 3) { + return null; + } + return parts[1]; +}; + +describe('tui-v2 protocol validation', () => { + const validator = createValidator(); + + it('accepts all valid request fixtures', () => { + for (const file of listFixtures()) { + if (!file.startsWith('request.')) continue; + const method = extractMethod(file); + const payload = readFixture(file); + const result = validator.validateRequest(payload); + expect(result.ok, `${file} failed: ${JSON.stringify(result.error)}`).to.equal(true); + expect(payload.method).to.equal(method); + } + }); + + it('accepts all valid response fixtures', () => { + for (const file of listFixtures()) { + if (!file.startsWith('response.')) continue; + const method = extractMethod(file); + const payload = readFixture(file); + const result = validator.validateResponse(payload, method); + expect(result.ok, `${file} failed: ${JSON.stringify(result.error)}`).to.equal(true); + } + }); + + it('accepts all valid notification fixtures', () => { + for (const file of listFixtures()) { + if (!file.startsWith('notification.')) continue; + const method = extractMethod(file); + const payload = readFixture(file); + const result = validator.validateNotification(payload); + expect(result.ok, `${file} failed: ${JSON.stringify(result.error)}`).to.equal(true); + expect(payload.method).to.equal(method); + } + }); + + it('rejects invalid fixtures with structured RPC errors', () => { + for (const file of listFixtures()) { + if (!file.startsWith('invalid.')) continue; + const payload = readFixture(file); + const result = validator.validateRequest(payload); + expect(result.ok, `${file} unexpectedly ok`).to.equal(false); + expect(result.error).to.be.an('object'); + + if (file.startsWith('invalid.request.')) { + expect(result.error.code).to.equal(RPC_ERROR_CODES.INVALID_REQUEST); + } else if (file.startsWith('invalid.params.')) { + expect(result.error.code).to.equal(RPC_ERROR_CODES.INVALID_PARAMS); + } + } + }); +}); diff --git a/tests/unit/tui-binary.test.js b/tests/unit/tui-binary.test.js new file mode 100644 index 00000000..239331dc --- /dev/null +++ b/tests/unit/tui-binary.test.js @@ -0,0 +1,82 @@ +/** + * Test: TUI Binary helpers + * + * Validates platform/arch mapping, asset naming, download URL building, + * and env overrides used by the install script and launcher. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + ENV_BINARY_PATH, + ENV_BINARY_SKIP, + getAssetName, + getInstalledBinaryPath, + resolveBinaryPathOverride, + resolveDownloadUrl, + resolveTarget, + shouldSkipBinaryInstall, +} = require('../../lib/tui-binary'); + +describe('TUI Binary helpers', function () { + const originalEnv = { ...process.env }; + + afterEach(function () { + process.env = { ...originalEnv }; + }); + + it('builds asset names for supported targets', function () { + assert.strictEqual(getAssetName('darwin', 'arm64'), 'zeroshot-tui-darwin-arm64.tar.gz'); + assert.strictEqual(getAssetName('linux', 'x64'), 'zeroshot-tui-linux-x64.tar.gz'); + }); + + it('returns null for unsupported targets', function () { + assert.strictEqual(resolveTarget('win32', 'x64'), null); + assert.strictEqual(resolveTarget('darwin', 'ia32'), null); + }); + + it('builds release URLs with version overrides', function () { + const url = resolveDownloadUrl({ + version: '1.2.3', + platform: 'darwin', + arch: 'arm64', + }); + assert.strictEqual( + url, + 'https://github.com/covibes/zeroshot/releases/download/v1.2.3/zeroshot-tui-darwin-arm64.tar.gz' + ); + }); + + it('uses binary path override when provided', function () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-tui-test-')); + const tempBin = path.join(tempDir, 'zeroshot-tui'); + fs.writeFileSync(tempBin, 'binary'); + + process.env[ENV_BINARY_PATH] = tempBin; + + assert.strictEqual(resolveBinaryPathOverride(), tempBin); + }); + + it('throws when binary override path is missing', function () { + process.env[ENV_BINARY_PATH] = '/tmp/missing-zeroshot-tui'; + assert.throws(() => resolveBinaryPathOverride(), /Rust TUI binary not found/); + }); + + it('returns installed binary path', function () { + const expected = path.join(path.resolve(__dirname, '..', '..'), 'libexec', 'zeroshot-tui'); + assert.strictEqual(getInstalledBinaryPath(), expected); + }); + + it('honors skip env values', function () { + assert.strictEqual(shouldSkipBinaryInstall(), false); + + process.env[ENV_BINARY_SKIP] = '1'; + assert.strictEqual(shouldSkipBinaryInstall(), true); + + process.env[ENV_BINARY_SKIP] = 'false'; + assert.strictEqual(shouldSkipBinaryInstall(), false); + }); +}); diff --git a/tests/unit/tui-start-cluster.test.js b/tests/unit/tui-start-cluster.test.js new file mode 100644 index 00000000..cf5e1814 --- /dev/null +++ b/tests/unit/tui-start-cluster.test.js @@ -0,0 +1,82 @@ +const assert = require('assert'); + +const { startClusterFromText, startClusterFromIssue } = require('../../lib/start-cluster'); + +function createOrchestrator() { + const calls = { loadConfig: [], start: [] }; + return { + calls, + loadConfig(configPath) { + calls.loadConfig.push(configPath); + return { agents: [] }; + }, + start(config, input, options) { + calls.start.push({ config, input, options }); + return { id: 'cluster-test' }; + }, + }; +} + +describe('TUI start cluster helper', function () { + const originalCwd = process.env.ZEROSHOT_CWD; + + afterEach(function () { + if (originalCwd === undefined) { + delete process.env.ZEROSHOT_CWD; + } else { + process.env.ZEROSHOT_CWD = originalCwd; + } + }); + + it('startClusterFromText builds text input and forwards providerOverride', async function () { + process.env.ZEROSHOT_CWD = '/tmp'; + const orchestrator = createOrchestrator(); + const settings = { defaultProvider: 'claude', providerSettings: {} }; + const configPath = '/tmp/config.json'; + + const result = await startClusterFromText({ + orchestrator, + text: 'Launch cluster', + configPath, + settings, + providerOverride: 'codex', + modelOverride: 'gpt-4o', + forceProvider: 'github', + clusterId: 'cluster-123', + options: { docker: false, worktree: false, pr: false, mounts: false }, + }); + + assert.strictEqual(orchestrator.calls.loadConfig[0], configPath); + assert.strictEqual(orchestrator.calls.start.length, 1); + const call = orchestrator.calls.start[0]; + assert.deepStrictEqual(call.input, { text: 'Launch cluster' }); + assert.strictEqual(call.options.providerOverride, 'codex'); + assert.strictEqual(call.options.clusterId, 'cluster-123'); + assert.strictEqual(call.options.noMounts, true); + assert.strictEqual(result.id, 'cluster-test'); + }); + + it('startClusterFromIssue builds issue input and forwards providerOverride', async function () { + process.env.ZEROSHOT_CWD = '/tmp'; + const orchestrator = createOrchestrator(); + const settings = { defaultProvider: 'claude', providerSettings: {} }; + const configPath = '/tmp/config.json'; + + await startClusterFromIssue({ + orchestrator, + issue: '123', + configPath, + settings, + providerOverride: 'codex', + clusterId: 'cluster-456', + options: { docker: false, worktree: false, pr: false }, + }); + + assert.strictEqual(orchestrator.calls.loadConfig[0], configPath); + assert.strictEqual(orchestrator.calls.start.length, 1); + const call = orchestrator.calls.start[0]; + assert.deepStrictEqual(call.input, { issue: '123' }); + assert.strictEqual(call.options.providerOverride, 'codex'); + assert.strictEqual(call.options.clusterId, 'cluster-456'); + }); +}); diff --git a/tests/verify-github-pr-hook.test.js b/tests/verify-github-pr-hook.test.js new file mode 100644 index 00000000..0abecf52 --- /dev/null +++ b/tests/verify-github-pr-hook.test.js @@ -0,0 +1,327 @@ +/** + * verify_github_pr Hook Action Test Suite + * + * Tests for the verify_github_pr hook action that validates PR existence and merge status + * Part of issue #340 - Prevent git-pusher hallucination + */ + +const assert = require('assert'); +const path = require('path'); + +// Mock agent with required methods +function createMockAgent(workingDirectory = process.cwd()) { + return { + id: 'test-agent', + role: 'test', + workingDirectory, + _log: () => {}, + _publish: function (message) { + this.lastPublished = message; + }, + lastPublished: null, + }; +} + +describe('verify_github_pr hook action', function () { + this.timeout(60000); + + let executeHook; + let mockExecSyncFn; + + beforeEach(() => { + // Clear module cache + const hookExecutorPath = path.join(__dirname, '../src/agent/agent-hook-executor.js'); + delete require.cache[require.resolve(hookExecutorPath)]; + + const safeExecPath = path.join(__dirname, '../src/lib/safe-exec.js'); + delete require.cache[require.resolve(safeExecPath)]; + + // Mock safe-exec module + require.cache[require.resolve(safeExecPath)] = { + exports: { + execSync: function (...args) { + if (mockExecSyncFn) { + return mockExecSyncFn(...args); + } + throw new Error('Mock execSync not configured'); + }, + }, + }; + + // Reload executeHook with mocked safe-exec + executeHook = require('../src/agent/agent-hook-executor').executeHook; + mockExecSyncFn = null; + }); + + afterEach(() => { + mockExecSyncFn = null; + }); + + it('should not require pr_number in structured output', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + summary: 'Merged', + result: + 'PR merged: {"pr_url":"https://github.com/org/repo/pull/123","pr_number":123,"merged":true}', + }), + }; + + mockExecSyncFn = () => { + return JSON.stringify({ + number: 123, + state: 'MERGED', + mergedAt: '2026-01-15T10:30:00Z', + url: 'https://github.com/org/repo/pull/123', + }); + }; + + await executeHook({ hook, agent, result }); + assert(agent.lastPublished, 'Expected message to be published'); + assert.strictEqual(agent.lastPublished.topic, 'CLUSTER_COMPLETE'); + }); + + it('should throw when PR does not exist in GitHub', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/9999', + merged: true, + }), + }; + + mockExecSyncFn = () => { + const error = new Error('Could not resolve to a PullRequest'); + error.status = 1; + throw error; + }; + + try { + await executeHook({ hook, agent, result }); + assert.fail('Expected error to be thrown'); + } catch (err) { + assert.match(err.message, /DOES NOT EXIST/); + assert.match(err.message, /HALLUCINATED/); + } + }); + + it('should throw when PR exists but genuinely not merged after all polls', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/123', + pr_number: 123, + merged: true, + }), + }; + + // Always returns OPEN — genuinely not merged + mockExecSyncFn = () => { + return JSON.stringify({ + number: 123, + state: 'OPEN', + mergedAt: null, + url: 'https://github.com/org/repo/pull/123', + }); + }; + + try { + await executeHook({ hook, agent, result }); + assert.fail('Expected error to be thrown'); + } catch (err) { + assert.match(err.message, /LIED/i); + assert.match(err.message, /polls/i); + } + }); + + // REGRESSION: gentle-hydra-56 (2026-02-11) + // GitHub API returned state="OPEN" immediately after gh pr merge, but PR was actually merged. + // Old code had no merge propagation polling — killed the cluster after 3s. + it('should succeed when GitHub API shows OPEN initially then MERGED after propagation delay', async function () { + const agent = createMockAgent(); + agent._log = () => {}; // suppress log noise + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/1411', + pr_number: 1411, + merged: true, + }), + }; + + // Simulate GitHub eventual consistency: OPEN for first 3 calls, then MERGED + let callCount = 0; + mockExecSyncFn = () => { + callCount++; + if (callCount <= 3) { + return JSON.stringify({ + number: 1411, + state: 'OPEN', + mergedAt: null, + url: 'https://github.com/org/repo/pull/1411', + }); + } + return JSON.stringify({ + number: 1411, + state: 'MERGED', + mergedAt: '2026-02-11T10:08:37Z', + url: 'https://github.com/org/repo/pull/1411', + }); + }; + + await executeHook({ hook, agent, result }); + + assert(agent.lastPublished, 'Expected CLUSTER_COMPLETE to be published'); + assert.strictEqual(agent.lastPublished.topic, 'CLUSTER_COMPLETE'); + assert.strictEqual(agent.lastPublished.content.data.pr_number, 1411); + assert(callCount >= 4, `Expected at least 4 gh calls (got ${callCount})`); + }); + + it('should use explicit PR number in gh command when available in agent output', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/555', + pr_number: 555, + merged: true, + }), + }; + + let capturedCmd; + mockExecSyncFn = (cmd) => { + capturedCmd = cmd; + return JSON.stringify({ + number: 555, + state: 'MERGED', + mergedAt: '2026-02-11T10:00:00Z', + url: 'https://github.com/org/repo/pull/555', + }); + }; + + await executeHook({ hook, agent, result }); + assert(capturedCmd.includes('gh pr view 555'), `Expected PR number in command, got: ${capturedCmd}`); + }); + + it('should fall back to branch-based resolution when pr_number not in output', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + summary: 'Done', + }), + }; + + let capturedCmd; + mockExecSyncFn = (cmd) => { + capturedCmd = cmd; + return JSON.stringify({ + number: 100, + state: 'MERGED', + mergedAt: '2026-02-11T10:00:00Z', + url: 'https://github.com/org/repo/pull/100', + }); + }; + + await executeHook({ hook, agent, result }); + assert.strictEqual(capturedCmd, 'gh pr view --json state,mergedAt,url,number'); + }); + + it('should publish CLUSTER_COMPLETE when PR verified merged', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/456', + merged: true, + }), + }; + + mockExecSyncFn = () => { + return JSON.stringify({ + number: 456, + state: 'MERGED', + mergedAt: '2026-01-15T10:30:00Z', + url: 'https://github.com/org/repo/pull/456', + }); + }; + + await executeHook({ hook, agent, result }); + + assert(agent.lastPublished, 'Expected message to be published'); + assert.strictEqual(agent.lastPublished.topic, 'CLUSTER_COMPLETE'); + assert.strictEqual(agent.lastPublished.content.data.pr_number, 456); + }); + + it('should pass correct workingDirectory to gh CLI', async function () { + const agent = createMockAgent('/custom/work/dir'); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/789', + merged: true, + }), + }; + + let capturedCwd; + mockExecSyncFn = (cmd, opts) => { + capturedCwd = opts.cwd; + return JSON.stringify({ + number: 789, + state: 'MERGED', + mergedAt: '2026-01-15T10:30:00Z', + url: 'https://github.com/org/repo/pull/789', + }); + }; + + await executeHook({ hook, agent, result }); + assert.strictEqual(capturedCwd, '/custom/work/dir'); + }); + + it('should propagate non-hallucination errors', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/999', + merged: true, + }), + }; + + mockExecSyncFn = () => { + throw new Error('Network error: timeout'); + }; + + try { + await executeHook({ hook, agent, result }); + assert.fail('Expected error to be thrown'); + } catch (err) { + assert.match(err.message, /Network error: timeout/); + } + }); + + it('should throw when claimed pr_url does not match the branch PR', async function () { + const agent = createMockAgent(); + const hook = { action: 'verify_github_pr' }; + const result = { + output: JSON.stringify({ + pr_url: 'https://github.com/org/repo/pull/111', + merged: true, + }), + }; + + mockExecSyncFn = () => { + return JSON.stringify({ + number: 222, + state: 'MERGED', + mergedAt: '2026-01-15T10:30:00Z', + url: 'https://github.com/org/repo/pull/222', + }); + }; + + await assert.rejects(() => executeHook({ hook, agent, result }), /claimed PR URL/i); + }); +}); diff --git a/tests/worktree-compose-cleanup.test.js b/tests/worktree-compose-cleanup.test.js new file mode 100644 index 00000000..9898ffab --- /dev/null +++ b/tests/worktree-compose-cleanup.test.js @@ -0,0 +1,266 @@ +/** + * Worktree Docker Compose Cleanup Test Suite + * + * Regression test for bug where `docker compose down` was never called when + * cleaning up zeroshot worktrees. Agents could run `docker compose up` inside + * worktrees, and those containers would keep running after session end, + * hogging host ports (5433, 6379, 3001, etc.) and blocking the main project. + * + * Root cause: removeWorktree() only did `git worktree remove` + `fs.rmSync`, + * never tore down Docker Compose services. The orchestrator stop() path + * (Ctrl+C / SIGINT) preserved worktrees entirely, including running containers. + * + * Fix: + * - isolation-manager.js: removeWorktree() now calls `docker compose down` first + * - orchestrator.js: stop() calls _teardownWorktreeCompose() even when preserving worktree + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +describe('Worktree Docker Compose Cleanup', function () { + this.timeout(10000); + + let tmpDir; + + beforeEach(function () { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-compose-cleanup-')); + }); + + afterEach(function () { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('IsolationManager.removeWorktree', function () { + it('should call docker compose down before removing worktree with docker-compose.yml', function () { + // Mock safe-exec to intercept execSync calls from isolation-manager + const safeExec = require('../src/lib/safe-exec'); + const origExecSync = safeExec.execSync; + const calls = []; + + safeExec.execSync = function (cmd, opts) { + calls.push({ cmd, cwd: opts?.cwd }); + if (cmd.includes('docker compose down')) { + return ''; + } + if (cmd.includes('git worktree') || cmd.includes('git branch')) { + throw new Error('not a git repo'); + } + return ''; + }; + + try { + // Re-require to get fresh module with mocked execSync + // (isolation-manager destructures execSync at import, but from safe-exec module object) + // Since IsolationManager binds execSync at require-time, we need to clear cache + delete require.cache[require.resolve('../src/isolation-manager')]; + const IsolationManager = require('../src/isolation-manager'); + const manager = new IsolationManager(); + + // Create a fake worktree directory with docker-compose.yml + const fakeWorktreePath = path.join(tmpDir, 'test-worktree'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + fs.writeFileSync(path.join(fakeWorktreePath, 'docker-compose.yml'), 'version: "3"'); + + // Register worktree in manager's internal map + manager.worktrees.set('test-cluster', { + path: fakeWorktreePath, + branch: 'zeroshot/test-cluster', + repoRoot: tmpDir, + }); + + manager.cleanupWorktreeIsolation('test-cluster'); + + // Verify docker compose down was called + const composeDownCall = calls.find((c) => c.cmd.includes('docker compose down')); + assert.ok(composeDownCall, 'docker compose down should be called during cleanup'); + assert.strictEqual( + composeDownCall.cwd, + fakeWorktreePath, + 'docker compose down should run in the worktree directory' + ); + assert.ok( + composeDownCall.cmd.includes('--remove-orphans'), + 'docker compose down should use --remove-orphans' + ); + assert.ok( + composeDownCall.cmd.includes('--volumes'), + 'docker compose down should use --volumes to free disk' + ); + + // Verify ordering: compose down appears before git worktree remove + const composeIdx = calls.findIndex((c) => c.cmd.includes('docker compose down')); + const gitIdx = calls.findIndex((c) => c.cmd.includes('git worktree remove')); + assert.ok( + composeIdx < gitIdx, + `docker compose down (idx ${composeIdx}) should run before git worktree remove (idx ${gitIdx})` + ); + } finally { + safeExec.execSync = origExecSync; + delete require.cache[require.resolve('../src/isolation-manager')]; + } + }); + + it('should skip docker compose down when no docker-compose.yml exists', function () { + const safeExec = require('../src/lib/safe-exec'); + const origExecSync = safeExec.execSync; + const calls = []; + + safeExec.execSync = function (cmd, opts) { + calls.push({ cmd, cwd: opts?.cwd }); + if (cmd.includes('git worktree') || cmd.includes('git branch')) { + throw new Error('not a git repo'); + } + return ''; + }; + + try { + delete require.cache[require.resolve('../src/isolation-manager')]; + const IsolationManager = require('../src/isolation-manager'); + const manager = new IsolationManager(); + + // Create a fake worktree directory WITHOUT docker-compose.yml + const fakeWorktreePath = path.join(tmpDir, 'test-worktree-no-compose'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + + manager.worktrees.set('test-no-compose', { + path: fakeWorktreePath, + branch: 'zeroshot/test-no-compose', + repoRoot: tmpDir, + }); + + manager.cleanupWorktreeIsolation('test-no-compose'); + + const composeCall = calls.find((c) => c.cmd.includes('docker compose')); + assert.strictEqual( + composeCall, + undefined, + 'docker compose should NOT be called when no docker-compose.yml exists' + ); + } finally { + safeExec.execSync = origExecSync; + delete require.cache[require.resolve('../src/isolation-manager')]; + } + }); + + it('should not fail when docker compose down throws', function () { + const safeExec = require('../src/lib/safe-exec'); + const origExecSync = safeExec.execSync; + + safeExec.execSync = function (cmd) { + if (cmd.includes('docker compose down')) { + throw new Error('Docker daemon not running'); + } + if (cmd.includes('git')) { + throw new Error('not a git repo'); + } + return ''; + }; + + try { + delete require.cache[require.resolve('../src/isolation-manager')]; + const IsolationManager = require('../src/isolation-manager'); + const manager = new IsolationManager(); + + const fakeWorktreePath = path.join(tmpDir, 'test-worktree-compose-fail'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + fs.writeFileSync(path.join(fakeWorktreePath, 'docker-compose.yml'), 'version: "3"'); + + manager.worktrees.set('test-compose-fail', { + path: fakeWorktreePath, + branch: 'zeroshot/test-compose-fail', + repoRoot: tmpDir, + }); + + // Should not throw — compose down failure is best-effort + assert.doesNotThrow(() => { + manager.cleanupWorktreeIsolation('test-compose-fail'); + }); + } finally { + safeExec.execSync = origExecSync; + delete require.cache[require.resolve('../src/isolation-manager')]; + } + }); + }); + + describe('Orchestrator._teardownWorktreeCompose', function () { + // _teardownWorktreeCompose uses safe-exec (required lazily inside the method). + // Mock via the safe-exec module object since the method re-requires it each call. + let safeExec, origExecSync; + + beforeEach(function () { + safeExec = require('../src/lib/safe-exec'); + origExecSync = safeExec.execSync; + }); + + afterEach(function () { + safeExec.execSync = origExecSync; + }); + + it('should tear down compose services during stop (Ctrl+C path)', function () { + const Orchestrator = require('../src/orchestrator'); + const orchestrator = new Orchestrator({ dataDir: tmpDir }); + + const fakeWorktreePath = path.join(tmpDir, 'stop-test-worktree'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + fs.writeFileSync(path.join(fakeWorktreePath, 'docker-compose.yml'), 'version: "3"'); + + const calls = []; + safeExec.execSync = function (cmd, opts) { + calls.push({ cmd, cwd: opts?.cwd }); + return ''; + }; + + orchestrator._teardownWorktreeCompose(fakeWorktreePath); + + const composeCall = calls.find((c) => c.cmd.includes('docker compose down')); + assert.ok(composeCall, '_teardownWorktreeCompose should call docker compose down'); + assert.strictEqual(composeCall.cwd, fakeWorktreePath); + assert.ok(composeCall.cmd.includes('--remove-orphans')); + assert.ok(composeCall.cmd.includes('--volumes')); + }); + + it('should skip when no docker-compose.yml exists', function () { + const Orchestrator = require('../src/orchestrator'); + const orchestrator = new Orchestrator({ dataDir: tmpDir }); + + const fakeWorktreePath = path.join(tmpDir, 'no-compose-worktree'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + + const calls = []; + safeExec.execSync = function (cmd, opts) { + calls.push({ cmd, cwd: opts?.cwd }); + return ''; + }; + + orchestrator._teardownWorktreeCompose(fakeWorktreePath); + assert.strictEqual( + calls.length, + 0, + 'No commands should be called without docker-compose.yml' + ); + }); + + it('should not throw when docker compose down fails', function () { + const Orchestrator = require('../src/orchestrator'); + const orchestrator = new Orchestrator({ dataDir: tmpDir }); + + const fakeWorktreePath = path.join(tmpDir, 'fail-compose-worktree'); + fs.mkdirSync(fakeWorktreePath, { recursive: true }); + fs.writeFileSync(path.join(fakeWorktreePath, 'docker-compose.yml'), 'version: "3"'); + + safeExec.execSync = function (cmd) { + if (cmd.includes('docker compose')) { + throw new Error('no such service'); + } + return ''; + }; + + assert.doesNotThrow(() => { + orchestrator._teardownWorktreeCompose(fakeWorktreePath); + }); + }); + }); +}); diff --git a/tests/worktree-cwd-injection.test.js b/tests/worktree-cwd-injection.test.js index 546d2d13..eaae0bd9 100644 --- a/tests/worktree-cwd-injection.test.js +++ b/tests/worktree-cwd-injection.test.js @@ -17,7 +17,7 @@ describe('Worktree CWD Injection', function () { describe('_opAddAgents cwd injection', function () { it('should inject worktree path into dynamically added agents', function () { // Simulate a cluster with worktree enabled - const worktreePath = '/tmp/zeroshot-worktrees/test-cluster-123'; + const worktreePath = '/home/eivind/.zeroshot/worktrees/test-cluster-123'; const cluster = { id: 'test-cluster-123', worktree: { @@ -63,7 +63,7 @@ describe('Worktree CWD Injection', function () { }); it('should not override agent cwd if already set', function () { - const worktreePath = '/tmp/zeroshot-worktrees/test-cluster-456'; + const worktreePath = '/home/eivind/.zeroshot/worktrees/test-cluster-456'; const customCwd = '/custom/path/for/agent'; const cluster = { @@ -136,7 +136,7 @@ describe('Worktree CWD Injection', function () { describe('resume path cwd fix', function () { it('should fix agents saved without cwd on resume', function () { - const worktreePath = '/tmp/zeroshot-worktrees/old-cluster'; + const worktreePath = '/home/eivind/.zeroshot/worktrees/old-cluster'; // Simulate cluster data saved BEFORE the bugfix (agents have cwd: null) const clusterData = { diff --git a/tsconfig.json b/tsconfig.json index 722cbadb..1caf592d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "resolveJsonModule": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, + "typeRoots": ["./node_modules/@types"], // Target Node.js 18+ "target": "ES2022", @@ -52,10 +53,5 @@ "cluster-scripts/**/*", "test/**/*" ], - "exclude": [ - "node_modules", - "dist", - "coverage", - "task-lib" - ] + "exclude": ["node_modules", "dist", "coverage", "task-lib"] } diff --git a/tsconfig.tui-backend.json b/tsconfig.tui-backend.json new file mode 100644 index 00000000..1e6de1fe --- /dev/null +++ b/tsconfig.tui-backend.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "jsx": "react", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "typeRoots": ["./node_modules/@types"], + "strict": false, + "skipLibCheck": true, + "moduleDetection": "force", + "outDir": "lib/tui-backend", + "rootDir": "src/tui-backend" + }, + "include": ["src/tui-backend/**/*.ts"], + "exclude": ["node_modules", "lib"] +} diff --git a/tui-rs/Cargo.lock b/tui-rs/Cargo.lock new file mode 100644 index 00000000..ea85e762 --- /dev/null +++ b/tui-rs/Cargo.lock @@ -0,0 +1,651 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroshot-tui" +version = "0.1.0" +dependencies = [ + "crossterm", + "ratatui", + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1966f8ac2c1f76987d69a74d0e0f929241c10e78136434e3be70ff7f58f64214" diff --git a/tui-rs/Cargo.toml b/tui-rs/Cargo.toml new file mode 100644 index 00000000..b63f7c4a --- /dev/null +++ b/tui-rs/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["crates/zeroshot-tui"] +resolver = "2" diff --git a/tui-rs/crates/zeroshot-tui/Cargo.toml b/tui-rs/crates/zeroshot-tui/Cargo.toml new file mode 100644 index 00000000..b1435700 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "zeroshot-tui" +version = "0.1.0" +edition = "2021" + +[dependencies] +ratatui = { version = "0.29", features = ["crossterm"] } +crossterm = "0.28" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/tui-rs/crates/zeroshot-tui/src/app/agent_microscope.rs b/tui-rs/crates/zeroshot-tui/src/app/agent_microscope.rs new file mode 100644 index 00000000..3f9c0a3c --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/app/agent_microscope.rs @@ -0,0 +1,48 @@ +use crate::protocol::ClusterLogLine; +use crate::ui::shared::TimeIndexedBuffer; + +pub const MAX_LOG_LINES: usize = 1000; + +#[derive(Debug, Clone)] +pub struct State { + pub logs_time: TimeIndexedBuffer, + pub log_drop_seq: u64, + pub log_subscription: Option, + pub role: Option, + pub status: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + logs_time: TimeIndexedBuffer::new(MAX_LOG_LINES), + log_drop_seq: 0, + log_subscription: None, + role: None, + status: None, + } + } +} + +impl State { + pub fn push_log_lines(&mut self, mut lines: Vec, dropped_count: Option) { + let mut to_push = Vec::new(); + if let Some(count) = dropped_count { + if count > 0 { + let line = ClusterLogLine { + id: format!("dropped-{}", self.log_drop_seq), + timestamp: lines.first().map(|line| line.timestamp).unwrap_or(0), + text: format!("[dropped {} log lines]", count), + agent: None, + role: None, + sender: None, + }; + self.log_drop_seq = self.log_drop_seq.saturating_add(1); + to_push.push(line); + } + } + + to_push.append(&mut lines); + self.logs_time.push_many(to_push); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/app/animation.rs b/tui-rs/crates/zeroshot-tui/src/app/animation.rs new file mode 100644 index 00000000..65fd7c46 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/app/animation.rs @@ -0,0 +1,102 @@ +use std::f32::consts::TAU; + +pub const DEFAULT_TICK_MS: i64 = 250; +pub const MIN_TICK_MS: i64 = 16; +pub const MAX_TICK_MS: i64 = 1000; +pub const PHASE_TICKS: u64 = 24; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AnimClock { + pub now_ms: i64, + pub tick: u64, + pub phase: f32, +} + +impl Default for AnimClock { + fn default() -> Self { + Self { + now_ms: 0, + tick: 0, + phase: 0.0, + } + } +} + +impl AnimClock { + pub fn advance(&mut self, now_ms: i64) { + self.now_ms = now_ms; + self.tick = self.tick.saturating_add(1); + self.phase = ((self.tick % PHASE_TICKS) as f32) / (PHASE_TICKS as f32); + } +} + +pub fn pulse_factor(phase: f32) -> f32 { + 0.5 + 0.5 * (phase * TAU).sin() +} + +pub fn smooth_factor(dt_ms: i64, base: f64) -> f64 { + let dt_scale = (dt_ms as f64 / DEFAULT_TICK_MS as f64).clamp(0.2, 2.0); + (base * dt_scale).clamp(0.0, 1.0) +} + +pub fn lerp_f64(current: f64, target: f64, t: f64) -> f64 { + current + (target - current) * t +} + +pub fn smooth_toward_f64(current: f64, target: f64, dt_ms: i64, rate: f64) -> f64 { + let t = smooth_factor(dt_ms, rate); + lerp_f64(current, target, t) +} + +pub fn step_spring_f32( + position: (f32, f32), + velocity: (f32, f32), + target: (f32, f32), + dt_ms: i64, + accel: f32, + friction: f32, +) -> ((f32, f32), (f32, f32)) { + let dt_scale = (dt_ms as f32 / DEFAULT_TICK_MS as f32).clamp(0.2, 2.0); + let accel_step = accel * dt_scale; + let friction_step = friction.powf(dt_scale); + + let mut vx = velocity.0 + (target.0 - position.0) * accel_step; + let mut vy = velocity.1 + (target.1 - position.1) * accel_step; + vx *= friction_step; + vy *= friction_step; + + let px = position.0 + vx * dt_scale; + let py = position.1 + vy * dt_scale; + + ((px, py), (vx, vy)) +} + +pub fn step_spring_f64( + position: (f64, f64), + velocity: (f64, f64), + target: (f64, f64), + dt_ms: i64, + accel: f64, + friction: f64, +) -> ((f64, f64), (f64, f64)) { + let dt_scale = (dt_ms as f64 / DEFAULT_TICK_MS as f64).clamp(0.2, 2.0); + let accel_step = accel * dt_scale; + let friction_step = friction.powf(dt_scale); + + let mut vx = velocity.0 + (target.0 - position.0) * accel_step; + let mut vy = velocity.1 + (target.1 - position.1) * accel_step; + vx *= friction_step; + vy *= friction_step; + + let px = position.0 + vx * dt_scale; + let py = position.1 + vy * dt_scale; + + ((px, py), (vx, vy)) +} + +pub fn clamp_tick_delta(last_tick_ms: Option, now_ms: i64) -> i64 { + let raw = last_tick_ms + .map(|last| now_ms.saturating_sub(last)) + .unwrap_or(DEFAULT_TICK_MS); + raw.clamp(MIN_TICK_MS, MAX_TICK_MS) +} diff --git a/tui-rs/crates/zeroshot-tui/src/app/mod.rs b/tui-rs/crates/zeroshot-tui/src/app/mod.rs new file mode 100644 index 00000000..6f68cfb7 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/app/mod.rs @@ -0,0 +1,2889 @@ +use std::collections::{HashMap, HashSet}; + +use crate::backend::{BackendExit, BackendNotification}; +use crate::protocol::ClusterMetrics; +use crate::screens::{agent, cluster, cluster_canvas, launcher, monitor, radar}; +use crate::ui::shared::InputState; + +pub mod agent_microscope; +pub mod animation; +mod spine_completion; +mod spine_hint; +use animation::{clamp_tick_delta, step_spring_f32, AnimClock}; +use spine_completion::{build_spine_completion, select_spine_completion}; +pub use spine_hint::{compute_spine_hint, SpineHint, SpineHintTone}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AgentKey { + pub cluster_id: String, + pub agent_id: String, +} + +impl AgentKey { + pub fn new(cluster_id: impl Into, agent_id: impl Into) -> Self { + Self { + cluster_id: cluster_id.into(), + agent_id: agent_id.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ScreenId { + Launcher, + Monitor, + IntentConsole, + FleetRadar, + Cluster { + id: String, + }, + ClusterCanvas { + id: String, + }, + Agent { + cluster_id: String, + agent_id: String, + }, + AgentMicroscope { + cluster_id: String, + agent_id: String, + }, +} + +impl ScreenId { + pub fn title(&self) -> String { + match self { + ScreenId::Launcher => "Launcher".to_string(), + ScreenId::Monitor => "Monitor".to_string(), + ScreenId::IntentConsole => "Intent Console".to_string(), + ScreenId::FleetRadar => "Fleet Radar".to_string(), + ScreenId::Cluster { id } => format!("Cluster {id}"), + ScreenId::ClusterCanvas { id } => format!("Cluster Canvas {id}"), + ScreenId::Agent { + cluster_id, + agent_id, + } => format!("Agent {agent_id} @ {cluster_id}"), + ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => format!("Agent Microscope {agent_id} @ {cluster_id}"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ZoomStackContext { + Root, + FleetRadar, + Cluster { + id: String, + }, + Agent { + cluster_id: String, + agent_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum TemporalFocus { + #[default] + None, + Cluster { + id: String, + }, + Agent { + cluster_id: String, + agent_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FocusTarget { + Cluster { + id: String, + }, + Agent { + cluster_id: String, + agent_id: String, + }, +} + +impl FocusTarget { + fn label(&self) -> String { + match self { + FocusTarget::Cluster { id } => format!("cluster {id}"), + FocusTarget::Agent { + cluster_id, + agent_id, + } => format!("agent {agent_id} @ {cluster_id}"), + } + } +} + +impl TemporalFocus { + pub fn is_active(&self) -> bool { + !matches!(self, TemporalFocus::None) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InitialScreen { + Launcher, + Monitor, +} + +impl InitialScreen { + pub fn parse(value: &str) -> Result { + match value.trim().to_lowercase().as_str() { + "launcher" => Ok(Self::Launcher), + "monitor" => Ok(Self::Monitor), + other => Err(format!( + "Unknown initial screen: {other}. Valid: launcher, monitor" + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UiVariant { + #[default] + Classic, + Disruptive, +} + +impl UiVariant { + pub fn parse(value: &str) -> Result { + match value.trim().to_lowercase().as_str() { + "classic" => Ok(Self::Classic), + "disruptive" => Ok(Self::Disruptive), + other => Err(format!( + "Unknown UI variant: {other}. Valid: classic, disruptive" + )), + } + } +} + +pub fn resolve_ui_variant( + cli_value: Option<&str>, + env_value: Option<&str>, +) -> Result, String> { + if let Some(raw) = cli_value { + if !raw.trim().is_empty() { + return Ok(Some(UiVariant::parse(raw)?)); + } + } + + if let Some(raw) = env_value { + if !raw.trim().is_empty() { + return Ok(Some(UiVariant::parse(raw)?)); + } + } + + Ok(None) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Camera { + pub position: (f32, f32), + pub zoom: f32, +} + +impl Default for Camera { + fn default() -> Self { + Self { + position: (0.0, 0.0), + zoom: 1.0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TimeCursorMode { + #[default] + Live, + Scrub, +} + +const DEFAULT_TIME_WINDOW_MS: i64 = 60_000; +pub const TIME_SCRUB_STEP_MS: i64 = 1000; +pub const TIME_SCRUB_STEP_LARGE_MS: i64 = 5000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimeCursor { + pub mode: TimeCursorMode, + pub t_ms: i64, + pub window_ms: i64, +} + +impl Default for TimeCursor { + fn default() -> Self { + Self { + mode: TimeCursorMode::Live, + t_ms: 0, + window_ms: DEFAULT_TIME_WINDOW_MS, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SpineMode { + #[default] + Intent, + Command, + WhisperCluster, + WhisperAgent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpineCompletion { + pub candidates: Vec, + pub selected: usize, + pub ghost: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpineState { + pub mode: SpineMode, + pub input: InputState, + pub hint: SpineHint, + pub completion: Option, +} + +impl Default for SpineState { + fn default() -> Self { + Self { + mode: SpineMode::Intent, + input: InputState::default(), + hint: SpineHint::default(), + completion: None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StartupOptions { + pub initial_screen: Option, + pub provider_override: Option, + pub ui_variant: Option, +} + +#[derive(Debug, Clone)] +pub enum BackendStatus { + Disconnected, + Connected, + Error(String), + Exited(BackendExit), +} + +#[derive(Debug, Clone, Default)] +pub struct CommandBarState { + pub active: bool, + inner: InputState, +} + +impl CommandBarState { + /// Read-only access to input text. + pub fn input(&self) -> &str { + &self.inner.input + } + + /// Read-only access to cursor position. + pub fn cursor(&self) -> usize { + self.inner.cursor + } + + pub fn open_with(&mut self, prefill: String) { + self.active = true; + self.inner.input = prefill; + self.inner.move_end(); + } + + pub fn close(&mut self) { + self.active = false; + self.inner.clear(); + } + + pub fn insert_char(&mut self, ch: char) { + self.inner.insert_char(ch); + } + + pub fn backspace(&mut self) { + self.inner.backspace(); + } + + pub fn delete(&mut self) { + self.inner.delete(); + } + + pub fn move_left(&mut self) { + self.inner.move_left(); + } + + pub fn move_right(&mut self) { + self.inner.move_right(); + } + + pub fn move_home(&mut self) { + self.inner.move_home(); + } + + pub fn move_end(&mut self) { + self.inner.move_end(); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToastLevel { + Info, + Success, + Error, +} + +#[derive(Debug, Clone)] +pub struct ToastState { + pub message: String, + pub level: ToastLevel, + pub expires_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandContext { + pub provider_override: Option, + pub active_screen: ScreenId, + pub ui_variant: UiVariant, +} + +#[derive(Debug, Clone)] +pub struct AppState { + pub screen_stack: Vec, + pub launcher: launcher::State, + pub monitor: monitor::State, + pub fleet_radar: radar::FleetRadarState, + pub metrics: HashMap, + pub last_metrics_poll_at: Option, + pub clusters: HashMap, + pub cluster_canvases: HashMap, + pub agents: HashMap, + pub agent_microscopes: HashMap, + pub last_size: Option<(u16, u16)>, + pub tick_count: u64, + pub now_ms: i64, + pub anim_clock: AnimClock, + pub last_tick_ms: Option, + pub should_quit: bool, + pub backend_status: BackendStatus, + pub last_error: Option, + pub provider_override: Option, + pub ui_variant: UiVariant, + pub camera: Camera, + pub camera_target: (f32, f32), + pub camera_velocity: (f32, f32), + pub time_cursor: TimeCursor, + pub temporal_focus: TemporalFocus, + pub pinned_target: Option, + pub spine: SpineState, + pub command_bar: CommandBarState, + pub toast: Option, +} + +impl Default for AppState { + fn default() -> Self { + Self { + screen_stack: vec![ScreenId::Launcher], + launcher: launcher::State::default(), + monitor: monitor::State::default(), + fleet_radar: radar::FleetRadarState::default(), + metrics: HashMap::new(), + last_metrics_poll_at: None, + clusters: HashMap::new(), + cluster_canvases: HashMap::new(), + agents: HashMap::new(), + agent_microscopes: HashMap::new(), + last_size: None, + tick_count: 0, + now_ms: 0, + anim_clock: AnimClock::default(), + last_tick_ms: None, + should_quit: false, + backend_status: BackendStatus::Disconnected, + last_error: None, + provider_override: None, + ui_variant: UiVariant::Classic, + camera: Camera::default(), + camera_target: (0.0, 0.0), + camera_velocity: (0.0, 0.0), + time_cursor: TimeCursor::default(), + temporal_focus: TemporalFocus::default(), + pinned_target: None, + spine: SpineState::default(), + command_bar: CommandBarState::default(), + toast: None, + } + } +} + +impl AppState { + pub fn new() -> Self { + Self::default() + } + + pub fn apply_startup_options(&mut self, options: StartupOptions) { + if let Some(provider) = options.provider_override { + self.provider_override = Some(provider); + } + + if let Some(ui_variant) = options.ui_variant { + self.ui_variant = ui_variant; + } + + let initial_screen = options.initial_screen; + if matches!(self.ui_variant, UiVariant::Disruptive) { + let mut stack = vec![ScreenId::IntentConsole]; + stack.push(ScreenId::FleetRadar); + self.screen_stack = stack; + } else if let Some(initial_screen) = initial_screen { + self.screen_stack = vec![ScreenId::Launcher]; + match initial_screen { + InitialScreen::Launcher => {} + InitialScreen::Monitor => { + self.screen_stack.push(ScreenId::Monitor); + } + } + } + } + + pub fn active_screen(&self) -> &ScreenId { + self.screen_stack.last().unwrap_or(&ScreenId::Launcher) + } + + pub fn temporal_focus_scope(&self) -> Option { + match self.active_screen() { + ScreenId::ClusterCanvas { id } => Some(TemporalFocus::Cluster { id: id.clone() }), + ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => Some(TemporalFocus::Agent { + cluster_id: cluster_id.clone(), + agent_id: agent_id.clone(), + }), + _ => None, + } + } + + pub fn zoom_stack_context(&self) -> ZoomStackContext { + for screen in self.screen_stack.iter().rev() { + match screen { + ScreenId::Agent { + cluster_id, + agent_id, + } + | ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => { + return ZoomStackContext::Agent { + cluster_id: cluster_id.clone(), + agent_id: agent_id.clone(), + }; + } + ScreenId::Cluster { id } | ScreenId::ClusterCanvas { id } => { + return ZoomStackContext::Cluster { id: id.clone() }; + } + ScreenId::Monitor | ScreenId::FleetRadar => { + return ZoomStackContext::FleetRadar; + } + ScreenId::Launcher | ScreenId::IntentConsole => {} + } + } + ZoomStackContext::Root + } + + pub fn command_context(&self) -> CommandContext { + CommandContext { + provider_override: self.provider_override.clone(), + active_screen: self.active_screen().clone(), + ui_variant: self.ui_variant, + } + } + + fn metrics_poll_due(&self, now_ms: i64) -> bool { + match self.last_metrics_poll_at { + None => true, + Some(last) => now_ms.saturating_sub(last) >= METRICS_POLL_INTERVAL_MS, + } + } + + fn mark_metrics_polled(&mut self, now_ms: i64) { + self.last_metrics_poll_at = Some(now_ms); + } + + fn ensure_screen_state(&mut self, screen: &ScreenId) { + match screen { + ScreenId::Launcher + | ScreenId::Monitor + | ScreenId::IntentConsole + | ScreenId::FleetRadar => {} + ScreenId::Cluster { id } => { + self.clusters.entry(id.clone()).or_default(); + } + ScreenId::ClusterCanvas { id } => { + self.clusters.entry(id.clone()).or_default(); + self.cluster_canvases.entry(id.clone()).or_default(); + } + ScreenId::Agent { + cluster_id, + agent_id, + } => { + let key = AgentKey::new(cluster_id.clone(), agent_id.clone()); + self.agents.entry(key).or_default(); + } + ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => { + let key = AgentKey::new(cluster_id.clone(), agent_id.clone()); + self.agents.entry(key.clone()).or_default(); + self.agent_microscopes.entry(key).or_default(); + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NavigationAction { + Push(ScreenId), + Pop, + ReplaceTop(ScreenId), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScreenAction { + Launcher(launcher::Action), + Monitor(monitor::Action), + FleetRadar(radar::Action), + Cluster { + id: String, + action: cluster::Action, + }, + ClusterCanvas { + id: String, + action: cluster_canvas::Action, + }, + Agent { + cluster_id: String, + agent_id: String, + action: agent::Action, + }, +} + +#[derive(Debug, Clone)] +pub enum BackendAction { + Connected, + ConnectionFailed(String), + BackendExited(BackendExit), + Notification(BackendNotification), + ClustersListed(Vec), + ClusterMetricsListed { + metrics: Vec, + }, + ClusterSummary { + summary: crate::protocol::ClusterSummary, + }, + ClusterTopology { + cluster_id: String, + topology: crate::protocol::ClusterTopology, + }, + ClusterTopologyError { + cluster_id: String, + message: String, + }, + SubscribedClusterLogs { + cluster_id: String, + agent_id: Option, + subscription_id: String, + }, + SubscribedClusterTimeline { + cluster_id: String, + subscription_id: String, + }, + GuidanceToAgentResult { + cluster_id: String, + agent_id: String, + result: crate::protocol::GuidanceDeliveryResult, + }, + GuidanceToAgentError { + cluster_id: String, + agent_id: String, + message: String, + }, + StartClusterResult { + cluster_id: String, + }, + Error(String), +} + +#[derive(Debug, Clone)] +pub enum Action { + Tick { now_ms: i64 }, + Resize { width: u16, height: u16 }, + Quit, + Navigate(NavigationAction), + Screen(ScreenAction), + Backend(BackendAction), + CommandBar(CommandBarAction), + Spine(SpineAction), + TimeCursor(TimeCursorAction), + Command(CommandAction), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Effect { + Backend(BackendRequest), + Command(CommandRequest), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BackendRequest { + ListClusters, + ListClusterMetrics { + cluster_ids: Option>, + }, + GetClusterSummary { + cluster_id: String, + }, + GetClusterTopology { + cluster_id: String, + }, + SubscribeClusterLogs { + cluster_id: String, + agent_id: Option, + }, + SubscribeClusterTimeline { + cluster_id: String, + }, + StartClusterFromText { + text: String, + provider_override: Option, + }, + StartClusterFromIssue { + reference: String, + provider_override: Option, + }, + SendGuidanceToCluster { + cluster_id: String, + message: String, + }, + SendGuidanceToAgent { + cluster_id: String, + agent_id: String, + message: String, + }, + Unsubscribe { + subscription_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandRequest { + SubmitRaw { + raw: String, + context: CommandContext, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandBarAction { + Open { prefill: String }, + Close, + InsertChar(char), + Backspace, + Delete, + MoveCursorLeft, + MoveCursorRight, + MoveCursorHome, + MoveCursorEnd, + Submit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpineAction { + SetMode(SpineMode), + SetHint(SpineHint), + SetCompletion(Option), + EnterMode { mode: SpineMode, prefill: String }, + Cancel, + Submit, + AcceptCompletion, + CycleCompletion, + InsertChar(char), + Backspace, + Delete, + MoveCursorLeft, + MoveCursorRight, + MoveCursorHome, + MoveCursorEnd, + Clear, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TimeCursorAction { + Step { delta_ms: i64 }, + JumpToLive, + ToggleFollow, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandAction { + ShowToast { + level: ToastLevel, + message: String, + }, + SetProviderOverride { + provider: Option, + }, + StartClusterFromIssue { + reference: String, + provider_override: Option, + }, + SendGuidance { + message: String, + prefix: Option, + }, + TogglePin, +} + +const TOAST_DURATION_MS: i64 = 5000; +const METRICS_POLL_INTERVAL_MS: i64 = 2000; +const CAMERA_ACCEL: f32 = 0.16; +const CAMERA_FRICTION: f32 = 0.82; +const CAMERA_SNAP_EPSILON: f32 = 0.08; + +pub fn update(mut state: AppState, action: Action) -> (AppState, Vec) { + let mut effects = Vec::new(); + match action { + Action::Tick { now_ms } => { + let dt_ms = clamp_tick_delta(state.last_tick_ms, now_ms); + state.last_tick_ms = Some(now_ms); + state.tick_count = state.tick_count.saturating_add(1); + state.now_ms = now_ms; + state.anim_clock.advance(now_ms); + if let Some(toast) = &state.toast { + if toast.expires_at_ms <= state.now_ms { + state.toast = None; + } + } + state.fleet_radar.tick_orb_smoothing(state.now_ms, dt_ms); + update_radar_camera_smoothing(&mut state, dt_ms); + for canvas_state in state.cluster_canvases.values_mut() { + canvas_state.tick_camera(dt_ms); + } + let should_poll = if matches!(state.ui_variant, UiVariant::Disruptive) { + state.fleet_radar.poll_due(now_ms) + } else { + match state.active_screen() { + ScreenId::Monitor => state.monitor.poll_due(now_ms), + ScreenId::FleetRadar => state.fleet_radar.poll_due(now_ms), + _ => false, + } + }; + if should_poll { + if matches!(state.ui_variant, UiVariant::Disruptive) { + state.fleet_radar.mark_polled(now_ms); + } else { + match state.active_screen() { + ScreenId::Monitor => state.monitor.mark_polled(now_ms), + ScreenId::FleetRadar => state.fleet_radar.mark_polled(now_ms), + _ => {} + } + } + effects.push(Effect::Backend(BackendRequest::ListClusters)); + } + let should_poll_metrics = matches!( + state.active_screen(), + ScreenId::Monitor + | ScreenId::FleetRadar + | ScreenId::Cluster { .. } + | ScreenId::ClusterCanvas { .. } + ) && state.metrics_poll_due(now_ms); + if should_poll_metrics { + if let Some(request) = metrics_request_for_screen(&state) { + state.mark_metrics_polled(now_ms); + effects.push(Effect::Backend(request)); + } + } + } + Action::Resize { width, height } => { + state.last_size = Some((width, height)); + } + Action::Quit => { + state.should_quit = true; + } + Action::Navigate(nav) => { + apply_navigation(&mut state, nav, &mut effects); + } + Action::Screen(screen_action) => { + handle_screen_action(&mut state, screen_action, &mut effects); + } + Action::Backend(backend_action) => { + handle_backend_action(&mut state, backend_action, &mut effects); + } + Action::CommandBar(command_action) => { + handle_command_bar_action(&mut state, command_action, &mut effects); + } + Action::Spine(spine_action) => { + handle_spine_action(&mut state, spine_action, &mut effects); + } + Action::TimeCursor(time_action) => { + handle_time_cursor_action(&mut state, time_action); + } + Action::Command(command_action) => { + handle_command_action(&mut state, command_action, &mut effects); + } + } + + (state, effects) +} + +fn apply_navigation(state: &mut AppState, nav: NavigationAction, effects: &mut Vec) { + match nav { + NavigationAction::Push(screen) => { + cleanup_active_screen(state, effects); + seed_agent_role_for_navigation(state, &screen); + state.ensure_screen_state(&screen); + if let ScreenId::ClusterCanvas { id } = &screen { + ensure_cluster_canvas_focus(state, id); + } + if matches!(screen, ScreenId::Monitor) { + state.monitor.mark_polled(state.now_ms); + } else if matches!(screen, ScreenId::FleetRadar) { + state.fleet_radar.mark_polled(state.now_ms); + } + state.screen_stack.push(screen.clone()); + queue_navigation_effects(&screen, effects); + } + NavigationAction::Pop => { + if state.screen_stack.len() > 1 { + cleanup_active_screen(state, effects); + state.screen_stack.pop(); + if let Some(active) = state.screen_stack.last() { + if matches!(active, ScreenId::Monitor) { + state.monitor.mark_polled(state.now_ms); + } else if matches!(active, ScreenId::FleetRadar) { + state.fleet_radar.mark_polled(state.now_ms); + } + queue_navigation_effects(active, effects); + } + } + } + NavigationAction::ReplaceTop(screen) => { + cleanup_active_screen(state, effects); + seed_agent_role_for_navigation(state, &screen); + state.ensure_screen_state(&screen); + if let ScreenId::ClusterCanvas { id } = &screen { + ensure_cluster_canvas_focus(state, id); + } + if matches!(screen, ScreenId::Monitor) { + state.monitor.mark_polled(state.now_ms); + } else if matches!(screen, ScreenId::FleetRadar) { + state.fleet_radar.mark_polled(state.now_ms); + } + if state.screen_stack.is_empty() { + state.screen_stack.push(screen.clone()); + } else { + let top = state.screen_stack.len() - 1; + state.screen_stack[top] = screen.clone(); + } + queue_navigation_effects(&screen, effects); + } + } + + apply_spine_defaults_for_screen(state); + refresh_spine_hint(state); + refresh_spine_completion(state); + sync_temporal_focus(state); +} + +fn ensure_cluster_canvas_focus(state: &mut AppState, id: &str) { + let Some(cluster_state) = state.clusters.get(id) else { + return; + }; + let Some(topology) = cluster_state.topology.as_ref() else { + return; + }; + if let Some(canvas_state) = state.cluster_canvases.get_mut(id) { + canvas_state.update_layout(topology); + } +} + +fn seed_agent_role_for_navigation(state: &mut AppState, screen: &ScreenId) { + let (cluster_id, agent_id) = match screen { + ScreenId::Agent { + cluster_id, + agent_id, + } + | ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => (cluster_id, agent_id), + _ => return, + }; + + let role = state.clusters.get(cluster_id).and_then(|cluster_state| { + cluster_state + .agents + .iter() + .find(|agent| agent.id == *agent_id) + .and_then(|agent| agent.role.clone()) + }); + seed_agent_role(state, cluster_id, agent_id, role); +} + +fn queue_navigation_effects(screen: &ScreenId, effects: &mut Vec) { + match screen { + ScreenId::Monitor | ScreenId::FleetRadar => { + effects.push(Effect::Backend(BackendRequest::ListClusters)); + } + ScreenId::Cluster { id } | ScreenId::ClusterCanvas { id } => { + effects.push(Effect::Backend(BackendRequest::GetClusterSummary { + cluster_id: id.clone(), + })); + effects.push(Effect::Backend(BackendRequest::GetClusterTopology { + cluster_id: id.clone(), + })); + effects.push(Effect::Backend(BackendRequest::SubscribeClusterLogs { + cluster_id: id.clone(), + agent_id: None, + })); + effects.push(Effect::Backend(BackendRequest::SubscribeClusterTimeline { + cluster_id: id.clone(), + })); + } + ScreenId::Agent { + cluster_id, + agent_id, + } => { + effects.push(Effect::Backend(BackendRequest::SubscribeClusterLogs { + cluster_id: cluster_id.clone(), + agent_id: Some(agent_id.clone()), + })); + } + ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => { + effects.push(Effect::Backend(BackendRequest::SubscribeClusterLogs { + cluster_id: cluster_id.clone(), + agent_id: Some(agent_id.clone()), + })); + effects.push(Effect::Backend(BackendRequest::SubscribeClusterTimeline { + cluster_id: cluster_id.clone(), + })); + } + ScreenId::Launcher | ScreenId::IntentConsole => {} + } +} + +fn handle_screen_action(state: &mut AppState, action: ScreenAction, effects: &mut Vec) { + match action { + ScreenAction::Launcher(action) => handle_launcher_action(state, action, effects), + ScreenAction::Monitor(action) => handle_monitor_action(state, action, effects), + ScreenAction::FleetRadar(action) => handle_radar_action(state, action, effects), + ScreenAction::Cluster { id, action } => handle_cluster_action(state, id, action, effects), + ScreenAction::ClusterCanvas { id, action } => { + handle_cluster_canvas_action(state, id, action, effects) + } + ScreenAction::Agent { + cluster_id, + agent_id, + action, + } => handle_agent_action(state, cluster_id, agent_id, action, effects), + } +} + +fn handle_launcher_action( + state: &mut AppState, + action: launcher::Action, + effects: &mut Vec, +) { + match action { + launcher::Action::Submit => { + let trimmed = state.launcher.input.trim(); + if trimmed.is_empty() { + state.last_error = Some("Enter text to start a cluster.".to_string()); + return; + } + + state.last_error = None; + if trimmed.starts_with('/') { + effects.push(Effect::Command(CommandRequest::SubmitRaw { + raw: trimmed.to_string(), + context: state.command_context(), + })); + } else { + effects.push(Effect::Backend(BackendRequest::StartClusterFromText { + text: trimmed.to_string(), + provider_override: state.provider_override.clone(), + })); + } + } + launcher::Action::InsertChar(ch) => { + state.launcher.insert_char(ch); + state.last_error = None; + } + launcher::Action::Backspace => { + state.launcher.backspace(); + state.last_error = None; + } + launcher::Action::Delete => { + state.launcher.delete(); + state.last_error = None; + } + launcher::Action::MoveCursorLeft => { + state.launcher.move_left(); + } + launcher::Action::MoveCursorRight => { + state.launcher.move_right(); + } + launcher::Action::MoveCursorHome => { + state.launcher.move_home(); + } + launcher::Action::MoveCursorEnd => { + state.launcher.move_end(); + } + } +} + +fn handle_monitor_action(state: &mut AppState, action: monitor::Action, effects: &mut Vec) { + match action { + monitor::Action::MoveSelection(delta) => { + state.monitor.move_selection(delta); + } + monitor::Action::OpenSelected => { + if let Some(cluster_id) = state.monitor.selected_cluster_id() { + apply_navigation( + state, + NavigationAction::Push(ScreenId::Cluster { id: cluster_id }), + effects, + ); + } + } + } +} + +fn handle_radar_action(state: &mut AppState, action: radar::Action, _effects: &mut Vec) { + match action { + radar::Action::MoveSelection { direction, speed } => { + if state + .fleet_radar + .move_selection_direction(state.now_ms, direction, speed) + { + sync_camera_to_selection(state); + } + } + radar::Action::CenterOnSelection => { + sync_camera_to_selection(state); + } + radar::Action::ResetView => { + state.camera = Camera::default(); + state.camera_target = state.camera.position; + state.camera_velocity = (0.0, 0.0); + } + } +} + +fn handle_cluster_action( + state: &mut AppState, + id: String, + action: cluster::Action, + effects: &mut Vec, +) { + match action { + cluster::Action::CycleFocus(direction) => { + let entry = state.clusters.entry(id).or_default(); + entry.cycle_focus(direction); + } + cluster::Action::MoveFocused(delta) => { + let entry = state.clusters.entry(id).or_default(); + entry.move_focused(delta); + } + cluster::Action::ActivateFocused => { + let (agent_id, role) = { + let entry = state.clusters.entry(id.clone()).or_default(); + let agent_id = entry.activate_focused(); + let role = agent_id.as_ref().and_then(|selected| { + entry + .agents + .iter() + .find(|agent| agent.id == *selected) + .and_then(|agent| agent.role.clone()) + }); + (agent_id, role) + }; + if let Some(agent_id) = agent_id { + seed_agent_role(state, &id, &agent_id, role); + apply_navigation( + state, + NavigationAction::Push(ScreenId::Agent { + cluster_id: id, + agent_id, + }), + effects, + ); + } + } + cluster::Action::OpenAgent(agent_id) => { + let role = { + let entry = state.clusters.entry(id.clone()).or_default(); + entry + .agents + .iter() + .find(|agent| agent.id == agent_id) + .and_then(|agent| agent.role.clone()) + }; + seed_agent_role(state, &id, &agent_id, role); + apply_navigation( + state, + NavigationAction::Push(ScreenId::Agent { + cluster_id: id, + agent_id, + }), + effects, + ); + } + } +} + +fn handle_cluster_canvas_action( + state: &mut AppState, + id: String, + action: cluster_canvas::Action, + effects: &mut Vec, +) { + match action { + cluster_canvas::Action::MoveFocus { direction, speed } => { + let entry = state.cluster_canvases.entry(id).or_default(); + entry.move_focus(direction, speed); + } + cluster_canvas::Action::ZoomIn => { + let agent_id = state + .cluster_canvases + .get(&id) + .and_then(|entry| entry.focused_agent_id()); + if let Some(agent_id) = agent_id { + let role = state.clusters.get(&id).and_then(|entry| { + entry + .agents + .iter() + .find(|agent| agent.id == agent_id) + .and_then(|agent| agent.role.clone()) + }); + seed_agent_role(state, &id, &agent_id, role); + apply_navigation( + state, + NavigationAction::Push(ScreenId::AgentMicroscope { + cluster_id: id, + agent_id, + }), + effects, + ); + } + } + } +} + +fn seed_agent_role(state: &mut AppState, cluster_id: &str, agent_id: &str, role: Option) { + if let Some(role) = role { + let key = AgentKey::new(cluster_id.to_string(), agent_id.to_string()); + let entry = state.agents.entry(key.clone()).or_default(); + if entry.role.is_none() { + entry.role = Some(role.clone()); + } + let microscope_entry = state.agent_microscopes.entry(key).or_default(); + if microscope_entry.role.is_none() { + microscope_entry.role = Some(role); + } + } +} + +fn handle_agent_action( + state: &mut AppState, + cluster_id: String, + agent_id: String, + action: agent::Action, + effects: &mut Vec, +) { + let key = AgentKey::new(cluster_id.clone(), agent_id.clone()); + let entry = state.agents.entry(key).or_default(); + match action { + agent::Action::SubmitGuidance => { + let trimmed = entry.guidance_input.input.trim(); + if trimmed.is_empty() { + entry.apply_guidance_error("Enter guidance text.".to_string()); + return; + } + entry.guidance_pending = true; + entry.last_guidance_error = None; + effects.push(Effect::Backend(BackendRequest::SendGuidanceToAgent { + cluster_id, + agent_id, + message: trimmed.to_string(), + })); + } + agent::Action::InsertChar(ch) => { + entry.guidance_input.insert_char(ch); + } + agent::Action::Backspace => { + entry.guidance_input.backspace(); + } + agent::Action::Delete => { + entry.guidance_input.delete(); + } + agent::Action::MoveCursorLeft => { + entry.guidance_input.move_left(); + } + agent::Action::MoveCursorRight => { + entry.guidance_input.move_right(); + } + agent::Action::MoveCursorHome => { + entry.guidance_input.move_home(); + } + agent::Action::MoveCursorEnd => { + entry.guidance_input.move_end(); + } + agent::Action::ScrollLogs(delta) => { + entry.move_log_scroll(delta); + } + } +} + +fn handle_backend_action(state: &mut AppState, action: BackendAction, effects: &mut Vec) { + match action { + BackendAction::Connected => handle_backend_connected(state), + BackendAction::ConnectionFailed(message) => { + handle_backend_connection_failed(state, message) + } + BackendAction::BackendExited(exit) => handle_backend_exited(state, exit), + BackendAction::Notification(notification) => { + handle_backend_notification(state, notification) + } + BackendAction::ClustersListed(clusters) => handle_clusters_listed(state, clusters), + BackendAction::ClusterMetricsListed { metrics } => { + handle_cluster_metrics_listed(state, metrics) + } + BackendAction::ClusterSummary { summary } => handle_cluster_summary(state, summary), + BackendAction::ClusterTopology { + cluster_id, + topology, + } => handle_cluster_topology(state, cluster_id, topology), + BackendAction::ClusterTopologyError { + cluster_id, + message, + } => handle_cluster_topology_error(state, cluster_id, message), + BackendAction::SubscribedClusterLogs { + cluster_id, + agent_id, + subscription_id, + } => handle_log_subscription(state, cluster_id, agent_id, subscription_id), + BackendAction::SubscribedClusterTimeline { + cluster_id, + subscription_id, + } => handle_cluster_timeline_subscription(state, cluster_id, subscription_id), + BackendAction::GuidanceToAgentResult { + cluster_id, + agent_id, + result, + } => handle_guidance_result(state, cluster_id, agent_id, result), + BackendAction::GuidanceToAgentError { + cluster_id, + agent_id, + message, + } => handle_guidance_error(state, cluster_id, agent_id, message), + BackendAction::StartClusterResult { cluster_id } => { + handle_start_cluster_result(state, cluster_id, effects) + } + BackendAction::Error(message) => handle_backend_error(state, message), + } +} + +fn handle_backend_connected(state: &mut AppState) { + state.backend_status = BackendStatus::Connected; +} + +fn handle_backend_connection_failed(state: &mut AppState, message: String) { + state.backend_status = BackendStatus::Error(message.clone()); + state.last_error = Some(message); +} + +fn handle_backend_exited(state: &mut AppState, exit: BackendExit) { + state.backend_status = BackendStatus::Exited(exit.clone()); + state.last_error = Some(exit.message); +} + +fn handle_backend_notification(state: &mut AppState, notification: BackendNotification) { + match notification { + BackendNotification::ClusterLogLines(params) => { + let latest_ts = params.lines.iter().map(|line| line.timestamp).max(); + let lines = params.lines; + let dropped_count = params.dropped_count; + let role_from_lines = lines.iter().find_map(|line| line.role.clone()); + if let Some((key, entry)) = state.agent_microscopes.iter_mut().find(|(_, entry)| { + entry.log_subscription.as_deref() == Some(params.subscription_id.as_str()) + }) { + if entry.role.is_none() { + if let Some(role) = role_from_lines.clone() { + entry.role = Some(role); + } + } + entry.push_log_lines(lines, dropped_count); + if let Some(role) = role_from_lines { + let entry = state.agents.entry(key.clone()).or_default(); + if entry.role.is_none() { + entry.role = Some(role); + } + } + advance_time_cursor_if_live(state, latest_ts); + return; + } + + if let Some(entry) = state.agents.values_mut().find(|agent| { + agent.log_subscription.as_deref() == Some(params.subscription_id.as_str()) + }) { + if entry.role.is_none() { + if let Some(role) = role_from_lines.clone() { + entry.role = Some(role); + } + } + entry.push_log_lines(lines, dropped_count); + advance_time_cursor_if_live(state, latest_ts); + return; + } + + if let Some(entry) = state.clusters.get_mut(¶ms.cluster_id) { + if entry.log_subscription.as_deref() == Some(params.subscription_id.as_str()) { + entry.push_log_lines(lines, dropped_count); + advance_time_cursor_if_live(state, latest_ts); + } + } + } + BackendNotification::ClusterTimelineEvents(params) => { + let latest_ts = params.events.iter().map(|event| event.timestamp).max(); + let entry = state.clusters.entry(params.cluster_id).or_default(); + entry.push_timeline_events(params.events); + advance_time_cursor_if_live(state, latest_ts); + } + BackendNotification::Unknown { method, .. } => { + state.last_error = Some(format!("Unhandled backend notification: {method}")); + } + } +} + +fn advance_time_cursor_if_live(state: &mut AppState, latest_ts: Option) { + if state.time_cursor.mode != TimeCursorMode::Live { + return; + } + let Some(latest_ts) = latest_ts else { + return; + }; + if latest_ts > state.time_cursor.t_ms { + state.time_cursor.t_ms = latest_ts; + } +} + +fn handle_clusters_listed(state: &mut AppState, clusters: Vec) { + let radar_clusters = clusters.clone(); + state.monitor.set_clusters(clusters, state.now_ms); + state.fleet_radar.set_clusters(radar_clusters, state.now_ms); + sync_camera_to_selection(state); + let ids: HashSet = state + .monitor + .clusters + .iter() + .map(|cluster| cluster.id.clone()) + .collect(); + state.metrics.retain(|id, _| ids.contains(id)); +} + +fn sync_camera_to_selection(state: &mut AppState) { + if let Some(layout) = state.fleet_radar.selected_layout(state.now_ms) { + state.camera_target = (layout.x as f32, layout.y as f32); + state.camera_velocity = (0.0, 0.0); + } +} + +fn update_radar_camera_smoothing(state: &mut AppState, dt_ms: i64) { + let (position, velocity) = step_spring_f32( + state.camera.position, + state.camera_velocity, + state.camera_target, + dt_ms, + CAMERA_ACCEL, + CAMERA_FRICTION, + ); + state.camera.position = position; + state.camera_velocity = velocity; + + let dx = state.camera.position.0 - state.camera_target.0; + let dy = state.camera.position.1 - state.camera_target.1; + if dx.abs() <= CAMERA_SNAP_EPSILON && dy.abs() <= CAMERA_SNAP_EPSILON { + state.camera.position = state.camera_target; + state.camera_velocity = (0.0, 0.0); + } +} + +fn handle_cluster_metrics_listed(state: &mut AppState, metrics: Vec) { + for metric in metrics { + state.metrics.insert(metric.id.clone(), metric); + } +} + +fn handle_cluster_summary(state: &mut AppState, summary: crate::protocol::ClusterSummary) { + let entry = state.clusters.entry(summary.id.clone()).or_default(); + entry.summary = Some(summary); +} + +fn handle_cluster_topology( + state: &mut AppState, + cluster_id: String, + topology: crate::protocol::ClusterTopology, +) { + let canvas_entry = state + .cluster_canvases + .entry(cluster_id.clone()) + .or_default(); + canvas_entry.update_layout(&topology); + + let entry = state.clusters.entry(cluster_id).or_default(); + entry.topology = Some(topology); + entry.topology_error = None; +} + +fn handle_cluster_topology_error(state: &mut AppState, cluster_id: String, message: String) { + let entry = state.clusters.entry(cluster_id.clone()).or_default(); + entry.topology = None; + entry.topology_error = Some(message); + + if let Some(canvas_entry) = state.cluster_canvases.get_mut(&cluster_id) { + canvas_entry.clear_layout(); + } +} + +fn handle_log_subscription( + state: &mut AppState, + cluster_id: String, + agent_id: Option, + subscription_id: String, +) { + match agent_id { + Some(agent_id) => { + let key = AgentKey::new(cluster_id, agent_id); + let active_is_microscope = matches!( + state.active_screen(), + ScreenId::AgentMicroscope { cluster_id, agent_id } + if cluster_id == &key.cluster_id && agent_id == &key.agent_id + ); + if active_is_microscope { + let entry = state.agent_microscopes.entry(key).or_default(); + entry.log_subscription = Some(subscription_id); + } else { + let entry = state.agents.entry(key).or_default(); + entry.log_subscription = Some(subscription_id); + } + } + None => { + let entry = state.clusters.entry(cluster_id.clone()).or_default(); + entry.log_subscription = Some(subscription_id.clone()); + if let Some(canvas_entry) = state.cluster_canvases.get_mut(&cluster_id) { + canvas_entry.log_subscription = Some(subscription_id); + } + } + } +} + +fn handle_cluster_timeline_subscription( + state: &mut AppState, + cluster_id: String, + subscription_id: String, +) { + let entry = state.clusters.entry(cluster_id.clone()).or_default(); + entry.timeline_subscription = Some(subscription_id.clone()); + if let Some(canvas_entry) = state.cluster_canvases.get_mut(&cluster_id) { + canvas_entry.timeline_subscription = Some(subscription_id); + } +} + +fn handle_guidance_result( + state: &mut AppState, + cluster_id: String, + agent_id: String, + result: crate::protocol::GuidanceDeliveryResult, +) { + let key = AgentKey::new(cluster_id, agent_id); + let entry = state.agents.entry(key).or_default(); + entry.apply_guidance_result(result); +} + +fn handle_guidance_error( + state: &mut AppState, + cluster_id: String, + agent_id: String, + message: String, +) { + let key = AgentKey::new(cluster_id, agent_id); + let entry = state.agents.entry(key).or_default(); + entry.apply_guidance_error(message.clone()); + state.last_error = Some(message); +} + +fn handle_start_cluster_result( + state: &mut AppState, + cluster_id: String, + effects: &mut Vec, +) { + state.launcher.clear(); + let screen = if matches!(state.ui_variant, UiVariant::Disruptive) { + ScreenId::ClusterCanvas { id: cluster_id } + } else { + ScreenId::Cluster { id: cluster_id } + }; + apply_navigation(state, NavigationAction::Push(screen), effects); +} + +fn handle_backend_error(state: &mut AppState, message: String) { + state.last_error = Some(message); +} + +fn handle_command_bar_action( + state: &mut AppState, + action: CommandBarAction, + effects: &mut Vec, +) { + match action { + CommandBarAction::Open { prefill } => { + state.command_bar.open_with(prefill); + } + CommandBarAction::Close => { + state.command_bar.close(); + } + CommandBarAction::InsertChar(ch) => { + if state.command_bar.active { + state.command_bar.insert_char(ch); + } + } + CommandBarAction::Backspace => { + if state.command_bar.active { + state.command_bar.backspace(); + } + } + CommandBarAction::Delete => { + if state.command_bar.active { + state.command_bar.delete(); + } + } + CommandBarAction::MoveCursorLeft => { + if state.command_bar.active { + state.command_bar.move_left(); + } + } + CommandBarAction::MoveCursorRight => { + if state.command_bar.active { + state.command_bar.move_right(); + } + } + CommandBarAction::MoveCursorHome => { + if state.command_bar.active { + state.command_bar.move_home(); + } + } + CommandBarAction::MoveCursorEnd => { + if state.command_bar.active { + state.command_bar.move_end(); + } + } + CommandBarAction::Submit => { + let raw = state.command_bar.input().to_string(); + let context = state.command_context(); + state.command_bar.close(); + effects.push(Effect::Command(CommandRequest::SubmitRaw { raw, context })); + } + } +} + +fn set_spine_input(state: &mut AppState, value: String) { + state.spine.input.input = value; + state.spine.input.cursor = state.spine.input.input.chars().count(); +} + +fn reset_spine_state(state: &mut AppState) { + state.spine.mode = SpineMode::Intent; + state.spine.input.clear(); + state.spine.completion = None; + state.spine.hint = SpineHint::default(); + apply_spine_defaults_for_screen(state); +} + +fn set_disruptive_spine_toast(state: &mut AppState, level: ToastLevel, message: String) { + if !matches!(state.ui_variant, UiVariant::Disruptive) { + return; + } + state.toast = Some(ToastState { + message, + level, + expires_at_ms: state.now_ms.saturating_add(TOAST_DURATION_MS), + }); +} + +fn spine_idle(state: &SpineState) -> bool { + matches!(state.mode, SpineMode::Intent) + && state.input.input.is_empty() + && state.completion.is_none() +} + +fn apply_spine_defaults_for_screen(state: &mut AppState) { + if matches!(state.active_screen(), ScreenId::AgentMicroscope { .. }) && spine_idle(&state.spine) + { + state.spine.mode = SpineMode::WhisperAgent; + } +} + +fn refresh_spine_hint(state: &mut AppState) { + state.spine.hint = compute_spine_hint(state); +} + +fn refresh_spine_completion(state: &mut AppState) { + state.spine.completion = build_spine_completion( + state.spine.mode, + state.spine.input.input.as_str(), + state.spine.input.cursor, + ); +} + +fn sync_temporal_focus(state: &mut AppState) { + if !state.temporal_focus.is_active() { + return; + } + state.temporal_focus = state.temporal_focus_scope().unwrap_or(TemporalFocus::None); +} + +fn detect_issue_reference(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.chars().all(|ch| ch.is_ascii_digit()) { + return Some(trimmed.to_string()); + } + if let Some(reference) = parse_owner_repo_issue(trimmed) { + return Some(reference); + } + parse_github_issue_url(trimmed) +} + +fn parse_owner_repo_issue(input: &str) -> Option { + let mut parts = input.split('#'); + let repo_ref = parts.next()?; + let number = parts.next()?; + if parts.next().is_some() { + return None; + } + if number.is_empty() || !number.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + let mut repo_parts = repo_ref.split('/'); + let owner = repo_parts.next()?; + let repo = repo_parts.next()?; + if owner.is_empty() || repo.is_empty() { + return None; + } + if repo_parts.next().is_some() { + return None; + } + Some(format!("{owner}/{repo}#{number}")) +} + +fn parse_github_issue_url(input: &str) -> Option { + let trimmed = input.trim(); + let without_scheme = trimmed + .strip_prefix("https://") + .or_else(|| trimmed.strip_prefix("http://")) + .unwrap_or(trimmed); + let without_host = without_scheme.strip_prefix("github.com/")?; + let mut parts = without_host.split('/'); + let owner = parts.next()?; + let repo = parts.next()?; + let issues = parts.next()?; + if owner.is_empty() || repo.is_empty() || issues != "issues" { + return None; + } + let number_segment = parts.next()?; + let number = number_segment.split(['?', '#']).next().unwrap_or(""); + if number.is_empty() || !number.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + Some(format!("{owner}/{repo}#{number}")) +} + +fn resolve_spine_cluster_target(state: &AppState) -> Option { + match state.zoom_stack_context() { + ZoomStackContext::Agent { cluster_id, .. } => Some(cluster_id), + ZoomStackContext::Cluster { id } => Some(id), + ZoomStackContext::FleetRadar => match state.active_screen() { + ScreenId::Monitor => state.monitor.selected_cluster_id(), + _ => state.fleet_radar.selected_cluster_id(), + }, + ZoomStackContext::Root => None, + } +} + +fn resolve_spine_agent_target(state: &AppState) -> Option<(String, String)> { + match state.zoom_stack_context() { + ZoomStackContext::Agent { + cluster_id, + agent_id, + } => Some((cluster_id, agent_id)), + ZoomStackContext::Cluster { id } => { + let cluster_state = state.clusters.get(&id)?; + let agent = cluster_state.agents.get(cluster_state.selected_agent)?; + Some((id, agent.id.clone())) + } + ZoomStackContext::FleetRadar | ZoomStackContext::Root => None, + } +} + +fn handle_spine_action(state: &mut AppState, action: SpineAction, effects: &mut Vec) { + let mut should_refresh_hint = false; + let mut should_refresh_completion = false; + match action { + SpineAction::SetMode(mode) => { + state.spine.mode = mode; + should_refresh_hint = true; + should_refresh_completion = true; + } + SpineAction::SetHint(hint) => { + state.spine.hint = hint; + } + SpineAction::SetCompletion(completion) => { + state.spine.completion = completion; + } + SpineAction::EnterMode { mode, prefill } => { + state.spine.mode = mode; + set_spine_input(state, prefill); + state.spine.completion = None; + should_refresh_hint = true; + should_refresh_completion = true; + } + SpineAction::Cancel => { + reset_spine_state(state); + should_refresh_hint = true; + should_refresh_completion = true; + } + SpineAction::Submit => { + let mode = state.spine.mode; + let raw_input = state.spine.input.input.clone(); + let trimmed = raw_input.trim(); + let mut toast_message: Option = None; + match mode { + SpineMode::Command => { + let raw = if raw_input.starts_with('/') { + raw_input + } else { + format!("/{}", raw_input) + }; + let context = state.command_context(); + effects.push(Effect::Command(CommandRequest::SubmitRaw { raw, context })); + } + SpineMode::Intent => { + if !trimmed.is_empty() { + if let Some(reference) = detect_issue_reference(trimmed) { + effects.push(Effect::Backend(BackendRequest::StartClusterFromIssue { + reference: reference.clone(), + provider_override: state.provider_override.clone(), + })); + toast_message = + Some(format!("Starting cluster from issue {reference}...")); + } else { + effects.push(Effect::Backend(BackendRequest::StartClusterFromText { + text: trimmed.to_string(), + provider_override: state.provider_override.clone(), + })); + toast_message = Some("Starting cluster...".to_string()); + } + } + } + SpineMode::WhisperCluster => { + if !trimmed.is_empty() { + if let Some(cluster_id) = resolve_spine_cluster_target(state) { + effects.push(Effect::Backend(BackendRequest::SendGuidanceToCluster { + cluster_id, + message: trimmed.to_string(), + })); + toast_message = Some("Whisper sent to cluster.".to_string()); + } + } + } + SpineMode::WhisperAgent => { + if !trimmed.is_empty() { + if let Some((cluster_id, agent_id)) = resolve_spine_agent_target(state) { + effects.push(Effect::Backend(BackendRequest::SendGuidanceToAgent { + cluster_id, + agent_id, + message: trimmed.to_string(), + })); + toast_message = Some("Whisper sent to agent.".to_string()); + } + } + } + } + if let Some(message) = toast_message { + set_disruptive_spine_toast(state, ToastLevel::Success, message); + } + reset_spine_state(state); + should_refresh_hint = true; + should_refresh_completion = true; + } + SpineAction::AcceptCompletion => { + if let Some(completion) = state.spine.completion.take() { + if !completion.ghost.is_empty() { + state.spine.input.input.push_str(completion.ghost.as_str()); + state.spine.input.cursor = state.spine.input.input.chars().count(); + } + } + should_refresh_hint = true; + should_refresh_completion = true; + } + SpineAction::CycleCompletion => { + if let Some(completion) = state.spine.completion.as_ref() { + if completion.candidates.len() > 1 { + let next = (completion.selected + 1) % completion.candidates.len(); + state.spine.completion = select_spine_completion( + state.spine.mode, + state.spine.input.input.as_str(), + state.spine.input.cursor, + next, + ); + } + } + } + SpineAction::InsertChar(ch) => { + state.spine.input.insert_char(ch); + should_refresh_hint = true; + should_refresh_completion = true; + } + SpineAction::Backspace => { + state.spine.input.backspace(); + should_refresh_hint = true; + should_refresh_completion = true; + } + SpineAction::Delete => { + state.spine.input.delete(); + should_refresh_hint = true; + should_refresh_completion = true; + } + SpineAction::MoveCursorLeft => { + state.spine.input.move_left(); + should_refresh_completion = true; + } + SpineAction::MoveCursorRight => { + state.spine.input.move_right(); + should_refresh_completion = true; + } + SpineAction::MoveCursorHome => { + state.spine.input.move_home(); + should_refresh_completion = true; + } + SpineAction::MoveCursorEnd => { + state.spine.input.move_end(); + should_refresh_completion = true; + } + SpineAction::Clear => { + state.spine.input.clear(); + state.spine.completion = None; + should_refresh_hint = true; + should_refresh_completion = true; + } + } + + if should_refresh_completion { + refresh_spine_completion(state); + } + if should_refresh_hint { + refresh_spine_hint(state); + } +} + +fn handle_time_cursor_action(state: &mut AppState, action: TimeCursorAction) { + if !state.temporal_focus.is_active() { + match action { + TimeCursorAction::ToggleFollow => { + let Some(scope) = state.temporal_focus_scope() else { + return; + }; + state.temporal_focus = scope; + } + _ => return, + } + } + + let bounds = time_bounds_for_focus(state); + match action { + TimeCursorAction::Step { delta_ms } => { + let Some((min, max)) = bounds else { + return; + }; + let next = state.time_cursor.t_ms.saturating_add(delta_ms); + state.time_cursor.t_ms = next.clamp(min, max); + state.time_cursor.mode = TimeCursorMode::Scrub; + } + TimeCursorAction::JumpToLive => { + state.time_cursor.mode = TimeCursorMode::Live; + if let Some((_, max)) = bounds { + state.time_cursor.t_ms = max; + } + } + TimeCursorAction::ToggleFollow => { + if matches!(state.time_cursor.mode, TimeCursorMode::Live) { + state.time_cursor.mode = TimeCursorMode::Scrub; + } else { + state.time_cursor.mode = TimeCursorMode::Live; + if let Some((_, max)) = bounds { + state.time_cursor.t_ms = max; + } + } + } + } + + if let Some((min, max)) = bounds { + state.time_cursor.t_ms = state.time_cursor.t_ms.clamp(min, max); + } + + if matches!(state.time_cursor.mode, TimeCursorMode::Live) { + state.temporal_focus = TemporalFocus::None; + } +} + +fn time_bounds_for_focus(state: &AppState) -> Option<(i64, i64)> { + match &state.temporal_focus { + TemporalFocus::None => None, + TemporalFocus::Cluster { id } => time_bounds_for_cluster(state, id, None), + TemporalFocus::Agent { + cluster_id, + agent_id, + } => time_bounds_for_agent_microscope(state, cluster_id, agent_id) + .or_else(|| time_bounds_for_cluster(state, cluster_id, Some(agent_id.as_str()))), + } +} + +fn time_bounds_for_agent_microscope( + state: &AppState, + cluster_id: &str, + agent_id: &str, +) -> Option<(i64, i64)> { + let key = AgentKey::new(cluster_id.to_string(), agent_id.to_string()); + let entry = state.agent_microscopes.get(&key)?; + let mut min: Option = None; + let mut max: Option = None; + for line in entry.logs_time.iter() { + min = Some(min.map_or(line.timestamp, |value| value.min(line.timestamp))); + max = Some(max.map_or(line.timestamp, |value| value.max(line.timestamp))); + } + match (min, max) { + (Some(min), Some(max)) => Some((min, max)), + _ => None, + } +} + +fn time_bounds_for_cluster( + state: &AppState, + cluster_id: &str, + agent_id: Option<&str>, +) -> Option<(i64, i64)> { + let cluster_state = state.clusters.get(cluster_id)?; + let mut min: Option = None; + let mut max: Option = None; + let update = |ts: i64, min: &mut Option, max: &mut Option| { + *min = Some(min.map_or(ts, |value| value.min(ts))); + *max = Some(max.map_or(ts, |value| value.max(ts))); + }; + + for line in cluster_state.logs_time.iter() { + if let Some(agent_id) = agent_id { + let matches_agent = + line.agent.as_deref() == Some(agent_id) || line.sender.as_deref() == Some(agent_id); + if !matches_agent { + continue; + } + } + update(line.timestamp, &mut min, &mut max); + } + + if agent_id.is_none() { + for event in cluster_state.timeline_time.iter() { + update(event.timestamp, &mut min, &mut max); + } + } + + match (min, max) { + (Some(min), Some(max)) => Some((min, max)), + _ => None, + } +} + +fn build_guidance_message(prefix: Option<&str>, message: &str) -> String { + let trimmed = message.trim(); + match prefix { + Some(prefix) if trimmed.is_empty() => prefix.to_string(), + Some(prefix) => format!("{prefix} {trimmed}"), + None => trimmed.to_string(), + } +} + +fn resolve_canvas_focused_agent(state: &AppState, cluster_id: &str) -> Option { + let canvas_state = state.cluster_canvases.get(cluster_id)?; + let focused_id = canvas_state.focused_id.as_deref()?; + if let Some(layout) = canvas_state.layout.as_ref() { + if let Some(node) = layout.nodes.get(focused_id) { + if matches!(node.kind, cluster_canvas::NodeKind::Agent) { + return Some(node.id.clone()); + } + } + } + let cluster_state = state.clusters.get(cluster_id)?; + let topology = cluster_state.topology.as_ref()?; + if topology.agents.iter().any(|agent| agent.id == focused_id) { + Some(focused_id.to_string()) + } else { + None + } +} + +fn resolve_focus_target(state: &AppState) -> Option { + match state.active_screen() { + ScreenId::Agent { + cluster_id, + agent_id, + } + | ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => Some(FocusTarget::Agent { + cluster_id: cluster_id.clone(), + agent_id: agent_id.clone(), + }), + ScreenId::ClusterCanvas { id } => { + if let Some(agent_id) = resolve_canvas_focused_agent(state, id) { + return Some(FocusTarget::Agent { + cluster_id: id.clone(), + agent_id, + }); + } + Some(FocusTarget::Cluster { id: id.clone() }) + } + ScreenId::Cluster { id } => Some(FocusTarget::Cluster { id: id.clone() }), + ScreenId::Monitor => state + .monitor + .selected_cluster_id() + .map(|id| FocusTarget::Cluster { id }), + ScreenId::FleetRadar => state + .fleet_radar + .selected_cluster_id() + .map(|id| FocusTarget::Cluster { id }), + ScreenId::Launcher | ScreenId::IntentConsole => None, + } +} + +fn handle_command_action(state: &mut AppState, action: CommandAction, effects: &mut Vec) { + match action { + CommandAction::ShowToast { level, message } => { + state.toast = Some(ToastState { + message, + level, + expires_at_ms: state.now_ms.saturating_add(TOAST_DURATION_MS), + }); + } + CommandAction::SetProviderOverride { provider } => { + state.provider_override = provider; + refresh_spine_hint(state); + } + CommandAction::StartClusterFromIssue { + reference, + provider_override, + } => { + effects.push(Effect::Backend(BackendRequest::StartClusterFromIssue { + reference, + provider_override, + })); + } + CommandAction::SendGuidance { message, prefix } => { + let Some(target) = resolve_focus_target(state) else { + state.toast = Some(ToastState { + message: "No focused cluster or agent.".to_string(), + level: ToastLevel::Error, + expires_at_ms: state.now_ms.saturating_add(TOAST_DURATION_MS), + }); + return; + }; + let built = build_guidance_message(prefix.as_deref(), &message); + if built.trim().is_empty() { + state.toast = Some(ToastState { + message: "Guidance text is required.".to_string(), + level: ToastLevel::Error, + expires_at_ms: state.now_ms.saturating_add(TOAST_DURATION_MS), + }); + return; + } + let toast_message = match &target { + FocusTarget::Cluster { id } => { + effects.push(Effect::Backend(BackendRequest::SendGuidanceToCluster { + cluster_id: id.clone(), + message: built.clone(), + })); + format!("Guidance sent to cluster {id}.") + } + FocusTarget::Agent { + cluster_id, + agent_id, + } => { + effects.push(Effect::Backend(BackendRequest::SendGuidanceToAgent { + cluster_id: cluster_id.clone(), + agent_id: agent_id.clone(), + message: built.clone(), + })); + format!("Guidance sent to agent {agent_id} @ {cluster_id}.") + } + }; + state.toast = Some(ToastState { + message: toast_message, + level: ToastLevel::Success, + expires_at_ms: state.now_ms.saturating_add(TOAST_DURATION_MS), + }); + } + CommandAction::TogglePin => { + if !matches!(state.ui_variant, UiVariant::Disruptive) { + state.toast = Some(ToastState { + message: "Pinning is only available in Disruptive UI.".to_string(), + level: ToastLevel::Error, + expires_at_ms: state.now_ms.saturating_add(TOAST_DURATION_MS), + }); + return; + } + let Some(target) = resolve_focus_target(state) else { + state.toast = Some(ToastState { + message: "No focus target to pin.".to_string(), + level: ToastLevel::Error, + expires_at_ms: state.now_ms.saturating_add(TOAST_DURATION_MS), + }); + return; + }; + let message = if state.pinned_target.as_ref() == Some(&target) { + state.pinned_target = None; + format!("Unpinned {}.", target.label()) + } else { + state.pinned_target = Some(target.clone()); + format!("Pinned {}.", target.label()) + }; + state.toast = Some(ToastState { + message, + level: ToastLevel::Success, + expires_at_ms: state.now_ms.saturating_add(TOAST_DURATION_MS), + }); + } + } +} + +fn cleanup_active_screen(state: &mut AppState, effects: &mut Vec) { + let active = state.screen_stack.last().cloned(); + match active { + Some(ScreenId::Cluster { id }) | Some(ScreenId::ClusterCanvas { id }) => { + cleanup_cluster_subscriptions(state, &id, effects) + } + Some(ScreenId::Agent { + cluster_id, + agent_id, + }) => cleanup_agent_subscriptions(state, &cluster_id, &agent_id, effects), + Some(ScreenId::AgentMicroscope { + cluster_id, + agent_id, + }) => { + cleanup_agent_subscriptions(state, &cluster_id, &agent_id, effects); + cleanup_cluster_timeline_subscription(state, &cluster_id, effects); + } + _ => {} + } +} + +fn cleanup_cluster_subscriptions(state: &mut AppState, id: &str, effects: &mut Vec) { + let Some(entry) = state.clusters.get_mut(id) else { + return; + }; + if let Some(subscription_id) = entry.log_subscription.take() { + effects.push(Effect::Backend(BackendRequest::Unsubscribe { + subscription_id, + })); + } + if let Some(subscription_id) = entry.timeline_subscription.take() { + effects.push(Effect::Backend(BackendRequest::Unsubscribe { + subscription_id, + })); + } + if let Some(canvas_entry) = state.cluster_canvases.get_mut(id) { + canvas_entry.log_subscription = None; + canvas_entry.timeline_subscription = None; + } +} + +fn cleanup_cluster_timeline_subscription( + state: &mut AppState, + id: &str, + effects: &mut Vec, +) { + let Some(entry) = state.clusters.get_mut(id) else { + return; + }; + if let Some(subscription_id) = entry.timeline_subscription.take() { + effects.push(Effect::Backend(BackendRequest::Unsubscribe { + subscription_id, + })); + } + if let Some(canvas_entry) = state.cluster_canvases.get_mut(id) { + canvas_entry.timeline_subscription = None; + } +} + +fn cleanup_agent_subscriptions( + state: &mut AppState, + cluster_id: &str, + agent_id: &str, + effects: &mut Vec, +) { + let key = AgentKey::new(cluster_id.to_string(), agent_id.to_string()); + let mut unsubscribed: HashSet = HashSet::new(); + if let Some(entry) = state.agents.get_mut(&key) { + if let Some(subscription_id) = entry.log_subscription.take() { + unsubscribed.insert(subscription_id.clone()); + effects.push(Effect::Backend(BackendRequest::Unsubscribe { + subscription_id, + })); + } + } + if let Some(entry) = state.agent_microscopes.get_mut(&key) { + if let Some(subscription_id) = entry.log_subscription.take() { + if !unsubscribed.contains(&subscription_id) { + effects.push(Effect::Backend(BackendRequest::Unsubscribe { + subscription_id, + })); + } + } + } +} + +fn metrics_request_for_screen(state: &AppState) -> Option { + match state.active_screen() { + ScreenId::Monitor | ScreenId::FleetRadar => { + let ids: Vec = state + .monitor + .clusters + .iter() + .map(|cluster| cluster.id.clone()) + .collect(); + if ids.is_empty() { + None + } else { + Some(BackendRequest::ListClusterMetrics { + cluster_ids: Some(ids), + }) + } + } + ScreenId::Cluster { id } | ScreenId::ClusterCanvas { id } => { + Some(BackendRequest::ListClusterMetrics { + cluster_ids: Some(vec![id.clone()]), + }) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands; + use crate::protocol::{ + ClusterLogLine, ClusterLogLinesParams, ClusterSummary, ClusterTimelineEventsParams, + TimelineEvent, + }; + + fn radar_cluster(id: &str) -> ClusterSummary { + ClusterSummary { + id: id.to_string(), + state: "running".to_string(), + provider: None, + created_at: 0, + agent_count: 1, + message_count: 0, + cwd: None, + } + } + + fn apply_actions(mut state: AppState, actions: Vec) -> (AppState, Vec) { + let mut effects = Vec::new(); + for action in actions { + let (next_state, next_effects) = update(state, action); + state = next_state; + effects.extend(next_effects); + } + (state, effects) + } + + #[test] + fn provider_override_applies_to_issue_start() { + let state = AppState::default(); + let actions = commands::dispatch(CommandRequest::SubmitRaw { + raw: "/provider codex".to_string(), + context: state.command_context(), + }) + .expect("dispatch provider"); + let (state, _) = apply_actions(state, actions); + assert_eq!(state.provider_override, Some("codex".to_string())); + + let actions = commands::dispatch(CommandRequest::SubmitRaw { + raw: "/issue org/repo#123".to_string(), + context: state.command_context(), + }) + .expect("dispatch issue"); + let (_state, effects) = apply_actions(state, actions); + let mut found = false; + for effect in effects { + if let Effect::Backend(BackendRequest::StartClusterFromIssue { + reference, + provider_override, + }) = effect + { + found = true; + assert_eq!(reference, "org/repo#123"); + assert_eq!(provider_override, Some("codex".to_string())); + } + } + assert!(found, "expected StartClusterFromIssue effect"); + } + + #[test] + fn command_guidance_sends_to_agent_with_prefix() { + let mut state = AppState::default(); + state.screen_stack = vec![ScreenId::Agent { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + }]; + + let (_state, effects) = update( + state, + Action::Command(CommandAction::SendGuidance { + message: "hi".to_string(), + prefix: Some("[nudge]".to_string()), + }), + ); + + assert!( + effects.contains(&Effect::Backend(BackendRequest::SendGuidanceToAgent { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + message: "[nudge] hi".to_string(), + })) + ); + } + + #[test] + fn pin_toggles_pinned_target() { + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + state.screen_stack = vec![ScreenId::FleetRadar]; + state + .fleet_radar + .set_clusters(vec![radar_cluster("cluster-1")], 0); + + let (state, _) = update(state, Action::Command(CommandAction::TogglePin)); + assert_eq!( + state.pinned_target, + Some(FocusTarget::Cluster { + id: "cluster-1".to_string() + }) + ); + + let (state, _) = update(state, Action::Command(CommandAction::TogglePin)); + assert_eq!(state.pinned_target, None); + } + + #[test] + fn spine_state_editing() { + let mut state = AppState::default(); + + let (next, _) = update(state, Action::Spine(SpineAction::InsertChar('a'))); + state = next; + assert_eq!(state.spine.input.input, "a"); + assert_eq!(state.spine.input.cursor, 1); + + let (next, _) = update(state, Action::Spine(SpineAction::InsertChar('b'))); + state = next; + assert_eq!(state.spine.input.input, "ab"); + assert_eq!(state.spine.input.cursor, 2); + + let (next, _) = update(state, Action::Spine(SpineAction::InsertChar('c'))); + state = next; + assert_eq!(state.spine.input.input, "abc"); + assert_eq!(state.spine.input.cursor, 3); + + let (next, _) = update(state, Action::Spine(SpineAction::MoveCursorLeft)); + state = next; + assert_eq!(state.spine.input.cursor, 2); + + let (next, _) = update(state, Action::Spine(SpineAction::Backspace)); + state = next; + assert_eq!(state.spine.input.input, "ac"); + assert_eq!(state.spine.input.cursor, 1); + + let (next, _) = update(state, Action::Spine(SpineAction::Delete)); + state = next; + assert_eq!(state.spine.input.input, "a"); + assert_eq!(state.spine.input.cursor, 1); + + let (next, _) = update(state, Action::Spine(SpineAction::MoveCursorHome)); + state = next; + assert_eq!(state.spine.input.cursor, 0); + + let (next, _) = update(state, Action::Spine(SpineAction::InsertChar('z'))); + state = next; + assert_eq!(state.spine.input.input, "za"); + assert_eq!(state.spine.input.cursor, 1); + + let (next, _) = update(state, Action::Spine(SpineAction::MoveCursorEnd)); + state = next; + assert_eq!(state.spine.input.cursor, 2); + + let (next, _) = update(state, Action::Spine(SpineAction::MoveCursorRight)); + state = next; + assert_eq!(state.spine.input.cursor, 2); + + let (next, _) = update(state, Action::Spine(SpineAction::Clear)); + state = next; + assert_eq!(state.spine.input.input, ""); + assert_eq!(state.spine.input.cursor, 0); + } + + #[test] + fn spine_accept_completion_appends_ghost() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Command; + + let (state, _) = update(state, Action::Spine(SpineAction::InsertChar('p'))); + let completion = state.spine.completion.as_ref().expect("completion"); + assert_eq!(completion.ghost, "rovider"); + + let (state, _) = update(state, Action::Spine(SpineAction::AcceptCompletion)); + assert_eq!(state.spine.input.input, "provider"); + assert!(state.spine.completion.is_none()); + } + + #[test] + fn spine_cycle_completion_updates_ghost() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Command; + + let (state, _) = update(state, Action::Spine(SpineAction::InsertChar('i'))); + let completion = state.spine.completion.as_ref().expect("completion"); + assert_eq!(completion.ghost, "ssue"); + + let (state, _) = update(state, Action::Spine(SpineAction::CycleCompletion)); + let completion = state.spine.completion.as_ref().expect("completion"); + assert_eq!(completion.ghost, "nterrupt"); + } + + #[test] + fn spine_cancel_resets_mode_and_input() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Command; + state.spine.input.input = "help".to_string(); + state.spine.input.cursor = 4; + state.spine.completion = Some(SpineCompletion { + candidates: vec!["help".to_string()], + selected: 0, + ghost: "er".to_string(), + }); + + let (state, effects) = update(state, Action::Spine(SpineAction::Cancel)); + assert!(effects.is_empty()); + assert_eq!(state.spine.mode, SpineMode::Intent); + assert_eq!(state.spine.input.input, ""); + assert_eq!(state.spine.input.cursor, 0); + assert!(state.spine.completion.is_none()); + } + + #[test] + fn spine_submit_command_emits_command_effect() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Command; + state.spine.input.input = "help ".to_string(); + state.spine.input.cursor = 5; + + let (state, effects) = update(state, Action::Spine(SpineAction::Submit)); + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Command(CommandRequest::SubmitRaw { raw, .. }) if raw == "/help " + ) + })); + assert_eq!(state.spine.mode, SpineMode::Intent); + assert_eq!(state.spine.input.input, ""); + assert!(state.spine.completion.is_none()); + } + + #[test] + fn spine_submit_intent_starts_cluster() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Intent; + state.spine.input.input = "launch".to_string(); + state.spine.input.cursor = 6; + + let (state, effects) = update(state, Action::Spine(SpineAction::Submit)); + assert!( + effects.contains(&Effect::Backend(BackendRequest::StartClusterFromText { + text: "launch".to_string(), + provider_override: None, + })) + ); + assert_eq!(state.spine.mode, SpineMode::Intent); + assert_eq!(state.spine.input.input, ""); + } + + #[test] + fn spine_submit_whisper_cluster_sends_guidance() { + let mut state = AppState::default(); + state.screen_stack = vec![ScreenId::Cluster { + id: "cluster-1".to_string(), + }]; + state.spine.mode = SpineMode::WhisperCluster; + state.spine.input.input = "ping".to_string(); + state.spine.input.cursor = 4; + + let (state, effects) = update(state, Action::Spine(SpineAction::Submit)); + assert!( + effects.contains(&Effect::Backend(BackendRequest::SendGuidanceToCluster { + cluster_id: "cluster-1".to_string(), + message: "ping".to_string(), + })) + ); + assert_eq!(state.spine.mode, SpineMode::Intent); + assert_eq!(state.spine.input.input, ""); + } + + #[test] + fn spine_submit_whisper_agent_sends_guidance() { + let mut state = AppState::default(); + state.screen_stack = vec![ScreenId::Agent { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + }]; + state.spine.mode = SpineMode::WhisperAgent; + state.spine.input.input = "ping".to_string(); + state.spine.input.cursor = 4; + + let (state, effects) = update(state, Action::Spine(SpineAction::Submit)); + assert!( + effects.contains(&Effect::Backend(BackendRequest::SendGuidanceToAgent { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + message: "ping".to_string(), + })) + ); + assert_eq!(state.spine.mode, SpineMode::Intent); + assert_eq!(state.spine.input.input, ""); + } + + #[test] + fn navigation_to_microscope_sets_spine_mode() { + let state = AppState::default(); + let (state, _) = update( + state, + Action::Navigate(NavigationAction::Push(ScreenId::AgentMicroscope { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + })), + ); + assert_eq!(state.spine.mode, SpineMode::WhisperAgent); + } + + #[test] + fn navigation_to_microscope_subscribes_timeline() { + let state = AppState::default(); + let (_, effects) = update( + state, + Action::Navigate(NavigationAction::Push(ScreenId::AgentMicroscope { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + })), + ); + assert!( + effects.contains(&Effect::Backend(BackendRequest::SubscribeClusterTimeline { + cluster_id: "cluster-1".to_string(), + })) + ); + } + + #[test] + fn spine_submit_whisper_agent_sends_guidance_from_microscope() { + let mut state = AppState::default(); + state.screen_stack = vec![ScreenId::AgentMicroscope { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + }]; + state.spine.mode = SpineMode::WhisperAgent; + state.spine.input.input = "ping".to_string(); + state.spine.input.cursor = 4; + + let (state, effects) = update(state, Action::Spine(SpineAction::Submit)); + assert!( + effects.contains(&Effect::Backend(BackendRequest::SendGuidanceToAgent { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + message: "ping".to_string(), + })) + ); + assert_eq!(state.spine.mode, SpineMode::WhisperAgent); + assert_eq!(state.spine.input.input, ""); + } + + #[test] + fn time_cursor_live_updates() { + let mut state = AppState::default(); + state.time_cursor.mode = TimeCursorMode::Live; + state.time_cursor.t_ms = 10; + + let cluster_id = "cluster-1".to_string(); + let subscription_id = "sub-logs".to_string(); + state + .clusters + .entry(cluster_id.clone()) + .or_default() + .log_subscription = Some(subscription_id.clone()); + + handle_backend_notification( + &mut state, + BackendNotification::ClusterLogLines(ClusterLogLinesParams { + subscription_id, + cluster_id: cluster_id.clone(), + lines: vec![ + ClusterLogLine { + id: "line-1".to_string(), + timestamp: 100, + text: "hello".to_string(), + agent: None, + role: None, + sender: None, + }, + ClusterLogLine { + id: "line-2".to_string(), + timestamp: 150, + text: "world".to_string(), + agent: None, + role: None, + sender: None, + }, + ], + dropped_count: None, + }), + ); + assert_eq!(state.time_cursor.t_ms, 150); + + handle_backend_notification( + &mut state, + BackendNotification::ClusterTimelineEvents(ClusterTimelineEventsParams { + subscription_id: "sub-timeline".to_string(), + cluster_id, + events: vec![TimelineEvent { + id: "event-1".to_string(), + timestamp: 175, + topic: "ISSUE_OPENED".to_string(), + label: "opened".to_string(), + approved: None, + sender: None, + }], + }), + ); + assert_eq!(state.time_cursor.t_ms, 175); + } + + #[test] + fn time_cursor_scrub_does_not_update() { + let mut state = AppState::default(); + state.time_cursor.mode = TimeCursorMode::Scrub; + state.time_cursor.t_ms = 200; + + let cluster_id = "cluster-2".to_string(); + let subscription_id = "sub-logs".to_string(); + state + .clusters + .entry(cluster_id.clone()) + .or_default() + .log_subscription = Some(subscription_id.clone()); + + handle_backend_notification( + &mut state, + BackendNotification::ClusterLogLines(ClusterLogLinesParams { + subscription_id, + cluster_id, + lines: vec![ClusterLogLine { + id: "line-3".to_string(), + timestamp: 500, + text: "late".to_string(), + agent: None, + role: None, + sender: None, + }], + dropped_count: None, + }), + ); + assert_eq!(state.time_cursor.t_ms, 200); + } + + fn sample_log_line(id: &str, timestamp: i64) -> ClusterLogLine { + ClusterLogLine { + id: id.to_string(), + timestamp, + text: "log".to_string(), + agent: None, + role: None, + sender: None, + } + } + + #[test] + fn time_cursor_step_clamps_and_enters_scrub() { + let mut state = AppState::default(); + state.temporal_focus = TemporalFocus::Cluster { + id: "cluster-1".to_string(), + }; + state.time_cursor.mode = TimeCursorMode::Live; + state.time_cursor.t_ms = 200; + + let mut cluster_state = cluster::State::default(); + cluster_state.push_log_lines( + vec![sample_log_line("l1", 100), sample_log_line("l2", 200)], + None, + ); + state + .clusters + .insert("cluster-1".to_string(), cluster_state); + + let (state, _) = update( + state, + Action::TimeCursor(TimeCursorAction::Step { + delta_ms: -TIME_SCRUB_STEP_MS, + }), + ); + assert_eq!(state.time_cursor.mode, TimeCursorMode::Scrub); + assert_eq!(state.time_cursor.t_ms, 100); + } + + #[test] + fn time_cursor_jump_and_toggle_follow() { + let mut state = AppState::default(); + state.screen_stack = vec![ScreenId::ClusterCanvas { + id: "cluster-2".to_string(), + }]; + state.temporal_focus = TemporalFocus::Cluster { + id: "cluster-2".to_string(), + }; + state.time_cursor.mode = TimeCursorMode::Scrub; + state.time_cursor.t_ms = 120; + + let mut cluster_state = cluster::State::default(); + cluster_state.push_log_lines( + vec![sample_log_line("l1", 100), sample_log_line("l2", 250)], + None, + ); + state + .clusters + .insert("cluster-2".to_string(), cluster_state); + + let (state, _) = update(state, Action::TimeCursor(TimeCursorAction::JumpToLive)); + assert_eq!(state.time_cursor.mode, TimeCursorMode::Live); + assert_eq!(state.time_cursor.t_ms, 250); + + let (state, _) = update(state, Action::TimeCursor(TimeCursorAction::ToggleFollow)); + assert_eq!(state.time_cursor.mode, TimeCursorMode::Scrub); + + let (state, _) = update(state, Action::TimeCursor(TimeCursorAction::ToggleFollow)); + assert_eq!(state.time_cursor.mode, TimeCursorMode::Live); + assert_eq!(state.time_cursor.t_ms, 250); + } + + #[test] + fn time_cursor_large_step_clamps_to_max() { + let mut state = AppState::default(); + state.temporal_focus = TemporalFocus::Cluster { + id: "cluster-3".to_string(), + }; + state.time_cursor.mode = TimeCursorMode::Scrub; + state.time_cursor.t_ms = 150; + + let mut cluster_state = cluster::State::default(); + cluster_state.push_log_lines( + vec![sample_log_line("l1", 100), sample_log_line("l2", 220)], + None, + ); + state + .clusters + .insert("cluster-3".to_string(), cluster_state); + + let (state, _) = update( + state, + Action::TimeCursor(TimeCursorAction::Step { + delta_ms: TIME_SCRUB_STEP_LARGE_MS, + }), + ); + assert_eq!(state.time_cursor.t_ms, 220); + } + + #[test] + fn radar_camera_centering() { + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + state.now_ms = 10_000; + state.fleet_radar.set_clusters( + vec![radar_cluster("west"), radar_cluster("east")], + state.now_ms, + ); + state + .fleet_radar + .layout_angles + .insert("west".to_string(), std::f64::consts::PI); + state + .fleet_radar + .layout_angles + .insert("east".to_string(), 0.0); + state.fleet_radar.selected = 0; + + let (state, _) = update( + state, + Action::Screen(ScreenAction::FleetRadar(radar::Action::CenterOnSelection)), + ); + assert!(state.camera_target.0 < 0.0); + + let (state, _) = update( + state, + Action::Screen(ScreenAction::FleetRadar(radar::Action::MoveSelection { + direction: radar::Direction::Right, + speed: radar::MoveSpeed::Step, + })), + ); + assert_eq!( + state.fleet_radar.selected_cluster_id().as_deref(), + Some("east") + ); + assert!(state.camera_target.0 > 0.0); + + let (state, _) = update( + state, + Action::Screen(ScreenAction::FleetRadar(radar::Action::ResetView)), + ); + assert_eq!(state.camera, Camera::default()); + assert_eq!(state.camera_target, (0.0, 0.0)); + assert_eq!(state.camera_velocity, (0.0, 0.0)); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/app/spine_completion.rs b/tui-rs/crates/zeroshot-tui/src/app/spine_completion.rs new file mode 100644 index 00000000..3b430df2 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/app/spine_completion.rs @@ -0,0 +1,179 @@ +use crate::commands::VALID_PROVIDERS; + +use super::{SpineCompletion, SpineMode}; + +const COMMAND_CANDIDATES: [&str; 10] = [ + "help", + "monitor", + "issue", + "provider", + "guide", + "nudge", + "interrupt", + "pin", + "quit", + "exit", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompletionContext { + prefix: String, + candidates: Vec, +} + +pub fn build_spine_completion( + mode: SpineMode, + input: &str, + cursor: usize, +) -> Option { + if cursor != input.chars().count() { + return None; + } + + match mode { + SpineMode::Command => build_command_completion(input), + SpineMode::Intent | SpineMode::WhisperCluster | SpineMode::WhisperAgent => None, + } +} + +pub fn select_spine_completion( + mode: SpineMode, + input: &str, + cursor: usize, + selected: usize, +) -> Option { + let context = completion_context(mode, input, cursor)?; + build_completion(&context.prefix, &context.candidates, selected) +} + +fn build_command_completion(input: &str) -> Option { + let context = completion_context(SpineMode::Command, input, input.chars().count())?; + build_completion(&context.prefix, &context.candidates, 0) +} + +fn completion_context(mode: SpineMode, input: &str, cursor: usize) -> Option { + if cursor != input.chars().count() { + return None; + } + match mode { + SpineMode::Command => command_completion_context(input), + SpineMode::Intent | SpineMode::WhisperCluster | SpineMode::WhisperAgent => None, + } +} + +fn command_completion_context(input: &str) -> Option { + let trimmed = input.trim_start_matches('/'); + if trimmed.is_empty() { + return None; + } + + let ends_with_space = trimmed.ends_with(' '); + let mut parts = trimmed.split_whitespace(); + let command = parts.next().unwrap_or(""); + if command.is_empty() { + return None; + } + let args = parts.collect::>(); + + let (prefix, candidates, allow_empty): (&str, &[&str], bool) = if args.is_empty() + && !ends_with_space + { + (command, &COMMAND_CANDIDATES, false) + } else if command.eq_ignore_ascii_case("provider") && (!args.is_empty() || ends_with_space) { + let prefix = if ends_with_space { + "" + } else { + args.last().copied().unwrap_or("") + }; + (prefix, &VALID_PROVIDERS, true) + } else { + return None; + }; + + let mut candidates = prefix_matches(prefix, candidates, allow_empty); + if args.is_empty() && !ends_with_space && prefix.chars().count() == 1 { + // Keep `/p` completion unambiguous; "pin" still appears for `pi`. + candidates.retain(|candidate| candidate != "pin"); + } + if candidates.is_empty() { + return None; + } + + Some(CompletionContext { + prefix: prefix.to_string(), + candidates, + }) +} + +fn prefix_matches(prefix: &str, candidates: &[&str], allow_empty: bool) -> Vec { + if prefix.is_empty() && !allow_empty { + return Vec::new(); + } + + let prefix_lower = prefix.to_lowercase(); + candidates + .iter() + .filter(|candidate| candidate.starts_with(prefix_lower.as_str())) + .filter(|candidate| candidate.chars().count() > prefix_lower.chars().count()) + .map(|candidate| candidate.to_string()) + .collect() +} + +fn build_completion( + prefix: &str, + candidates: &[String], + selected: usize, +) -> Option { + if candidates.is_empty() || selected >= candidates.len() { + return None; + } + + let candidate = candidates.get(selected)?; + let ghost = suffix_after_prefix(prefix, candidate); + if ghost.is_empty() { + return None; + } + + Some(SpineCompletion { + candidates: candidates.to_vec(), + selected, + ghost, + }) +} + +fn suffix_after_prefix(prefix: &str, candidate: &str) -> String { + let prefix_len = prefix.chars().count(); + candidate.chars().skip(prefix_len).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_prefix_suggests_provider() { + let completion = build_spine_completion(SpineMode::Command, "p", 1).expect("completion"); + assert_eq!(completion.ghost, "rovider"); + } + + #[test] + fn provider_arg_suggests_known_provider() { + let completion = + build_spine_completion(SpineMode::Command, "provider c", 10).expect("completion"); + assert_eq!(completion.ghost, "laude"); + } + + #[test] + fn empty_prefix_does_not_suggest_command_names() { + let completion = build_spine_completion(SpineMode::Command, "", 0); + assert!(completion.is_none()); + } + + #[test] + fn select_completion_cycles_candidates() { + let completion = build_spine_completion(SpineMode::Command, "i", 1).expect("completion"); + let cycled = select_spine_completion(SpineMode::Command, "i", 1, 1).expect("cycle"); + assert_ne!(completion.ghost, cycled.ghost); + assert_eq!(cycled.ghost, "nterrupt"); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/app/spine_hint.rs b/tui-rs/crates/zeroshot-tui/src/app/spine_hint.rs new file mode 100644 index 00000000..ee374951 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/app/spine_hint.rs @@ -0,0 +1,346 @@ +use crate::commands::{parse, VALID_PROVIDERS}; +use crate::protocol::GuidanceDeliveryResult; + +use super::{ + detect_issue_reference, resolve_focus_target, resolve_spine_agent_target, + resolve_spine_cluster_target, AgentKey, AppState, SpineMode, ToastLevel, UiVariant, + ZoomStackContext, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpineHintTone { + Muted, + Info, + Success, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpineHint { + pub text: String, + pub tone: SpineHintTone, +} + +impl SpineHint { + pub fn new(text: impl Into, tone: SpineHintTone) -> Self { + Self { + text: text.into(), + tone, + } + } + + pub fn empty() -> Self { + Self::new("", SpineHintTone::Muted) + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + pub fn from_toast(text: String, level: ToastLevel) -> Self { + let tone = match level { + ToastLevel::Info => SpineHintTone::Info, + ToastLevel::Success => SpineHintTone::Success, + ToastLevel::Error => SpineHintTone::Error, + }; + Self::new(text, tone) + } +} + +impl Default for SpineHint { + fn default() -> Self { + Self::empty() + } +} + +pub fn compute_spine_hint(state: &AppState) -> SpineHint { + match state.spine.mode { + SpineMode::Command => command_hint(state), + SpineMode::Intent => intent_hint(state), + SpineMode::WhisperCluster => whisper_cluster_hint(state), + SpineMode::WhisperAgent => whisper_agent_hint(state), + } +} + +fn command_hint(state: &AppState) -> SpineHint { + let trimmed = state.spine.input.input.trim(); + if trimmed.is_empty() { + return SpineHint::new(command_help_line(), SpineHintTone::Muted); + } + + let raw = if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + }; + + match parse(&raw) { + Ok(parsed) => match parsed.name.as_str() { + "help" => SpineHint::new(command_help_line(), SpineHintTone::Info), + "monitor" => { + let label = if matches!(state.ui_variant, UiVariant::Disruptive) { + "Fleet Radar" + } else { + "Monitor" + }; + SpineHint::new(format!("Open {label}"), SpineHintTone::Info) + } + "issue" => { + let Some(reference) = parsed.args.first() else { + return SpineHint::new(issue_usage(), SpineHintTone::Error); + }; + SpineHint::new( + format!("Start cluster from issue {reference}"), + SpineHintTone::Info, + ) + } + "guide" => guidance_command_hint(state, &parsed.args, "Guide", true), + "nudge" => guidance_command_hint(state, &parsed.args, "Nudge", true), + "interrupt" => guidance_command_hint(state, &parsed.args, "Interrupt", false), + "pin" => pin_command_hint(state), + "provider" => provider_hint(&parsed.args), + "quit" | "exit" => SpineHint::new("Quit TUI", SpineHintTone::Info), + other => SpineHint::new( + format!("Unknown command: {other}. Try /help."), + SpineHintTone::Error, + ), + }, + Err(err) => SpineHint::new(err.to_string(), SpineHintTone::Error), + } +} + +fn provider_hint(args: &[String]) -> SpineHint { + let Some(name) = args.first() else { + return SpineHint::new(provider_usage(), SpineHintTone::Error); + }; + let normalized = name.to_lowercase(); + if !VALID_PROVIDERS.contains(&normalized.as_str()) { + return SpineHint::new( + format!( + "Unknown provider '{name}'. Use one of: {}", + VALID_PROVIDERS.join(", ") + ), + SpineHintTone::Error, + ); + } + SpineHint::new( + format!("Set provider override to {normalized}"), + SpineHintTone::Info, + ) +} + +fn command_help_line() -> String { + "Commands: /help /monitor /issue /provider /guide /nudge /interrupt [text] /pin /quit /exit".to_string() +} + +fn provider_usage() -> String { + format!("Usage: /provider <{}>", VALID_PROVIDERS.join("|")) +} + +fn issue_usage() -> &'static str { + "Usage: /issue " +} + +fn guidance_command_hint( + state: &AppState, + args: &[String], + verb: &str, + require_text: bool, +) -> SpineHint { + if require_text && args.is_empty() { + return SpineHint::new( + format!("Usage: /{} ", verb.to_lowercase()), + SpineHintTone::Error, + ); + } + let Some(target) = resolve_focus_target(state) else { + return SpineHint::new( + "Select a cluster or agent to guide.".to_string(), + SpineHintTone::Error, + ); + }; + let label = target.label(); + SpineHint::new(format!("{verb} {label}"), SpineHintTone::Info) +} + +fn pin_command_hint(state: &AppState) -> SpineHint { + if !matches!(state.ui_variant, UiVariant::Disruptive) { + return SpineHint::new( + "Pinning is only available in Disruptive UI.".to_string(), + SpineHintTone::Error, + ); + } + let Some(target) = resolve_focus_target(state) else { + return SpineHint::new( + "Select a cluster or agent to pin.".to_string(), + SpineHintTone::Error, + ); + }; + let action = if state.pinned_target.as_ref() == Some(&target) { + "Unpin" + } else { + "Pin" + }; + SpineHint::new(format!("{action} {}", target.label()), SpineHintTone::Info) +} + +fn intent_hint(state: &AppState) -> SpineHint { + if !matches!(state.zoom_stack_context(), ZoomStackContext::Root) { + return SpineHint::empty(); + } + + let trimmed = state.spine.input.input.trim(); + if trimmed.is_empty() { + return SpineHint::empty(); + } + + let mut hint = if detect_issue_reference(trimmed).is_some() { + SpineHint::new("Start cluster from issue", SpineHintTone::Info) + } else { + SpineHint::new("Start cluster from text", SpineHintTone::Info) + }; + + if let Some(provider) = state.provider_override.as_deref() { + hint.text = format!("{} (provider: {provider})", hint.text); + } + + hint +} + +fn whisper_cluster_hint(state: &AppState) -> SpineHint { + let Some(cluster_id) = resolve_spine_cluster_target(state) else { + return SpineHint::empty(); + }; + SpineHint::new( + format!("Whisper to cluster {cluster_id}"), + SpineHintTone::Info, + ) +} + +fn whisper_agent_hint(state: &AppState) -> SpineHint { + let Some((cluster_id, agent_id)) = resolve_spine_agent_target(state) else { + return SpineHint::empty(); + }; + let mut hint = SpineHint::new( + format!("Whisper to agent {agent_id} @ {cluster_id}"), + SpineHintTone::Info, + ); + if let Some(status) = guidance_status_hint(state, &cluster_id, &agent_id) { + hint.text = format!("{} ({status})", hint.text); + } + hint +} + +fn guidance_status_hint( + state: &AppState, + cluster_id: &str, + agent_id: &str, +) -> Option<&'static str> { + let key = AgentKey::new(cluster_id, agent_id); + let agent_state = state.agents.get(&key)?; + let result = agent_state.last_guidance.as_ref()?; + guidance_status(result) +} + +fn guidance_status(result: &GuidanceDeliveryResult) -> Option<&'static str> { + match result.status.to_lowercase().as_str() { + "injected" => Some("likely injected"), + "queued" => Some("likely queued"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ScreenId, SpineAction}; + use crate::protocol::GuidanceDeliveryResult; + use crate::screens::agent; + + #[test] + fn command_provider_missing_arg_shows_usage() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Command; + state.spine.input.input = "provider".to_string(); + + let hint = compute_spine_hint(&state); + + assert_eq!(hint.tone, SpineHintTone::Error); + assert!(hint.text.contains("Usage: /provider")); + } + + #[test] + fn command_unknown_shows_error() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Command; + state.spine.input.input = "nope".to_string(); + + let hint = compute_spine_hint(&state); + + assert_eq!(hint.tone, SpineHintTone::Error); + assert!(hint.text.contains("Unknown command")); + } + + #[test] + fn intent_issue_prediction() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Intent; + state.spine.input.input = "123".to_string(); + + let hint = compute_spine_hint(&state); + + assert!(hint.text.contains("Start cluster from issue")); + } + + #[test] + fn intent_text_prediction() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Intent; + state.spine.input.input = "Implement X".to_string(); + + let hint = compute_spine_hint(&state); + + assert!(hint.text.contains("Start cluster from text")); + } + + #[test] + fn whisper_agent_includes_delivery_hint() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::WhisperAgent; + state.screen_stack = vec![ScreenId::AgentMicroscope { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + }]; + let mut agent_state = agent::State::default(); + agent_state.last_guidance = Some(GuidanceDeliveryResult { + status: "queued".to_string(), + reason: None, + method: Some("pty".to_string()), + task_id: None, + }); + state.agents.insert( + AgentKey::new("cluster-1".to_string(), "agent-1".to_string()), + agent_state, + ); + + let hint = compute_spine_hint(&state); + + assert!(hint.text.contains("agent-1")); + assert!(hint.text.contains("cluster-1")); + assert!(hint.text.contains("queued")); + } + + #[test] + fn spine_action_insert_char_updates_hint_without_backend_effects() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Intent; + + let (next, effects) = crate::app::update( + state, + crate::app::Action::Spine(SpineAction::InsertChar('1')), + ); + + assert!(effects.is_empty()); + assert!(next.spine.hint.text.contains("Start cluster from issue")); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/backend/framing.rs b/tui-rs/crates/zeroshot-tui/src/backend/framing.rs new file mode 100644 index 00000000..b8dc9204 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/backend/framing.rs @@ -0,0 +1,130 @@ +const HEADER_DELIMITER: &[u8] = b"\r\n\r\n"; +const MAX_HEADER_BYTES: usize = 8 * 1024; + +pub const MAX_FRAME_SIZE: usize = 10 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + MissingContentLength, + InvalidContentLength(String), + InvalidHeader(String), + FrameTooLarge(usize), + HeaderTooLarge(usize), +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::MissingContentLength => write!(f, "Missing Content-Length header"), + FrameError::InvalidContentLength(value) => { + write!(f, "Invalid Content-Length: {value}") + } + FrameError::InvalidHeader(value) => write!(f, "Invalid header: {value}"), + FrameError::FrameTooLarge(size) => write!(f, "Frame too large: {size} bytes"), + FrameError::HeaderTooLarge(size) => write!(f, "Header too large: {size} bytes"), + } + } +} + +impl std::error::Error for FrameError {} + +pub struct FrameEncoder; + +impl FrameEncoder { + pub fn encode(payload: &[u8]) -> Result, FrameError> { + if payload.len() > MAX_FRAME_SIZE { + return Err(FrameError::FrameTooLarge(payload.len())); + } + let mut framed = Vec::with_capacity(payload.len() + 64); + let header = format!("Content-Length: {}\r\n\r\n", payload.len()); + framed.extend_from_slice(header.as_bytes()); + framed.extend_from_slice(payload); + Ok(framed) + } +} + +#[derive(Debug, Default)] +pub struct FrameDecoder { + buffer: Vec, +} + +impl FrameDecoder { + pub fn new() -> Self { + Self { buffer: Vec::new() } + } + + pub fn push(&mut self, chunk: &[u8]) -> Result>, FrameError> { + self.buffer.extend_from_slice(chunk); + if self.buffer.len() > MAX_FRAME_SIZE + MAX_HEADER_BYTES { + return Err(FrameError::FrameTooLarge(self.buffer.len())); + } + let mut frames = Vec::new(); + loop { + let header_end = match find_header_end(&self.buffer) { + Some(index) => index, + None => break, + }; + if header_end > MAX_HEADER_BYTES { + return Err(FrameError::HeaderTooLarge(header_end)); + } + let header_bytes = &self.buffer[..header_end]; + let header_str = std::str::from_utf8(header_bytes) + .map_err(|err| FrameError::InvalidHeader(err.to_string()))?; + let content_length = parse_content_length(header_str)?; + if content_length > MAX_FRAME_SIZE { + return Err(FrameError::FrameTooLarge(content_length)); + } + let payload_start = header_end + HEADER_DELIMITER.len(); + let payload_end = payload_start + content_length; + if self.buffer.len() < payload_end { + break; + } + let payload = self.buffer[payload_start..payload_end].to_vec(); + self.buffer.drain(0..payload_end); + frames.push(payload); + } + Ok(frames) + } +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer + .windows(HEADER_DELIMITER.len()) + .position(|window| window == HEADER_DELIMITER) +} + +fn parse_content_length(header: &str) -> Result { + let mut content_length: Option = None; + for line in header.split("\r\n") { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let mut parts = trimmed.splitn(2, ':'); + let name = parts.next().unwrap_or("").trim(); + let value = parts.next().unwrap_or("").trim(); + if name.eq_ignore_ascii_case("content-length") { + if value.is_empty() { + return Err(FrameError::InvalidContentLength(value.to_string())); + } + let parsed = value + .parse::() + .map_err(|_| FrameError::InvalidContentLength(value.to_string()))?; + content_length = Some(parsed); + } + } + content_length.ok_or(FrameError::MissingContentLength) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_content_length_handles_case() { + let header = "Content-Length: 10\r\nX-Other: abc"; + assert_eq!(parse_content_length(header).unwrap(), 10); + let header_lower = "content-length: 5"; + assert_eq!(parse_content_length(header_lower).unwrap(), 5); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/backend/mod.rs b/tui-rs/crates/zeroshot-tui/src/backend/mod.rs new file mode 100644 index 00000000..2f1094d1 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/backend/mod.rs @@ -0,0 +1,126 @@ +use std::path::PathBuf; +use std::time::Duration; + +use crate::protocol::{ + ClientCapabilities, ClientInfo, ClusterLogLinesParams, ClusterTimelineEventsParams, RpcError, + ServerCapabilities, +}; + +pub mod framing; +pub mod stdio; + +pub const DEFAULT_PROTOCOL_VERSION: i64 = 1; +pub const BACKEND_PATH_ENV: &str = "ZEROSHOT_TUI_BACKEND_PATH"; +pub const DEFAULT_BACKEND_RELATIVE_PATH: &str = "lib/tui-backend/server.js"; + +#[derive(Debug, Clone)] +pub struct BackendConfig { + pub backend_path: Option, + pub protocol_version: i64, + pub client: ClientInfo, + pub capabilities: Option, + pub request_timeout: Option, +} + +impl Default for BackendConfig { + fn default() -> Self { + let client = ClientInfo { + name: "zeroshot-tui".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + pid: Some(std::process::id() as i64), + }; + let backend_path = std::env::var(BACKEND_PATH_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from); + Self { + backend_path, + protocol_version: DEFAULT_PROTOCOL_VERSION, + client, + capabilities: None, + request_timeout: Some(Duration::from_secs(30)), + } + } +} + +impl BackendConfig { + pub fn with_backend_path(path: impl Into) -> Self { + let mut config = Self::default(); + config.backend_path = Some(path.into()); + config + } +} + +#[derive(Debug, Clone)] +pub struct BackendExit { + pub code: Option, + pub message: String, +} + +#[derive(Debug, Clone)] +pub enum BackendNotification { + ClusterLogLines(ClusterLogLinesParams), + ClusterTimelineEvents(ClusterTimelineEventsParams), + Unknown { + method: String, + params: Option, + }, +} + +#[derive(Debug, Clone)] +pub enum BackendEvent { + Notification(BackendNotification), + BackendExited(BackendExit), +} + +#[derive(Debug)] +pub enum BackendError { + Io(std::io::Error), + Json(serde_json::Error), + Frame(framing::FrameError), + Rpc(RpcError), + Protocol(String), + Disconnected(String), + Timeout(String), +} + +impl std::fmt::Display for BackendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BackendError::Io(err) => write!(f, "IO error: {err}"), + BackendError::Json(err) => write!(f, "JSON error: {err}"), + BackendError::Frame(err) => write!(f, "Frame error: {err}"), + BackendError::Rpc(err) => write!(f, "RPC error {}: {}", err.code, err.message), + BackendError::Protocol(message) => write!(f, "Protocol error: {message}"), + BackendError::Disconnected(message) => write!(f, "Backend disconnected: {message}"), + BackendError::Timeout(message) => write!(f, "Request timeout: {message}"), + } + } +} + +impl std::error::Error for BackendError {} + +impl From for BackendError { + fn from(err: std::io::Error) -> Self { + BackendError::Io(err) + } +} + +impl From for BackendError { + fn from(err: serde_json::Error) -> Self { + BackendError::Json(err) + } +} + +impl From for BackendError { + fn from(err: framing::FrameError) -> Self { + BackendError::Frame(err) + } +} + +pub trait BackendClient { + fn take_event_receiver(&mut self) -> Option>; + fn server_capabilities(&self) -> Option; + fn protocol_version(&self) -> i64; + fn shutdown(&mut self) -> Result<(), BackendError>; +} diff --git a/tui-rs/crates/zeroshot-tui/src/backend/stdio.rs b/tui-rs/crates/zeroshot-tui/src/backend/stdio.rs new file mode 100644 index 00000000..c3a910d5 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/backend/stdio.rs @@ -0,0 +1,638 @@ +use std::collections::HashMap; +use std::env; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; + +use crate::backend::framing::{FrameDecoder, FrameEncoder}; +use crate::backend::{ + BackendClient, BackendConfig, BackendError, BackendEvent, BackendExit, BackendNotification, + BACKEND_PATH_ENV, DEFAULT_BACKEND_RELATIVE_PATH, +}; +use crate::protocol::{ + ClientCapabilities, ClientInfo, ClusterLogLinesParams, ClusterTimelineEventsParams, + GetClusterSummaryParams, GetClusterSummaryResult, GetClusterTopologyParams, + GetClusterTopologyResult, InitializeParams, InitializeResult, JsonRpcId, JsonRpcRequest, + ListClusterMetricsParams, ListClusterMetricsResult, ListClustersParams, ListClustersResult, + SendGuidanceToAgentParams, SendGuidanceToAgentResult, SendGuidanceToClusterParams, + SendGuidanceToClusterResult, ServerCapabilities, StartClusterFromIssueParams, + StartClusterFromTextParams, StartClusterResult, SubscribeClusterLogsParams, + SubscribeClusterTimelineParams, SubscribeResult, UnsubscribeParams, UnsubscribeResult, +}; + +const JSONRPC_VERSION: &str = "2.0"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum RequestKey { + Number(i64), + String(String), +} + +impl RequestKey { + fn from_value(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_i64().map(RequestKey::Number), + Value::String(value) => Some(RequestKey::String(value.clone())), + _ => None, + } + } +} + +enum WriterCommand { + Frame(Vec), + Shutdown, +} + +pub struct StdioBackendClient { + config: BackendConfig, + writer: Option>, + events: Option>, + pending: Arc>>>>, + next_id: AtomicI64, + read_handle: Option>, + write_handle: Option>, + child: Arc>>, + protocol_version: i64, + server_capabilities: Option, +} + +impl StdioBackendClient { + pub fn connect(config: BackendConfig) -> Result { + let backend_path = resolve_backend_path(&config)?; + let mut command = Command::new("node"); + command.arg(backend_path); + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::inherit()); + let mut child = command.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| BackendError::Protocol("Failed to capture backend stdout".into()))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| BackendError::Protocol("Failed to capture backend stdin".into()))?; + + let (event_tx, event_rx) = mpsc::channel(); + let (writer_tx, writer_rx) = mpsc::channel(); + let pending: Arc>>>> = + Arc::new(Mutex::new(HashMap::new())); + let child = Arc::new(Mutex::new(Some(child))); + + let write_handle = spawn_writer_thread(stdin, writer_rx, pending.clone(), event_tx.clone()); + let read_handle = spawn_reader_thread(stdout, pending.clone(), event_tx.clone()); + + let mut client = Self { + config, + writer: Some(writer_tx), + events: Some(event_rx), + pending, + next_id: AtomicI64::new(1), + read_handle: Some(read_handle), + write_handle: Some(write_handle), + child, + protocol_version: 0, + server_capabilities: None, + }; + + let initialize = client.initialize()?; + client.validate_initialize(&initialize)?; + client.protocol_version = initialize.protocol_version; + client.server_capabilities = Some(initialize.capabilities.clone()); + + Ok(client) + } + + pub fn list_clusters(&self) -> Result { + self.send_request("listClusters", ListClustersParams {}) + } + + pub fn list_cluster_metrics( + &self, + params: ListClusterMetricsParams, + ) -> Result { + self.send_request("listClusterMetrics", params) + } + + pub fn get_cluster_summary( + &self, + params: GetClusterSummaryParams, + ) -> Result { + self.send_request("getClusterSummary", params) + } + + pub fn get_cluster_topology( + &self, + params: GetClusterTopologyParams, + ) -> Result { + self.send_request("getClusterTopology", params) + } + + pub fn subscribe_cluster_logs( + &self, + params: SubscribeClusterLogsParams, + ) -> Result { + self.send_request("subscribeClusterLogs", params) + } + + pub fn subscribe_cluster_timeline( + &self, + params: SubscribeClusterTimelineParams, + ) -> Result { + self.send_request("subscribeClusterTimeline", params) + } + + pub fn unsubscribe( + &self, + params: UnsubscribeParams, + ) -> Result { + self.send_request("unsubscribe", params) + } + + pub fn start_cluster_from_text( + &self, + params: StartClusterFromTextParams, + ) -> Result { + self.send_request("startClusterFromText", params) + } + + pub fn start_cluster_from_issue( + &self, + params: StartClusterFromIssueParams, + ) -> Result { + self.send_request("startClusterFromIssue", params) + } + + pub fn send_guidance_to_agent( + &self, + params: SendGuidanceToAgentParams, + ) -> Result { + self.send_request("sendGuidanceToAgent", params) + } + + pub fn send_guidance_to_cluster( + &self, + params: SendGuidanceToClusterParams, + ) -> Result { + self.send_request("sendGuidanceToCluster", params) + } + + pub fn client_info(&self) -> &ClientInfo { + &self.config.client + } + + pub fn client_capabilities(&self) -> Option<&ClientCapabilities> { + self.config.capabilities.as_ref() + } + + fn initialize(&self) -> Result { + let params = InitializeParams { + protocol_version: self.config.protocol_version, + client: self.config.client.clone(), + capabilities: self.config.capabilities.clone(), + }; + self.send_request("initialize", params) + } + + fn validate_initialize(&self, initialize: &InitializeResult) -> Result<(), BackendError> { + if initialize.protocol_version != self.config.protocol_version { + return Err(BackendError::Protocol(format!( + "Protocol version mismatch: expected {}, got {}", + self.config.protocol_version, initialize.protocol_version + ))); + } + Ok(()) + } + + fn send_request( + &self, + method: &str, + params: P, + ) -> Result { + let (key, frame, rx) = self.prepare_request(method, params)?; + self.send_frame(&key, frame)?; + let response = self.await_response(method, &key, rx)?; + Ok(serde_json::from_value(response)?) + } + + fn prepare_request( + &self, + method: &str, + params: P, + ) -> Result< + ( + RequestKey, + Vec, + mpsc::Receiver>, + ), + BackendError, + > { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let request = JsonRpcRequest { + jsonrpc: JSONRPC_VERSION.to_string(), + id: JsonRpcId::Number(id), + method: method.to_string(), + params: Some(params), + }; + let payload = serde_json::to_vec(&request)?; + let frame = FrameEncoder::encode(&payload)?; + let (tx, rx) = mpsc::channel(); + let key = RequestKey::Number(id); + let mut pending = self + .pending + .lock() + .map_err(|_| BackendError::Protocol("Pending request lock poisoned".into()))?; + pending.insert(key.clone(), tx); + Ok((key, frame, rx)) + } + + fn send_frame(&self, key: &RequestKey, frame: Vec) -> Result<(), BackendError> { + let writer = self + .writer + .as_ref() + .ok_or_else(|| BackendError::Disconnected("Backend writer closed".into()))?; + if writer.send(WriterCommand::Frame(frame)).is_err() { + self.remove_pending(key)?; + return Err(BackendError::Disconnected( + "Backend writer channel closed".into(), + )); + } + Ok(()) + } + + fn await_response( + &self, + method: &str, + key: &RequestKey, + rx: mpsc::Receiver>, + ) -> Result { + let response = match self.config.request_timeout { + Some(timeout) => match rx.recv_timeout(timeout) { + Ok(value) => value, + Err(err) => { + self.remove_pending(key)?; + return Err(BackendError::Timeout(format!("{method} failed: {err}"))); + } + }, + None => match rx.recv() { + Ok(value) => value, + Err(_) => { + self.remove_pending(key)?; + return Err(BackendError::Disconnected("Backend disconnected".into())); + } + }, + }?; + Ok(response) + } + + fn remove_pending(&self, key: &RequestKey) -> Result<(), BackendError> { + let mut pending = self + .pending + .lock() + .map_err(|_| BackendError::Protocol("Pending request lock poisoned".into()))?; + pending.remove(key); + Ok(()) + } +} + +impl BackendClient for StdioBackendClient { + fn take_event_receiver(&mut self) -> Option> { + self.events.take() + } + + fn server_capabilities(&self) -> Option { + self.server_capabilities.clone() + } + + fn protocol_version(&self) -> i64 { + self.protocol_version + } + + fn shutdown(&mut self) -> Result<(), BackendError> { + if let Some(writer) = self.writer.take() { + let _ = writer.send(WriterCommand::Shutdown); + } + + if let Some(mut child) = self + .child + .lock() + .map_err(|_| BackendError::Protocol("Child lock poisoned".into()))? + .take() + { + let _ = child.kill(); + let _ = child.wait(); + } + + if let Some(handle) = self.write_handle.take() { + let _ = handle.join(); + } + if let Some(handle) = self.read_handle.take() { + let _ = handle.join(); + } + Ok(()) + } +} + +impl Drop for StdioBackendClient { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +fn spawn_writer_thread( + mut stdin: impl Write + Send + 'static, + receiver: mpsc::Receiver, + pending: Arc>>>>, + event_tx: mpsc::Sender, +) -> thread::JoinHandle<()> { + thread::spawn(move || writer_loop(&mut stdin, receiver, pending, event_tx)) +} + +fn writer_loop( + stdin: &mut (impl Write + Send + 'static), + receiver: mpsc::Receiver, + pending: Arc>>>>, + event_tx: mpsc::Sender, +) { + for command in receiver { + match command { + WriterCommand::Frame(frame) => { + if let Err(err) = stdin.write_all(&frame) { + let error = BackendError::Io(err); + let message = error.to_string(); + drain_pending(&pending, error); + let _ = event_tx.send(BackendEvent::BackendExited(BackendExit { + code: None, + message, + })); + break; + } + let _ = stdin.flush(); + } + WriterCommand::Shutdown => break, + } + } +} + +fn spawn_reader_thread( + mut stdout: impl Read + Send + 'static, + pending: Arc>>>>, + event_tx: mpsc::Sender, +) -> thread::JoinHandle<()> { + thread::spawn(move || reader_loop(&mut stdout, pending, event_tx)) +} + +fn reader_loop( + stdout: &mut (impl Read + Send + 'static), + pending: Arc>>>>, + event_tx: mpsc::Sender, +) { + let mut decoder = FrameDecoder::new(); + let mut buffer = [0u8; 8192]; + loop { + match stdout.read(&mut buffer) { + Ok(0) => { + handle_reader_disconnect( + &pending, + &event_tx, + BackendError::Disconnected("Backend closed stdout".into()), + "Backend closed stdout", + ); + break; + } + Ok(bytes) => { + if let Err(err) = + handle_reader_bytes(&buffer[..bytes], &mut decoder, &pending, &event_tx) + { + let message = err.to_string(); + drain_pending(&pending, err); + let _ = event_tx.send(BackendEvent::BackendExited(BackendExit { + code: None, + message, + })); + return; + } + } + Err(err) => { + handle_reader_disconnect( + &pending, + &event_tx, + BackendError::Io(err), + "Backend stdout read failed", + ); + break; + } + } + } +} + +fn handle_reader_bytes( + bytes: &[u8], + decoder: &mut FrameDecoder, + pending: &Arc>>>>, + event_tx: &mpsc::Sender, +) -> Result<(), BackendError> { + let frames = decoder.push(bytes)?; + for frame in frames { + handle_frame(&frame, pending, event_tx)?; + } + Ok(()) +} + +fn handle_reader_disconnect( + pending: &Arc>>>>, + event_tx: &mpsc::Sender, + error: BackendError, + message: &str, +) { + let event_message = if matches!(error, BackendError::Disconnected(_)) { + message.to_string() + } else { + error.to_string() + }; + drain_pending(pending, error); + let _ = event_tx.send(BackendEvent::BackendExited(BackendExit { + code: None, + message: event_message, + })); +} + +fn handle_frame( + frame: &[u8], + pending: &Arc>>>>, + event_tx: &mpsc::Sender, +) -> Result<(), BackendError> { + let value = parse_frame_json(frame)?; + ensure_jsonrpc_version(&value)?; + if is_notification(&value) { + return handle_notification(value, event_tx); + } + let key = parse_response_id(&value)?; + dispatch_response(value, key, pending); + Ok(()) +} + +fn parse_frame_json(frame: &[u8]) -> Result { + let value: Value = serde_json::from_slice(frame)?; + if !value.is_object() { + return Err(BackendError::Protocol("Non-object JSON-RPC message".into())); + } + Ok(value) +} + +fn ensure_jsonrpc_version(value: &Value) -> Result<(), BackendError> { + let jsonrpc = value + .get("jsonrpc") + .and_then(|value| value.as_str()) + .ok_or_else(|| BackendError::Protocol("Missing jsonrpc version".into()))?; + if jsonrpc != JSONRPC_VERSION { + return Err(BackendError::Protocol(format!( + "Unsupported jsonrpc version: {jsonrpc}" + ))); + } + Ok(()) +} + +fn is_notification(value: &Value) -> bool { + value.get("id").is_none() +} + +fn parse_response_id(value: &Value) -> Result { + let id_value = value + .get("id") + .ok_or_else(|| BackendError::Protocol("Missing id".into()))?; + RequestKey::from_value(id_value).ok_or_else(|| BackendError::Protocol("Invalid id type".into())) +} + +fn dispatch_response( + value: Value, + key: RequestKey, + pending: &Arc>>>>, +) { + let sender = { + let mut pending = match pending.lock() { + Ok(lock) => lock, + Err(poisoned) => poisoned.into_inner(), + }; + pending.remove(&key) + }; + + if let Some(sender) = sender { + if let Some(error_value) = value.get("error") { + let error = parse_rpc_error(error_value); + let _ = sender.send(Err(BackendError::Rpc(error))); + return; + } + if let Some(result) = value.get("result") { + let _ = sender.send(Ok(result.clone())); + return; + } + let _ = sender.send(Err(BackendError::Protocol( + "Response missing result or error".into(), + ))); + } +} + +fn parse_rpc_error(error_value: &Value) -> crate::protocol::RpcError { + serde_json::from_value(error_value.clone()).unwrap_or_else(|err| crate::protocol::RpcError { + code: -32603, + message: format!("Failed to parse RPC error: {err}"), + data: None, + }) +} + +fn handle_notification( + value: Value, + event_tx: &mpsc::Sender, +) -> Result<(), BackendError> { + let method = value + .get("method") + .and_then(|value| value.as_str()) + .ok_or_else(|| BackendError::Protocol("Notification missing method".into()))?; + let params = value.get("params").cloned(); + + let notification = match method { + "clusterLogLines" => { + let params_value = params + .ok_or_else(|| BackendError::Protocol("clusterLogLines missing params".into()))?; + let parsed: ClusterLogLinesParams = serde_json::from_value(params_value)?; + BackendNotification::ClusterLogLines(parsed) + } + "clusterTimelineEvents" => { + let params_value = params.ok_or_else(|| { + BackendError::Protocol("clusterTimelineEvents missing params".into()) + })?; + let parsed: ClusterTimelineEventsParams = serde_json::from_value(params_value)?; + BackendNotification::ClusterTimelineEvents(parsed) + } + _ => BackendNotification::Unknown { + method: method.to_string(), + params, + }, + }; + + let _ = event_tx.send(BackendEvent::Notification(notification)); + Ok(()) +} + +fn drain_pending( + pending: &Arc>>>>, + error: BackendError, +) { + let message = error.to_string(); + let senders = { + let mut pending = match pending.lock() { + Ok(lock) => lock, + Err(poisoned) => poisoned.into_inner(), + }; + let mut items = Vec::with_capacity(pending.len()); + for (_, sender) in pending.drain() { + items.push(sender); + } + items + }; + + for sender in senders { + let _ = sender.send(Err(BackendError::Disconnected(message.clone()))); + } +} + +pub fn resolve_backend_path(config: &BackendConfig) -> Result { + if let Some(path) = config.backend_path.clone() { + return Ok(path); + } + + if let Ok(value) = env::var(BACKEND_PATH_ENV) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + let cwd = env::current_dir()?; + if let Some(found) = find_in_ancestors(&cwd, DEFAULT_BACKEND_RELATIVE_PATH) { + return Ok(found); + } + + Err(BackendError::Protocol(format!( + "Backend path not found (set {BACKEND_PATH_ENV} or build {DEFAULT_BACKEND_RELATIVE_PATH})" + ))) +} + +fn find_in_ancestors(start: &Path, relative: &str) -> Option { + for ancestor in start.ancestors() { + let candidate = ancestor.join(relative); + if candidate.is_file() { + return Some(candidate); + } + } + None +} diff --git a/tui-rs/crates/zeroshot-tui/src/commands/dispatcher.rs b/tui-rs/crates/zeroshot-tui/src/commands/dispatcher.rs new file mode 100644 index 00000000..4bcc08e0 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/commands/dispatcher.rs @@ -0,0 +1,272 @@ +use crate::app::{ + Action, CommandAction, CommandContext, NavigationAction, ScreenId, ToastLevel, UiVariant, +}; +use crate::commands::types::{ParsedCommand, VALID_PROVIDERS}; + +pub fn dispatch(parsed: ParsedCommand, context: CommandContext) -> Vec { + match parsed.name.as_str() { + "help" => vec![Action::Command(CommandAction::ShowToast { + level: ToastLevel::Info, + message: help_message(), + })], + "monitor" => handle_monitor(context), + "guide" => handle_guidance(parsed, None, true), + "nudge" => handle_guidance(parsed, Some("[nudge]"), true), + "interrupt" => handle_guidance(parsed, Some("[interrupt]"), false), + "pin" => vec![Action::Command(CommandAction::TogglePin)], + "issue" => handle_issue(parsed, context), + "provider" => handle_provider(parsed), + "quit" | "exit" => vec![Action::Quit], + other => vec![Action::Command(CommandAction::ShowToast { + level: ToastLevel::Error, + message: format!("Unknown command: {other}. Try /help."), + })], + } +} + +fn handle_monitor(context: CommandContext) -> Vec { + let (target, label) = if matches!(context.ui_variant, UiVariant::Disruptive) { + (ScreenId::FleetRadar, "Fleet Radar") + } else { + (ScreenId::Monitor, "Monitor") + }; + + if context.active_screen == target { + return vec![Action::Command(CommandAction::ShowToast { + level: ToastLevel::Info, + message: format!("Already on {label}."), + })]; + } + + vec![ + Action::Navigate(NavigationAction::Push(target)), + Action::Command(CommandAction::ShowToast { + level: ToastLevel::Success, + message: format!("Opened {label}."), + }), + ] +} + +fn handle_issue(parsed: ParsedCommand, context: CommandContext) -> Vec { + let Some(reference) = parsed.args.first() else { + return vec![Action::Command(CommandAction::ShowToast { + level: ToastLevel::Error, + message: "Usage: /issue ".to_string(), + })]; + }; + + vec![ + Action::Command(CommandAction::StartClusterFromIssue { + reference: reference.to_string(), + provider_override: context.provider_override, + }), + Action::Command(CommandAction::ShowToast { + level: ToastLevel::Info, + message: format!("Starting cluster from issue {reference}..."), + }), + ] +} + +fn handle_guidance( + parsed: ParsedCommand, + prefix: Option<&'static str>, + require_text: bool, +) -> Vec { + let message = parsed.args.join(" "); + if require_text && message.trim().is_empty() { + return vec![Action::Command(CommandAction::ShowToast { + level: ToastLevel::Error, + message: format!("Usage: /{} ", parsed.name), + })]; + } + vec![Action::Command(CommandAction::SendGuidance { + message, + prefix: prefix.map(|value| value.to_string()), + })] +} + +fn handle_provider(parsed: ParsedCommand) -> Vec { + let Some(name) = parsed.args.first() else { + return vec![Action::Command(CommandAction::ShowToast { + level: ToastLevel::Error, + message: format!("Usage: /provider <{}>", VALID_PROVIDERS.join("|")), + })]; + }; + + let normalized = name.to_lowercase(); + if !VALID_PROVIDERS.contains(&normalized.as_str()) { + return vec![Action::Command(CommandAction::ShowToast { + level: ToastLevel::Error, + message: format!( + "Unknown provider '{name}'. Use one of: {}", + VALID_PROVIDERS.join(", ") + ), + })]; + } + + vec![ + Action::Command(CommandAction::SetProviderOverride { + provider: Some(normalized.clone()), + }), + Action::Command(CommandAction::ShowToast { + level: ToastLevel::Success, + message: format!("Provider override set to {normalized}."), + }), + ] +} + +fn help_message() -> String { + let lines = [ + "Commands: /help /monitor /issue /provider /guide /nudge /interrupt [text] /pin /quit /exit", + "Keys: / command bar, ? help, Esc back, q quit (not in Launcher), Ctrl+C quit, j/k or arrows move, PgUp/PgDn fast, Tab/Shift+Tab or h/l switch panes", + ]; + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::dispatch; + use crate::app::{Action, CommandContext, NavigationAction, ScreenId, ToastLevel, UiVariant}; + use crate::commands::types::ParsedCommand; + + fn context() -> CommandContext { + CommandContext { + provider_override: None, + active_screen: ScreenId::Launcher, + ui_variant: UiVariant::Classic, + } + } + + #[test] + fn unknown_command_returns_error_toast() { + let parsed = ParsedCommand { + raw: "/nope".to_string(), + name: "nope".to_string(), + args: vec![], + }; + let actions = dispatch(parsed, context()); + let toast = actions + .iter() + .find_map(|action| match action { + crate::app::Action::Command(crate::app::CommandAction::ShowToast { + level, + message, + }) => Some((level, message)), + _ => None, + }) + .expect("expected toast action"); + assert_eq!(toast.0, &ToastLevel::Error); + assert!(toast.1.contains("Unknown command")); + } + + #[test] + fn invalid_provider_is_rejected() { + let parsed = ParsedCommand { + raw: "/provider nope".to_string(), + name: "provider".to_string(), + args: vec!["nope".to_string()], + }; + let actions = dispatch(parsed, context()); + let toast = actions + .iter() + .find_map(|action| match action { + crate::app::Action::Command(crate::app::CommandAction::ShowToast { + level, + message, + }) => Some((level, message)), + _ => None, + }) + .expect("expected toast action"); + assert_eq!(toast.0, &ToastLevel::Error); + assert!(toast.1.contains("Unknown provider")); + } + + #[test] + fn monitor_command_targets_fleet_radar_in_disruptive() { + let parsed = ParsedCommand { + raw: "/monitor".to_string(), + name: "monitor".to_string(), + args: vec![], + }; + let mut context = context(); + context.ui_variant = UiVariant::Disruptive; + let actions = dispatch(parsed, context); + assert!(actions.iter().any(|action| matches!( + action, + Action::Navigate(NavigationAction::Push(ScreenId::FleetRadar)) + ))); + } + + #[test] + fn monitor_command_targets_monitor_in_classic() { + let parsed = ParsedCommand { + raw: "/monitor".to_string(), + name: "monitor".to_string(), + args: vec![], + }; + let actions = dispatch(parsed, context()); + assert!(actions.iter().any(|action| matches!( + action, + Action::Navigate(NavigationAction::Push(ScreenId::Monitor)) + ))); + } + + #[test] + fn guide_command_dispatches_guidance_without_prefix() { + let parsed = ParsedCommand { + raw: "/guide hi there".to_string(), + name: "guide".to_string(), + args: vec!["hi".to_string(), "there".to_string()], + }; + let actions = dispatch(parsed, context()); + assert!(actions.iter().any(|action| matches!( + action, + Action::Command(crate::app::CommandAction::SendGuidance { message, prefix }) + if message == "hi there" && prefix.is_none() + ))); + } + + #[test] + fn nudge_command_dispatches_guidance_with_prefix() { + let parsed = ParsedCommand { + raw: "/nudge hi".to_string(), + name: "nudge".to_string(), + args: vec!["hi".to_string()], + }; + let actions = dispatch(parsed, context()); + assert!(actions.iter().any(|action| matches!( + action, + Action::Command(crate::app::CommandAction::SendGuidance { message, prefix }) + if message == "hi" && prefix.as_deref() == Some("[nudge]") + ))); + } + + #[test] + fn interrupt_command_allows_empty_text() { + let parsed = ParsedCommand { + raw: "/interrupt".to_string(), + name: "interrupt".to_string(), + args: vec![], + }; + let actions = dispatch(parsed, context()); + assert!(actions.iter().any(|action| matches!( + action, + Action::Command(crate::app::CommandAction::SendGuidance { message, prefix }) + if message.is_empty() && prefix.as_deref() == Some("[interrupt]") + ))); + } + + #[test] + fn pin_command_dispatches_toggle() { + let parsed = ParsedCommand { + raw: "/pin".to_string(), + name: "pin".to_string(), + args: vec![], + }; + let actions = dispatch(parsed, context()); + assert!(actions.iter().any(|action| matches!( + action, + Action::Command(crate::app::CommandAction::TogglePin) + ))); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/commands/mod.rs b/tui-rs/crates/zeroshot-tui/src/commands/mod.rs new file mode 100644 index 00000000..eca3748f --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/commands/mod.rs @@ -0,0 +1,20 @@ +use crate::app::{Action, CommandAction, CommandRequest, ToastLevel}; + +mod dispatcher; +mod parser; +mod types; + +pub use parser::parse; +pub use types::{CommandError, ParsedCommand, VALID_PROVIDERS}; + +pub fn dispatch(request: CommandRequest) -> Result, CommandError> { + match request { + CommandRequest::SubmitRaw { raw, context } => match parser::parse(&raw) { + Ok(parsed) => Ok(dispatcher::dispatch(parsed, context)), + Err(err) => Ok(vec![Action::Command(CommandAction::ShowToast { + level: ToastLevel::Error, + message: err.to_string(), + })]), + }, + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/commands/parser.rs b/tui-rs/crates/zeroshot-tui/src/commands/parser.rs new file mode 100644 index 00000000..7a6b8d01 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/commands/parser.rs @@ -0,0 +1,66 @@ +use crate::commands::types::{CommandError, ParsedCommand}; + +pub fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CommandError::new("Enter a command.")); + } + if !trimmed.starts_with('/') { + return Err(CommandError::new("Commands must start with '/'.")); + } + + let body = trimmed.trim_start_matches('/').trim(); + if body.is_empty() { + return Err(CommandError::new("Enter a command after '/'.")); + } + + let mut parts = body.split_whitespace(); + let Some(name) = parts.next() else { + return Err(CommandError::new("Enter a command after '/'.")); + }; + + let args = parts.map(|part| part.to_string()).collect::>(); + Ok(ParsedCommand { + raw: trimmed.to_string(), + name: name.to_lowercase(), + args, + }) +} + +#[cfg(test)] +mod tests { + use super::parse; + + #[test] + fn parse_empty() { + let err = parse("").expect_err("expected error"); + assert_eq!(err.to_string(), "Enter a command."); + } + + #[test] + fn parse_slash_only() { + let err = parse("/").expect_err("expected error"); + assert_eq!(err.to_string(), "Enter a command after '/'."); + } + + #[test] + fn parse_whitespace_tolerant() { + let parsed = parse(" /provider codex ").expect("expected command"); + assert_eq!(parsed.name, "provider"); + assert_eq!(parsed.args, vec!["codex".to_string()]); + } + + #[test] + fn parse_provider_command() { + let parsed = parse("/provider codex").expect("expected command"); + assert_eq!(parsed.name, "provider"); + assert_eq!(parsed.args, vec!["codex".to_string()]); + } + + #[test] + fn parse_issue_command() { + let parsed = parse("/issue org/repo#123").expect("expected command"); + assert_eq!(parsed.name, "issue"); + assert_eq!(parsed.args, vec!["org/repo#123".to_string()]); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/commands/types.rs b/tui-rs/crates/zeroshot-tui/src/commands/types.rs new file mode 100644 index 00000000..cdfcc726 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/commands/types.rs @@ -0,0 +1,41 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedCommand { + pub raw: String, + pub name: String, + pub args: Vec, +} + +impl ParsedCommand { + pub fn name(&self) -> &str { + self.name.as_str() + } + + pub fn args(&self) -> &[String] { + self.args.as_slice() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandError { + message: String, +} + +impl CommandError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for CommandError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for CommandError {} + +pub const VALID_PROVIDERS: [&str; 4] = ["claude", "codex", "gemini", "opencode"]; diff --git a/tui-rs/crates/zeroshot-tui/src/input.rs b/tui-rs/crates/zeroshot-tui/src/input.rs new file mode 100644 index 00000000..6013fb07 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/input.rs @@ -0,0 +1,489 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::app::{ + Action, AppState, CommandBarAction, NavigationAction, ScreenAction, ScreenId, SpineAction, + SpineMode, TimeCursorAction, UiVariant, ZoomStackContext, TIME_SCRUB_STEP_LARGE_MS, + TIME_SCRUB_STEP_MS, +}; +use crate::screens::{agent, cluster, cluster_canvas, launcher, monitor, radar}; + +pub fn route_key(state: &AppState, key: KeyEvent) -> Option { + if matches!(state.ui_variant, UiVariant::Disruptive) { + return route_disruptive(state, key); + } + + if state.command_bar.active { + return route_command_bar(key); + } + + let screen = state.active_screen(); + if let Some(action) = route_global(screen, key) { + return Some(action); + } + + if !matches!(screen, ScreenId::Launcher | ScreenId::IntentConsole) { + match key.code { + KeyCode::Char('/') + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + return Some(Action::CommandBar(CommandBarAction::Open { + prefill: "/".to_string(), + })); + } + KeyCode::Char('?') + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + return Some(Action::CommandBar(CommandBarAction::Open { + prefill: "/help ".to_string(), + })); + } + _ => {} + } + } + + match screen { + ScreenId::Launcher => route_launcher(key), + ScreenId::Monitor => route_monitor(key), + ScreenId::Cluster { id } => route_cluster(id, key), + ScreenId::Agent { + cluster_id, + agent_id, + } => route_agent(cluster_id, agent_id, key), + ScreenId::IntentConsole + | ScreenId::FleetRadar + | ScreenId::ClusterCanvas { .. } + | ScreenId::AgentMicroscope { .. } => None, + } +} + +fn route_disruptive(state: &AppState, key: KeyEvent) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + + if ctrl && matches!(key.code, KeyCode::Char('c')) { + return Some(Action::Quit); + } + + if !spine_active(state) { + if let Some(action) = route_time_scrub(state, key, ctrl, alt) { + return Some(action); + } + if let Some(action) = route_disruptive_radar(state, key, ctrl, alt) { + return Some(action); + } + if let Some(action) = route_disruptive_cluster_canvas(state, key, ctrl, alt) { + return Some(action); + } + } + + match key.code { + KeyCode::Esc => { + if spine_active(state) { + Some(Action::Spine(SpineAction::Cancel)) + } else { + Some(Action::Navigate(NavigationAction::Pop)) + } + } + KeyCode::Enter => { + if spine_active(state) { + Some(Action::Spine(SpineAction::Submit)) + } else if let Some(action) = cluster_canvas_zoom_action(state) { + Some(action) + } else { + zoom_in_action(state) + } + } + KeyCode::Char('?') if !ctrl && !alt => Some(Action::Spine(SpineAction::EnterMode { + mode: SpineMode::Command, + prefill: "help ".to_string(), + })), + KeyCode::Char('/') if !ctrl && !alt => Some(Action::Spine(SpineAction::EnterMode { + mode: SpineMode::Command, + prefill: String::new(), + })), + KeyCode::Char('i') if !ctrl && !alt => Some(Action::Spine(SpineAction::EnterMode { + mode: intent_mode_for_context(state), + prefill: String::new(), + })), + KeyCode::Char('u') if ctrl => Some(Action::Spine(SpineAction::Clear)), + KeyCode::Tab => match state.spine.completion.as_ref() { + Some(completion) if completion.candidates.len() > 1 => { + Some(Action::Spine(SpineAction::CycleCompletion)) + } + Some(completion) if !completion.ghost.is_empty() => { + Some(Action::Spine(SpineAction::AcceptCompletion)) + } + _ => None, + }, + KeyCode::Backspace => Some(Action::Spine(SpineAction::Backspace)), + KeyCode::Delete => Some(Action::Spine(SpineAction::Delete)), + KeyCode::Left => Some(Action::Spine(SpineAction::MoveCursorLeft)), + KeyCode::Right => Some(Action::Spine(SpineAction::MoveCursorRight)), + KeyCode::Home => Some(Action::Spine(SpineAction::MoveCursorHome)), + KeyCode::End => Some(Action::Spine(SpineAction::MoveCursorEnd)), + KeyCode::Char(ch) if !ctrl && !alt => Some(Action::Spine(SpineAction::InsertChar(ch))), + _ => None, + } +} + +fn route_time_scrub(state: &AppState, key: KeyEvent, ctrl: bool, alt: bool) -> Option { + if ctrl || alt { + return None; + } + let scope_available = state.temporal_focus_scope().is_some(); + let focus_active = state.temporal_focus.is_active(); + + let large = key.modifiers.contains(KeyModifiers::SHIFT); + let step = if large { + TIME_SCRUB_STEP_LARGE_MS + } else { + TIME_SCRUB_STEP_MS + }; + + match key.code { + KeyCode::Left if focus_active => Some(Action::TimeCursor(TimeCursorAction::Step { + delta_ms: -step, + })), + KeyCode::Right if focus_active => Some(Action::TimeCursor(TimeCursorAction::Step { + delta_ms: step, + })), + KeyCode::End if focus_active => Some(Action::TimeCursor(TimeCursorAction::JumpToLive)), + KeyCode::Char(' ') if focus_active || scope_available => { + Some(Action::TimeCursor(TimeCursorAction::ToggleFollow)) + } + _ => None, + } +} + +fn route_disruptive_radar( + state: &AppState, + key: KeyEvent, + ctrl: bool, + alt: bool, +) -> Option { + if ctrl || alt { + return None; + } + if !matches!(state.zoom_stack_context(), ZoomStackContext::FleetRadar) { + return None; + } + + let speed = if key.modifiers.contains(KeyModifiers::SHIFT) { + radar::MoveSpeed::Fast + } else { + radar::MoveSpeed::Step + }; + + let action = match key.code { + KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('H') => radar::Action::MoveSelection { + direction: radar::Direction::Left, + speed, + }, + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') => radar::Action::MoveSelection { + direction: radar::Direction::Right, + speed, + }, + KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => radar::Action::MoveSelection { + direction: radar::Direction::Up, + speed, + }, + KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => radar::Action::MoveSelection { + direction: radar::Direction::Down, + speed, + }, + KeyCode::Char('g') => radar::Action::CenterOnSelection, + KeyCode::Char('G') => radar::Action::ResetView, + _ => return None, + }; + + Some(Action::Screen(ScreenAction::FleetRadar(action))) +} + +fn route_disruptive_cluster_canvas( + state: &AppState, + key: KeyEvent, + ctrl: bool, + alt: bool, +) -> Option { + if ctrl || alt { + return None; + } + + let ScreenId::ClusterCanvas { id } = state.active_screen() else { + return None; + }; + + let speed = if key.modifiers.contains(KeyModifiers::SHIFT) { + cluster_canvas::MoveSpeed::Fast + } else { + cluster_canvas::MoveSpeed::Step + }; + + let action = match key.code { + KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('H') => { + cluster_canvas::Action::MoveFocus { + direction: cluster_canvas::Direction::Left, + speed, + } + } + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') => { + cluster_canvas::Action::MoveFocus { + direction: cluster_canvas::Direction::Right, + speed, + } + } + KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => { + cluster_canvas::Action::MoveFocus { + direction: cluster_canvas::Direction::Up, + speed, + } + } + KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => { + cluster_canvas::Action::MoveFocus { + direction: cluster_canvas::Direction::Down, + speed, + } + } + _ => return None, + }; + + Some(Action::Screen(ScreenAction::ClusterCanvas { + id: id.clone(), + action, + })) +} + +fn spine_active(state: &AppState) -> bool { + if matches!(state.active_screen(), ScreenId::AgentMicroscope { .. }) { + return !state.spine.input.input.is_empty() || state.spine.completion.is_some(); + } + !state.spine.input.input.is_empty() + || !matches!(state.spine.mode, SpineMode::Intent) + || state.spine.completion.is_some() +} + +fn intent_mode_for_context(state: &AppState) -> SpineMode { + match state.zoom_stack_context() { + ZoomStackContext::Agent { .. } => SpineMode::WhisperAgent, + ZoomStackContext::Cluster { .. } => SpineMode::WhisperCluster, + ZoomStackContext::FleetRadar => { + if selected_cluster_id_for_zoom(state).is_some() { + SpineMode::WhisperCluster + } else { + SpineMode::Intent + } + } + ZoomStackContext::Root => SpineMode::Intent, + } +} + +fn zoom_in_action(state: &AppState) -> Option { + match state.zoom_stack_context() { + ZoomStackContext::FleetRadar => selected_cluster_id_for_zoom(state).map(|cluster_id| { + Action::Navigate(NavigationAction::Push(ScreenId::ClusterCanvas { + id: cluster_id, + })) + }), + ZoomStackContext::Cluster { id } => selected_agent_id(state, &id).map(|agent_id| { + Action::Navigate(NavigationAction::Push(ScreenId::AgentMicroscope { + cluster_id: id, + agent_id, + })) + }), + ZoomStackContext::Agent { .. } | ZoomStackContext::Root => None, + } +} + +fn cluster_canvas_zoom_action(state: &AppState) -> Option { + match state.active_screen() { + ScreenId::ClusterCanvas { id } => Some(Action::Screen(ScreenAction::ClusterCanvas { + id: id.clone(), + action: cluster_canvas::Action::ZoomIn, + })), + _ => None, + } +} + +fn selected_cluster_id_for_zoom(state: &AppState) -> Option { + match state.active_screen() { + ScreenId::Monitor => state.monitor.selected_cluster_id(), + _ => state.fleet_radar.selected_cluster_id(), + } +} + +fn selected_agent_id(state: &AppState, cluster_id: &str) -> Option { + let cluster_state = state.clusters.get(cluster_id)?; + let agent = cluster_state.agents.get(cluster_state.selected_agent)?; + Some(agent.id.clone()) +} + +fn route_command_bar(key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => Some(Action::CommandBar(CommandBarAction::Close)), + KeyCode::Enter => Some(Action::CommandBar(CommandBarAction::Submit)), + KeyCode::Backspace => Some(Action::CommandBar(CommandBarAction::Backspace)), + KeyCode::Delete => Some(Action::CommandBar(CommandBarAction::Delete)), + KeyCode::Left => Some(Action::CommandBar(CommandBarAction::MoveCursorLeft)), + KeyCode::Right => Some(Action::CommandBar(CommandBarAction::MoveCursorRight)), + KeyCode::Home => Some(Action::CommandBar(CommandBarAction::MoveCursorHome)), + KeyCode::End => Some(Action::CommandBar(CommandBarAction::MoveCursorEnd)), + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + Some(Action::CommandBar(CommandBarAction::InsertChar(ch))) + } + _ => None, + } +} + +fn route_global(screen: &ScreenId, key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => Some(Action::Navigate(NavigationAction::Pop)), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::Quit), + KeyCode::Char('q') => match screen { + ScreenId::Launcher | ScreenId::IntentConsole => None, + _ => Some(Action::Quit), + }, + _ => None, + } +} + +fn route_launcher(key: KeyEvent) -> Option { + match key.code { + KeyCode::Enter => Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::Submit, + ))), + KeyCode::Backspace => Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::Backspace, + ))), + KeyCode::Delete => Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::Delete, + ))), + KeyCode::Left => Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::MoveCursorLeft, + ))), + KeyCode::Right => Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::MoveCursorRight, + ))), + KeyCode::Home => Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::MoveCursorHome, + ))), + KeyCode::End => Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::MoveCursorEnd, + ))), + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::InsertChar(ch), + ))) + } + _ => None, + } +} + +fn route_monitor(key: KeyEvent) -> Option { + match key.code { + KeyCode::Up | KeyCode::Char('k') => Some(Action::Screen(ScreenAction::Monitor( + monitor::Action::MoveSelection(-1), + ))), + KeyCode::Down | KeyCode::Char('j') => Some(Action::Screen(ScreenAction::Monitor( + monitor::Action::MoveSelection(1), + ))), + KeyCode::PageUp => Some(Action::Screen(ScreenAction::Monitor( + monitor::Action::MoveSelection(-5), + ))), + KeyCode::PageDown => Some(Action::Screen(ScreenAction::Monitor( + monitor::Action::MoveSelection(5), + ))), + KeyCode::Enter => Some(Action::Screen(ScreenAction::Monitor( + monitor::Action::OpenSelected, + ))), + _ => None, + } +} + +fn route_cluster(id: &str, key: KeyEvent) -> Option { + let action = match key.code { + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + cluster::Action::CycleFocus(cluster::FocusDirection::Next) + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + cluster::Action::CycleFocus(cluster::FocusDirection::Prev) + } + KeyCode::Up | KeyCode::Char('k') => cluster::Action::MoveFocused(-1), + KeyCode::Down | KeyCode::Char('j') => cluster::Action::MoveFocused(1), + KeyCode::PageUp => cluster::Action::MoveFocused(-5), + KeyCode::PageDown => cluster::Action::MoveFocused(5), + KeyCode::Enter => cluster::Action::ActivateFocused, + _ => return None, + }; + + Some(Action::Screen(ScreenAction::Cluster { + id: id.to_string(), + action, + })) +} + +fn route_agent(cluster_id: &str, agent_id: &str, key: KeyEvent) -> Option { + let action = match key.code { + KeyCode::Enter => agent::Action::SubmitGuidance, + KeyCode::Backspace => agent::Action::Backspace, + KeyCode::Delete => agent::Action::Delete, + KeyCode::Left => agent::Action::MoveCursorLeft, + KeyCode::Right => agent::Action::MoveCursorRight, + KeyCode::Home => agent::Action::MoveCursorHome, + KeyCode::End => agent::Action::MoveCursorEnd, + KeyCode::Up | KeyCode::Char('k') => agent::Action::ScrollLogs(-1), + KeyCode::Down | KeyCode::Char('j') => agent::Action::ScrollLogs(1), + KeyCode::PageUp => agent::Action::ScrollLogs(-5), + KeyCode::PageDown => agent::Action::ScrollLogs(5), + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + agent::Action::InsertChar(ch) + } + _ => return None, + }; + + Some(Action::Screen(ScreenAction::Agent { + cluster_id: cluster_id.to_string(), + agent_id: agent_id.to_string(), + action, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disruptive_esc_pops_microscope() { + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + state.screen_stack = vec![ + ScreenId::ClusterCanvas { + id: "cluster-1".to_string(), + }, + ScreenId::AgentMicroscope { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + }, + ]; + state.spine.mode = SpineMode::WhisperAgent; + state.spine.input.input.clear(); + state.spine.input.cursor = 0; + state.spine.completion = None; + + let action = route_key(&state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!( + action, + Some(Action::Navigate(NavigationAction::Pop)) + )); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/lib.rs b/tui-rs/crates/zeroshot-tui/src/lib.rs new file mode 100644 index 00000000..0e761b83 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/lib.rs @@ -0,0 +1,15 @@ +// Pre-existing clippy lints in backend/terminal modules — fix in a dedicated cleanup PR. +#![allow(clippy::type_complexity)] +#![allow(clippy::field_reassign_with_default)] +#![allow(clippy::question_mark)] +#![allow(clippy::while_let_loop)] +#![allow(clippy::needless_return)] + +pub mod app; +pub mod backend; +pub mod commands; +pub mod input; +pub mod protocol; +pub mod screens; +pub mod terminal; +pub mod ui; diff --git a/tui-rs/crates/zeroshot-tui/src/main.rs b/tui-rs/crates/zeroshot-tui/src/main.rs new file mode 100644 index 00000000..11412087 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/main.rs @@ -0,0 +1,557 @@ +#![allow(clippy::needless_return)] +#![allow(clippy::io_other_error)] + +use std::env; +use std::io::{self, stdout}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crossterm::event::{self, Event, KeyEventKind}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use zeroshot_tui::app::{ + resolve_ui_variant, Action, AppState, BackendAction, BackendRequest, Effect, InitialScreen, + StartupOptions, +}; +use zeroshot_tui::backend::stdio::StdioBackendClient; +use zeroshot_tui::backend::{BackendClient, BackendConfig, BackendError, BackendEvent}; +use zeroshot_tui::commands; +use zeroshot_tui::input; +use zeroshot_tui::terminal::TerminalGuard; +use zeroshot_tui::ui; + +type ActionSender = mpsc::Sender; +type ActionReceiver = mpsc::Receiver; +type TuiTerminal = Terminal>; + +const INITIAL_SCREEN_ENV: &str = "ZEROSHOT_TUI_INITIAL_SCREEN"; +const PROVIDER_OVERRIDE_ENV: &str = "ZEROSHOT_TUI_PROVIDER_OVERRIDE"; +const UI_VARIANT_ENV: &str = "ZEROSHOT_TUI_UI"; + +fn main() -> io::Result<()> { + if handle_cli_flags()? { + return Ok(()); + } + run_app() +} + +fn run_app() -> io::Result<()> { + let guard = init_terminal_guard()?; + let mut terminal = setup_terminal()?; + maybe_force_panic(); + let startup_options = parse_startup_options()?; + + let (action_tx, action_rx) = mpsc::channel::(); + let mut backend = connect_backend(&action_tx)?; + let mut state = AppState::new(); + state.apply_startup_options(startup_options); + let tick_rate = Duration::from_millis(250); + let mut last_tick = Instant::now(); + + app_loop( + &mut terminal, + &mut state, + &action_tx, + &action_rx, + &mut backend, + tick_rate, + &mut last_tick, + )?; + + shutdown_backend(backend)?; + drop(terminal); + guard.restore()?; + Ok(()) +} + +fn handle_cli_flags() -> io::Result { + let args: Vec = env::args().skip(1).collect(); + if args.iter().any(|arg| arg == "--version") { + println!("{}", env!("CARGO_PKG_VERSION")); + return Ok(true); + } + if args.iter().any(|arg| arg == "--smoke-test") { + println!("ok"); + return Ok(true); + } + Ok(false) +} + +fn init_terminal_guard() -> io::Result { + let guard = TerminalGuard::new()?; + guard.install_panic_hook(); + Ok(guard) +} + +fn setup_terminal() -> io::Result { + Terminal::new(CrosstermBackend::new(stdout())) +} + +fn maybe_force_panic() { + if env::var("ZEROSHOT_TUI_PANIC").ok().as_deref() == Some("1") { + panic!("ZEROSHOT_TUI_PANIC=1 requested"); + } +} + +fn parse_startup_options() -> io::Result { + let mut options = StartupOptions::default(); + let mut args = env::args().skip(1); + let mut ui_arg: Option = None; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--initial-screen" => { + let value = args.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "--initial-screen requires a value", + ) + })?; + options.initial_screen = Some(parse_initial_screen(&value)?); + } + "--provider-override" => { + let value = args.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "--provider-override requires a value", + ) + })?; + if !value.trim().is_empty() { + options.provider_override = Some(value.trim().to_string()); + } + } + "--ui" => { + let value = args.next().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "--ui requires a value") + })?; + ui_arg = Some(value); + } + _ => {} + } + } + + if options.initial_screen.is_none() { + if let Ok(value) = env::var(INITIAL_SCREEN_ENV) { + if !value.trim().is_empty() { + options.initial_screen = Some(parse_initial_screen(&value)?); + } + } + } + + if options.provider_override.is_none() { + if let Ok(value) = env::var(PROVIDER_OVERRIDE_ENV) { + if !value.trim().is_empty() { + options.provider_override = Some(value.trim().to_string()); + } + } + } + + let env_ui = env::var(UI_VARIANT_ENV).ok(); + options.ui_variant = resolve_ui_variant(ui_arg.as_deref(), env_ui.as_deref()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + + Ok(options) +} + +fn parse_initial_screen(value: &str) -> io::Result { + InitialScreen::parse(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err)) +} + +fn app_loop( + terminal: &mut TuiTerminal, + state: &mut AppState, + action_tx: &ActionSender, + action_rx: &ActionReceiver, + backend: &mut Option, + tick_rate: Duration, + last_tick: &mut Instant, +) -> io::Result<()> { + loop { + handle_terminal_events(state, action_tx, tick_rate, last_tick)?; + drain_actions(state, action_rx, backend, action_tx)?; + terminal.draw(|frame| ui::render(frame, state))?; + + if state.should_quit { + break; + } + } + + Ok(()) +} + +fn handle_terminal_events( + state: &AppState, + action_tx: &ActionSender, + tick_rate: Duration, + last_tick: &mut Instant, +) -> io::Result<()> { + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(action) = input::route_key(state, key) { + send_action(action_tx, action)?; + } + } + Event::Resize(width, height) => { + send_action(action_tx, Action::Resize { width, height })?; + } + _ => {} + } + } + + if last_tick.elapsed() >= tick_rate { + send_action(action_tx, Action::Tick { now_ms: now_ms() })?; + *last_tick = Instant::now(); + } + + Ok(()) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +fn drain_actions( + state: &mut AppState, + action_rx: &ActionReceiver, + backend: &mut Option, + action_tx: &ActionSender, +) -> io::Result<()> { + loop { + match action_rx.try_recv() { + Ok(action) => { + let (next_state, effects) = + zeroshot_tui::app::update(std::mem::take(state), action); + *state = next_state; + execute_effects(effects, backend, action_tx)?; + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "action channel disconnected", + )); + } + } + } + + Ok(()) +} + +fn connect_backend(action_tx: &ActionSender) -> io::Result> { + match StdioBackendClient::connect(BackendConfig::default()) { + Ok(mut client) => { + if let Some(events) = client.take_event_receiver() { + let tx = action_tx.clone(); + thread::spawn(move || handle_backend_events(events, tx)); + } + send_action(action_tx, Action::Backend(BackendAction::Connected))?; + Ok(Some(client)) + } + Err(err) => { + send_action( + action_tx, + Action::Backend(BackendAction::ConnectionFailed(err.to_string())), + )?; + Ok(None) + } + } +} + +fn handle_backend_events(events: mpsc::Receiver, action_tx: ActionSender) { + for event in events { + let action = match event { + BackendEvent::Notification(notification) => { + Action::Backend(BackendAction::Notification(notification)) + } + BackendEvent::BackendExited(exit) => { + Action::Backend(BackendAction::BackendExited(exit)) + } + }; + + if !send_action_thread(&action_tx, action) { + break; + } + } +} + +fn send_action(action_tx: &ActionSender, action: Action) -> io::Result<()> { + action_tx.send(action).map_err(|err| { + io::Error::new( + io::ErrorKind::BrokenPipe, + format!("action channel closed: {err}"), + ) + }) +} + +fn send_action_thread(action_tx: &ActionSender, action: Action) -> bool { + if let Err(err) = action_tx.send(action) { + eprintln!("backend event send failed: {err}"); + return false; + } + true +} + +fn execute_effects( + effects: Vec, + backend: &mut Option, + action_tx: &ActionSender, +) -> io::Result<()> { + for effect in effects { + match effect { + Effect::Backend(request) => { + if let Some(client) = backend.as_ref() { + match execute_backend_request(client, request) { + Ok(Some(action)) => { + send_action(action_tx, Action::Backend(action))?; + } + Ok(None) => {} + Err(err) => { + send_action( + action_tx, + Action::Backend(BackendAction::Error(err.to_string())), + )?; + } + } + } else { + send_action( + action_tx, + Action::Backend(BackendAction::ConnectionFailed( + "Backend unavailable".to_string(), + )), + )?; + } + } + Effect::Command(request) => match commands::dispatch(request) { + Ok(actions) => { + for action in actions { + send_action(action_tx, action)?; + } + } + Err(err) => { + send_action( + action_tx, + Action::Backend(BackendAction::Error(err.to_string())), + )?; + } + }, + } + } + + Ok(()) +} + +fn execute_backend_request( + client: &StdioBackendClient, + request: BackendRequest, +) -> Result, BackendError> { + match request { + BackendRequest::ListClusters => list_clusters(client), + BackendRequest::ListClusterMetrics { cluster_ids } => { + list_cluster_metrics(client, cluster_ids) + } + BackendRequest::GetClusterSummary { cluster_id } => get_cluster_summary(client, cluster_id), + BackendRequest::GetClusterTopology { cluster_id } => { + get_cluster_topology(client, cluster_id) + } + BackendRequest::SubscribeClusterLogs { + cluster_id, + agent_id, + } => subscribe_cluster_logs(client, cluster_id, agent_id), + BackendRequest::SubscribeClusterTimeline { cluster_id } => { + subscribe_cluster_timeline(client, cluster_id) + } + BackendRequest::StartClusterFromText { + text, + provider_override, + } => start_cluster_from_text(client, text, provider_override), + BackendRequest::StartClusterFromIssue { + reference, + provider_override, + } => start_cluster_from_issue(client, reference, provider_override), + BackendRequest::SendGuidanceToCluster { + cluster_id, + message, + } => send_guidance_to_cluster(client, cluster_id, message), + BackendRequest::SendGuidanceToAgent { + cluster_id, + agent_id, + message, + } => send_guidance_to_agent(client, cluster_id, agent_id, message), + BackendRequest::Unsubscribe { subscription_id } => unsubscribe(client, subscription_id), + } +} + +fn list_clusters(client: &StdioBackendClient) -> Result, BackendError> { + let result = client.list_clusters()?; + Ok(Some(BackendAction::ClustersListed(result.clusters))) +} + +fn list_cluster_metrics( + client: &StdioBackendClient, + cluster_ids: Option>, +) -> Result, BackendError> { + let result = client + .list_cluster_metrics(zeroshot_tui::protocol::ListClusterMetricsParams { cluster_ids })?; + Ok(Some(BackendAction::ClusterMetricsListed { + metrics: result.metrics, + })) +} + +fn get_cluster_summary( + client: &StdioBackendClient, + cluster_id: String, +) -> Result, BackendError> { + let result = client + .get_cluster_summary(zeroshot_tui::protocol::GetClusterSummaryParams { cluster_id })?; + Ok(Some(BackendAction::ClusterSummary { + summary: result.summary, + })) +} + +fn get_cluster_topology( + client: &StdioBackendClient, + cluster_id: String, +) -> Result, BackendError> { + match client.get_cluster_topology(zeroshot_tui::protocol::GetClusterTopologyParams { + cluster_id: cluster_id.clone(), + }) { + Ok(result) => Ok(Some(BackendAction::ClusterTopology { + cluster_id, + topology: result.topology, + })), + Err(err) => Ok(Some(BackendAction::ClusterTopologyError { + cluster_id, + message: err.to_string(), + })), + } +} + +fn subscribe_cluster_logs( + client: &StdioBackendClient, + cluster_id: String, + agent_id: Option, +) -> Result, BackendError> { + let result = + client.subscribe_cluster_logs(zeroshot_tui::protocol::SubscribeClusterLogsParams { + cluster_id: cluster_id.clone(), + agent_id: agent_id.clone(), + })?; + Ok(Some(BackendAction::SubscribedClusterLogs { + cluster_id, + agent_id, + subscription_id: result.subscription_id, + })) +} + +fn subscribe_cluster_timeline( + client: &StdioBackendClient, + cluster_id: String, +) -> Result, BackendError> { + let result = client.subscribe_cluster_timeline( + zeroshot_tui::protocol::SubscribeClusterTimelineParams { + cluster_id: cluster_id.clone(), + }, + )?; + Ok(Some(BackendAction::SubscribedClusterTimeline { + cluster_id, + subscription_id: result.subscription_id, + })) +} + +fn start_cluster_from_text( + client: &StdioBackendClient, + text: String, + provider_override: Option, +) -> Result, BackendError> { + let result = + client.start_cluster_from_text(zeroshot_tui::protocol::StartClusterFromTextParams { + text, + provider_override, + cluster_id: None, + })?; + Ok(Some(BackendAction::StartClusterResult { + cluster_id: result.cluster_id, + })) +} + +fn start_cluster_from_issue( + client: &StdioBackendClient, + reference: String, + provider_override: Option, +) -> Result, BackendError> { + let result = + client.start_cluster_from_issue(zeroshot_tui::protocol::StartClusterFromIssueParams { + r#ref: reference, + provider_override, + cluster_id: None, + })?; + Ok(Some(BackendAction::StartClusterResult { + cluster_id: result.cluster_id, + })) +} + +fn send_guidance_to_cluster( + client: &StdioBackendClient, + cluster_id: String, + message: String, +) -> Result, BackendError> { + client.send_guidance_to_cluster(zeroshot_tui::protocol::SendGuidanceToClusterParams { + cluster_id, + text: message, + timeout_ms: None, + })?; + Ok(None) +} + +fn send_guidance_to_agent( + client: &StdioBackendClient, + cluster_id: String, + agent_id: String, + message: String, +) -> Result, BackendError> { + match client.send_guidance_to_agent(zeroshot_tui::protocol::SendGuidanceToAgentParams { + cluster_id: cluster_id.clone(), + agent_id: agent_id.clone(), + text: message, + timeout_ms: None, + }) { + Ok(result) => Ok(Some(BackendAction::GuidanceToAgentResult { + cluster_id, + agent_id, + result: result.result, + })), + Err(err) => Ok(Some(BackendAction::GuidanceToAgentError { + cluster_id, + agent_id, + message: err.to_string(), + })), + } +} + +fn unsubscribe( + client: &StdioBackendClient, + subscription_id: String, +) -> Result, BackendError> { + client.unsubscribe(zeroshot_tui::protocol::UnsubscribeParams { subscription_id })?; + Ok(None) +} + +fn shutdown_backend(mut backend: Option) -> io::Result<()> { + if let Some(mut backend) = backend.take() { + backend.shutdown().map_err(|err| { + io::Error::new( + io::ErrorKind::Other, + format!("backend shutdown failed: {err}"), + ) + })?; + } + + Ok(()) +} diff --git a/tui-rs/crates/zeroshot-tui/src/protocol/mod.rs b/tui-rs/crates/zeroshot-tui/src/protocol/mod.rs new file mode 100644 index 00000000..0d30a4b7 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/protocol/mod.rs @@ -0,0 +1,393 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcId { + String(String), + Number(i64), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: JsonRpcId, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcNotification { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcSuccessResponse { + pub jsonrpc: String, + pub id: JsonRpcId, + pub result: T, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcErrorResponse { + pub jsonrpc: String, + pub id: JsonRpcId, + pub error: RpcError, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcErrorData { + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterSummary { + pub id: String, + pub state: String, + pub provider: Option, + pub created_at: i64, + pub agent_count: i64, + pub message_count: i64, + pub cwd: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterMetrics { + pub id: String, + pub supported: bool, + pub cpu_percent: Option, + pub memory_mb: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterLogLine { + pub id: String, + pub timestamp: i64, + pub text: String, + pub agent: Option, + pub role: Option, + pub sender: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimelineEvent { + pub id: String, + pub timestamp: i64, + pub topic: String, + pub label: String, + pub approved: Option, + pub sender: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TopologyAgent { + pub id: String, + pub role: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TopologyEdge { + pub from: String, + pub to: String, + pub topic: String, + pub kind: TopologyEdgeKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamic: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TopologyEdgeKind { + Trigger, + Publish, + Source, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterTopology { + pub agents: Vec, + pub edges: Vec, + pub topics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GuidanceDeliveryResult { + pub status: String, + pub reason: Option, + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterGuidanceSummary { + pub injected: i64, + pub queued: i64, + pub total: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterGuidanceDelivery { + pub summary: ClusterGuidanceSummary, + pub agents: HashMap, + pub timestamp: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientInfo { + pub name: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub wants_metrics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wants_topology: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeParams { + pub protocol_version: i64, + pub client: ClientInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerInfo { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerCapabilities { + pub methods: Vec, + pub notifications: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub protocol_version: i64, + pub server: ServerInfo, + pub capabilities: ServerCapabilities, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListClustersResult { + pub clusters: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetClusterSummaryParams { + pub cluster_id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetClusterSummaryResult { + pub summary: ClusterSummary, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListClusterMetricsParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub cluster_ids: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListClusterMetricsResult { + pub metrics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartClusterFromTextParams { + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_override: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cluster_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartClusterFromIssueParams { + #[serde(rename = "ref")] + pub r#ref: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_override: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cluster_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartClusterResult { + pub cluster_id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendGuidanceToAgentParams { + pub cluster_id: String, + pub agent_id: String, + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendGuidanceToClusterParams { + pub cluster_id: String, + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendGuidanceToAgentResult { + pub result: GuidanceDeliveryResult, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendGuidanceToClusterResult { + pub result: ClusterGuidanceDelivery, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscribeClusterLogsParams { + pub cluster_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscribeClusterTimelineParams { + pub cluster_id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscribeResult { + pub subscription_id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnsubscribeParams { + pub subscription_id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnsubscribeResult { + pub removed: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetClusterTopologyParams { + pub cluster_id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetClusterTopologyResult { + pub topology: ClusterTopology, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterLogLinesParams { + pub subscription_id: String, + pub cluster_id: String, + pub lines: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub dropped_count: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterTimelineEventsParams { + pub subscription_id: String, + pub cluster_id: String, + pub events: Vec, +} + +pub type InitializeRequest = JsonRpcRequest; +pub type InitializeResponse = JsonRpcSuccessResponse; +pub type ListClustersRequest = JsonRpcRequest; +pub type ListClustersResponse = JsonRpcSuccessResponse; +pub type GetClusterSummaryRequest = JsonRpcRequest; +pub type GetClusterSummaryResponse = JsonRpcSuccessResponse; +pub type ListClusterMetricsRequest = JsonRpcRequest; +pub type ListClusterMetricsResponse = JsonRpcSuccessResponse; +pub type StartClusterFromTextRequest = JsonRpcRequest; +pub type StartClusterFromTextResponse = JsonRpcSuccessResponse; +pub type StartClusterFromIssueRequest = JsonRpcRequest; +pub type StartClusterFromIssueResponse = JsonRpcSuccessResponse; +pub type SendGuidanceToAgentRequest = JsonRpcRequest; +pub type SendGuidanceToAgentResponse = JsonRpcSuccessResponse; +pub type SendGuidanceToClusterRequest = JsonRpcRequest; +pub type SendGuidanceToClusterResponse = JsonRpcSuccessResponse; +pub type SubscribeClusterLogsRequest = JsonRpcRequest; +pub type SubscribeClusterLogsResponse = JsonRpcSuccessResponse; +pub type SubscribeClusterTimelineRequest = JsonRpcRequest; +pub type SubscribeClusterTimelineResponse = JsonRpcSuccessResponse; +pub type UnsubscribeRequest = JsonRpcRequest; +pub type UnsubscribeResponse = JsonRpcSuccessResponse; +pub type GetClusterTopologyRequest = JsonRpcRequest; +pub type GetClusterTopologyResponse = JsonRpcSuccessResponse; + +pub type ClusterLogLinesNotification = JsonRpcNotification; +pub type ClusterTimelineEventsNotification = JsonRpcNotification; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListClustersParams {} diff --git a/tui-rs/crates/zeroshot-tui/src/screens/agent.rs b/tui-rs/crates/zeroshot-tui/src/screens/agent.rs new file mode 100644 index 00000000..d8e5970d --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/agent.rs @@ -0,0 +1,304 @@ +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, +}; +use ratatui::Frame; + +use crate::protocol::{ClusterLogLine, GuidanceDeliveryResult}; +use crate::ui::shared::{pane_block, InputState, ScrollableBuffer}; +use crate::ui::theme; +use crate::ui::widgets::stream; + +pub const MAX_LOG_LINES: usize = 500; + +#[derive(Debug, Clone)] +pub struct State { + pub logs: ScrollableBuffer, + pub log_drop_seq: u64, + pub log_subscription: Option, + pub guidance_input: InputState, + pub guidance_pending: bool, + pub last_guidance: Option, + pub last_guidance_error: Option, + pub role: Option, + pub status: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + SubmitGuidance, + InsertChar(char), + Backspace, + Delete, + MoveCursorLeft, + MoveCursorRight, + MoveCursorHome, + MoveCursorEnd, + ScrollLogs(i32), +} + +impl State { + pub fn push_log_lines(&mut self, mut lines: Vec, dropped_count: Option) { + let mut to_push = Vec::new(); + if let Some(count) = dropped_count { + if count > 0 { + let line = ClusterLogLine { + id: format!("dropped-{}", self.log_drop_seq), + timestamp: lines.first().map(|line| line.timestamp).unwrap_or(0), + text: format!("[dropped {} log lines]", count), + agent: None, + role: None, + sender: None, + }; + self.log_drop_seq = self.log_drop_seq.saturating_add(1); + to_push.push(line); + } + } + + to_push.append(&mut lines); + self.logs.push_many(to_push); + } + + pub fn move_log_scroll(&mut self, delta: i32) { + self.logs.move_scroll(delta); + } + + pub fn apply_guidance_result(&mut self, result: GuidanceDeliveryResult) { + self.last_guidance = Some(result); + self.last_guidance_error = None; + self.guidance_pending = false; + self.guidance_input.clear(); + } + + pub fn apply_guidance_error(&mut self, message: String) { + self.last_guidance_error = Some(message); + self.guidance_pending = false; + } + + pub fn guidance_status_line(&self) -> String { + if self.guidance_pending { + return "Sending guidance...".to_string(); + } + if let Some(error) = &self.last_guidance_error { + return format!("Last send failed: {error}"); + } + if let Some(result) = &self.last_guidance { + return format!("Last send: {}", format_guidance_result(result)); + } + "Guidance ready. Enter to send.".to_string() + } +} + +impl Default for State { + fn default() -> Self { + Self { + logs: ScrollableBuffer::new(MAX_LOG_LINES), + log_drop_seq: 0, + log_subscription: None, + guidance_input: InputState::default(), + guidance_pending: false, + last_guidance: None, + last_guidance_error: None, + role: None, + status: None, + } + } +} + +pub fn render(frame: &mut Frame<'_>, area: Rect, state: &State, cluster_id: &str, agent_id: &str) { + let [header_area, logs_area, guidance_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(4), + ]) + .areas(area); + + render_header(frame, header_area, state, cluster_id, agent_id); + render_logs(frame, logs_area, state); + render_guidance(frame, guidance_area, state); +} + +fn render_header( + frame: &mut Frame<'_>, + area: Rect, + state: &State, + cluster_id: &str, + agent_id: &str, +) { + let role = state.role.as_deref().unwrap_or("unknown"); + let status = state.status.as_deref().unwrap_or("unknown"); + let (status_dot, status_style) = match status { + "executing" | "running" | "active" => ("\u{25cf}", theme::status_style("running")), + "waiting" | "idle" => ("\u{25cf}", theme::status_style("pending")), + "error" | "failed" => ("\u{25cf}", theme::status_style("error")), + "done" | "completed" => ("\u{25cf}", theme::status_style("done")), + _ => ("\u{25cb}", theme::dim_style()), + }; + let agent_color = theme::agent_color(agent_id); + + let lines = vec![ + Line::from(vec![ + Span::styled(" Agent: ", theme::dim_style()), + Span::styled(agent_id, Style::default().fg(agent_color)), + Span::styled(" Role: ", theme::dim_style()), + Span::styled(role, theme::dim_style()), + Span::styled(" Status: ", theme::dim_style()), + Span::styled(status_dot, status_style), + Span::raw(" "), + Span::styled(status, status_style), + ]), + Line::from(vec![ + Span::styled(" Cluster: ", theme::dim_style()), + Span::styled(cluster_id, theme::muted_style()), + ]), + ]; + let widget = Paragraph::new(lines); + frame.render_widget(widget, area); +} + +fn render_logs(frame: &mut Frame<'_>, area: Rect, state: &State) { + let title = if state.logs.scroll_offset > 0 { + format!("Logs (up {})", state.logs.scroll_offset) + } else { + "Logs".to_string() + }; + let block = pane_block(title, true); + let inner = block.inner(area); + let height = inner.height as usize; + let lines = if state.logs.is_empty() || height == 0 { + stream::log_placeholder_lines(stream::LogPlaceholderContext::Agent) + } else { + let total = state.logs.len(); + let max_start = total.saturating_sub(height); + let start = max_start.saturating_sub(state.logs.scroll_offset.min(max_start)); + state + .logs + .items + .iter() + .skip(start) + .take(height) + .map(stream::format_log_line_styled) + .collect() + }; + let widget = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(widget, area); + + // Scrollbar + if !state.logs.is_empty() && height > 0 { + let total = state.logs.len(); + let position = total + .saturating_sub(height) + .saturating_sub(state.logs.scroll_offset); + let mut scrollbar_state = + ScrollbarState::new(total.saturating_sub(height)).position(position); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + inner, + &mut scrollbar_state, + ); + } +} + +fn render_guidance(frame: &mut Frame<'_>, area: Rect, state: &State) { + let status_line = Line::from(state.guidance_status_line()); + let input_line = if state.guidance_input.input.is_empty() { + Line::from(Span::styled("Type guidance...", theme::muted_style())) + } else { + Line::from(state.guidance_input.input.as_str()) + }; + let lines = vec![status_line, input_line]; + let block = Block::default() + .title("Guidance (Enter to send)") + .borders(Borders::ALL) + .border_style(theme::focus_border_style()); + let input = Paragraph::new(lines).block(block); + frame.render_widget(input, area); + + if area.height > 3 && area.width > 2 { + let max_x = area.x + area.width.saturating_sub(2); + let cursor_x = area.x + 1 + state.guidance_input.cursor as u16; + let cursor_x = cursor_x.min(max_x); + let cursor_y = area.y + 2; + frame.set_cursor_position(Position::new(cursor_x, cursor_y)); + } +} + +pub fn format_guidance_result(result: &GuidanceDeliveryResult) -> String { + let mut parts = vec![result.status.clone()]; + if let Some(method) = &result.method { + parts.push(format!("via {method}")); + } + if let Some(task_id) = &result.task_id { + parts.push(format!("task {task_id}")); + } + if let Some(reason) = &result.reason { + parts.push(format!("reason {reason}")); + } + parts.join(" | ") +} + +// shared stream formatters live in ui/widgets/stream.rs + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guidance_success_clears_input() { + let mut state = State::default(); + state.guidance_input.input = "keep".to_string(); + state.guidance_input.cursor = 4; + state.guidance_pending = true; + + let result = GuidanceDeliveryResult { + status: "injected".to_string(), + reason: None, + method: Some("pty".to_string()), + task_id: Some("task-1".to_string()), + }; + + state.apply_guidance_result(result.clone()); + + assert_eq!(state.guidance_input.input, ""); + assert_eq!(state.guidance_input.cursor, 0); + assert!(!state.guidance_pending); + assert_eq!(state.last_guidance, Some(result)); + assert_eq!(state.last_guidance_error, None); + } + + #[test] + fn guidance_error_preserves_input() { + let mut state = State::default(); + state.guidance_input.input = "stay".to_string(); + state.guidance_input.cursor = 4; + state.guidance_pending = true; + + state.apply_guidance_error("network".to_string()); + + assert_eq!(state.guidance_input.input, "stay"); + assert_eq!(state.guidance_input.cursor, 4); + assert!(!state.guidance_pending); + assert_eq!(state.last_guidance_error, Some("network".to_string())); + } + + #[test] + fn guidance_status_format_includes_details() { + let result = GuidanceDeliveryResult { + status: "queued".to_string(), + reason: Some("no tty".to_string()), + method: Some("queue".to_string()), + task_id: Some("task-9".to_string()), + }; + + let formatted = format_guidance_result(&result); + + assert!(formatted.contains("queued")); + assert!(formatted.contains("via queue")); + assert!(formatted.contains("task task-9")); + assert!(formatted.contains("reason no tty")); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/screens/agent_microscope.rs b/tui-rs/crates/zeroshot-tui/src/screens/agent_microscope.rs new file mode 100644 index 00000000..6855008a --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/agent_microscope.rs @@ -0,0 +1,511 @@ +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::app::{agent_microscope, TimeCursor}; +use crate::protocol::{ClusterLogLine, TimelineEvent}; +use crate::ui::shared::TimeIndexedBuffer; +use crate::ui::theme; +use crate::ui::widgets::stream::{self, StreamOverlay}; + +pub fn render( + frame: &mut Frame<'_>, + area: Rect, + cluster_id: &str, + agent_id: &str, + cluster_timeline: Option<&TimeIndexedBuffer>, + microscope_state: Option<&agent_microscope::State>, + time_cursor: &TimeCursor, +) { + if area.width == 0 || area.height == 0 { + return; + } + + let metadata = build_metadata_overlay(area, cluster_id, agent_id, microscope_state); + let reserved_lines = metadata + .as_ref() + .map(|overlay| overlay.reserved_lines) + .unwrap_or(0); + + let max_lines = area.height.saturating_sub(2) as usize; + let window_max = max_lines.saturating_sub(reserved_lines); + let log_entries = microscope_state + .map(|state| { + stream::select_time_window(&state.logs_time, time_cursor, window_max, |_| true) + }) + .unwrap_or_default() + .into_iter() + .collect::>(); + + let marker_margin = + build_phase_marker_margin(area, cluster_timeline, time_cursor, &log_entries); + + let mut content_lines = if log_entries.is_empty() { + stream::log_placeholder_lines(stream::LogPlaceholderContext::Agent) + } else { + let log_lines = log_entries + .iter() + .map(|line| stream::format_log_line_styled(line)) + .collect::>(); + if let Some(marker_margin) = marker_margin { + apply_phase_marker_margin(log_lines, &marker_margin) + } else { + log_lines + } + }; + + if reserved_lines > 0 { + let mut padded = Vec::with_capacity(reserved_lines + content_lines.len()); + for _ in 0..reserved_lines { + padded.push(Line::from("")); + } + padded.extend(content_lines); + content_lines = padded; + } + + let title = stream::overlay_title("Stream", time_cursor); + let overlay = + StreamOverlay::new(title, content_lines).border_style(theme::focus_border_style()); + frame.render_widget(overlay, area); + + if let Some(metadata) = metadata { + render_metadata_overlay(frame, metadata); + } +} + +struct PhaseMarkerMargin { + labels: Vec>, + margin_width: usize, +} + +struct MetadataOverlay { + area: Rect, + lines: Vec>, + reserved_lines: usize, +} + +fn build_metadata_overlay( + area: Rect, + cluster_id: &str, + agent_id: &str, + microscope_state: Option<&agent_microscope::State>, +) -> Option { + let available_width = area.width.saturating_sub(2); + let available_height = area.height.saturating_sub(2); + if available_width < 6 || available_height < 4 { + return None; + } + + let role = microscope_state + .and_then(|state| state.role.as_deref()) + .unwrap_or("unknown"); + let status = microscope_state + .and_then(|state| state.status.as_deref()) + .unwrap_or("unknown"); + + let raw_lines = [ + format!("Agent: {agent_id}"), + format!("Role: {role}"), + format!("Status: {status}"), + format!("Cluster: {cluster_id}"), + ]; + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Agent: ", theme::muted_style()), + Span::styled( + agent_id.to_string(), + Style::default().fg(theme::agent_color(agent_id)), + ), + ]), + Line::from(vec![ + Span::styled("Role: ", theme::muted_style()), + Span::styled(role.to_string(), theme::dim_style()), + ]), + Line::from(vec![ + Span::styled("Status: ", theme::muted_style()), + Span::styled(status.to_string(), theme::status_style(status)), + ]), + Line::from(vec![ + Span::styled("Cluster: ", theme::muted_style()), + Span::styled(cluster_id.to_string(), theme::muted_style()), + ]), + ]; + + let max_line_len = raw_lines + .iter() + .map(|line| line.chars().count()) + .max() + .unwrap_or_default() as u16; + + let overlay_width = (max_line_len + 2).min(available_width); + let overlay_height = (lines.len() as u16 + 2).min(available_height); + if overlay_width < 4 || overlay_height < 3 || overlay_height >= available_height { + return None; + } + + let max_lines = overlay_height.saturating_sub(2) as usize; + if lines.len() > max_lines { + lines.truncate(max_lines); + } + + let overlay_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: overlay_width, + height: overlay_height, + }; + + Some(MetadataOverlay { + area: overlay_area, + lines, + reserved_lines: overlay_height as usize, + }) +} + +fn render_metadata_overlay(frame: &mut Frame<'_>, metadata: MetadataOverlay) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme::unfocus_border_style()); + let widget = Paragraph::new(metadata.lines).block(block); + frame.render_widget(widget, metadata.area); +} + +fn build_phase_marker_margin( + area: Rect, + timeline: Option<&TimeIndexedBuffer>, + time_cursor: &TimeCursor, + log_entries: &[&ClusterLogLine], +) -> Option { + let timeline = timeline?; + if log_entries.is_empty() { + return None; + } + let available_width = area.width.saturating_sub(2); + let margin_width = phase_marker_margin_width(available_width); + if margin_width == 0 { + return None; + } + + let markers = stream::derive_phase_markers(timeline, time_cursor, stream::PHASE_MARKER_LIMIT); + if markers.is_empty() { + return None; + } + + let mut labels = vec![None; log_entries.len()]; + for marker in markers { + let index = marker_line_index(log_entries, marker.timestamp_ms); + let raw = stream::format_phase_marker_label(&marker.topic, &marker.label); + let truncated = stream::truncate_marker_label(&raw, margin_width); + labels[index] = Some(pad_phase_label(&truncated, margin_width)); + } + + Some(PhaseMarkerMargin { + labels, + margin_width, + }) +} + +fn phase_marker_margin_width(available_width: u16) -> usize { + let available = available_width as usize; + let min_content = 16usize; + let min_margin = 8usize; + if available < min_content + min_margin { + return 0; + } + let mut margin = available / 4; + if margin < min_margin { + margin = min_margin; + } + if margin > 14 { + margin = 14; + } + if available.saturating_sub(margin) < min_content { + return 0; + } + margin +} + +fn marker_line_index(log_entries: &[&ClusterLogLine], timestamp: i64) -> usize { + if log_entries.is_empty() { + return 0; + } + let mut left = 0usize; + let mut right = log_entries.len(); + while left < right { + let mid = left + (right - left) / 2; + if log_entries[mid].timestamp < timestamp { + left = mid + 1; + } else { + right = mid; + } + } + if left >= log_entries.len() { + log_entries.len().saturating_sub(1) + } else { + left + } +} + +fn pad_phase_label(label: &str, width: usize) -> String { + let len = label.chars().count(); + if len >= width { + return label.to_string(); + } + let mut out = String::with_capacity(width); + out.push_str(label); + for _ in 0..(width - len) { + out.push(' '); + } + out +} + +fn apply_phase_marker_margin<'a>( + lines: Vec>, + marker_margin: &PhaseMarkerMargin, +) -> Vec> { + let empty_label = " ".repeat(marker_margin.margin_width); + lines + .into_iter() + .enumerate() + .map(|(idx, line)| { + let label = marker_margin + .labels + .get(idx) + .and_then(|label| label.as_ref()) + .unwrap_or(&empty_label); + prepend_phase_marker_label(line, label, marker_margin.margin_width) + }) + .collect() +} + +fn prepend_phase_marker_label<'a>(line: Line<'a>, label: &str, margin_width: usize) -> Line<'a> { + let mut spans = Vec::with_capacity(line.spans.len() + 2); + let mut label_text = label.to_string(); + if label_text.chars().count() < margin_width { + label_text = pad_phase_label(&label_text, margin_width); + } + spans.push(Span::styled(label_text, theme::muted_style())); + spans.push(Span::raw(" ")); + spans.extend(line.spans); + Line { + style: line.style, + alignment: line.alignment, + spans, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use crate::app::TimeCursorMode; + use crate::protocol::ClusterLogLine; + use crate::ui::widgets::test_utils::line_text; + + fn buffer_contains(terminal: &Terminal, needle: &str) -> bool { + let buffer = terminal.backend().buffer(); + for y in 0..buffer.area.height { + if line_text(buffer, y).contains(needle) { + return true; + } + } + false + } + + fn sample_state(lines: Vec) -> agent_microscope::State { + let mut state = agent_microscope::State::default(); + state.push_log_lines(lines, None); + state + } + + fn sample_timeline_event(id: &str, timestamp: i64, topic: &str, label: &str) -> TimelineEvent { + TimelineEvent { + id: id.to_string(), + timestamp, + topic: topic.to_string(), + label: label.to_string(), + approved: None, + sender: None, + } + } + + #[test] + fn agent_microscope_renders_empty_state() { + let backend = TestBackend::new(70, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + let cursor = TimeCursor { + mode: TimeCursorMode::Live, + t_ms: 0, + window_ms: 60, + }; + + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, "cluster-1", "agent-1", None, None, &cursor); + }) + .expect("draw"); + + assert!(buffer_contains(&terminal, "No logs yet.")); + assert!(buffer_contains(&terminal, "Agent: agent-1")); + } + + #[test] + fn agent_microscope_renders_live_mode() { + let backend = TestBackend::new(70, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + let state = sample_state(vec![ + ClusterLogLine { + id: "old".to_string(), + timestamp: 100, + text: "old-line".to_string(), + agent: Some("agent-1".to_string()), + role: None, + sender: None, + }, + ClusterLogLine { + id: "new".to_string(), + timestamp: 300, + text: "new-line".to_string(), + agent: Some("agent-1".to_string()), + role: None, + sender: None, + }, + ]); + + let cursor = TimeCursor { + mode: TimeCursorMode::Live, + t_ms: 300, + window_ms: 60, + }; + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + "cluster-1", + "agent-1", + None, + Some(&state), + &cursor, + ); + }) + .expect("draw"); + + assert!(buffer_contains(&terminal, "new-line")); + } + + #[test] + fn agent_microscope_renders_scrub_window() { + let backend = TestBackend::new(70, 14); + let mut terminal = Terminal::new(backend).expect("terminal"); + let state = sample_state(vec![ + ClusterLogLine { + id: "old".to_string(), + timestamp: 100, + text: "old-line".to_string(), + agent: Some("agent-1".to_string()), + role: None, + sender: None, + }, + ClusterLogLine { + id: "mid".to_string(), + timestamp: 220, + text: "mid-line".to_string(), + agent: Some("agent-1".to_string()), + role: None, + sender: None, + }, + ClusterLogLine { + id: "new".to_string(), + timestamp: 400, + text: "new-line".to_string(), + agent: Some("agent-1".to_string()), + role: None, + sender: None, + }, + ]); + + let cursor = TimeCursor { + mode: TimeCursorMode::Scrub, + t_ms: 230, + window_ms: 60, + }; + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + "cluster-1", + "agent-1", + None, + Some(&state), + &cursor, + ); + }) + .expect("draw"); + + assert!(buffer_contains(&terminal, "mid-line")); + assert!(!buffer_contains(&terminal, "old-line")); + assert!(!buffer_contains(&terminal, "new-line")); + } + + #[test] + fn agent_microscope_renders_phase_markers_margin() { + let backend = TestBackend::new(70, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + let state = sample_state(vec![ + ClusterLogLine { + id: "line-1".to_string(), + timestamp: 100, + text: "first-line".to_string(), + agent: Some("agent-1".to_string()), + role: None, + sender: None, + }, + ClusterLogLine { + id: "line-2".to_string(), + timestamp: 220, + text: "second-line".to_string(), + agent: Some("agent-1".to_string()), + role: None, + sender: None, + }, + ]); + let mut timeline = TimeIndexedBuffer::new(16); + timeline.push_many(vec![sample_timeline_event("event-1", 150, "plan", "start")]); + + let cursor = TimeCursor { + mode: TimeCursorMode::Live, + t_ms: 220, + window_ms: 60, + }; + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + "cluster-1", + "agent-1", + Some(&timeline), + Some(&state), + &cursor, + ); + }) + .expect("draw"); + + assert!(buffer_contains(&terminal, "plan: start")); + assert!(buffer_contains(&terminal, "second-line")); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/screens/cluster.rs b/tui-rs/crates/zeroshot-tui/src/screens/cluster.rs new file mode 100644 index 00000000..e63d6b8c --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/cluster.rs @@ -0,0 +1,414 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, +}; +use ratatui::Frame; + +use crate::protocol::{ + ClusterLogLine, ClusterMetrics, ClusterSummary, ClusterTopology, TimelineEvent, +}; +use crate::screens::metrics; +use crate::ui::shared::{pane_block, HasTimestamp, ScrollableBuffer, TimeIndexedBuffer}; +use crate::ui::theme; +use crate::ui::widgets::{stream, topology}; + +pub const MAX_LOG_LINES: usize = 1000; +pub const MAX_TIMELINE_EVENTS: usize = 500; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FocusDirection { + Next, + Prev, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClusterPane { + Topology, + Logs, + Timeline, + Agents, +} + +impl ClusterPane { + fn next(&self) -> Self { + match self { + ClusterPane::Topology => ClusterPane::Logs, + ClusterPane::Logs => ClusterPane::Timeline, + ClusterPane::Timeline => ClusterPane::Agents, + ClusterPane::Agents => ClusterPane::Topology, + } + } + + fn prev(&self) -> Self { + match self { + ClusterPane::Topology => ClusterPane::Agents, + ClusterPane::Logs => ClusterPane::Topology, + ClusterPane::Timeline => ClusterPane::Logs, + ClusterPane::Agents => ClusterPane::Timeline, + } + } +} + +#[derive(Debug, Clone)] +pub struct State { + pub focus: ClusterPane, + pub summary: Option, + pub topology: Option, + pub topology_error: Option, + pub logs: ScrollableBuffer, + pub logs_time: TimeIndexedBuffer, + pub timeline: ScrollableBuffer, + pub timeline_time: TimeIndexedBuffer, + pub agents: Vec, + pub selected_agent: usize, + pub log_drop_seq: u64, + pub log_subscription: Option, + pub timeline_subscription: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + focus: ClusterPane::Topology, + summary: None, + topology: None, + topology_error: None, + logs: ScrollableBuffer::new(MAX_LOG_LINES), + logs_time: TimeIndexedBuffer::new(MAX_LOG_LINES), + timeline: ScrollableBuffer::new(MAX_TIMELINE_EVENTS), + timeline_time: TimeIndexedBuffer::new(MAX_TIMELINE_EVENTS), + agents: Vec::new(), + selected_agent: 0, + log_drop_seq: 0, + log_subscription: None, + timeline_subscription: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentInfo { + pub id: String, + pub role: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + CycleFocus(FocusDirection), + MoveFocused(i32), + ActivateFocused, + OpenAgent(String), +} + +impl State { + pub fn cycle_focus(&mut self, direction: FocusDirection) { + self.focus = match direction { + FocusDirection::Next => self.focus.next(), + FocusDirection::Prev => self.focus.prev(), + }; + } + + pub fn move_focused(&mut self, delta: i32) { + match self.focus { + ClusterPane::Topology => {} + ClusterPane::Logs => self.logs.move_scroll(delta), + ClusterPane::Timeline => self.timeline.move_scroll(delta), + ClusterPane::Agents => self.move_agent_selection(delta), + } + } + + pub fn activate_focused(&self) -> Option { + match self.focus { + ClusterPane::Agents => self.selected_agent_id(), + _ => None, + } + } + + pub fn push_log_lines(&mut self, mut lines: Vec, dropped_count: Option) { + self.update_agents_from_logs(&lines); + + let mut to_push = Vec::new(); + if let Some(count) = dropped_count { + if count > 0 { + let line = ClusterLogLine { + id: format!("dropped-{}", self.log_drop_seq), + timestamp: lines.first().map(|line| line.timestamp).unwrap_or(0), + text: format!("[dropped {} log lines]", count), + agent: None, + role: None, + sender: None, + }; + self.log_drop_seq = self.log_drop_seq.saturating_add(1); + to_push.push(line); + } + } + + to_push.append(&mut lines); + let time_lines = to_push.clone(); + self.logs.push_many(to_push); + self.logs_time.push_many(time_lines); + } + + pub fn push_timeline_events(&mut self, events: Vec) { + let time_events = events.clone(); + self.timeline.push_many(events); + self.timeline_time.push_many(time_events); + } + + fn move_agent_selection(&mut self, delta: i32) { + if self.agents.is_empty() { + self.selected_agent = 0; + return; + } + + let len = self.agents.len() as i32; + let mut next = self.selected_agent as i32 + delta; + if next < 0 { + next = 0; + } + if next >= len { + next = len - 1; + } + self.selected_agent = next as usize; + } + + fn update_agents_from_logs(&mut self, lines: &[ClusterLogLine]) { + let selected_id = self.selected_agent_id(); + for line in lines { + let Some(agent_id) = line.agent.as_ref() else { + continue; + }; + + match self.agents.iter_mut().find(|agent| agent.id == *agent_id) { + Some(agent) => { + if agent.role.is_none() { + agent.role = line.role.clone(); + } + } + None => { + self.agents.push(AgentInfo { + id: agent_id.clone(), + role: line.role.clone(), + }); + } + } + } + self.reconcile_agent_selection(selected_id); + } + + fn reconcile_agent_selection(&mut self, selected_id: Option) { + if let Some(id) = selected_id { + if let Some(index) = self.agents.iter().position(|agent| agent.id == id) { + self.selected_agent = index; + return; + } + } + + if self.selected_agent >= self.agents.len() { + self.selected_agent = self.agents.len().saturating_sub(1); + } + } + + fn selected_agent_id(&self) -> Option { + self.agents + .get(self.selected_agent) + .map(|agent| agent.id.clone()) + } +} + +impl HasTimestamp for ClusterLogLine { + fn timestamp_ms(&self) -> i64 { + self.timestamp + } +} + +impl HasTimestamp for TimelineEvent { + fn timestamp_ms(&self) -> i64 { + self.timestamp + } +} + +pub fn render(frame: &mut Frame<'_>, area: Rect, state: &State, metrics: Option<&ClusterMetrics>) { + let [metrics_area, content] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + render_metrics_line(frame, metrics_area, metrics); + + let [top, bottom] = + Layout::vertical([Constraint::Percentage(30), Constraint::Percentage(70)]).areas(content); + + let [topo_area, agents_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(top); + + let [logs_area, timeline_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(bottom); + + render_topology(frame, topo_area, state); + render_agents(frame, agents_area, state); + render_logs(frame, logs_area, state); + render_timeline(frame, timeline_area, state); +} + +fn render_metrics_line(frame: &mut Frame<'_>, area: Rect, metrics: Option<&ClusterMetrics>) { + let line = Line::from(vec![ + Span::styled("Metrics:", theme::dim_style()), + Span::raw(" "), + Span::styled(metrics::format_metrics_line(metrics), theme::dim_style()), + ]); + let widget = Paragraph::new(line); + frame.render_widget(widget, area); +} + +fn render_topology(frame: &mut Frame<'_>, area: Rect, state: &State) { + let block = pane_block("Topology", state.focus == ClusterPane::Topology); + topology::render( + frame, + area, + block, + state.summary.as_ref(), + state.topology.as_ref(), + state.topology_error.as_deref(), + ); +} + +fn render_agents(frame: &mut Frame<'_>, area: Rect, state: &State) { + let title = format!("Agents ({})", state.agents.len()); + let block = pane_block(title, state.focus == ClusterPane::Agents); + let inner = block.inner(area); + + if state.agents.is_empty() || inner.height == 0 { + let lines = vec![ + Line::from(Span::styled("No agents yet.", theme::muted_style())), + Line::from(Span::styled( + "Wait for logs to identify agents.", + theme::muted_style(), + )), + ]; + let widget = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(widget, area); + return; + } + + let items: Vec = state + .agents + .iter() + .map(|agent| { + let agent_color = theme::agent_color(&agent.id); + let text = match &agent.role { + Some(role) => format!("{} ({role})", agent.id), + None => agent.id.clone(), + }; + ListItem::new(text).style(Style::default().fg(agent_color)) + }) + .collect(); + + let list = List::new(items) + .block(block) + .highlight_style(theme::selected_style()) + .highlight_symbol(" > "); + + let mut list_state = ListState::default(); + list_state.select(Some(state.selected_agent)); + + frame.render_stateful_widget(list, area, &mut list_state); +} + +fn render_logs(frame: &mut Frame<'_>, area: Rect, state: &State) { + let title = if state.logs.scroll_offset > 0 { + format!("Logs (up {})", state.logs.scroll_offset) + } else { + "Logs".to_string() + }; + let block = pane_block(title, state.focus == ClusterPane::Logs); + let inner = block.inner(area); + let height = inner.height as usize; + + let lines: Vec = if state.logs.is_empty() || height == 0 { + stream::log_placeholder_lines(stream::LogPlaceholderContext::Cluster) + } else { + let total = state.logs.len(); + let max_start = total.saturating_sub(height); + let start = max_start.saturating_sub(state.logs.scroll_offset.min(max_start)); + state + .logs + .items + .iter() + .skip(start) + .take(height) + .map(stream::format_log_line_styled) + .collect() + }; + + let widget = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(widget, area); + + // Scrollbar + if !state.logs.is_empty() && height > 0 { + let total = state.logs.len(); + let position = total + .saturating_sub(height) + .saturating_sub(state.logs.scroll_offset); + let mut scrollbar_state = + ScrollbarState::new(total.saturating_sub(height)).position(position); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + inner, + &mut scrollbar_state, + ); + } +} + +fn render_timeline(frame: &mut Frame<'_>, area: Rect, state: &State) { + let title = if state.timeline.scroll_offset > 0 { + format!("Timeline (up {})", state.timeline.scroll_offset) + } else { + "Timeline".to_string() + }; + let block = pane_block(title, state.focus == ClusterPane::Timeline); + let inner = block.inner(area); + let height = inner.height as usize; + + let lines: Vec = if state.timeline.is_empty() || height == 0 { + stream::timeline_placeholder_lines() + } else { + let total = state.timeline.len(); + let max_start = total.saturating_sub(height); + let start = max_start.saturating_sub(state.timeline.scroll_offset.min(max_start)); + state + .timeline + .items + .iter() + .skip(start) + .take(height) + .map(stream::format_timeline_event_styled) + .collect() + }; + + let widget = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(widget, area); + + // Scrollbar + if !state.timeline.is_empty() && height > 0 { + let total = state.timeline.len(); + let position = total + .saturating_sub(height) + .saturating_sub(state.timeline.scroll_offset); + let mut scrollbar_state = + ScrollbarState::new(total.saturating_sub(height)).position(position); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + inner, + &mut scrollbar_state, + ); + } +} + +// shared stream formatters live in ui/widgets/stream.rs diff --git a/tui-rs/crates/zeroshot-tui/src/screens/cluster_canvas.rs b/tui-rs/crates/zeroshot-tui/src/screens/cluster_canvas.rs new file mode 100644 index 00000000..beec1e64 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/cluster_canvas.rs @@ -0,0 +1,1688 @@ +use std::collections::HashMap; + +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::symbols::Marker; +use ratatui::text::{Line, Span}; +use ratatui::widgets::canvas::{Canvas, Circle, Line as CanvasLine, Points}; +use ratatui::widgets::{Block, Borders}; +use ratatui::Frame; + +use crate::app::animation::AnimClock; +use crate::app::{animation, FocusTarget, TimeCursor}; +use crate::protocol::{ClusterTopology, TopologyAgent}; +use crate::screens::cluster; +use crate::ui::shared::calm_empty_state; +use crate::ui::theme; +use crate::ui::widgets::stream::{self, StreamOverlay}; + +const WORLD_RADIUS: f64 = 48.0; +const AGENT_RING_RADIUS: f64 = 28.0; +const TOPIC_RING_RADIUS: f64 = 14.0; +const AGENT_ORB_RADIUS: f64 = 1.8; +const TOPIC_ORB_RADIUS: f64 = 1.2; +const LABEL_OFFSET: f64 = 4.0; +const LABEL_RADIAL_OFFSET: f64 = 1.4; +const LABEL_LIMIT: usize = 14; +const PENDING_MESSAGE: &str = "Topology pending"; +const UNAVAILABLE_MESSAGE: &str = "Topology unavailable"; +const FOCUS_EPSILON: f64 = 0.0001; +const CANVAS_CAMERA_ACCEL: f64 = 0.16; +const CANVAS_CAMERA_FRICTION: f64 = 0.82; +const CANVAS_CAMERA_SNAP_EPSILON: f64 = 0.08; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NodeKind { + Agent, + Topic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Left, + Right, + Up, + Down, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveSpeed { + Step, + Fast, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + MoveFocus { + direction: Direction, + speed: MoveSpeed, + }, + ZoomIn, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NodeLayout { + pub id: String, + pub label: String, + pub x: f64, + pub y: f64, + pub kind: NodeKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LayoutEdge { + pub from: String, + pub to: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LayoutBounds { + pub min_x: f64, + pub max_x: f64, + pub min_y: f64, + pub max_y: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LayoutCache { + pub nodes: HashMap, + pub edges: Vec, + pub bounds: LayoutBounds, +} + +#[derive(Debug, Clone, Default)] +pub struct State { + pub focused_id: Option, + pub layout: Option, + pub log_subscription: Option, + pub timeline_subscription: Option, + pub camera: (f64, f64), + pub camera_target: (f64, f64), + pub camera_velocity: (f64, f64), +} + +impl State { + pub fn update_layout(&mut self, topology: &ClusterTopology) { + let had_layout = self.layout.is_some(); + self.layout = Some(layout_for(topology)); + self.ensure_focus(topology); + if had_layout { + self.set_camera_target_to_focus(); + } else { + self.snap_camera_to_focus(); + } + } + + pub fn ensure_focus(&mut self, topology: &ClusterTopology) { + let mut needs_focus = self.focused_id.is_none(); + if let Some(focused) = self.focused_id.as_ref() { + let in_agents = topology.agents.iter().any(|agent| agent.id == *focused); + let in_topics = topology.topics.iter().any(|topic| topic == focused); + if !in_agents && !in_topics { + needs_focus = true; + } + } + if needs_focus { + self.focused_id = default_focus_id(topology); + } + } + + pub fn move_focus(&mut self, direction: Direction, speed: MoveSpeed) { + if self.layout.is_none() { + return; + } + if let Some(layout) = self.layout.as_ref() { + match self.focused_id.as_ref() { + Some(focused) => { + if !layout.nodes.contains_key(focused) { + self.focused_id = default_focus_id_from_layout(layout); + } + } + None => { + self.focused_id = default_focus_id_from_layout(layout); + } + } + } + if self.focused_id.is_none() { + return; + } + + self.set_camera_target_to_focus(); + + let mut steps = match speed { + MoveSpeed::Step => 1, + MoveSpeed::Fast => 2, + }; + while steps > 0 { + let Some(next) = self.next_focus_id(direction) else { + break; + }; + self.focused_id = Some(next); + self.set_camera_target_to_focus(); + steps -= 1; + } + } + + pub fn focused_agent_id(&self) -> Option { + let layout = self.layout.as_ref()?; + let focused = self.focused_id.as_ref()?; + let node = layout.nodes.get(focused)?; + if node.kind == NodeKind::Agent { + Some(node.id.clone()) + } else { + None + } + } + + pub fn clear_layout(&mut self) { + self.layout = None; + self.camera = (0.0, 0.0); + self.camera_target = (0.0, 0.0); + self.camera_velocity = (0.0, 0.0); + } + + pub fn tick_camera(&mut self, dt_ms: i64) { + let (position, velocity) = animation::step_spring_f64( + self.camera, + self.camera_velocity, + self.camera_target, + dt_ms, + CANVAS_CAMERA_ACCEL, + CANVAS_CAMERA_FRICTION, + ); + self.camera = position; + self.camera_velocity = velocity; + + let dx = self.camera.0 - self.camera_target.0; + let dy = self.camera.1 - self.camera_target.1; + if dx.abs() <= CANVAS_CAMERA_SNAP_EPSILON && dy.abs() <= CANVAS_CAMERA_SNAP_EPSILON { + self.camera = self.camera_target; + self.camera_velocity = (0.0, 0.0); + } + } + + fn next_focus_id(&self, direction: Direction) -> Option { + let layout = self.layout.as_ref()?; + let focused_id = self.focused_id.as_ref()?; + let focused = layout.nodes.get(focused_id)?; + let (dir_x, dir_y) = direction_vector(direction); + let mut best: Option<(f64, String)> = None; + + for node in layout.nodes.values() { + if node.id == focused.id { + continue; + } + let dx = node.x - focused.x; + let dy = node.y - focused.y; + let dot = dx * dir_x + dy * dir_y; + if dot <= FOCUS_EPSILON { + continue; + } + let dist = dx * dx + dy * dy; + match &mut best { + Some((best_dist, best_id)) => { + if dist + FOCUS_EPSILON < *best_dist + || (dist - *best_dist).abs() <= FOCUS_EPSILON && node.id < *best_id + { + *best_dist = dist; + *best_id = node.id.clone(); + } + } + None => best = Some((dist, node.id.clone())), + } + } + + best.map(|(_, id)| id) + } + + fn set_camera_target_to_focus(&mut self) { + let Some((x, y)) = self.focus_position() else { + return; + }; + self.camera_target = (x, y); + } + + fn snap_camera_to_focus(&mut self) { + let Some((x, y)) = self.focus_position() else { + return; + }; + self.camera = (x, y); + self.camera_target = (x, y); + self.camera_velocity = (0.0, 0.0); + } + + fn focus_position(&self) -> Option<(f64, f64)> { + let Some(layout) = self.layout.as_ref() else { + return None; + }; + let Some(focused_id) = self.focused_id.as_ref() else { + return None; + }; + let Some(node) = layout.nodes.get(focused_id) else { + return None; + }; + Some((node.x, node.y)) + } +} + +struct OverlayTarget<'a> { + id: &'a str, + force_cluster: bool, +} + +pub struct RenderContext<'a> { + pub cluster_id: &'a str, + pub cluster_state: Option<&'a cluster::State>, + pub canvas_state: Option<&'a State>, + pub time_cursor: &'a TimeCursor, + pub anim_clock: &'a AnimClock, + pub pinned_target: Option<&'a FocusTarget>, +} + +pub fn render(frame: &mut Frame<'_>, area: Rect, context: RenderContext<'_>) { + let RenderContext { + cluster_id, + cluster_state, + canvas_state, + time_cursor, + anim_clock, + pinned_target, + } = context; + let block = Block::default() + .borders(Borders::ALL) + .title("Cluster Canvas"); + + let Some(cluster_state) = cluster_state else { + render_placeholder(frame, area, UNAVAILABLE_MESSAGE, None); + return; + }; + + if let Some(error) = cluster_state.topology_error.as_deref() { + render_placeholder(frame, area, UNAVAILABLE_MESSAGE, Some(error)); + return; + } + + let Some(topology) = cluster_state.topology.as_ref() else { + render_placeholder(frame, area, PENDING_MESSAGE, None); + return; + }; + + let fallback_layout; + let layout = match canvas_state.and_then(|state| state.layout.as_ref()) { + Some(layout) => layout, + None => { + fallback_layout = layout_for(topology); + &fallback_layout + } + }; + + let focused = canvas_state.and_then(|state| state.focused_id.as_deref()); + let pinned_highlight = match pinned_target { + Some(FocusTarget::Agent { + cluster_id: pinned_cluster_id, + agent_id, + }) if *pinned_cluster_id == cluster_id => Some(agent_id.as_str()), + _ => None, + }; + let pinned_overlay = + resolve_pinned_overlay(pinned_target, cluster_id, canvas_state, Some(cluster_state)); + let camera = canvas_state.map(|state| state.camera).unwrap_or((0.0, 0.0)); + let focus_pulse = animation::pulse_factor(anim_clock.phase) as f64; + + render_canvas( + frame, + CanvasRenderContext { + area, + cluster_id, + cluster_state, + topology, + layout, + focused, + pinned_highlight, + pinned_overlay, + camera, + block, + time_cursor, + focus_pulse, + }, + ); +} + +fn render_placeholder(frame: &mut Frame<'_>, area: Rect, headline: &str, detail: Option<&str>) { + let widget = calm_empty_state( + "Cluster Canvas", + headline, + detail, + Some("Press Esc to return to Fleet Radar"), + ); + frame.render_widget(widget, area); +} + +fn resolve_pinned_overlay<'a>( + pinned_target: Option<&'a FocusTarget>, + cluster_id: &str, + canvas_state: Option<&'a State>, + cluster_state: Option<&'a cluster::State>, +) -> Option> { + let pinned_target = pinned_target?; + match pinned_target { + FocusTarget::Agent { + cluster_id: pinned_cluster_id, + agent_id, + } if pinned_cluster_id == cluster_id => Some(OverlayTarget { + id: agent_id.as_str(), + force_cluster: false, + }), + FocusTarget::Cluster { id } if id == cluster_id => { + if let Some(focused_id) = canvas_state.and_then(|state| state.focused_id.as_deref()) { + return Some(OverlayTarget { + id: focused_id, + force_cluster: true, + }); + } + if let Some(layout) = canvas_state.and_then(|state| state.layout.as_ref()) { + if let Some(id) = layout.nodes.keys().next() { + return Some(OverlayTarget { + id: id.as_str(), + force_cluster: true, + }); + } + } + let topology = cluster_state.and_then(|state| state.topology.as_ref())?; + if let Some(agent) = topology.agents.first() { + return Some(OverlayTarget { + id: agent.id.as_str(), + force_cluster: true, + }); + } + topology.topics.first().map(|topic| OverlayTarget { + id: topic.as_str(), + force_cluster: true, + }) + } + _ => None, + } +} + +struct CanvasRenderContext<'a> { + area: Rect, + cluster_id: &'a str, + cluster_state: &'a cluster::State, + topology: &'a ClusterTopology, + layout: &'a LayoutCache, + focused: Option<&'a str>, + pinned_highlight: Option<&'a str>, + pinned_overlay: Option>, + camera: (f64, f64), + block: Block<'a>, + time_cursor: &'a TimeCursor, + focus_pulse: f64, +} + +fn render_canvas(frame: &mut Frame<'_>, canvas_ctx: CanvasRenderContext<'_>) { + let title = format!("Cluster Canvas {}", canvas_ctx.cluster_id); + let render_bounds = camera_bounds(canvas_ctx.layout, canvas_ctx.camera); + let block = canvas_ctx.block.title(title); + let canvas_inner = block.inner(canvas_ctx.area); + let layout = canvas_ctx.layout; + let focused = canvas_ctx.focused; + let pinned_highlight = canvas_ctx.pinned_highlight; + let topology = canvas_ctx.topology; + let focus_pulse = canvas_ctx.focus_pulse; + let canvas = Canvas::default() + .block(block) + .x_bounds([render_bounds.min_x, render_bounds.max_x]) + .y_bounds([render_bounds.min_y, render_bounds.max_y]) + .marker(Marker::Braille) + .paint(|ctx| { + for edge in &layout.edges { + let Some(from) = layout.nodes.get(&edge.from) else { + continue; + }; + let Some(to) = layout.nodes.get(&edge.to) else { + continue; + }; + ctx.draw(&CanvasLine { + x1: from.x, + y1: from.y, + x2: to.x, + y2: to.y, + color: theme::FG_DIM, + }); + } + + for node in layout.nodes.values() { + let color = match node.kind { + NodeKind::Agent => theme::agent_color(node.id.as_str()), + NodeKind::Topic => theme::FG_MUTED, + }; + let orb_radius = match node.kind { + NodeKind::Agent => AGENT_ORB_RADIUS, + NodeKind::Topic => TOPIC_ORB_RADIUS, + }; + + if focused == Some(node.id.as_str()) { + ctx.draw(&Circle { + x: node.x, + y: node.y, + radius: orb_radius + 0.8 + 0.25 * focus_pulse, + color: theme::ACCENT, + }); + } + + if pinned_highlight == Some(node.id.as_str()) && focused != Some(node.id.as_str()) { + ctx.draw(&Circle { + x: node.x, + y: node.y, + radius: orb_radius + 1.2, + color: theme::ACCENT2, + }); + } + + ctx.draw(&Circle { + x: node.x, + y: node.y, + radius: orb_radius, + color, + }); + + ctx.draw(&Points { + coords: &[(node.x, node.y)], + color, + }); + + let (label_x, label_y) = label_position(node.x, node.y, node.id.as_str()); + let label_style = Style::default().fg(color); + let label = node.label.clone(); + let line = Line::from(Span::styled(label, label_style)); + ctx.print(label_x, label_y, line); + } + + let summary_line = topology_summary(topology); + if let Some(summary_line) = summary_line { + let line = Line::from(Span::styled(summary_line, theme::dim_style())); + ctx.print(render_bounds.min_x + 1.0, render_bounds.max_y - 1.0, line); + } + }); + + frame.render_widget(canvas, canvas_ctx.area); + let overlay_layout = StreamOverlayLayout { + area: canvas_inner, + layout: canvas_ctx.layout, + focused: canvas_ctx.focused, + pinned_overlay: canvas_ctx.pinned_overlay, + render_bounds: &render_bounds, + spine_area: None, + }; + render_stream_overlay( + frame, + overlay_layout, + canvas_ctx.cluster_state, + canvas_ctx.time_cursor, + ); +} + +struct StreamOverlayLayout<'a> { + area: Rect, + layout: &'a LayoutCache, + focused: Option<&'a str>, + pinned_overlay: Option>, + render_bounds: &'a LayoutBounds, + spine_area: Option, +} + +fn render_stream_overlay( + frame: &mut Frame<'_>, + layout_ctx: StreamOverlayLayout<'_>, + cluster_state: &cluster::State, + time_cursor: &TimeCursor, +) { + if layout_ctx.area.width < 6 || layout_ctx.area.height < 4 { + return; + } + + if let Some(focused_id) = layout_ctx.focused { + if let Some(node) = layout_ctx.layout.nodes.get(focused_id) { + render_overlay_for_node( + frame, + &layout_ctx, + node, + cluster_state, + time_cursor, + theme::focus_border_style(), + false, + ); + } + } + + if let Some(pinned) = layout_ctx.pinned_overlay.as_ref() { + if Some(pinned.id) != layout_ctx.focused || pinned.force_cluster { + if let Some(node) = layout_ctx.layout.nodes.get(pinned.id) { + let style = Style::default() + .fg(theme::ACCENT2) + .add_modifier(Modifier::BOLD); + render_overlay_for_node( + frame, + &layout_ctx, + node, + cluster_state, + time_cursor, + style, + pinned.force_cluster, + ); + } + } + } +} + +fn render_overlay_for_node( + frame: &mut Frame<'_>, + layout_ctx: &StreamOverlayLayout<'_>, + node: &NodeLayout, + cluster_state: &cluster::State, + time_cursor: &TimeCursor, + border_style: Style, + force_cluster: bool, +) { + let focus_point = world_to_screen(layout_ctx.area, layout_ctx.render_bounds, node.x, node.y); + let overlay_size = overlay_dimensions(layout_ctx.area); + if overlay_size.0 == 0 || overlay_size.1 == 0 { + return; + } + + let overlay_rect = overlay_rect_near_focus( + layout_ctx.area, + focus_point, + overlay_size, + layout_ctx.spine_area, + ); + let inner = Block::default().borders(Borders::ALL).inner(overlay_rect); + let max_lines = inner.height as usize; + if max_lines == 0 { + return; + } + + let (title, lines) = + build_overlay_lines(cluster_state, node, time_cursor, max_lines, force_cluster); + let overlay = StreamOverlay::new(title, lines) + .placeholder_lines(stream::log_placeholder_lines( + stream::LogPlaceholderContext::Overlay, + )) + .border_style(border_style); + frame.render_widget(overlay, overlay_rect); +} + +fn build_overlay_lines<'a>( + cluster_state: &'a cluster::State, + node: &NodeLayout, + time_cursor: &TimeCursor, + max_lines: usize, + force_cluster: bool, +) -> (Line<'a>, Vec>) { + let is_agent = if force_cluster { + false + } else { + node.kind == NodeKind::Agent + }; + let log_title = if is_agent { + stream::overlay_title(format!("Logs - agent {}", node.id), time_cursor) + } else { + stream::overlay_title("Logs - cluster", time_cursor) + }; + let timeline_title = if is_agent { + stream::overlay_title(format!("Timeline - agent {}", node.id), time_cursor) + } else { + stream::overlay_title("Timeline - cluster", time_cursor) + }; + + let log_lines = collect_log_lines( + cluster_state, + time_cursor, + is_agent.then_some(node.id.as_str()), + max_lines, + ); + if !log_lines.is_empty() { + return (log_title, log_lines); + } + + let timeline_lines = collect_timeline_lines(cluster_state, time_cursor, max_lines); + if !timeline_lines.is_empty() { + return (timeline_title, timeline_lines); + } + + (log_title, Vec::new()) +} + +fn collect_log_lines<'a>( + cluster_state: &'a cluster::State, + time_cursor: &TimeCursor, + agent_id: Option<&str>, + max_lines: usize, +) -> Vec> { + if max_lines == 0 { + return Vec::new(); + } + let collected = + stream::select_time_window(&cluster_state.logs_time, time_cursor, max_lines, |line| { + if let Some(agent_id) = agent_id { + line.agent.as_deref() == Some(agent_id) || line.sender.as_deref() == Some(agent_id) + } else { + true + } + }); + collected + .into_iter() + .map(stream::format_log_line_styled) + .collect() +} + +fn collect_timeline_lines<'a>( + cluster_state: &'a cluster::State, + time_cursor: &TimeCursor, + max_lines: usize, +) -> Vec> { + if max_lines == 0 { + return Vec::new(); + } + let collected = + stream::select_time_window(&cluster_state.timeline_time, time_cursor, max_lines, |_| { + true + }); + collected + .into_iter() + .map(stream::format_timeline_event_styled) + .collect() +} + +fn overlay_dimensions(area: Rect) -> (u16, u16) { + if area.width < 6 || area.height < 4 { + return (0, 0); + } + let max_width = area.width.saturating_sub(2); + let max_height = area.height.saturating_sub(2); + if max_width == 0 || max_height == 0 { + return (0, 0); + } + + let mut width = ((area.width as f32) * 0.45).round() as u16; + let mut height = ((area.height as f32) * 0.35).round() as u16; + width = width.clamp(18, 52).min(max_width); + height = height.clamp(5, 12).min(max_height); + + if width == 0 || height == 0 { + return (0, 0); + } + (width, height) +} + +fn world_to_screen( + area: Rect, + render_bounds: &LayoutBounds, + world_x: f64, + world_y: f64, +) -> (u16, u16) { + if area.width == 0 || area.height == 0 { + return (area.x, area.y); + } + let width = (render_bounds.max_x - render_bounds.min_x).max(1.0); + let height = (render_bounds.max_y - render_bounds.min_y).max(1.0); + let mut rel_x = (world_x - render_bounds.min_x) / width; + let mut rel_y = (render_bounds.max_y - world_y) / height; + rel_x = rel_x.clamp(0.0, 1.0); + rel_y = rel_y.clamp(0.0, 1.0); + let x = area.x as f64 + rel_x * ((area.width - 1) as f64); + let y = area.y as f64 + rel_y * ((area.height - 1) as f64); + (x.round() as u16, y.round() as u16) +} + +fn overlay_rect_near_focus( + bounds: Rect, + focus: (u16, u16), + size: (u16, u16), + spine: Option, +) -> Rect { + let width = size.0.min(bounds.width); + let height = size.1.min(bounds.height); + let candidates = [(true, true), (true, false), (false, true), (false, false)]; + + for (right, down) in candidates { + let x = if right { + focus.0.saturating_add(1) + } else { + focus.0.saturating_sub(width.saturating_add(1)) + }; + let y = if down { + focus.1.saturating_add(1) + } else { + focus.1.saturating_sub(height.saturating_add(1)) + }; + let rect = clamp_rect_to_bounds( + Rect { + x, + y, + width, + height, + }, + bounds, + ); + let rect = avoid_spine(rect, bounds, spine); + if !rect_intersects_spine(rect, spine) { + return rect; + } + } + + let rect = clamp_rect_to_bounds( + Rect { + x: bounds.x, + y: bounds.y, + width, + height, + }, + bounds, + ); + avoid_spine(rect, bounds, spine) +} + +fn clamp_rect_to_bounds(rect: Rect, bounds: Rect) -> Rect { + let width = rect.width.min(bounds.width); + let height = rect.height.min(bounds.height); + if bounds.width == 0 || bounds.height == 0 || width == 0 || height == 0 { + return Rect { + x: bounds.x, + y: bounds.y, + width, + height, + }; + } + + let max_x = bounds.x.saturating_add(bounds.width.saturating_sub(width)); + let max_y = bounds + .y + .saturating_add(bounds.height.saturating_sub(height)); + let mut x = rect.x; + let mut y = rect.y; + if x < bounds.x { + x = bounds.x; + } else if x > max_x { + x = max_x; + } + if y < bounds.y { + y = bounds.y; + } else if y > max_y { + y = max_y; + } + + Rect { + x, + y, + width, + height, + } +} + +fn rect_intersects_spine(rect: Rect, spine: Option) -> bool { + spine.is_some_and(|spine| rects_intersect(rect, spine)) +} + +fn rects_intersect(a: Rect, b: Rect) -> bool { + let a_right = a.x.saturating_add(a.width); + let a_bottom = a.y.saturating_add(a.height); + let b_right = b.x.saturating_add(b.width); + let b_bottom = b.y.saturating_add(b.height); + a.x < b_right && a_right > b.x && a.y < b_bottom && a_bottom > b.y +} + +fn avoid_spine(rect: Rect, bounds: Rect, spine: Option) -> Rect { + let Some(spine) = spine else { + return rect; + }; + if !rects_intersect(rect, spine) { + return rect; + } + + let options = vec![ + Rect { + x: rect.x, + y: spine.y.saturating_sub(rect.height.saturating_add(1)), + width: rect.width, + height: rect.height, + }, + Rect { + x: rect.x, + y: spine.y.saturating_add(spine.height).saturating_add(1), + width: rect.width, + height: rect.height, + }, + Rect { + x: spine.x.saturating_sub(rect.width.saturating_add(1)), + y: rect.y, + width: rect.width, + height: rect.height, + }, + Rect { + x: spine.x.saturating_add(spine.width).saturating_add(1), + y: rect.y, + width: rect.width, + height: rect.height, + }, + ]; + + for option in options { + let candidate = clamp_rect_to_bounds(option, bounds); + if !rects_intersect(candidate, spine) { + return candidate; + } + } + + rect +} + +fn topology_summary(topology: &ClusterTopology) -> Option { + if topology.agents.is_empty() && topology.topics.is_empty() && topology.edges.is_empty() { + return None; + } + Some(format!( + "{} agents, {} topics, {} edges", + topology.agents.len(), + topology.topics.len(), + topology.edges.len() + )) +} + +fn camera_bounds(layout: &LayoutCache, camera: (f64, f64)) -> LayoutBounds { + let width = layout.bounds.max_x - layout.bounds.min_x; + let height = layout.bounds.max_y - layout.bounds.min_y; + let half_w = width / 2.0; + let half_h = height / 2.0; + LayoutBounds { + min_x: camera.0 - half_w, + max_x: camera.0 + half_w, + min_y: camera.1 - half_h, + max_y: camera.1 + half_h, + } +} + +fn direction_vector(direction: Direction) -> (f64, f64) { + match direction { + Direction::Left => (-1.0, 0.0), + Direction::Right => (1.0, 0.0), + Direction::Up => (0.0, 1.0), + Direction::Down => (0.0, -1.0), + } +} + +fn default_focus_id(topology: &ClusterTopology) -> Option { + let mut agent_ids: Vec<&String> = topology.agents.iter().map(|agent| &agent.id).collect(); + agent_ids.sort(); + if let Some(id) = agent_ids.first() { + return Some((*id).clone()); + } + let mut topics: Vec<&String> = topology.topics.iter().collect(); + topics.sort(); + topics.first().map(|id| (*id).clone()) +} + +fn default_focus_id_from_layout(layout: &LayoutCache) -> Option { + let mut agent_ids: Vec<&String> = layout + .nodes + .values() + .filter(|node| node.kind == NodeKind::Agent) + .map(|node| &node.id) + .collect(); + agent_ids.sort(); + if let Some(id) = agent_ids.first() { + return Some((*id).clone()); + } + let mut topic_ids: Vec<&String> = layout + .nodes + .values() + .filter(|node| node.kind == NodeKind::Topic) + .map(|node| &node.id) + .collect(); + topic_ids.sort(); + topic_ids.first().map(|id| (*id).clone()) +} + +fn layout_for(topology: &ClusterTopology) -> LayoutCache { + let mut nodes = HashMap::new(); + + for agent in &topology.agents { + let (x, y) = ring_position(&agent.id, AGENT_RING_RADIUS); + let label = truncate_label(&agent_label(agent)); + nodes.insert( + agent.id.clone(), + NodeLayout { + id: agent.id.clone(), + label, + x, + y, + kind: NodeKind::Agent, + }, + ); + } + + for topic in &topology.topics { + let (x, y) = ring_position(topic, TOPIC_RING_RADIUS); + let label = truncate_label(topic); + nodes.insert( + topic.clone(), + NodeLayout { + id: topic.clone(), + label, + x, + y, + kind: NodeKind::Topic, + }, + ); + } + + let mut edges = Vec::new(); + for edge in &topology.edges { + if nodes.contains_key(&edge.from) && nodes.contains_key(&edge.to) { + edges.push(LayoutEdge { + from: edge.from.clone(), + to: edge.to.clone(), + }); + } + } + + LayoutCache { + nodes, + edges, + bounds: LayoutBounds { + min_x: -WORLD_RADIUS, + max_x: WORLD_RADIUS, + min_y: -WORLD_RADIUS, + max_y: WORLD_RADIUS, + }, + } +} + +fn ring_position(id: &str, radius: f64) -> (f64, f64) { + let angle = stable_angle(id); + let jitter = jitter_offset(id); + let r = (radius + jitter).max(4.0); + (r * angle.cos(), r * angle.sin()) +} + +fn jitter_offset(id: &str) -> f64 { + let hash = stable_hash(id); + let step = (hash % 7) as f64 - 3.0; + step * 0.35 +} + +fn agent_label(agent: &TopologyAgent) -> String { + match agent.role.as_ref() { + Some(role) if !role.trim().is_empty() => format!("{} ({})", agent.id, role), + _ => agent.id.clone(), + } +} + +fn label_position(x: f64, y: f64, id: &str) -> (f64, f64) { + let hash = stable_hash(id); + let side = if hash & 1 == 0 { 1.0 } else { -1.0 }; + let angle = if x == 0.0 && y == 0.0 { + 0.0 + } else { + y.atan2(x) + }; + let tangent_x = -angle.sin(); + let tangent_y = angle.cos(); + let radial_x = angle.cos(); + let radial_y = angle.sin(); + let mut lx = x + tangent_x * LABEL_OFFSET * side + radial_x * LABEL_RADIAL_OFFSET; + let mut ly = y + tangent_y * LABEL_OFFSET * side + radial_y * LABEL_RADIAL_OFFSET; + let min = -WORLD_RADIUS + 1.0; + let max = WORLD_RADIUS - 1.0; + if lx < min { + lx = min; + } else if lx > max { + lx = max; + } + if ly < min { + ly = min; + } else if ly > max { + ly = max; + } + (lx, ly) +} + +fn truncate_label(label: &str) -> String { + let mut iter = label.chars(); + let mut out = String::new(); + for _ in 0..LABEL_LIMIT { + match iter.next() { + Some(ch) => out.push(ch), + None => return label.to_string(), + } + } + if iter.next().is_some() { + out.push_str(".."); + } + out +} + +fn stable_angle(input: &str) -> f64 { + let hash = stable_hash(input); + let fraction = (hash % 3600) as f64 / 3600.0; + std::f64::consts::TAU * fraction +} + +fn stable_hash(input: &str) -> u64 { + const FNV_OFFSET: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x00000100000001b3; + let mut hash = FNV_OFFSET; + for byte in input.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use ratatui::Terminal; + + use crate::app::animation::AnimClock; + use crate::protocol::{ClusterLogLine, TopologyAgent, TopologyEdge, TopologyEdgeKind}; + use crate::ui::widgets::test_utils::line_text; + use std::collections::HashMap; + + fn sample_topology() -> ClusterTopology { + ClusterTopology { + agents: vec![ + TopologyAgent { + id: "agent-alpha".to_string(), + role: Some("planner".to_string()), + }, + TopologyAgent { + id: "agent-bravo".to_string(), + role: Some("worker".to_string()), + }, + TopologyAgent { + id: "agent-charlie".to_string(), + role: None, + }, + ], + topics: vec!["ISSUE_OPENED".to_string()], + edges: vec![TopologyEdge { + from: "agent-alpha".to_string(), + to: "agent-bravo".to_string(), + topic: "ISSUE_OPENED".to_string(), + kind: TopologyEdgeKind::Publish, + dynamic: None, + }], + } + } + + fn buffer_contains(buffer: &Buffer, needle: &str) -> bool { + for y in 0..buffer.area.height { + if line_text(buffer, y).contains(needle) { + return true; + } + } + false + } + + fn buffers_differ(first: &Buffer, second: &Buffer) -> bool { + if first.area != second.area { + return true; + } + for y in first.area.top()..first.area.bottom() { + for x in first.area.left()..first.area.right() { + let first_cell = first.cell((x, y)); + let second_cell = second.cell((x, y)); + if first_cell != second_cell { + return true; + } + } + } + false + } + + fn rect_within_bounds(rect: Rect, bounds: Rect) -> bool { + let rect_right = rect.x.saturating_add(rect.width); + let rect_bottom = rect.y.saturating_add(rect.height); + let bounds_right = bounds.x.saturating_add(bounds.width); + let bounds_bottom = bounds.y.saturating_add(bounds.height); + rect.x >= bounds.x + && rect.y >= bounds.y + && rect_right <= bounds_right + && rect_bottom <= bounds_bottom + } + + fn layout_with_nodes(nodes: Vec) -> LayoutCache { + let mut map = HashMap::new(); + for node in nodes { + map.insert(node.id.clone(), node); + } + LayoutCache { + nodes: map, + edges: Vec::new(), + bounds: LayoutBounds { + min_x: -WORLD_RADIUS, + max_x: WORLD_RADIUS, + min_y: -WORLD_RADIUS, + max_y: WORLD_RADIUS, + }, + } + } + + #[test] + fn layout_is_deterministic() { + let topology = sample_topology(); + let first = layout_for(&topology); + let second = layout_for(&topology); + assert_eq!(first, second); + } + + #[test] + fn render_pending_topology() { + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + let cluster_state = cluster::State::default(); + let canvas_state = State::default(); + let anim_clock = AnimClock::default(); + let time_cursor = TimeCursor::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-1", + cluster_state: Some(&cluster_state), + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, PENDING_MESSAGE)); + } + + #[test] + fn render_missing_cluster_as_unavailable() { + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + let canvas_state = State::default(); + let anim_clock = AnimClock::default(); + let time_cursor = TimeCursor::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-missing", + cluster_state: None, + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, UNAVAILABLE_MESSAGE)); + } + + #[test] + fn render_topology_unavailable_on_error() { + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut cluster_state = cluster::State::default(); + cluster_state.topology_error = Some("backend timeout".to_string()); + let canvas_state = State::default(); + let anim_clock = AnimClock::default(); + let time_cursor = TimeCursor::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-2", + cluster_state: Some(&cluster_state), + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, UNAVAILABLE_MESSAGE)); + assert!(buffer_contains(buffer, "backend timeout")); + } + + #[test] + fn render_basic_topology() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).expect("terminal"); + let topology = sample_topology(); + let mut cluster_state = cluster::State::default(); + cluster_state.topology = Some(topology.clone()); + let mut canvas_state = State::default(); + canvas_state.update_layout(&topology); + let anim_clock = AnimClock::default(); + let time_cursor = TimeCursor::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-3", + cluster_state: Some(&cluster_state), + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, "agent-alpha")); + assert!(buffer_contains(buffer, "ISSUE_OPENED")); + } + + #[test] + fn default_focus_prefers_agents() { + let topology = ClusterTopology { + agents: vec![ + TopologyAgent { + id: "worker".to_string(), + role: None, + }, + TopologyAgent { + id: "planner".to_string(), + role: None, + }, + ], + topics: vec!["topic-b".to_string(), "topic-a".to_string()], + edges: Vec::new(), + }; + assert_eq!(default_focus_id(&topology), Some("planner".to_string())); + + let topology = ClusterTopology { + agents: Vec::new(), + topics: vec!["topic-b".to_string(), "topic-a".to_string()], + edges: Vec::new(), + }; + assert_eq!(default_focus_id(&topology), Some("topic-a".to_string())); + } + + #[test] + fn move_focus_direction() { + let layout = layout_with_nodes(vec![ + NodeLayout { + id: "center".to_string(), + label: "center".to_string(), + x: 0.0, + y: 0.0, + kind: NodeKind::Agent, + }, + NodeLayout { + id: "right".to_string(), + label: "right".to_string(), + x: 10.0, + y: 0.0, + kind: NodeKind::Agent, + }, + NodeLayout { + id: "right-far".to_string(), + label: "right-far".to_string(), + x: 18.0, + y: 2.0, + kind: NodeKind::Agent, + }, + NodeLayout { + id: "left".to_string(), + label: "left".to_string(), + x: -10.0, + y: 0.0, + kind: NodeKind::Agent, + }, + NodeLayout { + id: "up".to_string(), + label: "up".to_string(), + x: 0.0, + y: 10.0, + kind: NodeKind::Agent, + }, + NodeLayout { + id: "down".to_string(), + label: "down".to_string(), + x: 0.0, + y: -10.0, + kind: NodeKind::Agent, + }, + ]); + + let mut state = State { + focused_id: Some("center".to_string()), + layout: Some(layout), + ..State::default() + }; + state.move_focus(Direction::Right, MoveSpeed::Step); + assert_eq!(state.focused_id.as_deref(), Some("right")); + } + + #[test] + fn move_focus_fast() { + let layout = layout_with_nodes(vec![ + NodeLayout { + id: "a".to_string(), + label: "a".to_string(), + x: 0.0, + y: 0.0, + kind: NodeKind::Agent, + }, + NodeLayout { + id: "b".to_string(), + label: "b".to_string(), + x: 10.0, + y: 0.0, + kind: NodeKind::Agent, + }, + NodeLayout { + id: "c".to_string(), + label: "c".to_string(), + x: 20.0, + y: 0.0, + kind: NodeKind::Agent, + }, + ]); + + let mut state = State { + focused_id: Some("a".to_string()), + layout: Some(layout), + ..State::default() + }; + state.move_focus(Direction::Right, MoveSpeed::Fast); + assert_eq!(state.focused_id.as_deref(), Some("c")); + } + + #[test] + fn render_focus_ring_changes_output() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).expect("terminal"); + let topology = sample_topology(); + let layout = layout_for(&topology); + let mut cluster_state = cluster::State::default(); + cluster_state.topology = Some(topology.clone()); + let anim_clock = AnimClock::default(); + let time_cursor = TimeCursor::default(); + + let canvas_state = State { + focused_id: Some("agent-alpha".to_string()), + layout: Some(layout.clone()), + ..State::default() + }; + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-4", + cluster_state: Some(&cluster_state), + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + let first = terminal.backend().buffer().clone(); + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).expect("terminal"); + let canvas_state = State { + focused_id: Some("agent-bravo".to_string()), + layout: Some(layout), + ..State::default() + }; + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-4", + cluster_state: Some(&cluster_state), + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + let second = terminal.backend().buffer().clone(); + + assert!(buffers_differ(&first, &second)); + } + + #[test] + fn overlay_rect_clamps_to_bounds() { + let bounds = Rect { + x: 2, + y: 1, + width: 40, + height: 18, + }; + let focus = (41, 18); + let size = (26, 10); + let rect = overlay_rect_near_focus(bounds, focus, size, None); + assert!(rect_within_bounds(rect, bounds)); + } + + #[test] + fn overlay_rect_avoids_spine() { + let bounds = Rect { + x: 0, + y: 0, + width: 60, + height: 20, + }; + let spine = Rect { + x: 22, + y: 12, + width: 12, + height: 4, + }; + let focus = (26, 13); + let rect = overlay_rect_near_focus(bounds, focus, (20, 8), Some(spine)); + assert!(!rects_intersect(rect, spine)); + } + + #[test] + fn cluster_canvas_renders_log_overlay() { + let backend = TestBackend::new(90, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let topology = sample_topology(); + let mut cluster_state = cluster::State::default(); + cluster_state.topology = Some(topology.clone()); + cluster_state.push_log_lines( + vec![ClusterLogLine { + id: "line-1".to_string(), + timestamp: 0, + text: "build complete".to_string(), + agent: Some("agent-alpha".to_string()), + role: None, + sender: None, + }], + None, + ); + + let mut canvas_state = State::default(); + canvas_state.update_layout(&topology); + canvas_state.focused_id = Some("agent-alpha".to_string()); + let anim_clock = AnimClock::default(); + let time_cursor = TimeCursor::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-5", + cluster_state: Some(&cluster_state), + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, "Logs - agent agent-alpha")); + assert!(buffer_contains(buffer, "build complete")); + } + + #[test] + fn cluster_canvas_overlay_respects_time_window() { + let backend = TestBackend::new(90, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + let topology = sample_topology(); + let mut cluster_state = cluster::State::default(); + cluster_state.topology = Some(topology.clone()); + cluster_state.push_log_lines( + vec![ + ClusterLogLine { + id: "line-old".to_string(), + timestamp: 100, + text: "old".to_string(), + agent: Some("agent-alpha".to_string()), + role: None, + sender: None, + }, + ClusterLogLine { + id: "line-mid".to_string(), + timestamp: 200, + text: "mid".to_string(), + agent: Some("agent-alpha".to_string()), + role: None, + sender: None, + }, + ClusterLogLine { + id: "line-new".to_string(), + timestamp: 300, + text: "new".to_string(), + agent: Some("agent-alpha".to_string()), + role: None, + sender: None, + }, + ], + None, + ); + + let mut canvas_state = State::default(); + canvas_state.update_layout(&topology); + canvas_state.focused_id = Some("agent-alpha".to_string()); + + let time_cursor = TimeCursor { + mode: crate::app::TimeCursorMode::Scrub, + t_ms: 250, + window_ms: 120, + }; + let anim_clock = AnimClock::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-5", + cluster_state: Some(&cluster_state), + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, "mid")); + assert!(!buffer_contains(buffer, "old")); + assert!(!buffer_contains(buffer, "new")); + } + + #[test] + fn cluster_canvas_renders_overlay_placeholder() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).expect("terminal"); + let topology = sample_topology(); + let mut cluster_state = cluster::State::default(); + cluster_state.topology = Some(topology.clone()); + + let mut canvas_state = State::default(); + canvas_state.update_layout(&topology); + canvas_state.focused_id = Some("agent-alpha".to_string()); + let anim_clock = AnimClock::default(); + let time_cursor = TimeCursor::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + RenderContext { + cluster_id: "cluster-6", + cluster_state: Some(&cluster_state), + canvas_state: Some(&canvas_state), + time_cursor: &time_cursor, + anim_clock: &anim_clock, + pinned_target: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, "No logs yet.")); + } + + #[test] + fn truncate_label_handles_unicode() { + let label = "Ī±Ī²Ī³Ī“ĪµĪ¶Ī·ĪøĪ¹ĪŗĪ»Ī¼Ī½Ī¾ĪæĻ€ĻĻƒ"; + let prefix: String = label.chars().take(LABEL_LIMIT).collect(); + let truncated = truncate_label(label); + assert_eq!(truncated, format!("{}..", prefix)); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/screens/launcher.rs b/tui-rs/crates/zeroshot-tui/src/screens/launcher.rs new file mode 100644 index 00000000..7aa3351c --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/launcher.rs @@ -0,0 +1,97 @@ +#[derive(Debug, Clone, Default)] +pub struct State { + pub input: String, + pub cursor: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Submit, + InsertChar(char), + Backspace, + Delete, + MoveCursorLeft, + MoveCursorRight, + MoveCursorHome, + MoveCursorEnd, +} + +impl State { + pub fn insert_char(&mut self, ch: char) { + let idx = self.byte_index(self.cursor); + self.input.insert(idx, ch); + self.cursor = self.cursor.saturating_add(1); + } + + pub fn backspace(&mut self) { + if self.cursor == 0 { + return; + } + let start = self.byte_index(self.cursor - 1); + let end = self.byte_index(self.cursor); + if start < end { + self.input.replace_range(start..end, ""); + self.cursor = self.cursor.saturating_sub(1); + } + } + + pub fn delete(&mut self) { + let len = self.len_chars(); + if self.cursor >= len { + return; + } + let start = self.byte_index(self.cursor); + let end = self.byte_index(self.cursor + 1); + if start < end { + self.input.replace_range(start..end, ""); + } + } + + pub fn move_left(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn move_right(&mut self) { + let len = self.len_chars(); + if self.cursor < len { + self.cursor += 1; + } + } + + pub fn move_home(&mut self) { + self.cursor = 0; + } + + pub fn move_end(&mut self) { + self.cursor = self.len_chars(); + } + + pub fn clear(&mut self) { + self.input.clear(); + self.cursor = 0; + } + + pub fn clamp_cursor(&mut self) { + let len = self.len_chars(); + if self.cursor > len { + self.cursor = len; + } + } + + fn len_chars(&self) -> usize { + self.input.chars().count() + } + + fn byte_index(&self, char_index: usize) -> usize { + if char_index == 0 { + return 0; + } + self.input + .char_indices() + .nth(char_index) + .map(|(idx, _)| idx) + .unwrap_or_else(|| self.input.len()) + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/screens/metrics.rs b/tui-rs/crates/zeroshot-tui/src/screens/metrics.rs new file mode 100644 index 00000000..e7f18cdb --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/metrics.rs @@ -0,0 +1,90 @@ +use crate::protocol::ClusterMetrics; + +pub const CPU_COLUMN_WIDTH: usize = 6; +pub const MEM_COLUMN_WIDTH: usize = 8; +const PLACEHOLDER: &str = "-"; + +pub fn format_cpu_percent(metrics: Option<&ClusterMetrics>) -> String { + let Some(metrics) = metrics else { + return format_placeholder(CPU_COLUMN_WIDTH); + }; + if !metrics.supported { + return format_placeholder(CPU_COLUMN_WIDTH); + } + let Some(value) = metrics.cpu_percent else { + return format_placeholder(CPU_COLUMN_WIDTH); + }; + if !value.is_finite() { + return format_placeholder(CPU_COLUMN_WIDTH); + } + format!("{:>width$.1}%", value, width = CPU_COLUMN_WIDTH - 1) +} + +pub fn format_memory_mb(metrics: Option<&ClusterMetrics>) -> String { + let Some(metrics) = metrics else { + return format_placeholder(MEM_COLUMN_WIDTH); + }; + if !metrics.supported { + return format_placeholder(MEM_COLUMN_WIDTH); + } + let Some(value) = metrics.memory_mb else { + return format_placeholder(MEM_COLUMN_WIDTH); + }; + if !value.is_finite() { + return format_placeholder(MEM_COLUMN_WIDTH); + } + let rounded = value.round() as i64; + format!("{:>width$}MB", rounded, width = MEM_COLUMN_WIDTH - 2) +} + +pub fn format_metrics_line(metrics: Option<&ClusterMetrics>) -> String { + let cpu = format_cpu_percent(metrics); + let mem = format_memory_mb(metrics); + format!("CPU {cpu} | MEM {mem}") +} + +fn format_placeholder(width: usize) -> String { + format!("{:>width$}", PLACEHOLDER, width = width) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metrics(supported: bool, cpu: Option, mem: Option) -> ClusterMetrics { + ClusterMetrics { + id: "cluster-1".to_string(), + supported, + cpu_percent: cpu, + memory_mb: mem, + } + } + + #[test] + fn placeholder_when_unsupported() { + let sample = metrics(false, Some(12.3), Some(456.7)); + let cpu = format_cpu_percent(Some(&sample)); + assert_eq!(cpu.trim(), PLACEHOLDER); + assert_eq!(cpu.len(), CPU_COLUMN_WIDTH); + + let mem = format_memory_mb(Some(&sample)); + assert_eq!(mem.trim(), PLACEHOLDER); + assert_eq!(mem.len(), MEM_COLUMN_WIDTH); + } + + #[test] + fn cpu_rounds_to_one_decimal() { + let sample = metrics(true, Some(12.34), None); + assert_eq!(format_cpu_percent(Some(&sample)), " 12.3%"); + let sample = metrics(true, Some(12.36), None); + assert_eq!(format_cpu_percent(Some(&sample)), " 12.4%"); + } + + #[test] + fn memory_rounds_to_nearest_mb() { + let sample = metrics(true, None, Some(256.4)); + assert_eq!(format_memory_mb(Some(&sample)), " 256MB"); + let sample = metrics(true, None, Some(256.6)); + assert_eq!(format_memory_mb(Some(&sample)), " 257MB"); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/screens/mod.rs b/tui-rs/crates/zeroshot-tui/src/screens/mod.rs new file mode 100644 index 00000000..1a3edeea --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/mod.rs @@ -0,0 +1,8 @@ +pub mod agent; +pub mod agent_microscope; +pub mod cluster; +pub mod cluster_canvas; +pub mod launcher; +pub mod metrics; +pub mod monitor; +pub mod radar; diff --git a/tui-rs/crates/zeroshot-tui/src/screens/monitor.rs b/tui-rs/crates/zeroshot-tui/src/screens/monitor.rs new file mode 100644 index 00000000..91d4891a --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/monitor.rs @@ -0,0 +1,231 @@ +use std::collections::HashMap; + +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Cell, Paragraph, Row, Table, TableState}; +use ratatui::Frame; + +use crate::protocol::{ClusterMetrics, ClusterSummary}; +use crate::screens::metrics; +use crate::ui::theme; + +const POLL_INTERVAL_MS: i64 = 1000; + +#[derive(Debug, Clone, Default)] +pub struct State { + pub clusters: Vec, + pub selected: usize, + pub last_poll_at: Option, + pub last_message_counts: HashMap, + pub last_activity_at: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + MoveSelection(i32), + OpenSelected, +} + +impl State { + pub fn set_clusters(&mut self, clusters: Vec, now_ms: i64) { + let selected_id = self.selected_cluster_id(); + self.update_activity(&clusters, now_ms); + self.clusters = clusters; + self.reconcile_selection(selected_id); + } + + pub fn move_selection(&mut self, delta: i32) { + if self.clusters.is_empty() { + self.selected = 0; + return; + } + + let len = self.clusters.len() as i32; + let mut next = self.selected as i32 + delta; + if next < 0 { + next = 0; + } + if next >= len { + next = len - 1; + } + self.selected = next as usize; + } + + pub fn selected_cluster_id(&self) -> Option { + self.clusters + .get(self.selected) + .map(|cluster| cluster.id.clone()) + } + + pub fn poll_due(&self, now_ms: i64) -> bool { + match self.last_poll_at { + None => true, + Some(last) => now_ms.saturating_sub(last) >= POLL_INTERVAL_MS, + } + } + + pub fn mark_polled(&mut self, now_ms: i64) { + self.last_poll_at = Some(now_ms); + } + + fn update_activity(&mut self, clusters: &[ClusterSummary], now_ms: i64) { + let mut next_counts = HashMap::new(); + let mut next_activity = HashMap::new(); + + for cluster in clusters { + let prev_count = self.last_message_counts.get(&cluster.id).copied(); + let prev_activity = self.last_activity_at.get(&cluster.id).copied(); + let mut activity = prev_activity; + + if prev_count + .map(|prev| cluster.message_count > prev) + .unwrap_or(true) + { + activity = Some(now_ms); + } + + next_counts.insert(cluster.id.clone(), cluster.message_count); + if let Some(activity_at) = activity { + next_activity.insert(cluster.id.clone(), activity_at); + } + } + + self.last_message_counts = next_counts; + self.last_activity_at = next_activity; + } + + fn reconcile_selection(&mut self, selected_id: Option) { + if let Some(id) = selected_id { + if let Some(index) = self.clusters.iter().position(|cluster| cluster.id == id) { + self.selected = index; + return; + } + } + + if self.selected >= self.clusters.len() { + self.selected = self.clusters.len().saturating_sub(1); + } + } +} + +pub fn render( + frame: &mut Frame<'_>, + area: Rect, + state: &State, + metrics_map: &HashMap, + now_ms: i64, +) { + // Empty state + if state.clusters.is_empty() { + render_empty(frame, area); + return; + } + + let header = Row::new(vec![ + Cell::from("ID"), + Cell::from("STATE"), + Cell::from("PROVIDER"), + Cell::from("CPU%"), + Cell::from("MEM"), + Cell::from("DURATION"), + Cell::from("LAST"), + ]) + .style(theme::table_header_style()); + + let rows: Vec = state + .clusters + .iter() + .map(|cluster| { + let provider = cluster.provider.clone().unwrap_or_else(|| "-".to_string()); + let metrics = metrics_map.get(&cluster.id); + let cpu = metrics::format_cpu_percent(metrics); + let mem = metrics::format_memory_mb(metrics); + let duration = format_duration(now_ms.saturating_sub(cluster.created_at)); + let last_activity = state + .last_activity_at + .get(&cluster.id) + .map(|activity| format_duration(now_ms.saturating_sub(*activity))) + .unwrap_or_else(|| "-".to_string()); + + let state_style = theme::status_style(&cluster.state); + let is_done = matches!( + cluster.state.as_str(), + "done" | "completed" | "complete" | "stopped" + ); + let row_style = if is_done { + theme::done_row_style() + } else { + Style::default() + }; + + Row::new(vec![ + Cell::from(cluster.id.clone()), + Cell::from(Span::styled(cluster.state.clone(), state_style)), + Cell::from(provider), + Cell::from(cpu), + Cell::from(mem), + Cell::from(duration), + Cell::from(last_activity), + ]) + .style(row_style) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Min(18), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(metrics::CPU_COLUMN_WIDTH as u16), + Constraint::Length(metrics::MEM_COLUMN_WIDTH as u16), + Constraint::Length(10), + Constraint::Length(8), + ], + ) + .header(header) + .row_highlight_style(theme::selected_style()) + .highlight_symbol(" > "); + + let mut table_state = TableState::default(); + table_state.select(Some(state.selected)); + + frame.render_stateful_widget(table, area, &mut table_state); +} + +fn render_empty(frame: &mut Frame<'_>, area: Rect) { + let lines = vec![ + Line::from(""), + Line::from(""), + Line::from(Span::styled("No active clusters", theme::muted_style())), + Line::from(""), + Line::from(Span::styled( + "Start a cluster from the Launcher (Esc)", + theme::dim_style(), + )), + Line::from(Span::styled( + "or run: zeroshot run --ship", + theme::dim_style(), + )), + ]; + let widget = Paragraph::new(lines).alignment(Alignment::Center); + frame.render_widget(widget, area); +} + +fn format_duration(delta_ms: i64) -> String { + let seconds = if delta_ms < 0 { 0 } else { delta_ms } / 1000; + if seconds < 60 { + return format!("{}s", seconds); + } + let minutes = seconds / 60; + if minutes < 60 { + return format!("{}m", minutes); + } + let hours = minutes / 60; + if hours < 24 { + return format!("{}h", hours); + } + let days = hours / 24; + format!("{}d", days) +} diff --git a/tui-rs/crates/zeroshot-tui/src/screens/radar.rs b/tui-rs/crates/zeroshot-tui/src/screens/radar.rs new file mode 100644 index 00000000..328a5229 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/screens/radar.rs @@ -0,0 +1,673 @@ +use std::collections::HashMap; + +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::symbols::Marker; +use ratatui::text::{Line, Span}; +use ratatui::widgets::canvas::{Canvas, Circle, Points}; +use ratatui::widgets::{Block, Borders}; +use ratatui::Frame; + +use crate::app::animation::{self, AnimClock}; +use crate::app::Camera; +use crate::protocol::ClusterSummary; +use crate::ui::shared::calm_empty_state; +use crate::ui::theme; + +const POLL_INTERVAL_MS: i64 = 1000; +const WORLD_RADIUS: f64 = 48.0; +const LABEL_OFFSET: f64 = 6.0; +const RADIAL_LABEL_OFFSET: f64 = 2.0; +const BASE_ORB_RADIUS: f64 = 1.8; +const MAX_ORB_RADIUS: f64 = 4.6; +const ERROR_PULSE_RADIUS: f64 = 1.6; +const SELECTION_RING_RADIUS: f64 = 1.2; +const PIN_RING_RADIUS: f64 = 2.2; +const MIN_CAMERA_ZOOM: f32 = 0.2; +const ORB_SMOOTH_RATE: f64 = 0.25; + +const ACTIVITY_BANDS_MS: [i64; 4] = [5_000, 30_000, 120_000, 600_000]; +const RING_RADII: [f64; 5] = [10.0, 20.0, 30.0, 40.0, 46.0]; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LayoutPosition { + pub x: f64, + pub y: f64, + pub ring_radius: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OrbVisual { + pub radius: f64, + pub intensity: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Left, + Right, + Up, + Down, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveSpeed { + Step, + Fast, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + MoveSelection { + direction: Direction, + speed: MoveSpeed, + }, + CenterOnSelection, + ResetView, +} + +#[derive(Debug, Clone, Default)] +pub struct FleetRadarState { + pub clusters: Vec, + pub selected: usize, + pub last_poll_at: Option, + pub last_message_counts: HashMap, + pub last_activity_at: HashMap, + pub last_message_deltas: HashMap, + pub layout_angles: HashMap, + pub orb_states: HashMap, +} + +impl FleetRadarState { + pub fn set_clusters(&mut self, clusters: Vec, now_ms: i64) { + let selected_id = self.selected_cluster_id(); + self.update_activity(&clusters, now_ms); + self.ensure_angles(&clusters); + self.ensure_orb_states(&clusters, now_ms); + self.clusters = clusters; + self.reconcile_selection(selected_id); + } + + pub fn poll_due(&self, now_ms: i64) -> bool { + match self.last_poll_at { + None => true, + Some(last) => now_ms.saturating_sub(last) >= POLL_INTERVAL_MS, + } + } + + pub fn mark_polled(&mut self, now_ms: i64) { + self.last_poll_at = Some(now_ms); + } + + pub fn selected_cluster_id(&self) -> Option { + self.clusters + .get(self.selected) + .map(|cluster| cluster.id.clone()) + } + + pub fn selected_layout(&self, now_ms: i64) -> Option { + let cluster = self.clusters.get(self.selected)?; + let age_ms = self.activity_age_ms(cluster, now_ms); + Some(self.layout_for(cluster.id.as_str(), age_ms)) + } + + pub fn move_selection_direction( + &mut self, + now_ms: i64, + direction: Direction, + speed: MoveSpeed, + ) -> bool { + let steps = match speed { + MoveSpeed::Step => 1, + MoveSpeed::Fast => 2, + }; + let mut moved = false; + for _ in 0..steps { + if self.move_selection_step(now_ms, direction) { + moved = true; + } else { + break; + } + } + moved + } + + pub fn activity_age_ms(&self, cluster: &ClusterSummary, now_ms: i64) -> i64 { + let activity_at = self + .last_activity_at + .get(&cluster.id) + .copied() + .unwrap_or(cluster.created_at); + now_ms.saturating_sub(activity_at) + } + + pub fn activity_delta(&self, cluster_id: &str) -> i64 { + self.last_message_deltas + .get(cluster_id) + .copied() + .unwrap_or(0) + } + + pub fn layout_for(&self, cluster_id: &str, activity_age_ms: i64) -> LayoutPosition { + let angle = self + .layout_angles + .get(cluster_id) + .copied() + .unwrap_or_else(|| stable_angle(cluster_id)); + layout_with_angle(angle, activity_age_ms) + } + + pub fn tick_orb_smoothing(&mut self, now_ms: i64, dt_ms: i64) { + if self.clusters.is_empty() { + return; + } + for cluster in &self.clusters { + let (target_radius, target_intensity) = orb_targets(self, cluster, now_ms); + let entry = self + .orb_states + .entry(cluster.id.clone()) + .or_insert(OrbVisual { + radius: target_radius, + intensity: target_intensity, + }); + entry.radius = + animation::smooth_toward_f64(entry.radius, target_radius, dt_ms, ORB_SMOOTH_RATE); + entry.intensity = animation::smooth_toward_f64( + entry.intensity, + target_intensity, + dt_ms, + ORB_SMOOTH_RATE, + ); + } + } + + pub fn orb_visual(&self, cluster: &ClusterSummary, now_ms: i64) -> OrbVisual { + self.orb_states + .get(&cluster.id) + .copied() + .unwrap_or_else(|| { + let (radius, intensity) = orb_targets(self, cluster, now_ms); + OrbVisual { radius, intensity } + }) + } + + fn update_activity(&mut self, clusters: &[ClusterSummary], now_ms: i64) { + let mut next_counts = HashMap::new(); + let mut next_activity = HashMap::new(); + let mut next_deltas = HashMap::new(); + + for cluster in clusters { + let prev_count = self.last_message_counts.get(&cluster.id).copied(); + let prev_activity = self.last_activity_at.get(&cluster.id).copied(); + let delta = prev_count + .map(|prev| cluster.message_count.saturating_sub(prev)) + .unwrap_or(cluster.message_count); + let mut activity = prev_activity; + + if prev_count + .map(|prev| cluster.message_count > prev) + .unwrap_or(true) + { + activity = Some(now_ms); + } + + next_counts.insert(cluster.id.clone(), cluster.message_count); + next_deltas.insert(cluster.id.clone(), delta); + if let Some(activity_at) = activity { + next_activity.insert(cluster.id.clone(), activity_at); + } + } + + self.last_message_counts = next_counts; + self.last_activity_at = next_activity; + self.last_message_deltas = next_deltas; + } + + fn ensure_angles(&mut self, clusters: &[ClusterSummary]) { + for cluster in clusters { + self.layout_angles + .entry(cluster.id.clone()) + .or_insert_with(|| stable_angle(cluster.id.as_str())); + } + self.layout_angles + .retain(|id, _| clusters.iter().any(|cluster| cluster.id == *id)); + } + + fn ensure_orb_states(&mut self, clusters: &[ClusterSummary], now_ms: i64) { + for cluster in clusters { + let (radius, intensity) = orb_targets(self, cluster, now_ms); + self.orb_states + .entry(cluster.id.clone()) + .or_insert(OrbVisual { radius, intensity }); + } + self.orb_states + .retain(|id, _| clusters.iter().any(|cluster| cluster.id == *id)); + } + + fn reconcile_selection(&mut self, selected_id: Option) { + if self.clusters.is_empty() { + self.selected = 0; + return; + } + + if let Some(id) = selected_id { + if let Some(index) = self.clusters.iter().position(|cluster| cluster.id == id) { + self.selected = index; + return; + } + } + + self.selected = 0; + } + + fn move_selection_step(&mut self, now_ms: i64, direction: Direction) -> bool { + if self.clusters.is_empty() { + self.selected = 0; + return false; + } + if self.selected >= self.clusters.len() { + self.selected = self.clusters.len().saturating_sub(1); + } + + let current_cluster = &self.clusters[self.selected]; + let current_layout = self.layout_for( + current_cluster.id.as_str(), + self.activity_age_ms(current_cluster, now_ms), + ); + + let mut best: Option<(usize, f64, f64)> = None; + + for (idx, cluster) in self.clusters.iter().enumerate() { + if idx == self.selected { + continue; + } + let layout = + self.layout_for(cluster.id.as_str(), self.activity_age_ms(cluster, now_ms)); + let dx = layout.x - current_layout.x; + let dy = layout.y - current_layout.y; + + let (axis, off) = match direction { + Direction::Right if dx > 0.0 => (dx, dy.abs()), + Direction::Left if dx < 0.0 => (-dx, dy.abs()), + Direction::Up if dy > 0.0 => (dy, dx.abs()), + Direction::Down if dy < 0.0 => (-dy, dx.abs()), + _ => continue, + }; + + let angle_score = off / axis; + let dist2 = dx * dx + dy * dy; + let replace = match best { + None => true, + Some((_best_idx, best_angle, best_dist)) => { + const EPS: f64 = 1e-6; + if (angle_score - best_angle).abs() > EPS { + angle_score < best_angle + } else if (dist2 - best_dist).abs() > EPS { + dist2 < best_dist + } else { + idx < _best_idx + } + } + }; + + if replace { + best = Some((idx, angle_score, dist2)); + } + } + + if let Some((idx, _, _)) = best { + self.selected = idx; + true + } else { + false + } + } +} + +pub fn layout_position(cluster_id: &str, activity_age_ms: i64) -> LayoutPosition { + let angle = stable_angle(cluster_id); + layout_with_angle(angle, activity_age_ms) +} + +pub fn render( + frame: &mut Frame<'_>, + area: Rect, + state: &FleetRadarState, + camera: &Camera, + now_ms: i64, + anim_clock: &AnimClock, + pinned_cluster_id: Option<&str>, +) { + if state.clusters.is_empty() { + render_empty(frame, area); + return; + } + + let selected_id = state.selected_cluster_id(); + let selected_id = selected_id.as_deref(); + let pinned_id = pinned_cluster_id; + let zoom = camera.zoom.max(MIN_CAMERA_ZOOM); + let half_span = WORLD_RADIUS / zoom as f64; + let center_x = camera.position.0 as f64; + let center_y = camera.position.1 as f64; + let pulse = animation::pulse_factor(anim_clock.phase) as f64; + + let canvas = Canvas::default() + .block(Block::default().borders(Borders::ALL).title("Fleet Radar")) + .x_bounds([center_x - half_span, center_x + half_span]) + .y_bounds([center_y - half_span, center_y + half_span]) + .marker(Marker::Braille) + .paint(|ctx| { + for ring in RING_RADII.iter().take(RING_RADII.len().saturating_sub(1)) { + ctx.draw(&Circle { + x: 0.0, + y: 0.0, + radius: *ring, + color: theme::FG_DIM, + }); + } + + for cluster in &state.clusters { + let age_ms = state.activity_age_ms(cluster, now_ms); + let layout = state.layout_for(cluster.id.as_str(), age_ms); + let color = cluster_color(cluster); + let orb = state.orb_visual(cluster, now_ms); + let orb_radius = orb.radius; + let intensity = orb.intensity; + let is_selected = selected_id == Some(cluster.id.as_str()); + let is_pinned = pinned_id == Some(cluster.id.as_str()); + let is_error = matches!(cluster.state.as_str(), "error" | "failed" | "failure"); + + if is_error { + let intensity_scale = 0.6 + 0.4 * intensity; + let pulse_scale = 0.7 + 0.6 * pulse; + ctx.draw(&Circle { + x: layout.x, + y: layout.y, + radius: orb_radius + ERROR_PULSE_RADIUS * intensity_scale * pulse_scale, + color: theme::STATUS_ERROR, + }); + } + + if is_selected { + ctx.draw(&Circle { + x: layout.x, + y: layout.y, + radius: orb_radius + SELECTION_RING_RADIUS, + color: theme::ACCENT, + }); + } + + if is_pinned { + ctx.draw(&Circle { + x: layout.x, + y: layout.y, + radius: orb_radius + PIN_RING_RADIUS, + color: theme::ACCENT2, + }); + } + + ctx.draw(&Circle { + x: layout.x, + y: layout.y, + radius: orb_radius, + color, + }); + + ctx.draw(&Points { + coords: &[(layout.x, layout.y)], + color, + }); + + let label = truncate_label(cluster.id.as_str()); + let label_style = Style::default().fg(color); + let line = Line::from(Span::styled(label, label_style)); + let (label_x, label_y) = label_position(layout.x, layout.y, cluster.id.as_str()); + ctx.print(label_x, label_y, line); + } + }); + + frame.render_widget(canvas, area); +} + +fn render_empty(frame: &mut Frame<'_>, area: Rect) { + let widget = calm_empty_state( + "Fleet Radar", + "No clusters yet.", + Some("Type an intent in the spine to start a cluster."), + None, + ); + frame.render_widget(widget, area); +} + +fn activity_ring(age_ms: i64) -> f64 { + let age = if age_ms < 0 { 0 } else { age_ms }; + for (index, cutoff) in ACTIVITY_BANDS_MS.iter().enumerate() { + if age <= *cutoff { + return RING_RADII[index]; + } + } + *RING_RADII.last().unwrap_or(&WORLD_RADIUS) +} + +fn layout_with_angle(angle: f64, activity_age_ms: i64) -> LayoutPosition { + let ring_radius = activity_ring(activity_age_ms); + let x = ring_radius * angle.cos(); + let y = ring_radius * angle.sin(); + LayoutPosition { x, y, ring_radius } +} + +fn label_position(x: f64, y: f64, cluster_id: &str) -> (f64, f64) { + let hash = stable_hash(cluster_id); + let side = if hash & 1 == 0 { 1.0 } else { -1.0 }; + let angle = if x == 0.0 && y == 0.0 { + 0.0 + } else { + y.atan2(x) + }; + let tangent_x = -angle.sin(); + let tangent_y = angle.cos(); + let radial_x = -angle.cos(); + let radial_y = -angle.sin(); + let mut lx = x + tangent_x * LABEL_OFFSET * side + radial_x * RADIAL_LABEL_OFFSET; + let mut ly = y + tangent_y * LABEL_OFFSET * side + radial_y * RADIAL_LABEL_OFFSET; + let min = -WORLD_RADIUS + 1.0; + let max = WORLD_RADIUS - 1.0; + if lx < min { + lx = min; + } else if lx > max { + lx = max; + } + if ly < min { + ly = min; + } else if ly > max { + ly = max; + } + (lx, ly) +} + +fn orb_radius(delta: i64, age_ms: i64) -> f64 { + let delta_boost = (delta.max(0) as f64).min(6.0) * 0.35; + let recency_boost = if age_ms <= 5_000 { + 0.9 + } else if age_ms <= 30_000 { + 0.4 + } else { + 0.0 + }; + (BASE_ORB_RADIUS + delta_boost + recency_boost).min(MAX_ORB_RADIUS) +} + +fn orb_intensity(delta: i64, age_ms: i64) -> f64 { + let delta_norm = (delta.max(0) as f64).min(6.0) / 6.0; + let recency = if age_ms <= 5_000 { + 1.0 + } else if age_ms <= 30_000 { + 0.6 + } else { + 0.3 + }; + (0.3 + delta_norm * 0.5 + recency * 0.2).min(1.0) +} + +fn orb_targets(state: &FleetRadarState, cluster: &ClusterSummary, now_ms: i64) -> (f64, f64) { + let age_ms = state.activity_age_ms(cluster, now_ms); + let delta = state.activity_delta(cluster.id.as_str()); + (orb_radius(delta, age_ms), orb_intensity(delta, age_ms)) +} + +fn truncate_label(id: &str) -> String { + const LIMIT: usize = 10; + let mut iter = id.chars(); + let mut out = String::new(); + for _ in 0..LIMIT { + match iter.next() { + Some(ch) => out.push(ch), + None => return id.to_string(), + } + } + if iter.next().is_some() { + out.push_str(".."); + } + out +} + +fn cluster_color(cluster: &ClusterSummary) -> Color { + theme::status_style(&cluster.state) + .fg + .unwrap_or(theme::FG_MUTED) +} + +fn stable_angle(input: &str) -> f64 { + let hash = stable_hash(input); + let fraction = (hash % 3600) as f64 / 3600.0; + std::f64::consts::TAU * fraction +} + +fn stable_hash(input: &str) -> u64 { + const FNV_OFFSET: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x00000100000001b3; + let mut hash = FNV_OFFSET; + for byte in input.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::widgets::test_utils::line_text; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use ratatui::Terminal; + + fn cluster(id: &str) -> ClusterSummary { + ClusterSummary { + id: id.to_string(), + state: "running".to_string(), + provider: None, + created_at: 0, + agent_count: 1, + message_count: 0, + cwd: None, + } + } + + fn buffer_contains(buffer: &Buffer, needle: &str) -> bool { + for y in 0..buffer.area.height { + if line_text(buffer, y).contains(needle) { + return true; + } + } + false + } + + #[test] + fn fleet_radar_renders_empty_state() { + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + let state = FleetRadarState::default(); + let camera = Camera::default(); + let anim_clock = AnimClock::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &state, &camera, 0, &anim_clock, None); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, "No clusters yet.")); + assert!(buffer_contains( + buffer, + "Type an intent in the spine to start a cluster." + )); + } + + #[test] + fn layout_position_is_deterministic() { + let first = layout_position("cluster-1", 10_000); + let second = layout_position("cluster-1", 10_000); + assert_eq!(first, second); + } + + #[test] + fn layout_position_changes_with_age_band() { + let recent = layout_position("cluster-1", 1_000); + let older = layout_position("cluster-1", 1_000_000); + assert!(recent.ring_radius < older.ring_radius); + } + + #[test] + fn truncate_label_handles_unicode() { + let label = "αβγΓεζηθικλμ"; + let truncated = truncate_label(label); + assert_eq!(truncated, "αβγΓεζηθικ.."); + } + + #[test] + fn radar_directional_selection() { + let now_ms = 10_000; + let mut state = FleetRadarState::default(); + state.set_clusters( + vec![ + cluster("east"), + cluster("west"), + cluster("north"), + cluster("south"), + ], + now_ms, + ); + state.layout_angles.insert("east".to_string(), 0.0); + state + .layout_angles + .insert("north".to_string(), std::f64::consts::FRAC_PI_2); + state + .layout_angles + .insert("west".to_string(), std::f64::consts::PI); + state + .layout_angles + .insert("south".to_string(), std::f64::consts::TAU * 0.75); + + state.selected = state + .clusters + .iter() + .position(|cluster| cluster.id == "west") + .unwrap(); + + assert!(state.move_selection_direction(now_ms, Direction::Right, MoveSpeed::Step)); + assert_eq!(state.selected_cluster_id().as_deref(), Some("east")); + + assert!(state.move_selection_direction(now_ms, Direction::Up, MoveSpeed::Step)); + assert_eq!(state.selected_cluster_id().as_deref(), Some("north")); + + assert!(state.move_selection_direction(now_ms, Direction::Down, MoveSpeed::Step)); + assert_eq!(state.selected_cluster_id().as_deref(), Some("south")); + + assert!(!state.move_selection_direction(now_ms, Direction::Down, MoveSpeed::Step)); + assert_eq!(state.selected_cluster_id().as_deref(), Some("south")); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/terminal.rs b/tui-rs/crates/zeroshot-tui/src/terminal.rs new file mode 100644 index 00000000..d2006a75 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/terminal.rs @@ -0,0 +1,70 @@ +use std::io::{self, stdout}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crossterm::{cursor, execute, terminal}; + +#[derive(Debug)] +pub struct TerminalGuard { + restored: Arc, +} + +impl TerminalGuard { + pub fn new() -> io::Result { + terminal::enable_raw_mode()?; + if let Err(err) = execute!(stdout(), terminal::EnterAlternateScreen, cursor::Hide) { + if let Err(disable_err) = terminal::disable_raw_mode() { + return Err(disable_err); + } + return Err(err); + } + Ok(Self { + restored: Arc::new(AtomicBool::new(false)), + }) + } + + pub fn install_panic_hook(&self) { + let restored = self.restored.clone(); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Err(err) = restore_terminal(&restored) { + eprintln!("Failed to restore terminal on panic: {err}"); + } + previous(info); + })); + } + + pub fn restore(&self) -> io::Result<()> { + restore_terminal(&self.restored) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if let Err(err) = restore_terminal(&self.restored) { + eprintln!("Failed to restore terminal on drop: {err}"); + } + } +} + +fn restore_terminal(restored: &AtomicBool) -> io::Result<()> { + if restored.swap(true, Ordering::SeqCst) { + return Ok(()); + } + + let mut first_error: Option = None; + if let Err(err) = terminal::disable_raw_mode() { + first_error = Some(err); + } + if let Err(err) = execute!(stdout(), terminal::LeaveAlternateScreen, cursor::Show) { + if first_error.is_none() { + first_error = Some(err); + } + } + + if let Some(err) = first_error { + return Err(err); + } + + Ok(()) +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/launcher.rs b/tui-rs/crates/zeroshot-tui/src/ui/launcher.rs new file mode 100644 index 00000000..30a586b8 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/launcher.rs @@ -0,0 +1,156 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; +use ratatui::Frame; + +use crate::protocol::ClusterSummary; +use crate::screens::launcher::State; +use crate::ui::theme; + +/// Maximum width for the centered content area. +const MAX_CONTENT_WIDTH: u16 = 60; + +pub fn render( + frame: &mut Frame<'_>, + area: Rect, + state: &State, + provider_override: Option<&str>, + recent_clusters: &[ClusterSummary], +) { + let centered = center_rect(area, MAX_CONTENT_WIDTH); + + // Vertical layout: logo (3) + input (3) + gap (1) + quick actions (6) + gap (1) + recent (rest) + let [logo_area, input_area, _, actions_area, _, recent_area] = Layout::vertical([ + Constraint::Length(3), // logo + Constraint::Length(3), // input + Constraint::Length(1), // gap + Constraint::Length(6), // quick actions + Constraint::Length(1), // gap + Constraint::Min(2), // recent clusters + ]) + .areas(centered); + + render_logo(frame, logo_area, provider_override); + render_input(frame, input_area, state); + render_quick_actions(frame, actions_area); + render_recent(frame, recent_area, recent_clusters); +} + +fn render_logo(frame: &mut Frame<'_>, area: Rect, provider_override: Option<&str>) { + let provider = provider_override.unwrap_or("default"); + let lines = vec![ + Line::from(""), + Line::from(vec![Span::styled( + "\u{25c6} Z E R O S H O T", + theme::logo_style(), + )]), + Line::from(vec![ + Span::styled("Multi-Agent Orchestrator", theme::dim_style()), + Span::raw(" "), + Span::styled(format!("[{provider}]"), theme::muted_style()), + ]), + ]; + let widget = Paragraph::new(lines).alignment(Alignment::Center); + frame.render_widget(widget, area); +} + +fn render_input(frame: &mut Frame<'_>, area: Rect, state: &State) { + let content = if state.input.is_empty() { + Line::from(Span::styled( + "Describe a task or paste an issue URL...", + theme::muted_style(), + )) + } else { + Line::from(Span::styled(state.input.as_str(), theme::title_style())) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme::focus_border_style()) + .border_type(BorderType::Rounded); + + let widget = Paragraph::new(content).block(block); + frame.render_widget(widget, area); + + // Set cursor + if area.height > 2 && area.width > 2 { + let max_x = area.x + area.width.saturating_sub(2); + let cursor_x = area.x + 1 + state.cursor as u16; + let cursor_x = cursor_x.min(max_x); + let cursor_y = area.y + 1; + frame.set_cursor_position(Position::new(cursor_x, cursor_y)); + } +} + +fn render_quick_actions(frame: &mut Frame<'_>, area: Rect) { + let lines = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled("/issue", theme::key_style()), + Span::styled(" org/repo#123 ", theme::dim_style()), + Span::styled("Start from issue", theme::dim_style()), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("/monitor", theme::key_style()), + Span::styled(" ", theme::dim_style()), + Span::styled("View active runs", theme::dim_style()), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("/provider", theme::key_style()), + Span::styled(" ", theme::dim_style()), + Span::styled("Switch AI model", theme::dim_style()), + ]), + ]; + + let block = Block::default() + .title(Span::styled(" Quick Actions ", theme::dim_style())) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme::unfocus_border_style()); + + let widget = Paragraph::new(lines).block(block); + frame.render_widget(widget, area); +} + +fn render_recent(frame: &mut Frame<'_>, area: Rect, clusters: &[ClusterSummary]) { + let mut lines = vec![Line::from(Span::styled("Recent", theme::dim_style()))]; + + if clusters.is_empty() { + lines.push(Line::from(Span::styled( + "(no recent clusters)", + theme::muted_style(), + ))); + } else { + for cluster in clusters.iter().take(3) { + let state_style = theme::status_style(&cluster.state); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(&cluster.id, theme::dim_style()), + Span::raw(" "), + Span::styled(&cluster.state, state_style), + ])); + } + } + + let widget = Paragraph::new(lines); + frame.render_widget(widget, area); +} + +/// Center a rect horizontally within the outer area, capping at max_width. +fn center_rect(outer: Rect, max_width: u16) -> Rect { + let width = outer.width.min(max_width); + let x = outer.x + (outer.width.saturating_sub(width)) / 2; + + // Vertically center if enough space (aim for ~1/3 from top) + let content_height = 17u16; // approximate total height of all sections + let y = if outer.height > content_height + 4 { + outer.y + (outer.height.saturating_sub(content_height)) / 3 + } else { + outer.y + }; + let height = outer.height.saturating_sub(y - outer.y); + + Rect::new(x, y, width, height) +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/mod.rs b/tui-rs/crates/zeroshot-tui/src/ui/mod.rs new file mode 100644 index 00000000..ca8d74bf --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/mod.rs @@ -0,0 +1,443 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::{ + AppState, BackendStatus, FocusTarget, ScreenId, SpineHint, SpineHintTone, UiVariant, +}; +use crate::screens::{agent, agent_microscope, cluster, cluster_canvas, monitor, radar}; +use crate::ui::widgets::{command_bar, scrub_bar, spine, toast}; + +pub mod launcher; +pub mod scene; +pub mod shared; +pub mod theme; +pub mod widgets; + +const DISRUPTIVE_SPINE_HINT: &str = "/guide /nudge /interrupt /pin / i ? Tab Esc Enter"; + +pub fn render(frame: &mut Frame<'_>, state: &AppState) { + if matches!(state.ui_variant, UiVariant::Disruptive) { + render_disruptive(frame, state); + return; + } + + let size = frame.area(); + let [header_area, content_area, status_area] = Layout::vertical([ + Constraint::Length(1), // header + Constraint::Min(1), // content + Constraint::Length(1), // status bar / command bar + ]) + .areas(size); + + render_header(frame, header_area, state); + + match state.active_screen() { + ScreenId::Launcher | ScreenId::IntentConsole => launcher::render( + frame, + content_area, + &state.launcher, + state.provider_override.as_deref(), + &state.monitor.clusters, + ), + ScreenId::Monitor | ScreenId::FleetRadar => monitor::render( + frame, + content_area, + &state.monitor, + &state.metrics, + state.now_ms, + ), + ScreenId::Cluster { id } => { + if let Some(cluster_state) = state.clusters.get(id) { + let metrics = state.metrics.get(id); + cluster::render(frame, content_area, cluster_state, metrics); + } else { + let default_state = cluster::State::default(); + cluster::render(frame, content_area, &default_state, None); + } + } + ScreenId::ClusterCanvas { id } => { + let cluster_state = state.clusters.get(id); + let canvas_state = state.cluster_canvases.get(id); + cluster_canvas::render( + frame, + content_area, + cluster_canvas::RenderContext { + cluster_id: id, + cluster_state, + canvas_state, + time_cursor: &state.time_cursor, + anim_clock: &state.anim_clock, + pinned_target: state.pinned_target.as_ref(), + }, + ); + } + ScreenId::Agent { + cluster_id, + agent_id, + } => { + let key = crate::app::AgentKey::new(cluster_id.clone(), agent_id.clone()); + if let Some(agent_state) = state.agents.get(&key) { + agent::render(frame, content_area, agent_state, cluster_id, agent_id); + } else { + let default_state = agent::State::default(); + agent::render(frame, content_area, &default_state, cluster_id, agent_id); + } + } + ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => { + let key = crate::app::AgentKey::new(cluster_id.clone(), agent_id.clone()); + let microscope_state = state.agent_microscopes.get(&key); + let cluster_state = state.clusters.get(cluster_id); + agent_microscope::render( + frame, + content_area, + cluster_id, + agent_id, + cluster_state.map(|state| &state.timeline_time), + microscope_state, + &state.time_cursor, + ); + } + } + + // Status bar: if command bar active, show command input; otherwise show hints + toast + let allow_command_bar = !matches!( + state.active_screen(), + ScreenId::Launcher | ScreenId::IntentConsole + ); + if state.command_bar.active { + command_bar::render(frame, status_area, &state.command_bar, allow_command_bar); + command_bar::set_cursor(frame, status_area, &state.command_bar); + } else { + render_status_bar(frame, status_area, state); + } +} + +fn render_disruptive(frame: &mut Frame<'_>, state: &AppState) { + let size = frame.area(); + let (canvas_area, scrub_area, spine_area) = if size.height >= 4 { + let [canvas_area, scrub_area, spine_area] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(2), + ]) + .areas(size); + (canvas_area, Some(scrub_area), spine_area) + } else { + let [canvas_area, spine_area] = + Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(size); + (canvas_area, None, spine_area) + }; + + match state.active_screen() { + ScreenId::FleetRadar | ScreenId::Launcher | ScreenId::IntentConsole | ScreenId::Monitor => { + let pinned_cluster = match state.pinned_target.as_ref() { + Some(FocusTarget::Cluster { id }) => Some(id.as_str()), + _ => None, + }; + radar::render( + frame, + canvas_area, + &state.fleet_radar, + &state.camera, + state.now_ms, + &state.anim_clock, + pinned_cluster, + ); + } + ScreenId::ClusterCanvas { id } => { + let cluster_state = state.clusters.get(id); + let canvas_state = state.cluster_canvases.get(id); + cluster_canvas::render( + frame, + canvas_area, + cluster_canvas::RenderContext { + cluster_id: id, + cluster_state, + canvas_state, + time_cursor: &state.time_cursor, + anim_clock: &state.anim_clock, + pinned_target: state.pinned_target.as_ref(), + }, + ); + } + ScreenId::Cluster { id } => { + if let Some(cluster_state) = state.clusters.get(id) { + let metrics = state.metrics.get(id); + cluster::render(frame, canvas_area, cluster_state, metrics); + } else { + let default_state = cluster::State::default(); + cluster::render(frame, canvas_area, &default_state, None); + } + } + ScreenId::Agent { + cluster_id, + agent_id, + } => { + let key = crate::app::AgentKey::new(cluster_id.clone(), agent_id.clone()); + if let Some(agent_state) = state.agents.get(&key) { + agent::render(frame, canvas_area, agent_state, cluster_id, agent_id); + } else { + let default_state = agent::State::default(); + agent::render(frame, canvas_area, &default_state, cluster_id, agent_id); + } + } + ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => { + let key = crate::app::AgentKey::new(cluster_id.clone(), agent_id.clone()); + let microscope_state = state.agent_microscopes.get(&key); + let cluster_state = state.clusters.get(cluster_id); + agent_microscope::render( + frame, + canvas_area, + cluster_id, + agent_id, + cluster_state.map(|state| &state.timeline_time), + microscope_state, + &state.time_cursor, + ); + } + } + + if let Some(scrub_area) = scrub_area { + let scrub_state = match state.active_screen() { + ScreenId::ClusterCanvas { id } => Some(scrub_bar::ScrubBarState { + time_cursor: &state.time_cursor, + logs: state.clusters.get(id).map(|entry| &entry.logs_time), + agent_id: None, + }), + ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => Some(scrub_bar::ScrubBarState { + time_cursor: &state.time_cursor, + logs: state + .agent_microscopes + .get(&crate::app::AgentKey::new( + cluster_id.clone(), + agent_id.clone(), + )) + .map(|entry| &entry.logs_time), + agent_id: None, + }), + _ => None, + }; + if let Some(scrub_state) = scrub_state { + scrub_bar::render(frame, scrub_area, scrub_state); + } + } + + let mut spine_state = state.spine.clone(); + if let Some(toast_state) = state.toast.as_ref() { + if let Some((toast_text, _)) = toast::format_inline(Some(toast_state)) { + spine_state.hint = SpineHint::from_toast(toast_text, toast_state.level.clone()); + } + } else if spine_state.hint.is_empty() { + if let Some(hint) = backend_status_hint(&state.backend_status) { + spine_state.hint = hint; + } else { + spine_state.hint = SpineHint::new(DISRUPTIVE_SPINE_HINT, SpineHintTone::Muted); + } + } + spine::render(frame, spine_area, &spine_state); + spine::set_cursor(frame, spine_area, &spine_state); +} + +fn backend_status_hint(status: &BackendStatus) -> Option { + match status { + BackendStatus::Connected => None, + BackendStatus::Disconnected => Some(SpineHint::new( + "ā—‹ Backend disconnected", + SpineHintTone::Muted, + )), + BackendStatus::Error(_) => Some(SpineHint::new("āœ— Backend error", SpineHintTone::Error)), + BackendStatus::Exited(_) => Some(SpineHint::new("āœ— Backend exited", SpineHintTone::Error)), + } +} + +fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let screen = state.active_screen(); + let breadcrumb = screen_breadcrumb(screen); + + let (status_dot, status_style) = match &state.backend_status { + BackendStatus::Connected => ("ā—", theme::backend_connected_style()), + BackendStatus::Disconnected => ("ā—‹", theme::backend_error_style()), + BackendStatus::Error(_) => ("āœ—", theme::backend_error_style()), + BackendStatus::Exited(_) => ("āœ—", theme::backend_error_style()), + }; + + let provider_label = state.provider_override.as_deref().unwrap_or("default"); + + // Build left side + let left = Line::from(vec![ + Span::styled("ā—† ZEROSHOT", theme::logo_style()), + Span::raw(" "), + Span::styled(breadcrumb, theme::title_style()), + ]); + + // Build right side + let right_text = format!("{status_dot} {provider_label}"); + let right_len = right_text.len() as u16 + 1; + let right = Line::from(vec![ + Span::styled(status_dot, status_style), + Span::raw(" "), + Span::styled(provider_label, theme::dim_style()), + ]); + + // Render left-aligned header + let widget = Paragraph::new(left); + frame.render_widget(widget, area); + + // Render right-aligned status + if area.width > right_len + 20 { + let right_area = Rect { + x: area.x + area.width.saturating_sub(right_len), + y: area.y, + width: right_len, + height: 1, + }; + let right_widget = Paragraph::new(right).alignment(Alignment::Right); + frame.render_widget(right_widget, right_area); + } +} + +fn render_status_bar(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let hints = screen_hints(state.active_screen()); + let toast_msg = toast::format_inline(state.toast.as_ref()); + + let mut spans = Vec::new(); + spans.push(Span::raw(" ")); + for (i, (key, desc)) in hints.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(*key, theme::key_style())); + spans.push(Span::styled(format!(":{desc}"), theme::key_desc_style())); + } + + // Calculate space for toast on right side + if let Some((toast_text, toast_style)) = toast_msg { + let hints_len: usize = spans.iter().map(|s| s.content.len()).sum(); + let toast_len = toast_text.len() + 2; + let available = area.width as usize; + if hints_len + toast_len + 4 < available { + let gap = available.saturating_sub(hints_len + toast_len + 1); + spans.push(Span::raw(" ".repeat(gap))); + spans.push(Span::styled(toast_text, toast_style)); + } + } + + let widget = Paragraph::new(Line::from(spans)); + frame.render_widget(widget, area); +} + +fn screen_breadcrumb(screen: &ScreenId) -> String { + match screen { + ScreenId::Launcher => "Launcher".to_string(), + ScreenId::Monitor => "Monitor".to_string(), + ScreenId::IntentConsole => "Intent Console".to_string(), + ScreenId::FleetRadar => "Fleet Radar".to_string(), + ScreenId::Cluster { id } => format!("Monitor > {}", truncate_id(id)), + ScreenId::ClusterCanvas { id } => format!("Fleet Radar > {}", truncate_id(id)), + ScreenId::Agent { + cluster_id, + agent_id, + } => format!("Monitor > {} > {}", truncate_id(cluster_id), agent_id), + ScreenId::AgentMicroscope { + cluster_id, + agent_id, + } => format!("Fleet Radar > {} > {}", truncate_id(cluster_id), agent_id), + } +} + +fn truncate_id(id: &str) -> String { + const LIMIT: usize = 16; + let mut iter = id.chars(); + let mut out = String::new(); + for _ in 0..LIMIT { + match iter.next() { + Some(ch) => out.push(ch), + None => return id.to_string(), + } + } + out +} + +fn screen_hints(screen: &ScreenId) -> Vec<(&'static str, &'static str)> { + match screen { + ScreenId::Launcher => vec![("Enter", "start"), ("/", "commands"), ("Ctrl+C", "quit")], + ScreenId::IntentConsole => vec![("i", "intent"), ("/", "commands"), ("Esc", "back")], + ScreenId::Monitor => vec![ + ("j/k", "navigate"), + ("Enter", "open"), + ("/", "commands"), + ("Esc", "back"), + ], + ScreenId::FleetRadar => vec![ + ("h/j/k/l", "select"), + ("g/G", "center"), + ("Enter", "zoom"), + ("/", "commands"), + ("Esc", "back"), + ], + ScreenId::Cluster { .. } => vec![ + ("Tab", "pane"), + ("j/k", "scroll"), + ("Enter", "agent"), + ("Esc", "back"), + ], + ScreenId::ClusterCanvas { .. } => vec![ + ("h/j/k/l", "focus"), + ("Shift+h/j/k/l", "fast"), + ("Enter", "zoom"), + ("Esc", "back"), + ], + ScreenId::Agent { .. } => vec![("Enter", "send"), ("j/k", "scroll"), ("Esc", "back")], + ScreenId::AgentMicroscope { .. } => vec![("Esc", "back")], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::widgets::test_utils::line_text; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use ratatui::Terminal; + + fn buffer_contains(buffer: &Buffer, needle: &str) -> bool { + for y in 0..buffer.area.height { + if line_text(buffer, y).contains(needle) { + return true; + } + } + false + } + + #[test] + fn disruptive_spine_shows_backend_disconnected() { + let backend = TestBackend::new(80, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + state.screen_stack = vec![ScreenId::IntentConsole, ScreenId::FleetRadar]; + state.backend_status = BackendStatus::Disconnected; + state.spine.hint = SpineHint::empty(); + state.toast = None; + + terminal + .draw(|frame| { + render(frame, &state); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert!(buffer_contains(buffer, "Backend disconnected")); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/scene.rs b/tui-rs/crates/zeroshot-tui/src/ui/scene.rs new file mode 100644 index 00000000..88c40b88 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/scene.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; + +use ratatui::style::Style; + +#[derive(Debug, Clone, Copy)] +pub struct WorldBounds { + pub min: (f32, f32), + pub max: (f32, f32), +} + +impl Default for WorldBounds { + fn default() -> Self { + Self { + min: (0.0, 0.0), + max: (0.0, 0.0), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct Scene { + pub world_bounds: WorldBounds, + pub objects: Vec, + pub overlays: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectKind { + Node, + Edge, + Unknown, +} + +#[derive(Debug, Clone)] +pub struct Object { + pub id: String, + pub kind: ObjectKind, + pub pos: (f32, f32), + pub radius: f32, + pub style: Style, + pub label: Option, + pub metadata: HashMap, +} + +impl Default for Object { + fn default() -> Self { + Self { + id: String::new(), + kind: ObjectKind::Unknown, + pos: (0.0, 0.0), + radius: 0.0, + style: Style::default(), + label: None, + metadata: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverlayKind { + Label, + Grid, + Cursor, + Unknown, +} + +#[derive(Debug, Clone)] +pub struct Overlay { + pub kind: OverlayKind, + pub label: Option, + pub metadata: HashMap, +} + +impl Default for Overlay { + fn default() -> Self { + Self { + kind: OverlayKind::Unknown, + label: None, + metadata: HashMap::new(), + } + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/shared.rs b/tui-rs/crates/zeroshot-tui/src/ui/shared.rs new file mode 100644 index 00000000..07fe47d2 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/shared.rs @@ -0,0 +1,434 @@ +use std::collections::VecDeque; + +use ratatui::layout::Alignment; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; + +use crate::ui::theme; + +// ── ScrollableBuffer ────────────────────────────────────────────────────────── + +/// A capped VecDeque with scroll offset tracking. +/// +/// Used by cluster logs, timeline events, and agent logs to manage +/// scrollable content with a maximum capacity. +#[derive(Debug, Clone)] +pub struct ScrollableBuffer { + pub items: VecDeque, + pub scroll_offset: usize, + max_capacity: usize, +} + +impl ScrollableBuffer { + pub fn new(max_capacity: usize) -> Self { + Self { + items: VecDeque::new(), + scroll_offset: 0, + max_capacity, + } + } + + pub fn push_many(&mut self, items: impl IntoIterator) { + let before = self.items.len(); + self.items.extend(items); + let added = self.items.len() - before; + self.adjust_scroll_on_append(added); + let dropped = self.trim(); + self.adjust_scroll_on_trim(dropped); + self.clamp_scroll(); + } + + pub fn move_scroll(&mut self, delta: i32) { + let len = self.items.len(); + if len == 0 { + self.scroll_offset = 0; + return; + } + if delta < 0 { + self.scroll_offset = self + .scroll_offset + .saturating_add(delta.unsigned_abs() as usize); + } else { + self.scroll_offset = self.scroll_offset.saturating_sub(delta as usize); + } + self.clamp_scroll(); + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + fn adjust_scroll_on_append(&mut self, added: usize) { + if self.scroll_offset > 0 { + self.scroll_offset = self.scroll_offset.saturating_add(added); + } + } + + fn adjust_scroll_on_trim(&mut self, dropped: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(dropped); + } + + fn clamp_scroll(&mut self) { + let max_offset = self.items.len().saturating_sub(1); + if self.scroll_offset > max_offset { + self.scroll_offset = max_offset; + } + } + + fn trim(&mut self) -> usize { + if self.items.len() <= self.max_capacity { + return 0; + } + let mut dropped = 0usize; + while self.items.len() > self.max_capacity { + self.items.pop_front(); + dropped += 1; + } + dropped + } +} + +// ── TimeIndexedBuffer ──────────────────────────────────────────────────────── + +pub trait HasTimestamp { + fn timestamp_ms(&self) -> i64; +} + +/// A capped, time-indexed buffer with stable insertion ordering. +/// +/// Optimized for windowed reads by timestamp while maintaining bounded memory. +#[derive(Debug, Clone)] +pub struct TimeIndexedBuffer { + items: VecDeque, + max_capacity: usize, +} + +impl TimeIndexedBuffer { + pub fn new(max_capacity: usize) -> Self { + Self { + items: VecDeque::new(), + max_capacity, + } + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn push_many(&mut self, items: impl IntoIterator) { + self.items.extend(items); + self.trim(); + } + + pub fn window(&self, t_ms: i64, window_ms: i64) -> Vec<&T> { + if self.items.is_empty() { + return Vec::new(); + } + let window_ms = window_ms.max(0); + let start = t_ms.saturating_sub(window_ms); + let end = t_ms; + let lower = self.lower_bound(start); + let upper = self.upper_bound(end); + let mut out = Vec::with_capacity(upper.saturating_sub(lower)); + for idx in lower..upper { + if let Some(item) = self.items.get(idx) { + out.push(item); + } + } + out + } + + pub fn latest(&self, n: usize) -> Vec<&T> { + if n == 0 || self.items.is_empty() { + return Vec::new(); + } + let len = self.items.len(); + let start = len.saturating_sub(n); + let mut out = Vec::with_capacity(len - start); + for idx in start..len { + if let Some(item) = self.items.get(idx) { + out.push(item); + } + } + out + } + + pub fn iter(&self) -> impl Iterator { + self.items.iter() + } + + pub fn iter_rev(&self) -> impl Iterator { + self.items.iter().rev() + } + + fn lower_bound(&self, target: i64) -> usize { + let mut left = 0usize; + let mut right = self.items.len(); + while left < right { + let mid = left + (right - left) / 2; + let Some(item) = self.items.get(mid) else { + break; + }; + if item.timestamp_ms() < target { + left = mid + 1; + } else { + right = mid; + } + } + left + } + + fn upper_bound(&self, target: i64) -> usize { + let mut left = 0usize; + let mut right = self.items.len(); + while left < right { + let mid = left + (right - left) / 2; + let Some(item) = self.items.get(mid) else { + break; + }; + if item.timestamp_ms() <= target { + left = mid + 1; + } else { + right = mid; + } + } + left + } + + fn trim(&mut self) { + while self.items.len() > self.max_capacity { + self.items.pop_front(); + } + } +} + +// ── InputState ──────────────────────────────────────────────────────────────── + +/// Character-indexed cursor input state, shared between agent guidance, +/// launcher input, and command bar. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InputState { + pub input: String, + pub cursor: usize, +} + +impl InputState { + pub fn insert_char(&mut self, ch: char) { + let idx = self.byte_index(self.cursor); + self.input.insert(idx, ch); + self.cursor = self.cursor.saturating_add(1); + } + + pub fn backspace(&mut self) { + if self.cursor == 0 { + return; + } + let start = self.byte_index(self.cursor - 1); + let end = self.byte_index(self.cursor); + if start < end { + self.input.replace_range(start..end, ""); + self.cursor = self.cursor.saturating_sub(1); + } + } + + pub fn delete(&mut self) { + let len = self.len_chars(); + if self.cursor >= len { + return; + } + let start = self.byte_index(self.cursor); + let end = self.byte_index(self.cursor + 1); + if start < end { + self.input.replace_range(start..end, ""); + } + } + + pub fn move_left(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn move_right(&mut self) { + let len = self.len_chars(); + if self.cursor < len { + self.cursor += 1; + } + } + + pub fn move_home(&mut self) { + self.cursor = 0; + } + + pub fn move_end(&mut self) { + self.cursor = self.len_chars(); + } + + pub fn clear(&mut self) { + self.input.clear(); + self.cursor = 0; + } + + pub fn clamp_cursor(&mut self) { + let len = self.len_chars(); + if self.cursor > len { + self.cursor = len; + } + } + + fn len_chars(&self) -> usize { + self.input.chars().count() + } + + fn byte_index(&self, char_index: usize) -> usize { + if char_index == 0 { + return 0; + } + self.input + .char_indices() + .nth(char_index) + .map(|(idx, _)| idx) + .unwrap_or_else(|| self.input.len()) + } +} + +// ── pane_block ──────────────────────────────────────────────────────────────── + +/// Shared pane block with focus-dependent border styling. +pub fn pane_block<'a>(title: impl Into>, focused: bool) -> Block<'a> { + if focused { + Block::default() + .title(title) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .border_style(theme::focus_border_style()) + } else { + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(theme::unfocus_border_style()) + } +} + +/// Builds a calm, centered empty-state card with optional detail + footer. +pub fn calm_empty_state<'a>( + title: impl Into>, + headline: &'a str, + detail: Option<&'a str>, + footer: Option<&'a str>, +) -> Paragraph<'a> { + let mut lines = Vec::new(); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(headline, theme::muted_style()))); + if let Some(detail) = detail { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(detail, theme::dim_style()))); + } + if let Some(footer) = footer { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(footer, theme::dim_style()))); + } + + Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title(title)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone)] + struct Sample { + ts: i64, + label: &'static str, + } + + impl HasTimestamp for Sample { + fn timestamp_ms(&self) -> i64 { + self.ts + } + } + + #[test] + fn time_indexed_buffer_window_returns_expected_items() { + let mut buffer = TimeIndexedBuffer::new(10); + buffer.push_many([ + Sample { + ts: 100, + label: "a", + }, + Sample { + ts: 110, + label: "b", + }, + Sample { + ts: 120, + label: "c", + }, + Sample { + ts: 130, + label: "d", + }, + Sample { + ts: 140, + label: "e", + }, + ]); + + let window = buffer.window(130, 20); + let labels: Vec<&str> = window.iter().map(|item| item.label).collect(); + assert_eq!(labels, vec!["b", "c", "d"]); + } + + #[test] + fn time_indexed_buffer_trims_to_capacity_preserving_order() { + let mut buffer = TimeIndexedBuffer::new(3); + buffer.push_many([ + Sample { ts: 1, label: "a" }, + Sample { ts: 2, label: "b" }, + Sample { ts: 3, label: "c" }, + Sample { ts: 4, label: "d" }, + Sample { ts: 5, label: "e" }, + ]); + + let latest = buffer.latest(10); + let labels: Vec<&str> = latest.iter().map(|item| item.label).collect(); + assert_eq!(labels, vec!["c", "d", "e"]); + } + + #[test] + fn time_indexed_buffer_window_includes_equal_timestamps() { + let mut buffer = TimeIndexedBuffer::new(10); + buffer.push_many([ + Sample { + ts: 100, + label: "a", + }, + Sample { + ts: 100, + label: "b", + }, + Sample { + ts: 100, + label: "c", + }, + Sample { + ts: 110, + label: "d", + }, + ]); + + let window = buffer.window(100, 0); + let labels: Vec<&str> = window.iter().map(|item| item.label).collect(); + assert_eq!(labels, vec!["a", "b", "c"]); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/theme.rs b/tui-rs/crates/zeroshot-tui/src/ui/theme.rs new file mode 100644 index 00000000..ebe7d48d --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/theme.rs @@ -0,0 +1,190 @@ +//! Centralized color palette and style definitions. +//! +//! All render functions should import styles from here instead of using +//! inline `Style::default().fg(...)`. Based on Catppuccin Mocha palette. + +use ratatui::style::{Color, Modifier, Style}; + +use crate::app::SpineHintTone; + +// ── Base palette ──────────────────────────────────────────────────────────── + +pub const ACCENT: Color = Color::Rgb(137, 180, 250); // #89b4fa blue +pub const ACCENT2: Color = Color::Rgb(166, 227, 161); // #a6e3a1 green +pub const FG_PRIMARY: Color = Color::Rgb(205, 214, 244); // #cdd6f4 +pub const FG_DIM: Color = Color::DarkGray; +pub const FG_MUTED: Color = Color::Rgb(108, 112, 134); // #6c7086 +pub const SURFACE: Color = Color::Rgb(30, 30, 46); // #1e1e2e +pub const FOCUS_BORDER: Color = ACCENT; +pub const UNFOCUS_BORDER: Color = Color::Rgb(69, 71, 90); // #45475a + +// ── Status colors ─────────────────────────────────────────────────────────── + +pub const STATUS_RUNNING: Color = Color::Green; +pub const STATUS_DONE: Color = Color::Rgb(166, 227, 161); // #a6e3a1 +pub const STATUS_ERROR: Color = Color::Rgb(243, 139, 168); // #f38ba8 +pub const STATUS_PENDING: Color = Color::Yellow; +pub const STATUS_IDLE: Color = Color::DarkGray; + +// ── Agent colors (rotating) ──────────────────────────────────────────────── + +const AGENT_COLORS: [Color; 6] = [ + Color::Rgb(137, 180, 250), // blue + Color::Rgb(166, 227, 161), // green + Color::Rgb(249, 226, 175), // yellow #f9e2af + Color::Rgb(203, 166, 247), // mauve #cba6f7 + Color::Rgb(148, 226, 213), // teal #94e2d5 + Color::Rgb(242, 205, 205), // flamingo #f2cdcd +]; + +/// Get a color for an agent by hashing its ID to an index. +pub fn agent_color(agent_id: &str) -> Color { + let hash = agent_id + .bytes() + .fold(0u32, |acc, b| acc.wrapping_add(b as u32)); + AGENT_COLORS[hash as usize % AGENT_COLORS.len()] +} + +// ── Pre-built styles ──────────────────────────────────────────────────────── + +/// Logo / branding text. +pub fn logo_style() -> Style { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) +} + +/// Screen title in the header. +pub fn title_style() -> Style { + Style::default().fg(FG_PRIMARY).add_modifier(Modifier::BOLD) +} + +/// Hint / secondary text. +pub fn dim_style() -> Style { + Style::default().fg(FG_DIM) +} + +/// Muted / disabled text. +pub fn muted_style() -> Style { + Style::default().fg(FG_MUTED) +} + +/// Keyboard shortcut key label (e.g., "Enter", "Esc"). +pub fn key_style() -> Style { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) +} + +/// Keyboard shortcut description. +pub fn key_desc_style() -> Style { + Style::default().fg(FG_DIM) +} + +/// Spine mode label (Intent/Command/etc). +pub fn spine_mode_style() -> Style { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) +} + +/// Spine input text. +pub fn spine_input_style() -> Style { + Style::default().fg(FG_PRIMARY) +} + +/// Spine placeholder text. +pub fn spine_placeholder_style() -> Style { + Style::default().fg(FG_MUTED) +} + +/// Spine completion text (ghost). +pub fn spine_completion_style() -> Style { + Style::default().fg(FG_DIM) +} + +/// Spine right-side hint text. +pub fn spine_hint_style() -> Style { + spine_hint_style_for(SpineHintTone::Muted) +} + +/// Spine hint style by tone. +pub fn spine_hint_style_for(tone: SpineHintTone) -> Style { + match tone { + SpineHintTone::Muted => Style::default().fg(FG_MUTED), + SpineHintTone::Info => Style::default().fg(ACCENT), + SpineHintTone::Success => Style::default().fg(ACCENT2), + SpineHintTone::Error => Style::default().fg(STATUS_ERROR), + } +} + +/// Spine command prefix. +pub fn spine_prefix_style() -> Style { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) +} + +/// Focused pane border. +pub fn focus_border_style() -> Style { + Style::default() + .fg(FOCUS_BORDER) + .add_modifier(Modifier::BOLD) +} + +/// Unfocused pane border. +pub fn unfocus_border_style() -> Style { + Style::default().fg(UNFOCUS_BORDER) +} + +/// Spine border style. +pub fn spine_border_style() -> Style { + Style::default().fg(UNFOCUS_BORDER) +} + +/// Selected row in a list/table (accent bg, dark fg). +pub fn selected_style() -> Style { + Style::default() + .fg(SURFACE) + .bg(ACCENT) + .add_modifier(Modifier::BOLD) +} + +/// Backend status style by connection state. +pub fn backend_connected_style() -> Style { + Style::default().fg(STATUS_RUNNING) +} + +pub fn backend_error_style() -> Style { + Style::default().fg(STATUS_ERROR) +} + +/// Return a style for a cluster state string. +pub fn status_style(state: &str) -> Style { + match state { + "running" | "active" => Style::default().fg(STATUS_RUNNING), + "done" | "completed" | "complete" => Style::default().fg(STATUS_DONE), + "error" | "failed" => Style::default().fg(STATUS_ERROR), + "pending" | "starting" | "queued" => Style::default().fg(STATUS_PENDING), + "stopped" | "idle" => Style::default().fg(STATUS_IDLE), + _ => Style::default().fg(FG_DIM), + } +} + +/// Style for a "done" row (entire row dimmed). +pub fn done_row_style() -> Style { + Style::default().fg(FG_MUTED) +} + +/// Table header style. +pub fn table_header_style() -> Style { + Style::default() + .fg(FG_DIM) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED) +} + +/// Toast styles by level. +pub fn toast_success_style() -> Style { + Style::default().fg(ACCENT2) +} + +pub fn toast_error_style() -> Style { + Style::default().fg(STATUS_ERROR) +} + +pub fn toast_info_style() -> Style { + Style::default().fg(FG_DIM) +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/widgets/command_bar.rs b/tui-rs/crates/zeroshot-tui/src/ui/widgets/command_bar.rs new file mode 100644 index 00000000..2aa3d46a --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/widgets/command_bar.rs @@ -0,0 +1,67 @@ +use ratatui::layout::{Position, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::CommandBarState; +use crate::ui::theme; + +/// Render the command bar as a single line (replaces status bar when active). +pub fn render(frame: &mut Frame<'_>, area: Rect, state: &CommandBarState, _allow_open: bool) { + if !state.active { + return; + } + + let line = Line::from(vec![ + Span::raw(" "), + Span::styled("/", theme::key_style()), + Span::styled(state.input(), theme::title_style()), + ]); + + let widget = Paragraph::new(line); + frame.render_widget(widget, area); +} + +/// Set cursor position for the command bar input. +pub fn set_cursor(frame: &mut Frame<'_>, area: Rect, state: &CommandBarState) { + if !state.active { + return; + } + if area.width <= 3 { + return; + } + + // Offset: 1 (padding) + 1 (/) + cursor position + let max_x = area.x + area.width.saturating_sub(1); + let cursor_x = area.x + 2 + state.cursor() as u16; + let cursor_x = cursor_x.min(max_x); + frame.set_cursor_position(Position::new(cursor_x, area.y)); +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use crate::ui::widgets::test_utils::line_text; + + #[test] + fn active_command_bar_renders_input() { + let backend = TestBackend::new(40, 1); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = CommandBarState::default(); + state.open_with("help".to_string()); + + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &state, true); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + let content = line_text(buffer, 0); + assert!(content.contains("/help")); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/widgets/mod.rs b/tui-rs/crates/zeroshot-tui/src/ui/widgets/mod.rs new file mode 100644 index 00000000..eea3df6f --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/widgets/mod.rs @@ -0,0 +1,9 @@ +pub mod command_bar; +pub mod scrub_bar; +pub mod spine; +pub mod stream; +pub mod toast; +pub mod topology; + +#[cfg(test)] +pub(crate) mod test_utils; diff --git a/tui-rs/crates/zeroshot-tui/src/ui/widgets/scrub_bar.rs b/tui-rs/crates/zeroshot-tui/src/ui/widgets/scrub_bar.rs new file mode 100644 index 00000000..3607df50 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/widgets/scrub_bar.rs @@ -0,0 +1,244 @@ +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::{TimeCursor, TimeCursorMode}; +use crate::protocol::ClusterLogLine; +use crate::ui::shared::TimeIndexedBuffer; +use crate::ui::theme; + +const DENSITY_LEVELS: &[u8] = b" .:-=+*#"; + +pub struct ScrubBarState<'a> { + pub time_cursor: &'a TimeCursor, + pub logs: Option<&'a TimeIndexedBuffer>, + pub agent_id: Option<&'a str>, +} + +pub fn render(frame: &mut Frame<'_>, area: Rect, state: ScrubBarState<'_>) { + if area.width == 0 || area.height == 0 { + return; + } + + let label = match state.time_cursor.mode { + TimeCursorMode::Live => "LIVE", + TimeCursorMode::Scrub => "SCRUB", + }; + let label_style = match state.time_cursor.mode { + TimeCursorMode::Live => theme::toast_success_style(), + TimeCursorMode::Scrub => theme::key_style(), + }; + + let label_len = label.chars().count() as u16; + let mut spans = Vec::new(); + spans.push(Span::styled(label, label_style)); + + let bar_width = area.width.saturating_sub(label_len.saturating_add(1)) as usize; + if bar_width > 0 { + spans.push(Span::raw(" ")); + let bar = build_bar(bar_width, &state); + spans.push(Span::styled(bar, theme::dim_style())); + } + + let widget = Paragraph::new(Line::from(spans)); + frame.render_widget(widget, area); +} + +fn build_bar(width: usize, state: &ScrubBarState<'_>) -> String { + if width == 0 { + return String::new(); + } + + let window_ms = state.time_cursor.window_ms.max(1); + let window_end = state.time_cursor.t_ms; + let window_start = window_end.saturating_sub(window_ms); + + let latest_ts = state.logs.and_then(|logs| { + logs.iter() + .filter(|line| matches_agent(line, state.agent_id)) + .map(|line| line.timestamp) + .max() + }); + + let mut bins = vec![0u32; width]; + if let Some(logs) = state.logs { + let windowed = logs.window(window_end, window_ms); + for line in windowed { + if !matches_agent(line, state.agent_id) { + continue; + } + let rel = line.timestamp.saturating_sub(window_start); + let mut pos = ((rel * width as i64) / window_ms) as usize; + if pos >= width { + pos = width - 1; + } + bins[pos] = bins[pos].saturating_add(1); + } + } + + let max = bins.iter().copied().max().unwrap_or(0); + let mut chars: Vec = bins + .into_iter() + .map(|count| { + if max == 0 { + ' ' + } else { + let idx = (count as usize * (DENSITY_LEVELS.len() - 1)) / max as usize; + DENSITY_LEVELS[idx] as char + } + }) + .collect(); + + let now_pos = latest_ts.map_or(width.saturating_sub(1), |latest| { + let rel = latest.saturating_sub(window_start); + let mut pos = ((rel * width as i64) / window_ms) as usize; + if pos >= width { + pos = width - 1; + } + pos + }); + if !chars.is_empty() { + chars[now_pos] = '|'; + } + + if matches!(state.time_cursor.mode, TimeCursorMode::Scrub) && !chars.is_empty() { + let rel = state.time_cursor.t_ms.saturating_sub(window_start); + let mut pos = ((rel * width as i64) / window_ms) as usize; + if pos >= width { + pos = width - 1; + } + if pos == now_pos { + chars[pos] = '*'; + } else { + chars[pos] = '^'; + } + } + + chars.into_iter().collect() +} + +fn matches_agent(line: &ClusterLogLine, agent_id: Option<&str>) -> bool { + let Some(agent_id) = agent_id else { + return true; + }; + line.agent.as_deref() == Some(agent_id) || line.sender.as_deref() == Some(agent_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use crate::ui::widgets::test_utils::line_text; + + fn sample_logs(timestamps: &[i64]) -> TimeIndexedBuffer { + let mut buffer = TimeIndexedBuffer::new(64); + let lines = timestamps.iter().map(|ts| ClusterLogLine { + id: format!("log-{ts}"), + timestamp: *ts, + text: "event".to_string(), + agent: None, + role: None, + sender: None, + }); + buffer.push_many(lines); + buffer + } + + #[test] + fn scrub_bar_renders_live_mode() { + let logs = sample_logs(&[100, 200, 300]); + let cursor = TimeCursor { + mode: TimeCursorMode::Live, + t_ms: 300, + window_ms: 300, + }; + + let backend = TestBackend::new(40, 1); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + ScrubBarState { + time_cursor: &cursor, + logs: Some(&logs), + agent_id: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + let text = line_text(buffer, 0); + assert!(text.contains("LIVE")); + assert!(text.contains("|")); + } + + #[test] + fn scrub_bar_renders_scrub_mode_marker() { + let logs = sample_logs(&[100, 200, 300]); + let cursor = TimeCursor { + mode: TimeCursorMode::Scrub, + t_ms: 150, + window_ms: 300, + }; + + let backend = TestBackend::new(40, 1); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + ScrubBarState { + time_cursor: &cursor, + logs: Some(&logs), + agent_id: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + let text = line_text(buffer, 0); + assert!(text.contains("SCRUB")); + assert!(text.contains("*")); + } + + #[test] + fn scrub_bar_handles_empty_buffers() { + let logs = sample_logs(&[]); + let cursor = TimeCursor { + mode: TimeCursorMode::Live, + t_ms: 0, + window_ms: 500, + }; + + let backend = TestBackend::new(24, 1); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| { + let area = frame.area(); + render( + frame, + area, + ScrubBarState { + time_cursor: &cursor, + logs: Some(&logs), + agent_id: None, + }, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + let text = line_text(buffer, 0); + assert!(text.contains("LIVE")); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/widgets/spine.rs b/tui-rs/crates/zeroshot-tui/src/ui/widgets/spine.rs new file mode 100644 index 00000000..61921c32 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/widgets/spine.rs @@ -0,0 +1,290 @@ +use ratatui::layout::{Alignment, Position, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::app::{SpineHintTone, SpineMode, SpineState}; +use crate::ui::theme; + +const PLACEHOLDER_INTENT: &str = "Type intent..."; + +pub fn render(frame: &mut Frame<'_>, area: Rect, state: &SpineState) { + let block = spine_block(); + let inner = block.inner(area); + + let hint_text = state.hint.text.as_str(); + let hint_tone = state.hint.tone; + + let mut spans = build_spans(state); + let mut lines = Vec::new(); + let mut hint_on_first_line = false; + if hint_fits(&spans, inner.width, hint_text) { + append_hint(&mut spans, inner.width, hint_text, hint_tone); + hint_on_first_line = true; + } + lines.push(Line::from(spans)); + + if !hint_on_first_line && !hint_text.is_empty() && inner.height >= 2 { + lines.push(build_hint_line(inner.width, hint_text, hint_tone)); + } + + let widget = Paragraph::new(lines) + .block(block) + .alignment(Alignment::Left); + frame.render_widget(widget, area); +} + +pub fn set_cursor(frame: &mut Frame<'_>, area: Rect, state: &SpineState) { + let block = spine_block(); + let inner = block.inner(area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let label_len = mode_label(state.mode).len() as u16; + let prefix_len = command_prefix(state.mode).len() as u16; + let base_x = inner.x + 1 + label_len + 1 + prefix_len; + let cursor_x = base_x.saturating_add(state.input.cursor as u16); + let max_x = inner.x + inner.width.saturating_sub(1); + let cursor_x = cursor_x.min(max_x); + + frame.set_cursor_position(Position::new(cursor_x, inner.y)); +} + +fn spine_block<'a>() -> Block<'a> { + Block::default() + .borders(Borders::TOP) + .border_style(theme::spine_border_style()) +} + +fn mode_label(mode: SpineMode) -> &'static str { + match mode { + SpineMode::Intent => "Intent", + SpineMode::Command => "Command", + SpineMode::WhisperCluster => "Whisper Cluster", + SpineMode::WhisperAgent => "Whisper Agent", + } +} + +fn command_prefix(mode: SpineMode) -> &'static str { + match mode { + SpineMode::Command => "/", + _ => "", + } +} + +fn build_spans<'a>(state: &'a SpineState) -> Vec> { + let mut spans = Vec::new(); + push_mode_label(&mut spans, state.mode); + push_prefix(&mut spans, state.mode); + push_input_or_placeholder(&mut spans, state); + push_completion(&mut spans, state); + spans +} + +fn push_mode_label<'a>(spans: &mut Vec>, mode: SpineMode) { + spans.push(Span::raw(" ")); + spans.push(Span::styled(mode_label(mode), theme::spine_mode_style())); + spans.push(Span::raw(" ")); +} + +fn push_prefix<'a>(spans: &mut Vec>, mode: SpineMode) { + let prefix = command_prefix(mode); + if !prefix.is_empty() { + spans.push(Span::styled(prefix, theme::spine_prefix_style())); + } +} + +fn push_input_or_placeholder<'a>(spans: &mut Vec>, state: &'a SpineState) { + if state.input.input.is_empty() { + if matches!(state.mode, SpineMode::Intent) { + spans.push(Span::styled( + PLACEHOLDER_INTENT, + theme::spine_placeholder_style(), + )); + } + return; + } + + spans.push(Span::styled( + state.input.input.as_str(), + theme::spine_input_style(), + )); +} + +fn push_completion<'a>(spans: &mut Vec>, state: &'a SpineState) { + let Some(completion) = &state.completion else { + return; + }; + if completion.ghost.is_empty() || state.input.input.is_empty() { + return; + } + spans.push(Span::styled( + completion.ghost.as_str(), + theme::spine_completion_style(), + )); +} + +fn hint_fits(spans: &[Span<'_>], width: u16, hint: &str) -> bool { + if hint.is_empty() || width == 0 { + return false; + } + let used_len: usize = spans.iter().map(|span| span.content.len()).sum(); + let hint_len = hint.len(); + let width = width as usize; + width > used_len + hint_len + 1 +} + +fn append_hint<'a>(spans: &mut Vec>, width: u16, hint: &'a str, tone: SpineHintTone) { + if hint.is_empty() || width == 0 { + return; + } + let used_len: usize = spans.iter().map(|span| span.content.len()).sum(); + let hint_len = hint.len(); + let width = width as usize; + if width <= used_len + hint_len + 1 { + return; + } + let gap = width - used_len - hint_len; + spans.push(Span::raw(" ".repeat(gap))); + spans.push(Span::styled(hint, theme::spine_hint_style_for(tone))); +} + +fn build_hint_line<'a>(width: u16, hint: &'a str, tone: SpineHintTone) -> Line<'a> { + if width == 0 { + return Line::from(Span::raw("")); + } + let width = width as usize; + let hint_len = hint.len(); + if hint_len >= width { + return Line::from(Span::styled(hint, theme::spine_hint_style_for(tone))); + } + let gap = width - hint_len; + Line::from(vec![ + Span::raw(" ".repeat(gap)), + Span::styled(hint, theme::spine_hint_style_for(tone)), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::layout::Position; + use ratatui::Terminal; + + use crate::ui::widgets::test_utils::line_text; + + #[test] + fn intent_mode_shows_placeholder() { + let backend = TestBackend::new(40, 3); + let mut terminal = Terminal::new(backend).expect("terminal"); + let state = SpineState::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &state); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + let content = line_text(buffer, 1); + assert!(content.contains(PLACEHOLDER_INTENT)); + } + + #[test] + fn command_mode_prefix_is_rendered() { + let backend = TestBackend::new(40, 3); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = SpineState::default(); + state.mode = SpineMode::Command; + state.input.input = "help".to_string(); + + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &state); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + let content = line_text(buffer, 1); + assert!(content.contains("/help")); + } + + #[test] + fn spine_cursor_follows_input() { + let backend = TestBackend::new(40, 3); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = SpineState::default(); + state.input.input = "abcd".to_string(); + state.input.cursor = 2; + + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &state); + set_cursor(frame, area, &state); + }) + .expect("draw"); + + let label_len = mode_label(state.mode).len() as u16; + let base_x = 1 + label_len + 1; + let expected_x = base_x + state.input.cursor as u16; + terminal + .backend_mut() + .assert_cursor_position(Position::new(expected_x, 1)); + } + + #[test] + fn hint_moves_to_second_line_when_no_space() { + let backend = TestBackend::new(22, 4); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = SpineState::default(); + state.input.input = "extremelylonginput".to_string(); + state.hint = crate::app::SpineHint::new("Second line hint", SpineHintTone::Info); + + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &state); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + let line1 = line_text(buffer, 1); + let line2 = line_text(buffer, 2); + assert!(!line1.contains("Second line hint")); + assert!(line2.contains("Second line hint")); + } + + #[test] + fn completion_renders_dimmed_after_input() { + let backend = TestBackend::new(40, 3); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = SpineState::default(); + state.mode = SpineMode::Command; + state.input.input = "pro".to_string(); + state.input.cursor = 3; + state.completion = Some(crate::app::SpineCompletion { + candidates: vec!["provider".to_string()], + selected: 0, + ghost: "vider".to_string(), + }); + + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &state); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + let line = line_text(buffer, 1); + let ghost_start = line.find("vider").expect("ghost text"); + let cell = buffer.cell((ghost_start as u16, 1)).expect("ghost cell"); + let expected_fg = theme::spine_completion_style().fg.expect("fg"); + assert_eq!(cell.fg, expected_fg); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/widgets/stream.rs b/tui-rs/crates/zeroshot-tui/src/ui/widgets/stream.rs new file mode 100644 index 00000000..9527eb4e --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/widgets/stream.rs @@ -0,0 +1,479 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap}; + +use crate::app::{TimeCursor, TimeCursorMode}; +use crate::protocol::{ClusterLogLine, TimelineEvent}; +use crate::ui::shared::{HasTimestamp, TimeIndexedBuffer}; +use crate::ui::theme; + +pub const PHASE_MARKER_LIMIT: usize = 50; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogPlaceholderContext { + Cluster, + Agent, + Overlay, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PhaseMarker { + pub timestamp_ms: i64, + pub label: String, + pub topic: String, +} + +pub struct StreamOverlay<'a> { + title: Line<'a>, + lines: Vec>, + placeholder: Vec>, + border_style: Style, +} + +impl<'a> StreamOverlay<'a> { + pub fn new(title: impl Into>, lines: Vec>) -> Self { + Self { + title: title.into(), + lines, + placeholder: Vec::new(), + border_style: theme::unfocus_border_style(), + } + } + + pub fn placeholder_lines(mut self, lines: Vec>) -> Self { + self.placeholder = lines; + self + } + + pub fn border_style(mut self, style: Style) -> Self { + self.border_style = style; + self + } +} + +impl<'a> Widget for StreamOverlay<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + + let block = Block::default() + .title(self.title) + .borders(Borders::ALL) + .border_style(self.border_style); + let inner = block.inner(area); + block.render(area, buf); + + if inner.width == 0 || inner.height == 0 { + return; + } + + let mut lines = if self.lines.is_empty() { + self.placeholder + } else { + self.lines + }; + let max_lines = inner.height as usize; + if lines.len() > max_lines { + lines.truncate(max_lines); + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + paragraph.render(inner, buf); + } +} + +pub fn log_placeholder_lines<'a>(context: LogPlaceholderContext) -> Vec> { + let detail = match context { + LogPlaceholderContext::Cluster => "Waiting for cluster output.", + LogPlaceholderContext::Agent => "Waiting for agent output.", + LogPlaceholderContext::Overlay => "Waiting for stream output.", + }; + vec![ + Line::from(Span::styled("No logs yet.", theme::muted_style())), + Line::from(Span::styled(detail, theme::muted_style())), + ] +} + +pub fn timeline_placeholder_lines<'a>() -> Vec> { + vec![ + Line::from(Span::styled( + "No timeline events yet.", + theme::muted_style(), + )), + Line::from(Span::styled( + "New activity will appear here.", + theme::muted_style(), + )), + ] +} + +pub fn mode_tag_span(time_cursor: &TimeCursor) -> Span<'static> { + let (label, style) = match time_cursor.mode { + TimeCursorMode::Live => ("LIVE", theme::toast_success_style()), + TimeCursorMode::Scrub => ("SCRUB", theme::key_style()), + }; + Span::styled(format!("[{label}]"), style) +} + +pub fn derive_phase_markers( + timeline: &TimeIndexedBuffer, + time_cursor: &TimeCursor, + max_markers: usize, +) -> Vec { + if max_markers == 0 || timeline.is_empty() { + return Vec::new(); + } + + let max_items = timeline.len(); + let events = select_time_window(timeline, time_cursor, max_items, |_| true); + if events.is_empty() { + return Vec::new(); + } + + let mut markers = Vec::new(); + let mut last_topic = String::new(); + let mut last_label = String::new(); + let mut has_last = false; + for event in events { + if has_last && last_topic == event.topic && last_label == event.label { + continue; + } + markers.push(PhaseMarker { + timestamp_ms: event.timestamp, + label: event.label.clone(), + topic: event.topic.clone(), + }); + last_topic = event.topic.clone(); + last_label = event.label.clone(); + has_last = true; + } + + if markers.len() > max_markers { + let start = markers.len().saturating_sub(max_markers); + markers = markers.split_off(start); + } + markers +} + +pub fn format_phase_marker_label(topic: &str, label: &str) -> String { + if label.is_empty() { + topic.to_string() + } else { + format!("{topic}: {label}") + } +} + +pub fn truncate_marker_label(label: &str, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + let label_len = label.chars().count(); + if label_len <= max_chars { + return label.to_string(); + } + if max_chars <= 3 { + return label.chars().take(max_chars).collect(); + } + let mut out: String = label.chars().take(max_chars - 3).collect(); + out.push_str("..."); + out +} + +pub fn overlay_title(base: impl Into, time_cursor: &TimeCursor) -> Line<'static> { + Line::from(vec![ + Span::raw(base.into()), + Span::raw(" "), + mode_tag_span(time_cursor), + ]) +} + +pub fn select_time_window<'a, T, F>( + buffer: &'a TimeIndexedBuffer, + time_cursor: &TimeCursor, + max_items: usize, + filter: F, +) -> Vec<&'a T> +where + T: HasTimestamp, + F: Fn(&T) -> bool, +{ + if max_items == 0 || buffer.is_empty() { + return Vec::new(); + } + + match time_cursor.mode { + TimeCursorMode::Live => select_live_tail(buffer, max_items, filter), + TimeCursorMode::Scrub => select_scrub_window(buffer, time_cursor, max_items, filter), + } +} + +fn select_live_tail(buffer: &TimeIndexedBuffer, max_items: usize, filter: F) -> Vec<&T> +where + T: HasTimestamp, + F: Fn(&T) -> bool, +{ + let mut collected = Vec::with_capacity(max_items); + for item in buffer.iter_rev() { + if filter(item) { + collected.push(item); + if collected.len() >= max_items { + break; + } + } + } + collected.reverse(); + collected +} + +fn select_scrub_window<'a, T, F>( + buffer: &'a TimeIndexedBuffer, + time_cursor: &TimeCursor, + max_items: usize, + filter: F, +) -> Vec<&'a T> +where + T: HasTimestamp, + F: Fn(&T) -> bool, +{ + let windowed = buffer.window(time_cursor.t_ms, time_cursor.window_ms); + let mut collected: Vec<&T> = windowed.into_iter().filter(|item| filter(item)).collect(); + if collected.len() > max_items { + let start = collected.len().saturating_sub(max_items); + collected = collected.split_off(start); + } + collected +} + +pub fn format_log_line_styled(line: &ClusterLogLine) -> Line<'_> { + if let Some(agent) = line.agent.as_deref().or(line.sender.as_deref()) { + let color = theme::agent_color(agent); + Line::from(vec![ + Span::styled(format!("[{agent}]"), Style::default().fg(color)), + Span::raw(" "), + Span::raw(line.text.as_str()), + ]) + } else { + Line::from(line.text.as_str()) + } +} + +pub fn format_timeline_event_styled(event: &TimelineEvent) -> Line<'_> { + let icon = timeline_icon(&event.topic); + let label_style = timeline_label_style(&event.label); + let mut spans = vec![ + Span::styled(icon, theme::dim_style()), + Span::raw(" "), + Span::styled(event.topic.as_str(), theme::dim_style()), + Span::raw(" "), + Span::styled(event.label.as_str(), label_style), + ]; + if let Some(sender) = event.sender.as_deref() { + spans.push(Span::raw(" ")); + spans.push(Span::styled(format!("({sender})"), theme::muted_style())); + } + Line::from(spans) +} + +fn timeline_icon(topic: &str) -> &'static str { + let topic_lower = topic.to_lowercase(); + if topic_lower.contains("issue") { + "\u{25b6}" // ā–¶ + } else if topic_lower.contains("implementation") || topic_lower.contains("impl") { + "\u{25cf}" // ā— + } else if topic_lower.contains("validation") || topic_lower.contains("review") { + "\u{25c6}" // ā—† + } else if topic_lower.contains("consensus") || topic_lower.contains("complete") { + "\u{2605}" // ā˜… + } else { + "\u{00b7}" // Ā· + } +} + +fn timeline_label_style(label: &str) -> Style { + let label_lower = label.to_lowercase(); + if label_lower.contains("approved") + || label_lower.contains("done") + || label_lower.contains("complete") + { + theme::status_style("done") + } else if label_lower.contains("rejected") + || label_lower.contains("failed") + || label_lower.contains("error") + { + theme::status_style("error") + } else if label_lower.contains("pending") || label_lower.contains("waiting") { + theme::status_style("pending") + } else { + Style::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use crate::ui::widgets::test_utils::line_text; + + fn buffer_contains(terminal: &Terminal, needle: &str) -> bool { + let buffer = terminal.backend().buffer(); + for y in 0..buffer.area.height { + if line_text(buffer, y).contains(needle) { + return true; + } + } + false + } + + #[test] + fn stream_overlay_renders_title_and_lines() { + let backend = TestBackend::new(32, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + + terminal + .draw(|frame| { + let area = frame.area(); + let overlay = StreamOverlay::new( + Line::from("Logs - agent alpha"), + vec![Line::from("hello"), Line::from("world")], + ); + frame.render_widget(overlay, area); + }) + .expect("draw"); + + assert!(buffer_contains(&terminal, "Logs - agent alpha")); + assert!(buffer_contains(&terminal, "hello")); + assert!(buffer_contains(&terminal, "world")); + } + + #[test] + fn stream_overlay_renders_empty_placeholder() { + let backend = TestBackend::new(30, 7); + let mut terminal = Terminal::new(backend).expect("terminal"); + + terminal + .draw(|frame| { + let area = frame.area(); + let overlay = StreamOverlay::new(Line::from("Logs"), Vec::new()) + .placeholder_lines(log_placeholder_lines(LogPlaceholderContext::Overlay)); + frame.render_widget(overlay, area); + }) + .expect("draw"); + + assert!(buffer_contains(&terminal, "No logs yet.")); + assert!(buffer_contains(&terminal, "Waiting for stream output.")); + } + + fn sample_log(id: &str, timestamp: i64, agent: Option<&str>) -> ClusterLogLine { + ClusterLogLine { + id: id.to_string(), + timestamp, + text: format!("log-{id}"), + agent: agent.map(|value| value.to_string()), + role: None, + sender: None, + } + } + + fn sample_event(id: &str, timestamp: i64, topic: &str, label: &str) -> TimelineEvent { + TimelineEvent { + id: id.to_string(), + timestamp, + topic: topic.to_string(), + label: label.to_string(), + approved: None, + sender: None, + } + } + + #[test] + fn stream_window_live_uses_tail() { + let mut buffer = TimeIndexedBuffer::new(16); + buffer.push_many(vec![ + sample_log("one", 100, Some("alpha")), + sample_log("two", 200, Some("alpha")), + sample_log("three", 300, Some("alpha")), + ]); + + let cursor = TimeCursor::default(); + let selected = select_time_window(&buffer, &cursor, 2, |_| true); + let ids: Vec<&str> = selected.iter().map(|line| line.id.as_str()).collect(); + assert_eq!(ids, vec!["two", "three"]); + } + + #[test] + fn stream_window_scrub_uses_window() { + let mut buffer = TimeIndexedBuffer::new(16); + buffer.push_many(vec![ + sample_log("one", 100, Some("alpha")), + sample_log("two", 200, Some("alpha")), + sample_log("three", 300, Some("alpha")), + ]); + + let cursor = TimeCursor { + mode: TimeCursorMode::Scrub, + t_ms: 250, + window_ms: 120, + }; + let selected = select_time_window(&buffer, &cursor, 10, |_| true); + let ids: Vec<&str> = selected.iter().map(|line| line.id.as_str()).collect(); + assert_eq!(ids, vec!["two"]); + } + + #[test] + fn stream_overlay_renders_mode_tag() { + let backend = TestBackend::new(40, 6); + let mut terminal = Terminal::new(backend).expect("terminal"); + let cursor = TimeCursor::default(); + + terminal + .draw(|frame| { + let area = frame.area(); + let overlay = StreamOverlay::new(overlay_title("Logs", &cursor), Vec::new()) + .placeholder_lines(log_placeholder_lines(LogPlaceholderContext::Overlay)); + frame.render_widget(overlay, area); + }) + .expect("draw"); + + assert!(buffer_contains(&terminal, "LIVE")); + } + + #[test] + fn derive_phase_markers_caps_and_dedup() { + let mut buffer = TimeIndexedBuffer::new(128); + buffer.push_many(vec![ + sample_event("e1", 100, "topic-a", "phase-1"), + sample_event("e2", 110, "topic-a", "phase-1"), + sample_event("e3", 120, "topic-b", "phase-2"), + sample_event("e4", 130, "topic-b", "phase-2"), + sample_event("e5", 140, "topic-b", "phase-3"), + ]); + + let cursor = TimeCursor::default(); + let markers = derive_phase_markers(&buffer, &cursor, PHASE_MARKER_LIMIT); + assert_eq!(markers.len(), 3); + assert_eq!(markers[0].topic, "topic-a"); + assert_eq!(markers[1].label, "phase-2"); + assert_eq!(markers[2].label, "phase-3"); + + let mut buffer = TimeIndexedBuffer::new(128); + let mut events = Vec::new(); + for idx in 0..60 { + events.push(sample_event( + &format!("cap-{idx}"), + 1000 + idx as i64, + &format!("topic-{idx}"), + "phase", + )); + } + buffer.push_many(events); + let markers = derive_phase_markers(&buffer, &cursor, 50); + assert_eq!(markers.len(), 50); + assert_eq!(markers.first().unwrap().topic, "topic-10"); + assert_eq!(markers.last().unwrap().topic, "topic-59"); + } +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/widgets/test_utils.rs b/tui-rs/crates/zeroshot-tui/src/ui/widgets/test_utils.rs new file mode 100644 index 00000000..21599062 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/widgets/test_utils.rs @@ -0,0 +1,10 @@ +use ratatui::buffer::Buffer; + +pub fn line_text(buffer: &Buffer, y: u16) -> String { + let area = buffer.area; + let mut line = String::new(); + for x in area.left()..area.right() { + line.push_str(buffer.cell((x, y)).map_or("", |c| c.symbol())); + } + line +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/widgets/toast.rs b/tui-rs/crates/zeroshot-tui/src/ui/widgets/toast.rs new file mode 100644 index 00000000..93697bca --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/widgets/toast.rs @@ -0,0 +1,32 @@ +use ratatui::style::Style; + +use crate::app::{ToastLevel, ToastState}; +use crate::ui::theme; + +/// Format toast for inline display in the status bar. +/// Returns (text, style) or None if no toast. +pub fn format_inline(toast: Option<&ToastState>) -> Option<(String, Style)> { + let toast = toast?; + let (prefix, style) = match toast.level { + ToastLevel::Info => ("\u{2139}", theme::toast_info_style()), // ℹ + ToastLevel::Success => ("\u{2713}", theme::toast_success_style()), // āœ“ + ToastLevel::Error => ("\u{2717}", theme::toast_error_style()), // āœ— + }; + let first_line = toast.message.lines().next().unwrap_or(""); + let msg = format!("{prefix} {}", truncate_toast_line(first_line)); + Some((msg, style)) +} + +fn truncate_toast_line(line: &str) -> String { + const MAX_LEN: usize = 40; + const TRUNC_LEN: usize = 37; + if line.chars().count() <= MAX_LEN { + return line.to_string(); + } + let mut out = String::new(); + for ch in line.chars().take(TRUNC_LEN) { + out.push(ch); + } + out.push_str("..."); + out +} diff --git a/tui-rs/crates/zeroshot-tui/src/ui/widgets/topology.rs b/tui-rs/crates/zeroshot-tui/src/ui/widgets/topology.rs new file mode 100644 index 00000000..7eb20719 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/src/ui/widgets/topology.rs @@ -0,0 +1,161 @@ +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::{Block, Paragraph, Wrap}; +use ratatui::Frame; + +use crate::protocol::{ + ClusterSummary, ClusterTopology, TopologyAgent, TopologyEdge, TopologyEdgeKind, +}; + +pub fn render( + frame: &mut Frame<'_>, + area: Rect, + block: Block<'_>, + summary: Option<&ClusterSummary>, + topology: Option<&ClusterTopology>, + error: Option<&str>, +) { + let lines = build_lines(summary, topology, error) + .into_iter() + .map(Line::from) + .collect::>(); + let widget = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(widget, area); +} + +pub fn build_lines( + summary: Option<&ClusterSummary>, + topology: Option<&ClusterTopology>, + error: Option<&str>, +) -> Vec { + let mut lines = Vec::new(); + lines.push(summary_line(summary)); + + if let Some(message) = error { + append_error(&mut lines, message); + return lines; + } + + let Some(topology) = topology else { + append_pending(&mut lines); + return lines; + }; + + let (agents, topics, edges) = sorted_topology(topology); + append_counts(&mut lines, &agents, &topics, &edges); + + if edges.is_empty() { + append_no_edges(&mut lines, &agents, &topics); + return lines; + } + + append_edges(&mut lines, edges); + append_focus_hint(&mut lines); + lines +} + +fn append_error(lines: &mut Vec, message: &str) { + lines.push(format!("Topology unavailable: {message}")); + append_focus_hint(lines); +} + +fn append_pending(lines: &mut Vec) { + lines.push("Topology pending. Waiting for backend.".to_string()); + append_focus_hint(lines); +} + +fn append_counts( + lines: &mut Vec, + agents: &[TopologyAgent], + topics: &[String], + edges: &[TopologyEdge], +) { + lines.push(format!( + "Agents: {} | Topics: {} | Edges: {}", + agents.len(), + topics.len(), + edges.len() + )); +} + +fn append_no_edges(lines: &mut Vec, agents: &[TopologyAgent], topics: &[String]) { + if !agents.is_empty() { + let list = agents + .iter() + .map(|agent| agent.id.as_str()) + .collect::>() + .join(", "); + lines.push(format!("Agents: {list}")); + } + if !topics.is_empty() { + lines.push(format!("Topics: {}", topics.join(", "))); + } + lines.push("No edges yet.".to_string()); + append_focus_hint(lines); +} + +fn append_edges(lines: &mut Vec, edges: Vec) { + let mut current_from: Option = None; + for edge in edges { + if current_from.as_deref() != Some(edge.from.as_str()) { + current_from = Some(edge.from.clone()); + lines.push(format!("{}:", edge.from)); + } + lines.push(format!(" -> {}", edge_details(&edge))); + } +} + +fn append_focus_hint(lines: &mut Vec) { + lines.push("Tab/Shift+Tab or h/l (Left/Right) to switch panes".to_string()); +} + +fn sorted_topology( + topology: &ClusterTopology, +) -> (Vec, Vec, Vec) { + let mut agents = topology.agents.clone(); + agents.sort_by(|a, b| a.id.cmp(&b.id)); + + let mut topics = topology.topics.clone(); + topics.sort(); + + let mut edges = topology.edges.clone(); + edges.sort_by(|a, b| { + let kind_a = kind_label(&a.kind); + let kind_b = kind_label(&b.kind); + (a.from.as_str(), a.to.as_str(), kind_a, a.topic.as_str()).cmp(&( + b.from.as_str(), + b.to.as_str(), + kind_b, + b.topic.as_str(), + )) + }); + + (agents, topics, edges) +} + +fn summary_line(summary: Option<&ClusterSummary>) -> String { + summary + .map(|summary| { + let provider = summary.provider.as_deref().unwrap_or("default"); + format!("State: {} | Provider: {}", summary.state, provider) + }) + .unwrap_or_else(|| "Summary pending.".to_string()) +} + +fn edge_details(edge: &TopologyEdge) -> String { + let mut suffix = format!("{}:{}", kind_label(&edge.kind), edge.topic); + if edge.dynamic.unwrap_or(false) { + suffix.push_str(" dynamic"); + } + format!("{} ({suffix})", edge.to) +} + +fn kind_label(kind: &TopologyEdgeKind) -> &'static str { + match kind { + TopologyEdgeKind::Trigger => "trigger", + TopologyEdgeKind::Publish => "publish", + TopologyEdgeKind::Source => "source", + } +} diff --git a/tui-rs/crates/zeroshot-tui/tests/animation_smoothing.rs b/tui-rs/crates/zeroshot-tui/tests/animation_smoothing.rs new file mode 100644 index 00000000..113b52c1 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/animation_smoothing.rs @@ -0,0 +1,40 @@ +use zeroshot_tui::app::animation::{pulse_factor, step_spring_f32, AnimClock, PHASE_TICKS}; + +#[test] +fn anim_clock_advances_and_wraps() { + let mut clock = AnimClock::default(); + clock.advance(100); + let first_phase = clock.phase; + assert_eq!(clock.tick, 1); + assert_eq!(clock.now_ms, 100); + + for _ in 0..PHASE_TICKS { + clock.advance(200); + } + + assert_eq!(clock.tick, 1 + PHASE_TICKS); + assert!((clock.phase - first_phase).abs() < 1e-6); +} + +#[test] +fn camera_smoothing_moves_toward_target() { + let position = (0.0_f32, 0.0_f32); + let velocity = (0.0_f32, 0.0_f32); + let target = (10.0_f32, 0.0_f32); + + let (pos1, vel1) = step_spring_f32(position, velocity, target, 250, 0.16, 0.82); + assert!(pos1.0 > 0.0); + assert!(vel1.0 > 0.0); + + let (pos2, _vel2) = step_spring_f32(pos1, vel1, target, 250, 0.16, 0.82); + assert!(pos2.0 > pos1.0); +} + +#[test] +fn error_pulse_varies_with_phase() { + let start = pulse_factor(0.0); + let mid = pulse_factor(0.25); + assert!((start - mid).abs() > 0.1); + assert!((0.0..=1.0).contains(&start)); + assert!((0.0..=1.0).contains(&mid)); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/app_navigation.rs b/tui-rs/crates/zeroshot-tui/tests/app_navigation.rs new file mode 100644 index 00000000..171ee76b --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/app_navigation.rs @@ -0,0 +1,143 @@ +use zeroshot_tui::app::{ + self, Action, AppState, BackendRequest, Effect, NavigationAction, ScreenAction, ScreenId, +}; +use zeroshot_tui::screens::cluster; + +#[test] +fn esc_pops_until_launcher_root() { + let state = AppState::default(); + let (state, _) = app::update( + state, + Action::Navigate(NavigationAction::Push(ScreenId::Monitor)), + ); + let (state, _) = app::update( + state, + Action::Navigate(NavigationAction::Push(ScreenId::Cluster { + id: "cluster-1".to_string(), + })), + ); + + let (state, _) = app::update(state, Action::Navigate(NavigationAction::Pop)); + assert!(matches!(state.active_screen(), ScreenId::Monitor)); + + let (state, _) = app::update(state, Action::Navigate(NavigationAction::Pop)); + assert!(matches!(state.active_screen(), ScreenId::Launcher)); + + let (state, _) = app::update(state, Action::Navigate(NavigationAction::Pop)); + assert_eq!(state.screen_stack, vec![ScreenId::Launcher]); +} + +#[test] +fn push_replace_pop_behave_correctly() { + let state = AppState::default(); + let (state, _) = app::update( + state, + Action::Navigate(NavigationAction::Push(ScreenId::Monitor)), + ); + assert_eq!(state.screen_stack.len(), 2); + + let (state, _) = app::update( + state, + Action::Navigate(NavigationAction::ReplaceTop(ScreenId::Cluster { + id: "cluster-2".to_string(), + })), + ); + assert!(matches!(state.active_screen(), ScreenId::Cluster { .. })); + + let (state, _) = app::update(state, Action::Navigate(NavigationAction::Pop)); + assert!(matches!(state.active_screen(), ScreenId::Launcher)); +} + +#[test] +fn cluster_entry_requests_topology() { + let state = AppState::default(); + let (_state, effects) = app::update( + state, + Action::Navigate(NavigationAction::Push(ScreenId::Cluster { + id: "cluster-1".to_string(), + })), + ); + + assert!( + effects.contains(&Effect::Backend(BackendRequest::GetClusterTopology { + cluster_id: "cluster-1".to_string(), + })) + ); +} + +#[test] +fn pop_from_cluster_unsubscribes_active_streams() { + let state = AppState::default(); + let (state, _) = app::update( + state, + Action::Navigate(NavigationAction::Push(ScreenId::Cluster { + id: "cluster-1".to_string(), + })), + ); + + let mut state = state; + let entry = state.clusters.get_mut("cluster-1").expect("cluster state"); + entry.log_subscription = Some("log-sub".to_string()); + entry.timeline_subscription = Some("timeline-sub".to_string()); + + let (state, effects) = app::update(state, Action::Navigate(NavigationAction::Pop)); + assert!( + effects.contains(&Effect::Backend(BackendRequest::Unsubscribe { + subscription_id: "log-sub".to_string(), + })) + ); + assert!( + effects.contains(&Effect::Backend(BackendRequest::Unsubscribe { + subscription_id: "timeline-sub".to_string(), + })) + ); + + let entry = state.clusters.get("cluster-1").expect("cluster state"); + assert!(entry.log_subscription.is_none()); + assert!(entry.timeline_subscription.is_none()); +} + +#[test] +fn push_to_agent_unsubscribes_cluster_streams() { + let state = AppState::default(); + let (state, _) = app::update( + state, + Action::Navigate(NavigationAction::Push(ScreenId::Cluster { + id: "cluster-1".to_string(), + })), + ); + + let mut state = state; + let entry = state.clusters.get_mut("cluster-1").expect("cluster state"); + entry.log_subscription = Some("log-sub".to_string()); + entry.timeline_subscription = Some("timeline-sub".to_string()); + + let (state, effects) = app::update( + state, + Action::Screen(ScreenAction::Cluster { + id: "cluster-1".to_string(), + action: cluster::Action::OpenAgent("agent-1".to_string()), + }), + ); + + assert!( + effects.contains(&Effect::Backend(BackendRequest::Unsubscribe { + subscription_id: "log-sub".to_string(), + })) + ); + assert!( + effects.contains(&Effect::Backend(BackendRequest::Unsubscribe { + subscription_id: "timeline-sub".to_string(), + })) + ); + + let entry = state.clusters.get("cluster-1").expect("cluster state"); + assert!(entry.log_subscription.is_none()); + assert!(entry.timeline_subscription.is_none()); + + assert!(matches!( + state.active_screen(), + ScreenId::Agent { cluster_id, agent_id } + if cluster_id == "cluster-1" && agent_id == "agent-1" + )); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/backend_framing.rs b/tui-rs/crates/zeroshot-tui/tests/backend_framing.rs new file mode 100644 index 00000000..ead91e61 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/backend_framing.rs @@ -0,0 +1,57 @@ +use zeroshot_tui::backend::framing::{FrameDecoder, FrameEncoder, FrameError, MAX_FRAME_SIZE}; + +#[test] +fn single_frame_roundtrip() { + let payload = br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; + let framed = FrameEncoder::encode(payload).expect("encode"); + let mut decoder = FrameDecoder::new(); + let frames = decoder.push(&framed).expect("decode"); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0], payload); +} + +#[test] +fn multiple_frames_in_one_buffer() { + let payload_a = br#"{"jsonrpc":"2.0","id":1,"method":"one"}"#; + let payload_b = br#"{"jsonrpc":"2.0","id":2,"method":"two"}"#; + let mut combined = Vec::new(); + combined.extend(FrameEncoder::encode(payload_a).unwrap()); + combined.extend(FrameEncoder::encode(payload_b).unwrap()); + + let mut decoder = FrameDecoder::new(); + let frames = decoder.push(&combined).expect("decode"); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0], payload_a); + assert_eq!(frames[1], payload_b); +} + +#[test] +fn split_frame_across_chunks() { + let payload = br#"{"jsonrpc":"2.0","id":3,"method":"split"}"#; + let framed = FrameEncoder::encode(payload).unwrap(); + let mid = framed.len() / 2; + + let mut decoder = FrameDecoder::new(); + let frames = decoder.push(&framed[..mid]).expect("decode"); + assert!(frames.is_empty()); + + let frames = decoder.push(&framed[mid..]).expect("decode"); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0], payload); +} + +#[test] +fn oversized_frame_rejected() { + let oversized = MAX_FRAME_SIZE + 1; + let header = format!("Content-Length: {}\r\n\r\n", oversized); + let mut decoder = FrameDecoder::new(); + let error = decoder.push(header.as_bytes()).expect_err("oversized"); + assert!(matches!(error, FrameError::FrameTooLarge(_))); +} + +#[test] +fn encoder_rejects_oversized_payload() { + let payload = vec![0u8; MAX_FRAME_SIZE + 1]; + let error = FrameEncoder::encode(&payload).expect_err("oversized"); + assert!(matches!(error, FrameError::FrameTooLarge(_))); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/backend_integration.rs b/tui-rs/crates/zeroshot-tui/tests/backend_integration.rs new file mode 100644 index 00000000..505d8019 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/backend_integration.rs @@ -0,0 +1,203 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use zeroshot_tui::backend::stdio::StdioBackendClient; +use zeroshot_tui::backend::{BackendClient, BackendConfig, BackendEvent}; +use zeroshot_tui::protocol::{ + GetClusterSummaryParams, SubscribeClusterLogsParams, SubscribeClusterTimelineParams, + UnsubscribeParams, +}; + +static ENV_LOCK: OnceLock> = OnceLock::new(); +static BACKEND_PATH: OnceLock> = OnceLock::new(); + +fn is_ci() -> bool { + match std::env::var("CI") { + Ok(value) => matches!(value.to_lowercase().as_str(), "1" | "true" | "yes"), + Err(_) => false, + } +} + +struct EnvGuard { + keys: Vec<&'static str>, +} + +impl EnvGuard { + fn set(pairs: &[(&'static str, &'static str)]) -> Self { + let mut keys = Vec::with_capacity(pairs.len()); + for (key, value) in pairs { + std::env::set_var(key, value); + keys.push(*key); + } + Self { keys } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for key in &self.keys { + std::env::remove_var(key); + } + } +} + +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) +} + +fn repo_root() -> Option { + let cwd = std::env::current_dir().ok()?; + for ancestor in cwd.ancestors() { + if ancestor.join("package.json").is_file() { + return Some(ancestor.to_path_buf()); + } + } + None +} + +fn find_backend_path(start: &Path) -> Option { + for ancestor in start.ancestors() { + let candidate = ancestor.join("lib/tui-backend/server.js"); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn resolve_backend_path() -> Option { + let cwd = std::env::current_dir().ok()?; + if let Some(path) = find_backend_path(&cwd) { + return Some(path); + } + + let root = repo_root()?; + let status = Command::new("npm") + .args(["run", "build:tui-backend"]) + .current_dir(&root) + .status() + .ok()?; + if !status.success() { + eprintln!("npm run build:tui-backend failed"); + return None; + } + + find_backend_path(&root) +} + +fn build_client() -> Option { + let backend_path = BACKEND_PATH.get_or_init(resolve_backend_path).clone(); + let Some(backend_path) = backend_path else { + if is_ci() { + panic!("TUI backend not available. Run `npm ci` and `npm run build:tui-backend` before cargo test."); + } + return None; + }; + let mut config = BackendConfig::with_backend_path(backend_path); + config.request_timeout = Some(Duration::from_secs(10)); + match StdioBackendClient::connect(config) { + Ok(client) => Some(client), + Err(err) => { + if is_ci() { + panic!("Failed to connect to TUI backend: {err}"); + } + eprintln!("Skipping backend integration: {err}"); + None + } + } +} + +#[test] +fn initialize_and_list_clusters() { + let _guard = env_lock(); + let _env = EnvGuard::set(&[ + ("ZEROSHOT_TUI_BACKEND_MOCK_LAUNCH", "1"), + ("ZEROSHOT_TUI_BACKEND_MOCK_GUIDANCE", "1"), + ]); + + let Some(client) = build_client() else { + eprintln!("Skipping backend integration: backend build unavailable"); + return; + }; + assert_eq!(client.protocol_version(), 1); + assert!(client.server_capabilities().is_some()); + let result = client.list_clusters().expect("listClusters"); + let _ = result.clusters.len(); +} + +#[test] +fn interleaved_notifications_do_not_break_requests() { + let _guard = env_lock(); + let _env = EnvGuard::set(&[ + ("ZEROSHOT_TUI_BACKEND_MOCK_LAUNCH", "1"), + ("ZEROSHOT_TUI_BACKEND_MOCK_GUIDANCE", "1"), + ]); + + let Some(mut client) = build_client() else { + eprintln!("Skipping backend integration: backend build unavailable"); + return; + }; + let events = client.take_event_receiver().expect("event receiver"); + + let subscription = client + .subscribe_cluster_logs(SubscribeClusterLogsParams { + cluster_id: "unknown-cluster".to_string(), + agent_id: None, + }) + .expect("subscribe logs"); + + let _timeline = client + .subscribe_cluster_timeline(SubscribeClusterTimelineParams { + cluster_id: "unknown-cluster".to_string(), + }) + .expect("subscribe timeline"); + + let list_result = client.list_clusters().expect("listClusters"); + let summary = list_result + .clusters + .get(0) + .map(|cluster| cluster.id.clone()) + .unwrap_or_else(|| "unknown-cluster".to_string()); + let _ = client + .get_cluster_summary(GetClusterSummaryParams { + cluster_id: summary, + }) + .err(); + + let _ = events.recv_timeout(Duration::from_millis(200)); + + let _ = client + .unsubscribe(UnsubscribeParams { + subscription_id: subscription.subscription_id, + }) + .expect("unsubscribe"); +} + +#[test] +fn backend_exit_and_drop() { + let _guard = env_lock(); + let _env = EnvGuard::set(&[ + ("ZEROSHOT_TUI_BACKEND_MOCK_LAUNCH", "1"), + ("ZEROSHOT_TUI_BACKEND_MOCK_GUIDANCE", "1"), + ]); + + let Some(mut client) = build_client() else { + eprintln!("Skipping backend integration: backend build unavailable"); + return; + }; + let events = client.take_event_receiver().expect("event receiver"); + + client.shutdown().expect("shutdown"); + let event = events + .recv_timeout(Duration::from_secs(2)) + .expect("backend exit event"); + match event { + BackendEvent::BackendExited(_) => {} + BackendEvent::Notification(_) => panic!("expected BackendExited"), + } +} diff --git a/tui-rs/crates/zeroshot-tui/tests/cluster_canvas_snapshots.rs b/tui-rs/crates/zeroshot-tui/tests/cluster_canvas_snapshots.rs new file mode 100644 index 00000000..2664a5cd --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/cluster_canvas_snapshots.rs @@ -0,0 +1,115 @@ +mod ui_snapshot_helpers; + +use ui_snapshot_helpers::render_to_text; + +use zeroshot_tui::app::{AppState, ScreenId, TimeCursor, TimeCursorMode, UiVariant}; +use zeroshot_tui::protocol::{ + ClusterLogLine, ClusterTopology, TimelineEvent, TopologyAgent, TopologyEdge, TopologyEdgeKind, +}; +use zeroshot_tui::screens::{cluster, cluster_canvas}; +use zeroshot_tui::ui; + +fn sample_topology() -> ClusterTopology { + ClusterTopology { + agents: vec![ + TopologyAgent { + id: "worker".to_string(), + role: Some("implementation".to_string()), + }, + TopologyAgent { + id: "validator".to_string(), + role: Some("validator".to_string()), + }, + ], + edges: vec![ + TopologyEdge { + from: "ISSUE_OPENED".to_string(), + to: "worker".to_string(), + topic: "ISSUE_OPENED".to_string(), + kind: TopologyEdgeKind::Trigger, + dynamic: Some(true), + }, + TopologyEdge { + from: "worker".to_string(), + to: "IMPLEMENTATION_READY".to_string(), + topic: "IMPLEMENTATION_READY".to_string(), + kind: TopologyEdgeKind::Publish, + dynamic: None, + }, + ], + topics: vec![ + "ISSUE_OPENED".to_string(), + "IMPLEMENTATION_READY".to_string(), + ], + } +} + +fn sample_cluster_state() -> cluster::State { + let mut state = cluster::State::default(); + let topology = sample_topology(); + state.topology = Some(topology); + + state.logs_time.push_many(vec![ + ClusterLogLine { + id: "log-1".to_string(), + timestamp: 900, + text: "agent started".to_string(), + agent: Some("worker".to_string()), + role: Some("implementation".to_string()), + sender: Some("worker".to_string()), + }, + ClusterLogLine { + id: "log-2".to_string(), + timestamp: 950, + text: "cluster event".to_string(), + agent: None, + role: None, + sender: Some("system".to_string()), + }, + ]); + + state.timeline_time.push_many(vec![TimelineEvent { + id: "evt-1".to_string(), + timestamp: 920, + topic: "ISSUE_OPENED".to_string(), + label: "opened".to_string(), + approved: None, + sender: Some("system".to_string()), + }]); + + state +} + +#[test] +fn cluster_canvas_snapshot_with_focus_and_overlay() { + let cluster_id = "cluster-1".to_string(); + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + state.screen_stack = vec![ScreenId::ClusterCanvas { + id: cluster_id.clone(), + }]; + state.time_cursor = TimeCursor { + mode: TimeCursorMode::Live, + t_ms: 1000, + window_ms: 1000, + }; + + let cluster_state = sample_cluster_state(); + let topology = cluster_state.topology.clone().expect("topology"); + + let mut canvas_state = cluster_canvas::State::default(); + canvas_state.focused_id = Some("worker".to_string()); + canvas_state.update_layout(&topology); + + state.clusters.insert(cluster_id.clone(), cluster_state); + state + .cluster_canvases + .insert(cluster_id.clone(), canvas_state); + + let content = render_to_text(100, 26, |frame| ui::render(frame, &state)); + assert!(content.contains("Cluster Canvas cluster-1")); + assert!(content.contains("worker")); + assert!(content.contains("Logs - agent worker")); + assert!(content.contains("[LIVE]")); + assert!(content.contains("agent started")); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/cluster_reducer.rs b/tui-rs/crates/zeroshot-tui/tests/cluster_reducer.rs new file mode 100644 index 00000000..be9a2b16 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/cluster_reducer.rs @@ -0,0 +1,88 @@ +use zeroshot_tui::protocol::{ClusterLogLine, TimelineEvent}; +use zeroshot_tui::screens::cluster::{self, ClusterPane, FocusDirection}; + +fn log_line(id: usize, agent: Option<&str>, role: Option<&str>) -> ClusterLogLine { + ClusterLogLine { + id: format!("log-{id}"), + timestamp: id as i64, + text: format!("line-{id}"), + agent: agent.map(|value| value.to_string()), + role: role.map(|value| value.to_string()), + sender: None, + } +} + +fn timeline_event(id: usize) -> TimelineEvent { + TimelineEvent { + id: format!("event-{id}"), + timestamp: id as i64, + topic: format!("topic-{id}"), + label: format!("label-{id}"), + approved: None, + sender: None, + } +} + +#[test] +fn log_buffer_bounds_and_dropped_count() { + let mut state = cluster::State::default(); + state.push_log_lines(vec![log_line(0, None, None)], Some(3)); + assert_eq!(state.logs.len(), 2); + let first = state.logs.items.front().expect("expected synthetic line"); + assert!(first.text.contains("dropped 3")); + + let mut state = cluster::State::default(); + let lines: Vec<_> = (0..(cluster::MAX_LOG_LINES + 5)) + .map(|id| log_line(id, None, None)) + .collect(); + state.push_log_lines(lines, None); + assert_eq!(state.logs.len(), cluster::MAX_LOG_LINES); + let first = state.logs.items.front().expect("expected log line"); + assert_eq!(first.id, "log-5"); +} + +#[test] +fn timeline_buffer_bounds() { + let mut state = cluster::State::default(); + let events: Vec<_> = (0..(cluster::MAX_TIMELINE_EVENTS + 3)) + .map(timeline_event) + .collect(); + state.push_timeline_events(events); + assert_eq!(state.timeline.len(), cluster::MAX_TIMELINE_EVENTS); + let first = state.timeline.items.front().expect("expected event"); + assert_eq!(first.id, "event-3"); +} + +#[test] +fn focus_cycles_and_activate_uses_selected_agent() { + let mut state = cluster::State::default(); + assert_eq!(state.focus, ClusterPane::Topology); + state.cycle_focus(FocusDirection::Next); + assert_eq!(state.focus, ClusterPane::Logs); + state.cycle_focus(FocusDirection::Next); + assert_eq!(state.focus, ClusterPane::Timeline); + state.cycle_focus(FocusDirection::Next); + assert_eq!(state.focus, ClusterPane::Agents); + state.cycle_focus(FocusDirection::Prev); + assert_eq!(state.focus, ClusterPane::Timeline); + + state.focus = ClusterPane::Agents; + state.push_log_lines(vec![log_line(1, Some("agent-a"), Some("role-a"))], None); + state.push_log_lines(vec![log_line(2, Some("agent-b"), Some("role-b"))], None); + state.move_focused(1); + assert_eq!(state.activate_focused(), Some("agent-b".to_string())); + + state.push_log_lines(vec![log_line(3, Some("agent-c"), Some("role-c"))], None); + assert_eq!(state.activate_focused(), Some("agent-b".to_string())); +} + +#[test] +fn scroll_offset_grows_when_new_lines_arrive() { + let mut state = cluster::State::default(); + state.focus = ClusterPane::Logs; + state.push_log_lines(vec![log_line(0, None, None), log_line(1, None, None)], None); + state.move_focused(-1); + assert_eq!(state.logs.scroll_offset, 1); + state.push_log_lines(vec![log_line(2, None, None)], None); + assert_eq!(state.logs.scroll_offset, 2); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/disruptive_render.rs b/tui-rs/crates/zeroshot-tui/tests/disruptive_render.rs new file mode 100644 index 00000000..e738aeb1 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/disruptive_render.rs @@ -0,0 +1,80 @@ +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::Terminal; + +use zeroshot_tui::app::{AppState, UiVariant}; +use zeroshot_tui::protocol::ClusterSummary; +use zeroshot_tui::ui; + +fn buffer_text(buffer: &Buffer) -> String { + let area = buffer.area; + let mut lines = Vec::new(); + for y in area.top()..area.bottom() { + let mut line = String::new(); + for x in area.left()..area.right() { + line.push_str(buffer.cell((x, y)).map_or("", |c| c.symbol())); + } + lines.push(line); + } + lines.join("\n") +} + +fn cluster_summary(id: &str, state: &str) -> ClusterSummary { + ClusterSummary { + id: id.to_string(), + state: state.to_string(), + provider: None, + created_at: 0, + agent_count: 0, + message_count: 0, + cwd: None, + } +} + +#[test] +fn disruptive_render_empty_radar() { + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + + terminal + .draw(|frame| ui::render(frame, &state)) + .expect("draw"); + + let content = buffer_text(terminal.backend().buffer()); + assert!(content.contains("Fleet Radar")); + assert!(content.contains("No clusters yet.")); + assert!(content.contains("Type an intent in the spine to start a cluster.")); + assert!(!content.contains("ID")); + assert!(!content.contains("STATE")); + assert!(!content.contains("ZEROSHOT")); +} + +#[test] +fn disruptive_render_radar_three_states() { + let backend = TestBackend::new(80, 16); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + state.now_ms = 10_000; + state.fleet_radar.set_clusters( + vec![ + cluster_summary("run-1", "running"), + cluster_summary("done-1", "done"), + cluster_summary("err-1", "error"), + ], + state.now_ms, + ); + + terminal + .draw(|frame| ui::render(frame, &state)) + .expect("draw"); + + let content = buffer_text(terminal.backend().buffer()); + assert!(content.contains("run-1")); + assert!(content.contains("done-1")); + assert!(content.contains("err-1")); + assert!(!content.contains("ID")); + assert!(!content.contains("STATE")); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/input_routing.rs b/tui-rs/crates/zeroshot-tui/tests/input_routing.rs new file mode 100644 index 00000000..e9043a55 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/input_routing.rs @@ -0,0 +1,312 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use zeroshot_tui::app::{ + self, Action, NavigationAction, ScreenAction, ScreenId, SpineAction, SpineMode, UiVariant, +}; +use zeroshot_tui::input; +use zeroshot_tui::protocol::ClusterSummary; +use zeroshot_tui::screens::{cluster, launcher, monitor}; + +fn state_for(screen: ScreenId) -> app::AppState { + let mut state = app::AppState::default(); + state.screen_stack = vec![screen]; + state +} + +#[test] +fn global_keys_apply_everywhere() { + let screens = vec![ + ScreenId::Launcher, + ScreenId::Monitor, + ScreenId::Cluster { + id: "c1".to_string(), + }, + ScreenId::Agent { + cluster_id: "c1".to_string(), + agent_id: "a1".to_string(), + }, + ]; + + for screen in screens { + let state = state_for(screen); + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + let action = input::route_key(&state, esc); + assert!(matches!( + action, + Some(Action::Navigate(NavigationAction::Pop)) + )); + + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let action = input::route_key(&state, ctrl_c); + assert!(matches!(action, Some(Action::Quit))); + } +} + +#[test] +fn screen_specific_keys_only_apply_to_focused_screen() { + let launcher = ScreenId::Launcher; + let monitor_screen = ScreenId::Monitor; + let cluster_screen = ScreenId::Cluster { + id: "c1".to_string(), + }; + + let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + let state = state_for(launcher); + assert!(input::route_key(&state, down).is_none()); + + let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + let state = state_for(monitor_screen); + let action = input::route_key(&state, down); + assert!(matches!( + action, + Some(Action::Screen(ScreenAction::Monitor( + monitor::Action::MoveSelection(1) + ))) + )); + + let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + let state = state_for(cluster_screen.clone()); + let action = input::route_key(&state, tab); + match action { + Some(Action::Screen(ScreenAction::Cluster { id, action })) => { + assert_eq!(id, "c1"); + assert!(matches!( + action, + cluster::Action::CycleFocus(cluster::FocusDirection::Next) + )); + } + _ => panic!("expected cluster focus action"), + } + + let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE); + let state = state_for(cluster_screen.clone()); + let action = input::route_key(&state, up); + assert!(matches!( + action, + Some(Action::Screen(ScreenAction::Cluster { + action: cluster::Action::MoveFocused(-1), + .. + })) + )); + + let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + let state = state_for(cluster_screen.clone()); + let action = input::route_key(&state, down); + assert!(matches!( + action, + Some(Action::Screen(ScreenAction::Cluster { + action: cluster::Action::MoveFocused(1), + .. + })) + )); + + let k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE); + let state = state_for(cluster_screen.clone()); + let action = input::route_key(&state, k); + assert!(matches!( + action, + Some(Action::Screen(ScreenAction::Cluster { + action: cluster::Action::MoveFocused(-1), + .. + })) + )); + + let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + let state = state_for(cluster_screen.clone()); + let action = input::route_key(&state, j); + assert!(matches!( + action, + Some(Action::Screen(ScreenAction::Cluster { + action: cluster::Action::MoveFocused(1), + .. + })) + )); + + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + let state = state_for(cluster_screen); + let action = input::route_key(&state, enter); + assert!(matches!( + action, + Some(Action::Screen(ScreenAction::Cluster { + action: cluster::Action::ActivateFocused, + .. + })) + )); + + let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + let state = state_for(ScreenId::Monitor); + assert!(input::route_key(&state, tab).is_none()); +} + +#[test] +fn launcher_keys_edit_input_state() { + let mut state = app::AppState::default(); + state.screen_stack = vec![ScreenId::Launcher]; + + let action = input::route_key( + &state, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + ) + .expect("expected insert char"); + let (next_state, _) = app::update(state, action); + state = next_state; + assert_eq!(state.launcher.input, "a"); + assert_eq!(state.launcher.cursor, 1); + + let action = input::route_key( + &state, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), + ) + .expect("expected insert char"); + let (next_state, _) = app::update(state, action); + state = next_state; + assert_eq!(state.launcher.input, "ab"); + assert_eq!(state.launcher.cursor, 2); + + let action = input::route_key(&state, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)) + .expect("expected move left"); + let (next_state, _) = app::update(state, action); + state = next_state; + assert_eq!(state.launcher.cursor, 1); + + let action = input::route_key( + &state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ) + .expect("expected backspace"); + let (next_state, _) = app::update(state, action); + state = next_state; + assert_eq!(state.launcher.input, "b"); + assert_eq!(state.launcher.cursor, 0); + + let action = input::route_key(&state, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)) + .expect("expected delete"); + let (next_state, _) = app::update(state, action); + state = next_state; + assert_eq!(state.launcher.input, ""); + assert_eq!(state.launcher.cursor, 0); +} + +#[test] +fn q_quits_except_in_launcher_input() { + let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE); + let state = state_for(ScreenId::Launcher); + let action = input::route_key(&state, key); + assert!(matches!( + action, + Some(Action::Screen(ScreenAction::Launcher( + launcher::Action::InsertChar('q') + ))) + )); + + let screens = vec![ + ScreenId::Monitor, + ScreenId::Cluster { + id: "c1".to_string(), + }, + ScreenId::Agent { + cluster_id: "c1".to_string(), + agent_id: "a1".to_string(), + }, + ]; + + for screen in screens { + let state = state_for(screen); + let action = input::route_key( + &state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert!(matches!(action, Some(Action::Quit))); + } +} + +#[test] +fn disruptive_routes_spine_shortcuts() { + let mut state = state_for(ScreenId::FleetRadar); + state.ui_variant = UiVariant::Disruptive; + + let action = input::route_key( + &state, + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + ); + match action { + Some(Action::Spine(SpineAction::EnterMode { mode, prefill })) => { + assert_eq!(mode, SpineMode::Command); + assert_eq!(prefill, ""); + } + _ => panic!("expected command mode for /"), + } + + let action = input::route_key( + &state, + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE), + ); + match action { + Some(Action::Spine(SpineAction::EnterMode { mode, prefill })) => { + assert_eq!(mode, SpineMode::Command); + assert_eq!(prefill, "help "); + } + _ => panic!("expected help prefill for ?"), + } + + let action = input::route_key( + &state, + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + ); + assert!(matches!(action, Some(Action::Spine(SpineAction::Clear)))); +} + +#[test] +fn disruptive_esc_cancels_or_pops() { + let mut state = state_for(ScreenId::FleetRadar); + state.ui_variant = UiVariant::Disruptive; + + let action = input::route_key(&state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!( + action, + Some(Action::Navigate(NavigationAction::Pop)) + )); + + state.spine.mode = SpineMode::Command; + let action = input::route_key(&state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(action, Some(Action::Spine(SpineAction::Cancel)))); +} + +#[test] +fn disruptive_enter_submits_or_zooms() { + let mut state = state_for(ScreenId::FleetRadar); + state.ui_variant = UiVariant::Disruptive; + state.fleet_radar.set_clusters( + vec![ClusterSummary { + id: "c1".to_string(), + state: "running".to_string(), + provider: None, + created_at: 0, + agent_count: 0, + message_count: 0, + cwd: None, + }], + 1000, + ); + + let action = input::route_key(&state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + action, + Some(Action::Navigate(NavigationAction::Push(ScreenId::ClusterCanvas { id }))) + if id == "c1" + )); + + state.spine.input.input = "hello".to_string(); + let action = input::route_key(&state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(action, Some(Action::Spine(SpineAction::Submit)))); +} + +#[test] +fn disruptive_ctrl_c_quits() { + let mut state = state_for(ScreenId::FleetRadar); + state.ui_variant = UiVariant::Disruptive; + + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let action = input::route_key(&state, ctrl_c); + assert!(matches!(action, Some(Action::Quit))); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/launcher_reducer.rs b/tui-rs/crates/zeroshot-tui/tests/launcher_reducer.rs new file mode 100644 index 00000000..6d2cb5ef --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/launcher_reducer.rs @@ -0,0 +1,61 @@ +use zeroshot_tui::app::{ + self, Action, AppState, BackendAction, BackendRequest, CommandContext, CommandRequest, Effect, + ScreenAction, ScreenId, +}; +use zeroshot_tui::screens::launcher; + +#[test] +fn submit_text_routes_to_start_cluster_from_text() { + let mut state = AppState::default(); + state.launcher.input = "123".to_string(); + state.provider_override = Some("claude".to_string()); + + let (state, effects) = app::update( + state, + Action::Screen(ScreenAction::Launcher(launcher::Action::Submit)), + ); + + assert_eq!(state.last_error, None); + assert_eq!( + effects, + vec![Effect::Backend(BackendRequest::StartClusterFromText { + text: "123".to_string(), + provider_override: Some("claude".to_string()), + })] + ); +} + +#[test] +fn submit_command_routes_to_command_effect() { + let mut state = AppState::default(); + state.launcher.input = "/help".to_string(); + + let (state, effects) = app::update( + state, + Action::Screen(ScreenAction::Launcher(launcher::Action::Submit)), + ); + + assert_eq!(state.last_error, None); + assert_eq!( + effects, + vec![Effect::Command(CommandRequest::SubmitRaw { + raw: "/help".to_string(), + context: CommandContext { + provider_override: None, + active_screen: ScreenId::Launcher, + ui_variant: state.ui_variant, + }, + })] + ); +} + +#[test] +fn backend_error_sets_last_error() { + let state = AppState::default(); + let (state, _) = app::update( + state, + Action::Backend(BackendAction::Error("boom".to_string())), + ); + + assert_eq!(state.last_error.as_deref(), Some("boom")); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/microscope_snapshots.rs b/tui-rs/crates/zeroshot-tui/tests/microscope_snapshots.rs new file mode 100644 index 00000000..adafd1a6 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/microscope_snapshots.rs @@ -0,0 +1,114 @@ +mod ui_snapshot_helpers; + +use ui_snapshot_helpers::render_to_text; + +use zeroshot_tui::app::{agent_microscope, TimeCursor, TimeCursorMode}; +use zeroshot_tui::protocol::{ClusterLogLine, TimelineEvent}; +use zeroshot_tui::screens::agent_microscope as microscope_screen; +use zeroshot_tui::ui::shared::TimeIndexedBuffer; + +fn sample_logs() -> TimeIndexedBuffer { + let mut buffer = TimeIndexedBuffer::new(32); + buffer.push_many(vec![ + ClusterLogLine { + id: "log-1".to_string(), + timestamp: 100, + text: "started task".to_string(), + agent: Some("agent-9".to_string()), + role: Some("implementation".to_string()), + sender: Some("agent-9".to_string()), + }, + ClusterLogLine { + id: "log-2".to_string(), + timestamp: 200, + text: "finished task".to_string(), + agent: Some("agent-9".to_string()), + role: Some("implementation".to_string()), + sender: Some("agent-9".to_string()), + }, + ]); + buffer +} + +fn sample_timeline() -> TimeIndexedBuffer { + let mut buffer = TimeIndexedBuffer::new(16); + buffer.push_many(vec![TimelineEvent { + id: "evt-1".to_string(), + timestamp: 150, + topic: "IMPLEMENTATION_READY".to_string(), + label: "ready".to_string(), + approved: None, + sender: Some("agent-9".to_string()), + }]); + buffer +} + +fn sample_state() -> agent_microscope::State { + let mut state = agent_microscope::State::default(); + state.logs_time = sample_logs(); + state.role = Some("implementation".to_string()); + state.status = Some("running".to_string()); + state +} + +#[test] +fn microscope_snapshot_live_mode() { + let microscope_state = sample_state(); + let timeline = sample_timeline(); + let cursor = TimeCursor { + mode: TimeCursorMode::Live, + t_ms: 200, + window_ms: 400, + }; + + let content = render_to_text(80, 18, |frame| { + let area = frame.area(); + microscope_screen::render( + frame, + area, + "cluster-7", + "agent-9", + Some(&timeline), + Some(µscope_state), + &cursor, + ); + }); + + assert!(content.contains("Stream")); + assert!(content.contains("[LIVE]")); + assert!(content.contains("Agent: agent-9")); + assert!(content.contains("Role: implementation")); + assert!(content.contains("Status: running")); + assert!(content.contains("Cluster: cluster-7")); + assert!(content.contains("started task")); +} + +#[test] +fn microscope_snapshot_scrub_mode() { + let microscope_state = sample_state(); + let timeline = sample_timeline(); + let cursor = TimeCursor { + mode: TimeCursorMode::Scrub, + t_ms: 200, + window_ms: 200, + }; + + let content = render_to_text(80, 18, |frame| { + let area = frame.area(); + microscope_screen::render( + frame, + area, + "cluster-7", + "agent-9", + Some(&timeline), + Some(µscope_state), + &cursor, + ); + }); + + assert!(content.contains("Stream")); + assert!(content.contains("[SCRUB]")); + assert!(content.contains("Agent: agent-9")); + assert!(content.contains("Cluster: cluster-7")); + assert!(content.contains("finished task")); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/monitor_reducer.rs b/tui-rs/crates/zeroshot-tui/tests/monitor_reducer.rs new file mode 100644 index 00000000..e9d520c0 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/monitor_reducer.rs @@ -0,0 +1,107 @@ +use zeroshot_tui::app::{self, Action, AppState, BackendRequest, Effect, ScreenAction, ScreenId}; +use zeroshot_tui::protocol::ClusterSummary; +use zeroshot_tui::screens::monitor; + +fn cluster_summary(id: &str, message_count: i64) -> ClusterSummary { + ClusterSummary { + id: id.to_string(), + state: "running".to_string(), + provider: Some("codex".to_string()), + created_at: 0, + agent_count: 0, + message_count, + cwd: None, + } +} + +#[test] +fn monitor_selection_clamps_on_shrink() { + let mut state = monitor::State::default(); + state.set_clusters( + vec![ + cluster_summary("c1", 0), + cluster_summary("c2", 0), + cluster_summary("c3", 0), + ], + 1000, + ); + state.selected = 2; + + state.set_clusters(vec![cluster_summary("c1", 0)], 2000); + + assert_eq!(state.selected, 0); +} + +#[test] +fn monitor_tick_triggers_polling_interval() { + let mut state = AppState::default(); + state.screen_stack = vec![ScreenId::Monitor]; + state.monitor.last_poll_at = Some(1000); + + let (state, effects) = app::update(state, Action::Tick { now_ms: 1500 }); + assert!(effects.is_empty()); + assert_eq!(state.monitor.last_poll_at, Some(1000)); + + let (_state, effects) = app::update(state, Action::Tick { now_ms: 2001 }); + assert_eq!(effects, vec![Effect::Backend(BackendRequest::ListClusters)]); +} + +#[test] +fn monitor_open_selected_pushes_cluster() { + let mut state = AppState::default(); + state + .monitor + .set_clusters(vec![cluster_summary("c1", 0)], 1000); + + let (_state, effects) = app::update( + state, + Action::Screen(ScreenAction::Monitor(monitor::Action::OpenSelected)), + ); + + assert_eq!( + effects, + vec![ + Effect::Backend(BackendRequest::GetClusterSummary { + cluster_id: "c1".to_string() + }), + Effect::Backend(BackendRequest::GetClusterTopology { + cluster_id: "c1".to_string() + }), + Effect::Backend(BackendRequest::SubscribeClusterLogs { + cluster_id: "c1".to_string(), + agent_id: None, + }), + Effect::Backend(BackendRequest::SubscribeClusterTimeline { + cluster_id: "c1".to_string() + }), + ] + ); +} + +#[test] +fn monitor_last_activity_updates_only_on_increase() { + let mut state = monitor::State::default(); + state.set_clusters( + vec![cluster_summary("c1", 1), cluster_summary("c2", 2)], + 1000, + ); + + assert_eq!(state.last_activity_at.get("c1"), Some(&1000)); + assert_eq!(state.last_activity_at.get("c2"), Some(&1000)); + + state.set_clusters( + vec![cluster_summary("c1", 1), cluster_summary("c2", 2)], + 2000, + ); + + assert_eq!(state.last_activity_at.get("c1"), Some(&1000)); + assert_eq!(state.last_activity_at.get("c2"), Some(&1000)); + + state.set_clusters( + vec![cluster_summary("c1", 3), cluster_summary("c2", 2)], + 3000, + ); + + assert_eq!(state.last_activity_at.get("c1"), Some(&3000)); + assert_eq!(state.last_activity_at.get("c2"), Some(&1000)); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/protocol_fixtures.rs b/tui-rs/crates/zeroshot-tui/tests/protocol_fixtures.rs new file mode 100644 index 00000000..5d8a1a95 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/protocol_fixtures.rs @@ -0,0 +1,85 @@ +use std::fs; +use std::path::PathBuf; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use zeroshot_tui::protocol::*; + +fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join("tests/fixtures/tui-v2/protocol") +} + +fn round_trip(path: &PathBuf) { + let raw = fs::read_to_string(path).expect("read fixture"); + let parsed: T = serde_json::from_str(&raw).expect("deserialize fixture"); + let serialized = serde_json::to_string(&parsed).expect("serialize fixture"); + let reparsed: T = serde_json::from_str(&serialized).expect("re-deserialize fixture"); + assert_eq!(parsed, reparsed, "round-trip mismatch for {:?}", path); +} + +#[test] +fn protocol_fixtures_round_trip() { + let dir = fixtures_dir(); + let entries = fs::read_dir(&dir).expect("read fixtures dir"); + + for entry in entries { + let entry = entry.expect("read entry"); + let path = entry.path(); + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + + if !file_name.ends_with(".json") { + continue; + } + if file_name.starts_with("invalid.") { + continue; + } + + let parts: Vec<&str> = file_name.split('.').collect(); + if parts.len() < 3 { + continue; + } + + match parts[0] { + "request" => match parts[1] { + "initialize" => round_trip::(&path), + "listClusters" => round_trip::(&path), + "getClusterSummary" => round_trip::(&path), + "listClusterMetrics" => round_trip::(&path), + "startClusterFromText" => round_trip::(&path), + "startClusterFromIssue" => round_trip::(&path), + "sendGuidanceToAgent" => round_trip::(&path), + "sendGuidanceToCluster" => round_trip::(&path), + "subscribeClusterLogs" => round_trip::(&path), + "subscribeClusterTimeline" => round_trip::(&path), + "unsubscribe" => round_trip::(&path), + "getClusterTopology" => round_trip::(&path), + other => panic!("unknown request fixture: {other}"), + }, + "response" => match parts[1] { + "initialize" => round_trip::(&path), + "listClusters" => round_trip::(&path), + "getClusterSummary" => round_trip::(&path), + "listClusterMetrics" => round_trip::(&path), + "startClusterFromText" => round_trip::(&path), + "startClusterFromIssue" => round_trip::(&path), + "sendGuidanceToAgent" => round_trip::(&path), + "sendGuidanceToCluster" => round_trip::(&path), + "subscribeClusterLogs" => round_trip::(&path), + "subscribeClusterTimeline" => round_trip::(&path), + "unsubscribe" => round_trip::(&path), + "getClusterTopology" => round_trip::(&path), + other => panic!("unknown response fixture: {other}"), + }, + "notification" => match parts[1] { + "clusterLogLines" => round_trip::(&path), + "clusterTimelineEvents" => round_trip::(&path), + other => panic!("unknown notification fixture: {other}"), + }, + other => panic!("unknown fixture prefix: {other}"), + } + } +} diff --git a/tui-rs/crates/zeroshot-tui/tests/radar_layout.rs b/tui-rs/crates/zeroshot-tui/tests/radar_layout.rs new file mode 100644 index 00000000..e6c192c9 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/radar_layout.rs @@ -0,0 +1,39 @@ +use zeroshot_tui::protocol::ClusterSummary; +use zeroshot_tui::screens::radar::{layout_position, FleetRadarState}; + +fn cluster_summary(id: &str, message_count: i64) -> ClusterSummary { + ClusterSummary { + id: id.to_string(), + state: "running".to_string(), + provider: None, + created_at: 0, + agent_count: 0, + message_count, + cwd: None, + } +} + +#[test] +fn radar_layout_is_deterministic() { + let first = layout_position("cluster-1", 10_000); + let second = layout_position("cluster-1", 10_000); + assert_eq!(first, second); +} + +#[test] +fn radar_selects_first_cluster() { + let mut state = FleetRadarState::default(); + state.set_clusters( + vec![ + cluster_summary("c2", 0), + cluster_summary("c1", 0), + cluster_summary("c3", 0), + ], + 1000, + ); + assert_eq!(state.selected_cluster_id(), Some("c2".to_string())); + + state.selected = 2; + state.set_clusters(vec![cluster_summary("c1", 0)], 2000); + assert_eq!(state.selected_cluster_id(), Some("c1".to_string())); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/radar_snapshots.rs b/tui-rs/crates/zeroshot-tui/tests/radar_snapshots.rs new file mode 100644 index 00000000..35d31529 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/radar_snapshots.rs @@ -0,0 +1,54 @@ +mod ui_snapshot_helpers; + +use ui_snapshot_helpers::render_to_text; + +use zeroshot_tui::app::{AppState, ScreenId, UiVariant}; +use zeroshot_tui::protocol::ClusterSummary; +use zeroshot_tui::ui; + +fn cluster_summary(id: &str, state: &str) -> ClusterSummary { + ClusterSummary { + id: id.to_string(), + state: state.to_string(), + provider: None, + created_at: 0, + agent_count: 0, + message_count: 0, + cwd: None, + } +} + +#[test] +fn radar_snapshot_empty_state() { + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + state.screen_stack = vec![ScreenId::FleetRadar]; + + let content = render_to_text(60, 12, |frame| ui::render(frame, &state)); + assert!(content.contains("Fleet Radar")); + assert!(content.contains("No clusters yet.")); + assert!(content.contains("Type an intent in the spine")); +} + +#[test] +fn radar_snapshot_three_cluster_states() { + let mut state = AppState::default(); + state.ui_variant = UiVariant::Disruptive; + state.screen_stack = vec![ScreenId::FleetRadar]; + state.now_ms = 10_000; + state.fleet_radar.set_clusters( + vec![ + cluster_summary("run-1", "running"), + cluster_summary("done-1", "done"), + cluster_summary("err-1", "error"), + ], + state.now_ms, + ); + + let content = render_to_text(80, 16, |frame| ui::render(frame, &state)); + assert!(content.contains("Fleet Radar")); + assert!(content.contains("run-1")); + assert!(content.contains("done-1")); + assert!(content.contains("err-1")); + assert!(!content.contains("No clusters yet.")); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/scrub_bar_snapshots.rs b/tui-rs/crates/zeroshot-tui/tests/scrub_bar_snapshots.rs new file mode 100644 index 00000000..aa2c2147 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/scrub_bar_snapshots.rs @@ -0,0 +1,74 @@ +mod ui_snapshot_helpers; + +use ui_snapshot_helpers::render_to_text; + +use zeroshot_tui::app::{TimeCursor, TimeCursorMode}; +use zeroshot_tui::protocol::ClusterLogLine; +use zeroshot_tui::ui::shared::TimeIndexedBuffer; +use zeroshot_tui::ui::widgets::scrub_bar::{self, ScrubBarState}; + +fn sample_logs(timestamps: &[i64]) -> TimeIndexedBuffer { + let mut buffer = TimeIndexedBuffer::new(64); + let lines = timestamps.iter().map(|ts| ClusterLogLine { + id: format!("log-{ts}"), + timestamp: *ts, + text: "event".to_string(), + agent: None, + role: None, + sender: None, + }); + buffer.push_many(lines); + buffer +} + +#[test] +fn scrub_bar_snapshot_live_mode() { + let logs = sample_logs(&[100, 200, 300]); + let cursor = TimeCursor { + mode: TimeCursorMode::Live, + t_ms: 300, + window_ms: 300, + }; + + let content = render_to_text(40, 1, |frame| { + let area = frame.area(); + scrub_bar::render( + frame, + area, + ScrubBarState { + time_cursor: &cursor, + logs: Some(&logs), + agent_id: None, + }, + ); + }); + + assert!(content.contains("LIVE")); + assert!(content.contains("|")); +} + +#[test] +fn scrub_bar_snapshot_scrub_mode() { + let logs = sample_logs(&[100, 200, 300]); + let cursor = TimeCursor { + mode: TimeCursorMode::Scrub, + t_ms: 150, + window_ms: 300, + }; + + let content = render_to_text(40, 1, |frame| { + let area = frame.area(); + scrub_bar::render( + frame, + area, + ScrubBarState { + time_cursor: &cursor, + logs: Some(&logs), + agent_id: None, + }, + ); + }); + + assert!(content.contains("SCRUB")); + assert!(content.contains("*") || content.contains("^")); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/spine_snapshots.rs b/tui-rs/crates/zeroshot-tui/tests/spine_snapshots.rs new file mode 100644 index 00000000..1e264909 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/spine_snapshots.rs @@ -0,0 +1,74 @@ +mod ui_snapshot_helpers; + +use ui_snapshot_helpers::render_to_text; + +use zeroshot_tui::app::{SpineCompletion, SpineHint, SpineHintTone, SpineMode, SpineState}; +use zeroshot_tui::ui::widgets::spine; + +const WIDTH: u16 = 72; +const HEIGHT: u16 = 3; + +fn render_spine(state: &SpineState) -> String { + render_to_text(WIDTH, HEIGHT, |frame| { + let area = frame.area(); + spine::render(frame, area, state); + }) +} + +#[test] +fn spine_intent_mode_with_hint() { + let mut state = SpineState::default(); + state.hint = SpineHint::new("Ready", SpineHintTone::Info); + + let content = render_spine(&state); + assert!(content.contains("Intent")); + assert!(content.contains("Type intent...")); + assert!(content.contains("Ready")); +} + +#[test] +fn spine_command_mode_with_completion() { + let mut state = SpineState::default(); + state.mode = SpineMode::Command; + state.input.input = "pin".to_string(); + state.input.cursor = state.input.input.len(); + state.completion = Some(SpineCompletion { + candidates: vec!["cluster-1".to_string()], + selected: 0, + ghost: " cluster-1".to_string(), + }); + state.hint = SpineHint::new("Pin focus", SpineHintTone::Muted); + + let content = render_spine(&state); + assert!(content.contains("Command")); + assert!(content.contains("/pin cluster-1")); + assert!(content.contains("Pin focus")); +} + +#[test] +fn spine_whisper_cluster_mode() { + let mut state = SpineState::default(); + state.mode = SpineMode::WhisperCluster; + state.input.input = "cluster-7".to_string(); + state.input.cursor = state.input.input.len(); + state.hint = SpineHint::new("Whisper to cluster", SpineHintTone::Info); + + let content = render_spine(&state); + assert!(content.contains("Whisper Cluster")); + assert!(content.contains("cluster-7")); + assert!(content.contains("Whisper to cluster")); +} + +#[test] +fn spine_whisper_agent_mode() { + let mut state = SpineState::default(); + state.mode = SpineMode::WhisperAgent; + state.input.input = "agent-2".to_string(); + state.input.cursor = state.input.input.len(); + state.hint = SpineHint::new("Whisper to agent", SpineHintTone::Info); + + let content = render_spine(&state); + assert!(content.contains("Whisper Agent")); + assert!(content.contains("agent-2")); + assert!(content.contains("Whisper to agent")); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/spine_submit.rs b/tui-rs/crates/zeroshot-tui/tests/spine_submit.rs new file mode 100644 index 00000000..f2cd3f7f --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/spine_submit.rs @@ -0,0 +1,87 @@ +use zeroshot_tui::app::{ + self, Action, AppState, BackendRequest, Effect, ScreenId, SpineAction, SpineMode, +}; + +#[test] +fn intent_submit_text_starts_cluster_from_text() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Intent; + state.spine.input.input = "build".to_string(); + state.spine.input.cursor = 5; + + let (state, effects) = app::update(state, Action::Spine(SpineAction::Submit)); + + assert!( + effects.contains(&Effect::Backend(BackendRequest::StartClusterFromText { + text: "build".to_string(), + provider_override: None, + })) + ); + assert_eq!(state.spine.input.input, ""); + assert_eq!(state.spine.mode, SpineMode::Intent); +} + +#[test] +fn intent_submit_issue_starts_cluster_from_issue() { + let mut state = AppState::default(); + state.spine.mode = SpineMode::Intent; + state.spine.input.input = "org/repo#42".to_string(); + state.spine.input.cursor = 11; + + let (state, effects) = app::update(state, Action::Spine(SpineAction::Submit)); + + assert!( + effects.contains(&Effect::Backend(BackendRequest::StartClusterFromIssue { + reference: "org/repo#42".to_string(), + provider_override: None, + })) + ); + assert_eq!(state.spine.input.input, ""); + assert_eq!(state.spine.mode, SpineMode::Intent); +} + +#[test] +fn whisper_cluster_submit_sends_guidance_to_cluster() { + let mut state = AppState::default(); + state.screen_stack = vec![ScreenId::ClusterCanvas { + id: "cluster-1".to_string(), + }]; + state.spine.mode = SpineMode::WhisperCluster; + state.spine.input.input = "ping".to_string(); + state.spine.input.cursor = 4; + + let (state, effects) = app::update(state, Action::Spine(SpineAction::Submit)); + + assert!( + effects.contains(&Effect::Backend(BackendRequest::SendGuidanceToCluster { + cluster_id: "cluster-1".to_string(), + message: "ping".to_string(), + })) + ); + assert_eq!(state.spine.input.input, ""); + assert_eq!(state.spine.mode, SpineMode::Intent); +} + +#[test] +fn whisper_agent_submit_sends_guidance_to_agent() { + let mut state = AppState::default(); + state.screen_stack = vec![ScreenId::AgentMicroscope { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + }]; + state.spine.mode = SpineMode::WhisperAgent; + state.spine.input.input = "ping".to_string(); + state.spine.input.cursor = 4; + + let (state, effects) = app::update(state, Action::Spine(SpineAction::Submit)); + + assert!( + effects.contains(&Effect::Backend(BackendRequest::SendGuidanceToAgent { + cluster_id: "cluster-1".to_string(), + agent_id: "agent-1".to_string(), + message: "ping".to_string(), + })) + ); + assert_eq!(state.spine.input.input, ""); + assert_eq!(state.spine.mode, SpineMode::WhisperAgent); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/startup_options.rs b/tui-rs/crates/zeroshot-tui/tests/startup_options.rs new file mode 100644 index 00000000..7dc2ced7 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/startup_options.rs @@ -0,0 +1,20 @@ +use zeroshot_tui::app::{AppState, InitialScreen, ScreenId, StartupOptions, UiVariant}; + +#[test] +fn startup_options_apply_monitor_and_provider_override() { + let mut state = AppState::default(); + let options = StartupOptions { + initial_screen: Some(InitialScreen::Monitor), + provider_override: Some("codex".to_string()), + ui_variant: Some(UiVariant::Disruptive), + }; + + state.apply_startup_options(options); + + assert_eq!( + state.screen_stack, + vec![ScreenId::IntentConsole, ScreenId::FleetRadar] + ); + assert_eq!(state.provider_override, Some("codex".to_string())); + assert_eq!(state.ui_variant, UiVariant::Disruptive); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/topology_widget.rs b/tui-rs/crates/zeroshot-tui/tests/topology_widget.rs new file mode 100644 index 00000000..f5e3100a --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/topology_widget.rs @@ -0,0 +1,82 @@ +use zeroshot_tui::protocol::{ClusterTopology, TopologyAgent, TopologyEdge, TopologyEdgeKind}; +use zeroshot_tui::ui::widgets::topology; + +fn sample_topology() -> ClusterTopology { + ClusterTopology { + agents: vec![ + TopologyAgent { + id: "worker".to_string(), + role: Some("implementation".to_string()), + }, + TopologyAgent { + id: "system".to_string(), + role: None, + }, + TopologyAgent { + id: "validator".to_string(), + role: Some("validator".to_string()), + }, + ], + edges: vec![ + TopologyEdge { + from: "worker".to_string(), + to: "IMPLEMENTATION_READY".to_string(), + topic: "IMPLEMENTATION_READY".to_string(), + kind: TopologyEdgeKind::Publish, + dynamic: None, + }, + TopologyEdge { + from: "ISSUE_OPENED".to_string(), + to: "worker".to_string(), + topic: "ISSUE_OPENED".to_string(), + kind: TopologyEdgeKind::Trigger, + dynamic: Some(true), + }, + TopologyEdge { + from: "system".to_string(), + to: "ISSUE_OPENED".to_string(), + topic: "ISSUE_OPENED".to_string(), + kind: TopologyEdgeKind::Source, + dynamic: None, + }, + ], + topics: vec![ + "IMPLEMENTATION_READY".to_string(), + "ISSUE_OPENED".to_string(), + ], + } +} + +#[test] +fn renders_sorted_edges() { + let topology = sample_topology(); + let lines = topology::build_lines(None, Some(&topology), None); + + assert_eq!( + lines, + vec![ + "Summary pending.", + "Agents: 3 | Topics: 2 | Edges: 3", + "ISSUE_OPENED:", + " -> worker (trigger:ISSUE_OPENED dynamic)", + "system:", + " -> ISSUE_OPENED (source:ISSUE_OPENED)", + "worker:", + " -> IMPLEMENTATION_READY (publish:IMPLEMENTATION_READY)", + "Tab/Shift+Tab or h/l (Left/Right) to switch panes", + ] + ); +} + +#[test] +fn renders_placeholder_on_error() { + let lines = topology::build_lines(None, None, Some("backend unavailable")); + assert_eq!( + lines, + vec![ + "Summary pending.", + "Topology unavailable: backend unavailable", + "Tab/Shift+Tab or h/l (Left/Right) to switch panes", + ] + ); +} diff --git a/tui-rs/crates/zeroshot-tui/tests/ui_snapshot_helpers.rs b/tui-rs/crates/zeroshot-tui/tests/ui_snapshot_helpers.rs new file mode 100644 index 00000000..693ceeb8 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/ui_snapshot_helpers.rs @@ -0,0 +1,39 @@ +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::Frame; +use ratatui::Terminal; + +pub fn buffer_lines(buffer: &Buffer) -> Vec { + let area = buffer.area; + let mut lines = Vec::new(); + for y in area.top()..area.bottom() { + let mut line = String::new(); + for x in area.left()..area.right() { + line.push_str(buffer.cell((x, y)).map_or("", |c| c.symbol())); + } + lines.push(line); + } + lines +} + +pub fn buffer_text(buffer: &Buffer) -> String { + buffer_lines(buffer).join("\n") +} + +pub fn render_to_buffer(width: u16, height: u16, draw: F) -> Buffer +where + F: FnOnce(&mut Frame<'_>), +{ + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal.draw(draw).expect("draw"); + terminal.backend().buffer().clone() +} + +pub fn render_to_text(width: u16, height: u16, draw: F) -> String +where + F: FnOnce(&mut Frame<'_>), +{ + let buffer = render_to_buffer(width, height, draw); + buffer_text(&buffer) +} diff --git a/tui-rs/crates/zeroshot-tui/tests/ui_variant_parsing.rs b/tui-rs/crates/zeroshot-tui/tests/ui_variant_parsing.rs new file mode 100644 index 00000000..dcef1524 --- /dev/null +++ b/tui-rs/crates/zeroshot-tui/tests/ui_variant_parsing.rs @@ -0,0 +1,25 @@ +use zeroshot_tui::app::{resolve_ui_variant, UiVariant}; + +#[test] +fn ui_variant_defaults_to_none() { + let result = resolve_ui_variant(None, None).expect("resolve"); + assert_eq!(result, None); +} + +#[test] +fn ui_variant_parses_case_insensitive() { + let result = resolve_ui_variant(None, Some("Disruptive")).expect("resolve"); + assert_eq!(result, Some(UiVariant::Disruptive)); +} + +#[test] +fn ui_variant_cli_overrides_env() { + let result = resolve_ui_variant(Some("classic"), Some("disruptive")).expect("resolve"); + assert_eq!(result, Some(UiVariant::Classic)); +} + +#[test] +fn ui_variant_rejects_unknown() { + let err = resolve_ui_variant(Some("weird"), None).expect_err("expected error"); + assert!(err.contains("Unknown UI variant")); +} From 03fd141ec6f7c4a7682c8680999d1f37bf33aae0 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Wed, 4 Mar 2026 04:35:28 +0000 Subject: [PATCH 3/4] docs: fix Claude CLI login command in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b036bab..a6a8b359 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ npm i -g @google/gemini-cli # Opencode: see https://opencode.ai # Authenticate with the provider CLI -claude login # Claude +claude auth login # Claude codex login # Codex gemini auth login # Gemini opencode auth login # Opencode From 9f9ed4c302ead99e9affd5ca688c5b232ca1883e Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Thu, 5 Mar 2026 06:11:50 +0000 Subject: [PATCH 4/4] docs: update CONTRIBUTING prerequisites to list all supported providers CONTRIBUTING.md only listed Claude Code as a prerequisite, but zeroshot now supports Codex, Gemini CLI, and OpenCode as well. Update the prerequisites section to reflect all supported providers. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 964e5a3b..50d5a505 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ Thank you for your interest in contributing to Zeroshot! This guide covers every - **Node.js 18+** (check: `node --version`) - **npm** (bundled with Node) - **Docker** (optional, for isolation mode tests) -- **Claude Code CLI** - `npm i -g @anthropic-ai/claude-code && claude auth login` +- **AI Provider CLI** - At least one: Claude Code (`npm i -g @anthropic-ai/claude-code`), Codex, Gemini CLI, or OpenCode - **GitHub CLI** - Required for PR creation features ([install guide](https://cli.github.com/)) ### Installation