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 a1b81ec7..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 @@ -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 @@ -574,21 +586,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/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 04a46141..a6a8b359 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 @@ -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 @@ -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 @@ -468,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 821976a3..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,45 +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, settings) { - return normalizeProviderName( - options.provider || process.env.ZEROSHOT_PROVIDER || settings.defaultProvider - ); -} - function runClusterPreflight({ input, options, providerOverride, settings, forceProvider }) { // Detect which issue provider tool is needed let issueProvider = null; @@ -301,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', @@ -313,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, }; } @@ -342,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; @@ -436,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) { @@ -455,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, @@ -2325,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 @@ -2381,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') @@ -2440,11 +2348,35 @@ 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 }); + // 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)) { @@ -2470,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); @@ -3006,7 +2967,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); @@ -3336,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'); @@ -4418,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; @@ -4431,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))); @@ -5271,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/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..d7a7da3f 100644 --- a/cluster-templates/base-templates/full-workflow.json +++ b/cluster-templates/base-templates/full-workflow.json @@ -108,13 +108,21 @@ "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": [ { "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", @@ -230,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" } @@ -255,12 +277,92 @@ }, "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", + "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" + } + ], + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "CLUSTER_OPERATIONS" + }, + "transform": { + "engine": "javascript", + "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", + "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,25 +418,37 @@ } } }, - "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." + "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": [ { "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 +483,7 @@ "modelLevel": "{{validator_level}}", "timeout": "{{timeout}}", "maxRetries": 3, - "condition": "{{validator_count}} >= 2", + "condition": "{{validator_count}} >= 2 && {{validator_count}} < 4", "outputFormat": "json", "jsonSchema": { "type": "object", @@ -390,22 +504,34 @@ "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": [ { "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 +565,7 @@ "modelLevel": "{{validator_level}}", "timeout": "{{timeout}}", "maxRetries": 3, - "condition": "{{validator_count}} >= 3", + "condition": "{{validator_count}} == 3", "outputFormat": "json", "jsonSchema": { "type": "object", @@ -460,22 +586,34 @@ "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": [ { "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 +647,7 @@ "modelLevel": "{{validator_level}}", "timeout": "{{timeout}}", "maxRetries": 3, - "condition": "{{validator_count}} >= 4", + "condition": "{{validator_count}} == 3", "outputFormat": "json", "jsonSchema": { "type": "object", @@ -533,22 +671,34 @@ "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": [ { "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..50e76024 --- /dev/null +++ b/cluster-templates/base-templates/heavy-validation.json @@ -0,0 +1,272 @@ +{ + "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 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": [ + { + "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" + }, + "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 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": [ + { + "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}}", + "skippedTests": "{{result.skippedTests}}", + "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 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" + } + ], + "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..8025f5c8 --- /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\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": [ + { + "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\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": [ + { + "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 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" + } + ], + "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..16f23bd7 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", @@ -166,17 +178,27 @@ "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": [ { "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..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": { @@ -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 }, @@ -59,7 +61,7 @@ { "id": "senior-conductor", "role": "conductor", - "modelLevel": "level2", + "modelLevel": "level3", "useDirectApi": true, "outputFormat": "json", "jsonSchema": { @@ -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/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/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/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/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/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 00000000..6a4d4539 Binary files /dev/null and b/libexec/zeroshot-tui differ diff --git a/package-lock.json b/package-lock.json index 86f4f173..dd42d363 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,18 +10,20 @@ "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", "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,8 +34,11 @@ "@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", + "concurrently": "^9.1.0", "depcheck": "^1.4.7", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", @@ -94,6 +99,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 +1844,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", @@ -1870,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", @@ -2072,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", @@ -2271,6 +2310,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", @@ -2298,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", @@ -2671,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": { @@ -2991,6 +3047,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 +3321,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", @@ -3316,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", @@ -3430,6 +3555,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 +3640,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", @@ -3667,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", @@ -3720,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", @@ -4182,7 +4297,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 +4344,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", @@ -4397,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", @@ -4417,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", @@ -5210,7 +5308,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" @@ -5379,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", @@ -5881,18 +5952,319 @@ "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/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/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": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/into-stream": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", @@ -6014,6 +6386,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 +7346,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" }, @@ -7392,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": { @@ -7484,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", @@ -7527,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", @@ -7784,9 +8148,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 +8230,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 +8239,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 +8256,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 +8289,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 +8373,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.1.9", + "version": "9.1.10", "dev": true, "inBundle": true, "license": "ISC", @@ -8027,7 +8391,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 +8420,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.4.5", + "version": "10.5.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8251,7 +8615,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 +8633,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 +8686,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 +8737,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 +8765,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 +8858,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 +8896,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 +9091,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.0.1", + "version": "10.1.0", "dev": true, "inBundle": true, "license": "MIT", @@ -8866,12 +9194,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 +9213,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 +9236,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 +9261,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 +9336,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 +9750,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 +9766,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 +9909,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 +10056,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 +10147,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 +10211,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 +10718,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 +11431,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 +12001,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", @@ -12462,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", @@ -12830,6 +13210,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", @@ -13277,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", @@ -13489,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", @@ -14265,6 +14650,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 +15020,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..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,18 +100,20 @@ "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", "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,8 +121,11 @@ "@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", + "concurrently": "^9.1.0", "depcheck": "^1.4.7", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", @@ -135,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 edef2765..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,13 +428,22 @@ class AgentWrapper { lastAgentStartTime: previousAgentStart, triggeringMessage, selectedPrompt: this._selectPrompt(), + queuedGuidance: queuedGuidance.guidanceBlock, // Pass isolation state for conditional git restriction worktree: this.worktree, 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; + + if (queuedGuidance.latestTimestamp !== null) { + this.lastGuidanceAppliedAt = queuedGuidance.latestTimestamp; + } return context; } @@ -538,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 2be362f7..0be8b60a 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 @@ -169,14 +176,16 @@ 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') { 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 +210,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 +336,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 +346,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 +361,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 @@ -413,30 +389,84 @@ function buildContext({ lastAgentStartTime, triggeringMessage, selectedPrompt, + queuedGuidance, worktree, isolation, }) { 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 queuedGuidanceSection = queuedGuidance || ''; + 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('queuedGuidance', 'queuedGuidance', queuedGuidanceSection); + 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-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 dcbbd128..727c976d 100644 --- a/src/agent/agent-lifecycle.js +++ b/src/agent/agent-lifecycle.js @@ -14,11 +14,148 @@ 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, STUCK_THRESHOLD, } = require('./agent-stuck-detector'); +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') { + 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) @@ -103,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, @@ -119,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; } @@ -194,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 */ @@ -250,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` @@ -350,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 } ); } } @@ -401,6 +554,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'; @@ -424,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` ); @@ -488,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, @@ -507,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, @@ -541,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}`); @@ -590,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 */ @@ -602,10 +815,13 @@ 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 - const 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; for (let attempt = 1; attempt <= maxRetries; attempt++) { // Check if agent was stopped between retries @@ -617,6 +833,21 @@ async function executeTask(agent, triggeringMessage) { await runTaskAttempt(agent, triggeringMessage); return; } catch (error) { + 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 92ad8412..5bc4cd77 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(); @@ -203,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 @@ -218,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, @@ -904,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; @@ -976,7 +1147,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({ @@ -1072,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(); } /** @@ -1419,7 +1624,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 +1766,23 @@ 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, + 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); @@ -1584,6 +1813,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/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 82312b38..416bf404 100644 --- a/src/agent/output-extraction.js +++ b/src/agent/output-extraction.js @@ -105,18 +105,40 @@ 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 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; } /** @@ -145,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 */ @@ -161,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 { @@ -170,6 +235,91 @@ 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). + * + * @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 +333,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,14 +349,20 @@ function extractJsonFromOutput(output, providerName = 'claude') { const fromDirect = extractDirectJson(trimmedOutput); if (fromDirect) return fromDirect; + if (hasFatalStandaloneOutput(trimmedOutput)) { + return null; + } + return null; } module.exports = { extractJsonFromOutput, + extractCliError, extractFromResultWrapper, extractFromTextEvents, extractFromMarkdown, extractDirectJson, stripTimestamp, + hasFatalStandaloneOutput, }; 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/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..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; +} /** - * Platform-specific CLI commands and terminology + * 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 */ -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 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 }; +} + +/** + * 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 + */ +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,11 +437,34 @@ function generateGitPusherAgent(platform) { action: 'execute_task', }, ], - prompt: generatePrompt(config), + prompt: generatePrompt(platformConfig), + hooks: { + onComplete: { + 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'], + }, }; } @@ -263,7 +473,7 @@ function generateGitPusherAgent(platform) { * @returns {string[]} Array of platform IDs */ function getSupportedPlatforms() { - return Object.keys(PLATFORM_CONFIGS); + return SUPPORTED_PLATFORMS; } /** @@ -272,7 +482,7 @@ function getSupportedPlatforms() { * @returns {boolean} */ function isPlatformSupported(platform) { - return platform in PLATFORM_CONFIGS; + return SUPPORTED_PLATFORMS.includes(platform); } module.exports = { @@ -281,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-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..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 @@ -330,9 +331,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 +385,18 @@ 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', + ...GUIDANCE_TOPICS, + ]; + 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 +405,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 +521,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); @@ -981,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"` ); } } @@ -1203,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 ); } @@ -1459,9 +1474,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 +1490,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 +1503,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/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/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..8f55c370 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, @@ -102,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); @@ -118,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] }; + } + + // 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 }; } - // Otherwise assume it's already a number - return issueRef; + // Fallback: assume it's a number, no repo + return { repo: null, number: identifier }; } /** 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/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-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/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 e56a26fe..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,8 @@ 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'); function applyModelOverride(agentConfig, modelOverride) { @@ -131,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 @@ -270,29 +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); + 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) { @@ -346,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) { @@ -367,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) { @@ -391,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); } @@ -399,11 +447,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, '{}'); @@ -411,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 @@ -423,6 +541,9 @@ class Orchestrator { } const clustersFile = this._ensureClustersFile(); + if (!clustersFile) { + return; + } const lockfilePath = path.join(this.storageDir, 'clusters.json.lock'); let release; @@ -483,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 @@ -514,6 +639,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) { @@ -632,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 }); } @@ -695,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) @@ -726,6 +871,14 @@ 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) @@ -747,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}`); @@ -879,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; } @@ -980,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) => { @@ -1194,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, @@ -1210,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}`); @@ -1271,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'}`; @@ -1359,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 @@ -1369,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 @@ -1387,6 +1746,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,12 +1760,26 @@ 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 + // 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'; @@ -1423,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 @@ -1430,6 +1809,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 +1822,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 +1924,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); @@ -1544,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; @@ -1607,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, @@ -2028,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); @@ -2046,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); @@ -2135,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) @@ -2346,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 @@ -2374,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' }); @@ -2390,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', }, @@ -2494,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; + } + })(), }; } @@ -2521,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 b79461cb..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}`, }; } } @@ -473,6 +528,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/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 3283dea4..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: 'low' }, - level2: { rank: 2, model: null, reasoningEffort: 'medium' }, - level3: { rank: 3, model: null, reasoningEffort: 'high' }, + 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/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..11ecdd1a --- /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: 2500, // Slightly increased for actionable plans with embedded patterns (was 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..ced36853 100644 --- a/src/sub-cluster-wrapper.js +++ b/src/sub-cluster-wrapper.js @@ -15,6 +15,39 @@ 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') { + 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 = {}) { @@ -135,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) { @@ -147,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; } @@ -308,8 +354,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 +368,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)); @@ -430,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; }); } }); @@ -439,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; }); } }); @@ -456,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/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/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/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/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/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/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/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/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 80970198..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'))); }); }); @@ -2298,6 +2299,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 +2622,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 +2654,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/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/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/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/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/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/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/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/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/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 new file mode 100644 index 00000000..b37c8810 --- /dev/null +++ b/tests/two-stage-validation.test.js @@ -0,0 +1,356 @@ +/** + * Tests for two-stage validation pipeline (quick → heavy) + */ + +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; + + 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'); + }); + + 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 () { + 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'); + + // 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 () { + 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/.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 00000000..d53f7626 Binary files /dev/null and b/tests/unit/.test-storage/emerald-prism-4.db differ 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 00000000..3641d16a Binary files /dev/null and b/tests/unit/.test-storage/emerald-prism-4.db-shm differ 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 00000000..8c353136 Binary files /dev/null and b/tests/unit/.test-storage/emerald-prism-4.db-wal differ 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/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-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-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/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/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/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/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/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/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); + }); +}); 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")); +}