diff --git a/AGENTS.md b/AGENTS.md index bc38f0c4..408be88e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ bd init --quiet # Non-interactive, auto-installs git hooks, no prompts ``` **Why `--quiet`?** Regular `bd init` has interactive prompts (git hooks, merge driver) that confuse agents. The `--quiet` flag makes it fully non-interactive: + - Automatically installs git hooks - Automatically configures git merge driver for intelligent JSONL merging - No prompts for user input @@ -31,6 +32,7 @@ We use bd (beads) for issue tracking instead of Markdown TODOs or external tools **RECOMMENDED**: Use the MCP (Model Context Protocol) server for the best experience! The beads MCP server provides native integration with Claude and other MCP-compatible AI assistants. **Installation:** + ```bash # Install the MCP server pip install beads-mcp @@ -45,6 +47,7 @@ pip install beads-mcp ``` **Benefits:** + - Native function calls instead of shell commands - Automatic workspace detection - Better error handling and validation @@ -52,6 +55,7 @@ pip install beads-mcp - No need for `--json` flags **All bd commands are available as MCP functions** with the prefix `mcp__beads-*__`. For example: + - `bd ready` → `mcp__beads__ready()` - `bd create` → `mcp__beads__create(title="...", priority=1)` - `bd update` → `mcp__beads__update(issue_id="bd-42", status="in_progress")` @@ -67,6 +71,7 @@ See `integrations/beads-mcp/README.md` for complete documentation. **For complete multi-repo workflow guide**, see [docs/MULTI_REPO_MIGRATION.md](docs/MULTI_REPO_MIGRATION.md) (OSS contributors, teams, multi-phase development). **Setup (one-time):** + ```bash # MCP config in ~/.config/amp/settings.json or Claude Desktop config: { @@ -79,12 +84,14 @@ See `integrations/beads-mcp/README.md` for complete documentation. **How it works (LSP model):** The single MCP server instance automatically: + 1. Checks for local daemon socket (`.beads/bd.sock`) in your current workspace 2. Routes requests to the correct **per-project daemon** based on working directory 3. Auto-starts the local daemon if not running (with exponential backoff) 4. **Each project gets its own isolated daemon** serving only its database **Architecture:** + ``` MCP Server (one instance) ↓ @@ -94,6 +101,7 @@ SQLite Databases (complete isolation) ``` **Why per-project daemons?** + - ✅ Complete database isolation between projects - ✅ No cross-project pollution or git worktree conflicts - ✅ Simpler mental model: one project = one database = one daemon @@ -102,6 +110,7 @@ SQLite Databases (complete isolation) **Note:** The daemon **auto-starts automatically** when you run any `bd` command (v0.9.11+). To disable auto-start, set `BEADS_AUTO_START_DAEMON=false`. **Version Management:** bd automatically handles daemon version mismatches (v0.16.0+): + - When you upgrade bd, old daemons are automatically detected and restarted - Version compatibility is checked on every connection - No manual intervention required after upgrades @@ -111,6 +120,7 @@ SQLite Databases (complete isolation) **Alternative (not recommended): Multiple MCP Server Instances** If you must use separate MCP servers: + ```json { "beads-webapp": { @@ -127,6 +137,7 @@ If you must use separate MCP servers: } } ``` + ⚠️ **Problem**: AI may select the wrong MCP server for your workspace, causing commands to operate on the wrong database. ### CLI Quick Reference @@ -286,6 +297,7 @@ bd info --schema --json # Get schema, tables, con ``` **Migration safety:** The system verifies data integrity invariants after migrations: + - **required_config_present**: Ensures issue_prefix and schema_version are set - **foreign_keys_valid**: No orphaned dependencies or labels - **issue_count_stable**: Issue count doesn't decrease unexpectedly @@ -307,6 +319,10 @@ bd daemons health --json bd daemons stop /path/to/workspace --json bd daemons stop 12345 --json # By PID +# Restart a specific daemon +bd daemons restart /path/to/workspace --json +bd daemons restart 12345 --json # By PID + # View daemon logs bd daemons logs /path/to/workspace -n 100 bd daemons logs 12345 -f # Follow mode @@ -317,28 +333,65 @@ bd daemons killall --force --json # Force kill if graceful fails ``` **When to use:** + - **After upgrading bd**: Run `bd daemons health` to check for version mismatches, then `bd daemons killall` to restart all daemons with the new version - **Debugging**: Use `bd daemons logs ` to view daemon logs - **Cleanup**: `bd daemons list` auto-removes stale sockets **Troubleshooting:** + - **Stale sockets**: `bd daemons list` auto-cleans them - **Version mismatch**: `bd daemons killall` then let daemons auto-start on next command - **Daemon won't stop**: `bd daemons killall --force` See [commands/daemons.md](commands/daemons.md) for detailed documentation. +### Web Interface (Monitor) + +**Note for AI Agents:** The monitor is primarily for human visualization and supervision. Agents should continue using the CLI with `--json` flags. + +bd includes a built-in web interface for real-time issue monitoring: + +```bash +bd monitor # Start on localhost:8080 +bd monitor --port 3000 # Custom port +bd monitor --host 0.0.0.0 --port 80 # Public access +``` + +**Features:** + +- Real-time issue table with filtering (status, priority) +- Click-through to detailed issue view +- WebSocket updates (when daemon is running) +- Responsive mobile design +- Statistics dashboard + +**When humans might use it:** + +- Supervising AI agent work in real-time +- Quick project status overview +- Mobile access to issue tracking +- Team dashboard for shared visibility + +**AI agents should NOT:** + +- Parse HTML from the monitor (use `--json` flags instead) +- Try to interact with the web UI programmatically +- Use monitor for data retrieval (use CLI commands) + ### Event-Driven Daemon Mode (Experimental) **NEW in v0.16+**: The daemon supports an experimental event-driven mode that replaces 5-second polling with instant reactivity. **Benefits:** + - ⚡ **<500ms latency** (vs ~5000ms with polling) - 🔋 **~60% less CPU usage** (no continuous polling) - 🎯 **Instant sync** on mutations and file changes - 🛡️ **Dropped events safety net** prevents data loss **How it works:** + - **FileWatcher** monitors `.beads/issues.jsonl` and `.git/refs/heads` using platform-native APIs: - Linux: `inotify` - macOS: `FSEvents` (via kqueue) @@ -364,12 +417,14 @@ bd daemons killall ``` **Available modes:** + - `poll` (default) - Traditional 5-second polling, stable and battle-tested - `events` - New event-driven mode, experimental but thoroughly tested **Troubleshooting:** If the watcher fails to start: + - Check daemon logs: `bd daemons logs /path/to/workspace -n 100` - Look for "File watcher unavailable" warnings - Common causes: @@ -378,16 +433,19 @@ If the watcher fails to start: - Resource limits - check `ulimit -n` (open file descriptors) **Fallback behavior:** + - If `BEADS_DAEMON_MODE=events` but watcher fails, daemon falls back to polling automatically - Set `BEADS_WATCHER_FALLBACK=false` to disable fallback and require fsnotify **Disable polling fallback:** + ```bash # Require fsnotify, fail if unavailable BEADS_WATCHER_FALLBACK=false BEADS_DAEMON_MODE=events bd daemon start ``` **Switch back to polling:** + ```bash # Explicitly use polling mode BEADS_DAEMON_MODE=poll bd daemon start @@ -462,12 +520,14 @@ bd import -i issues.jsonl --dedupe-after **Detection strategies:** 1. **Before creating new issues**: Search for similar existing issues + ```bash bd list --json | grep -i "authentication" bd show bd-41 bd-42 --json # Compare candidates ``` 2. **Periodic duplicate scans**: Review issues by type or priority + ```bash bd list --status open --priority 1 --json # High-priority issues bd list --issue-type bug --json # All bugs @@ -498,18 +558,21 @@ bd show bd-41 --json # Verify merged content ``` **What gets merged:** + - ✅ All dependencies from source → target - ✅ Text references updated across ALL issues (descriptions, notes, design, acceptance criteria) - ✅ Source issues closed with "Merged into bd-X" reason - ❌ Source issue content NOT copied (target keeps its original content) **Important notes:** + - Merge preserves target issue completely; only dependencies/references migrate - If source issues have valuable content, manually copy it to target BEFORE merging - Cannot merge in daemon mode yet (bd-190); use `--no-daemon` flag - Operation cannot be undone (but git history preserves the original) **Best practices:** + - Merge early to prevent dependency fragmentation - Choose the oldest or most complete issue as merge target - Add labels like `duplicate` to source issues before merging (for tracking) @@ -550,6 +613,7 @@ beads/ ### Git Workflow **Auto-sync provides batching!** bd automatically: + - **Exports** to JSONL after CRUD operations (30-second debounce for batching) - **Imports** from JSONL when it's newer than DB (e.g., after `git pull`) - **Daemon commits/pushes** every 5 seconds (if `--auto-commit` / `--auto-push` enabled) @@ -569,6 +633,7 @@ bd config set sync.branch beads-metadata ``` **How it works:** + - Beads commits issue updates to `beads-metadata` instead of `main` - Uses git worktrees (lightweight checkouts) in `.git/beads-worktrees/` - Your main working directory is never affected @@ -600,6 +665,7 @@ bd sync --merge ``` **Benefits:** + - ✅ Works with protected `main` branches - ✅ No disruption to agent workflows - ✅ Platform-agnostic (works on any git platform) @@ -626,6 +692,7 @@ bd sync --merge - Format: "Continue work on bd-X: [issue title]. [Brief context about what's been done and what's next]" **Example "land the plane" session:** + ```bash # 1. File remaining work bd create "Add integration tests for sync" -t task -p 2 --json @@ -656,6 +723,7 @@ bd show bd-44 --json ``` **Then provide the user with:** + - Summary of what was completed this session - What issues were filed for follow-up - Status of quality gates (all passing / issues filed) @@ -670,6 +738,7 @@ bd sync ``` This immediately: + 1. Exports pending changes to JSONL (no 30s wait) 2. Commits to git 3. Pulls from remote @@ -677,6 +746,7 @@ This immediately: 5. Pushes to remote **Example agent session:** + ```bash # Make multiple changes (batched in 30-second window) bd create "Fix bug" -p 1 @@ -691,6 +761,7 @@ bd sync ``` **Why this matters:** + - Without `bd sync`, changes sit in 30-second debounce window - User might think you pushed but JSONL is still dirty - `bd sync` forces immediate flush/commit/push @@ -703,6 +774,7 @@ bd sync ``` This installs: + - **pre-commit** - Flushes pending changes immediately before commit (bypasses 30s debounce) - **post-merge** - Imports updated JSONL after pull/merge (guaranteed sync) - **pre-push** - Exports database to JSONL before push (prevents stale JSONL from reaching remote) @@ -720,6 +792,7 @@ See [examples/git-hooks/README.md](examples/git-hooks/README.md) for details. Git worktrees share the same `.git` directory and thus share the same `.beads` database. The daemon doesn't know which branch each worktree has checked out, which can cause it to commit/push to the wrong branch. **What you lose without daemon mode:** + - **Auto-sync** - No automatic commit/push of changes (use `bd sync` manually) - **MCP server** - The beads-mcp server requires daemon mode for multi-repo support - **Background watching** - No automatic detection of remote changes @@ -727,6 +800,7 @@ Git worktrees share the same `.git` directory and thus share the same `.beads` d **Solutions for Worktree Users:** 1. **Use `--no-daemon` flag** (recommended): + ```bash bd --no-daemon ready bd --no-daemon create "Fix bug" -p 1 @@ -734,6 +808,7 @@ Git worktrees share the same `.git` directory and thus share the same `.beads` d ``` 2. **Disable daemon via environment variable** (for entire worktree session): + ```bash export BEADS_NO_DAEMON=1 bd ready # All commands use direct mode @@ -759,10 +834,12 @@ Git conflicts in `.beads/beads.jsonl` happen when the same issue is modified on **Automatic detection:** bd automatically detects conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) and shows clear resolution steps: + - `bd import` rejects files with conflict markers and shows resolution commands - `bd validate --checks=conflicts` scans for conflicts in JSONL **Resolution workflow:** + ```bash # After git merge creates conflict in .beads/beads.jsonl @@ -771,7 +848,7 @@ git checkout --theirs .beads/beads.jsonl bd import -i .beads/beads.jsonl # Option 2: Keep our version (local) -git checkout --ours .beads/beads.jsonl +git checkout --ours .beads/beads.jsonl bd import -i .beads/beads.jsonl # Option 3: Manual resolution in editor @@ -790,6 +867,7 @@ git commit **As of v0.21+, bd automatically configures its own merge driver during `bd init`.** This uses the beads-merge algorithm (by @neongreen, vendored into bd) to provide intelligent JSONL merging and prevent conflicts when multiple branches modify issues. **What it does:** + - Performs field-level 3-way merging (not line-by-line) - Matches issues by identity (id + created_at + created_by) - Smart field merging: timestamps→max, dependencies→union, status/priority→3-way @@ -797,6 +875,7 @@ git commit - Automatically configured during `bd init` (both interactive and `--quiet` modes) **Auto-configuration (happens automatically):** + ```bash # During bd init, these are configured: git config merge.beads.driver "bd merge %A %O %L %R" @@ -805,6 +884,7 @@ git config merge.beads.name "bd JSONL merge driver" ``` **Manual setup (if skipped with `--skip-merge-driver`):** + ```bash git config merge.beads.driver "bd merge %A %O %L %R" git config merge.beads.name "bd JSONL merge driver" @@ -854,6 +934,7 @@ Run `bd stats` to see overall progress. ### 1.0 Milestone We're working toward 1.0. Key blockers tracked in bd. Run: + ```bash bd dep tree bd-8 # Show 1.0 epic dependencies ``` @@ -863,6 +944,7 @@ bd dep tree bd-8 # Show 1.0 epic dependencies **For external tools that need full database control** (e.g., CI/CD, deterministic execution systems): The bd daemon respects exclusive locks via `.beads/.exclusive-lock` file. When this lock exists: + - Daemon skips all operations for the locked database - External tool has complete control over git sync and database operations - Stale locks (dead process) are automatically cleaned up @@ -870,12 +952,14 @@ The bd daemon respects exclusive locks via `.beads/.exclusive-lock` file. When t **Use case:** Tools like VibeCoder that need deterministic execution without daemon interference. See [EXCLUSIVE_LOCK.md](EXCLUSIVE_LOCK.md) for: + - Lock file format (JSON schema) - Creating and releasing locks (Go/shell examples) - Stale lock detection behavior - Integration testing guidance **Quick example:** + ```bash # Create lock echo '{"holder":"my-tool","pid":'$$',"hostname":"'$(hostname)'","started_at":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","version":"1.0.0"}' > .beads/.exclusive-lock @@ -949,6 +1033,7 @@ rm .beads/.exclusive-lock **IMPORTANT**: When asked to check GitHub issues or PRs, use command-line tools like `gh` instead of browser/playwright tools. **Preferred approach:** + ```bash # List open issues with details gh issue list --limit 30 @@ -961,12 +1046,14 @@ gh issue view 201 ``` **Then provide an in-conversation summary** highlighting: + - Urgent/critical issues (regressions, bugs, broken builds) - Common themes or patterns - Feature requests with high engagement - Items that need immediate attention **Why this matters:** + - Browser tools consume more tokens and are slower - CLI summaries are easier to scan and discuss - Keeps the conversation focused and efficient @@ -1007,6 +1094,7 @@ git push origin main ``` **What it does:** + - Updates ALL version files (CLI, plugin, MCP server, docs) in one command - Validates semantic versioning format - Shows diff preview @@ -1014,17 +1102,20 @@ git push origin main - Creates standardized commit message **User will typically say:** + - "Bump to 0.9.3" - "Update version to 1.0.0" - "Rev the project to 0.9.4" - "Increment the version" **You should:** + 1. Run `./scripts/bump-version.sh --commit` 2. Push to GitHub 3. Confirm all versions updated correctly **Files updated automatically:** + - `cmd/bd/version.go` - CLI version - `.claude-plugin/plugin.json` - Plugin version - `.claude-plugin/marketplace.json` - Marketplace version @@ -1053,6 +1144,7 @@ See `scripts/README.md` for more details. Happy coding! 🔗 + ## Issue Tracking with bd (beads) **IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. @@ -1067,38 +1159,45 @@ Happy coding! 🔗 ### Quick Start **FIRST TIME?** Just run `bd init` - it auto-imports issues from git: + ```bash bd init --prefix bd ``` **OSS Contributor?** Use the contributor wizard for fork workflows: + ```bash bd init --contributor # Interactive setup for separate planning repo ``` **Team Member?** Use the team wizard for branch workflows: + ```bash bd init --team # Interactive setup for team collaboration ``` **Check for ready work:** + ```bash bd ready --json ``` **Create new issues:** + ```bash bd create "Issue title" -t bug|feature|task -p 0-4 --json bd create "Issue title" -p 1 --deps discovered-from:bd-123 --json ``` **Claim and update:** + ```bash bd update bd-42 --status in_progress --json bd update bd-42 --priority 1 --json ``` **Complete work:** + ```bash bd close bd-42 --reason "Completed" --json ``` @@ -1131,6 +1230,7 @@ bd close bd-42 --reason "Completed" --json ### Auto-Sync bd automatically syncs with git: + - Exports to `.beads/issues.jsonl` after changes (5s debounce) - Imports from JSONL when newer (e.g., after `git pull`) - No manual export/import needed! @@ -1144,6 +1244,7 @@ pip install beads-mcp ``` Add to MCP config (e.g., `~/.config/claude/config.json`): + ```json { "beads": { @@ -1158,6 +1259,7 @@ Then use `mcp__beads__*` functions instead of CLI commands. ### Managing AI-Generated Planning Documents AI assistants often create planning and design documents during development: + - PLAN.md, IMPLEMENTATION.md, ARCHITECTURE.md - DESIGN.md, CODEBASE_SUMMARY.md, INTEGRATION_PLAN.md - TESTING_GUIDE.md, TECHNICAL_DESIGN.md, and similar files @@ -1165,18 +1267,21 @@ AI assistants often create planning and design documents during development: **Best Practice: Use a dedicated directory for these ephemeral files** **Recommended approach:** + - Create a `history/` directory in the project root - Store ALL AI-generated planning/design docs in `history/` - Keep the repository root clean and focused on permanent project files - Only access `history/` when explicitly asked to review past planning **Example .gitignore entry (optional):** + ``` # AI planning documents (ephemeral) history/ ``` **Benefits:** + - ✅ Clean repository root - ✅ Clear separation between ephemeral and permanent documentation - ✅ Easy to exclude from version control if desired @@ -1196,4 +1301,5 @@ history/ - ❌ Do NOT clutter repo root with planning documents For more details, see README.md and QUICKSTART.md. + diff --git a/README.md b/README.md index 93a022b0..3a1d4ff5 100644 --- a/README.md +++ b/README.md @@ -634,6 +634,10 @@ bd daemons health bd daemons stop /path/to/workspace bd daemons stop 12345 # By PID +# Restart a specific daemon +bd daemons restart /path/to/workspace +bd daemons restart 12345 # By PID + # View daemon logs bd daemons logs /path/to/workspace -n 100 bd daemons logs 12345 -f # Follow mode @@ -650,6 +654,39 @@ bd daemons killall --force # Force kill if graceful fails See [commands/daemons.md](commands/daemons.md) for complete documentation. +### Web Interface + +A standalone web interface for real-time issue monitoring is available as an example: + +```bash +# Build the monitor-webui +cd examples/monitor-webui +go build + +# Start web UI on localhost:8080 +./monitor-webui + +# Custom port and host +./monitor-webui -port 3000 +./monitor-webui -host 0.0.0.0 -port 8080 # Listen on all interfaces +``` + +The monitor provides: +- **Real-time table view** of all issues with filtering by status and priority +- **Click-through details** - Click any issue to view full details in a modal +- **Live updates** - WebSocket connection for real-time changes via daemon RPC +- **Responsive design** - Mobile-friendly card view on small screens +- **Statistics dashboard** - Quick overview of issue counts and ready work +- **Clean UI** - Simple, fast interface styled with milligram.css + +The monitor is particularly useful for: +- **Team visibility** - Share a dashboard view of project status +- **AI agent supervision** - Watch your coding agent create and update issues in real-time +- **Quick browsing** - Faster than CLI for exploring issue details +- **Mobile access** - Check project status from your phone + +See [examples/monitor-webui/](examples/monitor-webui/) for complete documentation. + ## Examples Check out the **[examples/](examples/)** directory for: diff --git a/commands/daemons.md b/commands/daemons.md index e0ddad1a..a8a1e04c 100644 --- a/commands/daemons.md +++ b/commands/daemons.md @@ -77,6 +77,30 @@ bd daemons stop 12345 bd daemons stop /Users/me/projects/myapp --json ``` +### restart + +Restart a specific daemon gracefully. + +```bash +bd daemons restart [--search DIRS] [--json] +``` + +Stops the daemon gracefully, then starts a new one in its place. Useful after upgrading bd or when a daemon needs to be refreshed. + +**Arguments:** +- `` - Workspace path or PID of daemon to restart + +**Flags:** +- `--search` - Directories to search for daemons +- `--json` - Output in JSON format + +**Example:** +```bash +bd daemons restart /Users/me/projects/myapp +bd daemons restart 12345 +bd daemons restart /Users/me/projects/myapp --json +``` + ### logs View logs for a specific daemon. @@ -136,6 +160,9 @@ After upgrading bd, restart all daemons to use the new version: bd daemons health # Check for version mismatches bd daemons killall # Stop all old daemons # Daemons will auto-start with new version on next bd command + +# Or restart a specific daemon +bd daemons restart /path/to/workspace ``` ### Debugging diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index df8a6996..317d7c2c 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -1,10 +1,119 @@ +# CLAUDE.md - -**Note**: This project uses [bd (beads)](https://github.com/steveyegge/beads) for issue tracking. Use `bd` commands or the beads MCP server instead of markdown TODOs. See AGENTS.md for workflow details. - +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -# Instructions for Claude +## Project Overview -This file has been moved to **AGENTS.md** to support all AI agents, not just Claude. +**beads** (command: `bd`) is a git-backed issue tracker for AI-supervised coding workflows. We dogfood our own tool. -Please refer to [AGENTS.md](AGENTS.md) for complete instructions on working with the beads project. +**IMPORTANT**: See [AGENTS.md](AGENTS.md) for complete workflow instructions, bd commands, and development guidelines. + +## Architecture Overview + +### Three-Layer Design + +1. **Storage Layer** (`internal/storage/`) + - Interface-based design in `storage.go` + - SQLite implementation in `storage/sqlite/` + - Memory backend in `storage/memory/` for testing + - Extensions can add custom tables via `UnderlyingDB()` (see EXTENDING.md) + +2. **RPC Layer** (`internal/rpc/`) + - Client/server architecture using Unix domain sockets (Windows named pipes) + - Protocol defined in `protocol.go` + - Server split into focused files: `server_core.go`, `server_issues_epics.go`, `server_labels_deps_comments.go`, etc. + - Per-workspace daemons communicate via `.beads/bd.sock` + +3. **CLI Layer** (`cmd/bd/`) + - Cobra-based commands (one file per command: `create.go`, `list.go`, etc.) + - Commands try daemon RPC first, fall back to direct database access + - All commands support `--json` for programmatic use + - Main entry point in `main.go` + +### Distributed Database Pattern + +The "magic" is in the auto-sync between SQLite and JSONL: + +``` +SQLite DB (.beads/beads.db, gitignored) + ↕ auto-sync (5s debounce) +JSONL (.beads/issues.jsonl, git-tracked) + ↕ git push/pull +Remote JSONL (shared across machines) +``` + +- **Write path**: CLI → SQLite → JSONL export → git commit +- **Read path**: git pull → JSONL import → SQLite → CLI +- **Collision handling**: `bd import --resolve-collisions` (see AGENTS.md) + +Core implementation: +- Export: `cmd/bd/export.go`, `cmd/bd/autoflush.go` +- Import: `cmd/bd/import.go`, `cmd/bd/autoimport.go` +- Collision detection: `internal/importer/importer.go` + +### Key Data Types + +See `internal/types/types.go`: +- `Issue`: Core work item (title, description, status, priority, etc.) +- `Dependency`: Four types (blocks, related, parent-child, discovered-from) +- `Label`: Flexible tagging system +- `Comment`: Threaded discussions +- `Event`: Full audit trail + +### Daemon Architecture + +Each workspace gets its own daemon process: +- Auto-starts on first command (unless disabled) +- Handles auto-sync, batching, and background operations +- Socket at `.beads/bd.sock` (or `.beads/bd.pipe` on Windows) +- Version checking prevents mismatches after upgrades +- Manage with `bd daemons` command (see AGENTS.md) + +## Common Development Commands + +```bash +# Build and test +go build -o bd ./cmd/bd +go test ./... +go test -coverprofile=coverage.out ./... + +# Run linter (baseline warnings documented in docs/LINTING.md) +golangci-lint run ./... + +# Version management +./scripts/bump-version.sh 0.9.3 --commit + +# Local testing +./bd init --prefix test +./bd create "Test issue" -p 1 +./bd ready +``` + +## Testing Philosophy + +- Unit tests live next to implementation (`*_test.go`) +- Integration tests use real SQLite databases (`:memory:` or temp files) +- Script-based tests in `cmd/bd/testdata/*.txt` (see `scripttest_test.go`) +- RPC layer has extensive isolation and edge case coverage + +## Important Notes + +- **Always read AGENTS.md first** - it has the complete workflow +- Use `bd --no-daemon` in git worktrees (see AGENTS.md for why) +- Install git hooks for zero-lag sync: `./examples/git-hooks/install.sh` +- Run `bd sync` at end of agent sessions to force immediate flush/commit/push +- Check for duplicates proactively: `bd duplicates --auto-merge` +- Use `--json` flags for all programmatic use + +## Key Files + +- **AGENTS.md** - Complete workflow and development guide (READ THIS!) +- **README.md** - User-facing documentation +- **ADVANCED.md** - Advanced features (rename, merge, compaction) +- **EXTENDING.md** - How to add custom tables to the database +- **LABELS.md** - Complete label system guide +- **CONFIG.md** - Configuration system + +## When Adding Features + +See AGENTS.md "Adding a New Command" and "Adding Storage Features" sections for step-by-step guidance. diff --git a/examples/README.md b/examples/README.md index 3f09642a..6e383d5c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,7 @@ This directory contains examples of how to integrate bd with AI agents and workf - **[python-agent/](python-agent/)** - Simple Python agent that discovers ready work and completes tasks - **[bash-agent/](bash-agent/)** - Bash script showing the full agent workflow +- **[monitor-webui/](monitor-webui/)** - Standalone web interface for real-time issue monitoring and visualization - **[markdown-to-jsonl/](markdown-to-jsonl/)** - Convert markdown planning docs to bd issues - **[github-import/](github-import/)** - Import issues from GitHub repositories - **[git-hooks/](git-hooks/)** - Pre-configured git hooks for automatic export/import diff --git a/examples/monitor-webui/README.md b/examples/monitor-webui/README.md new file mode 100644 index 00000000..bdceca76 --- /dev/null +++ b/examples/monitor-webui/README.md @@ -0,0 +1,272 @@ +# Monitor WebUI - Real-time Issue Tracking Dashboard + +A standalone web-based monitoring interface for beads that provides real-time issue tracking through a clean, responsive web UI. + +## Overview + +The Monitor WebUI is a separate runtime that connects to the beads daemon via RPC to provide: + +- **Real-time updates** via WebSocket connections +- **Responsive design** with desktop table view and mobile card view +- **Issue filtering** by status and priority +- **Statistics dashboard** showing issue counts by status +- **Detailed issue views** with full metadata +- **Clean, modern UI** styled with Milligram CSS + +## Architecture + +The Monitor WebUI demonstrates how to build custom interfaces on top of beads using: + +- **RPC Protocol**: Connects to the daemon's Unix socket for database operations +- **WebSocket Broadcasting**: Polls mutation events and broadcasts to connected clients +- **Embedded Web Assets**: HTML, CSS, and JavaScript served from the binary +- **Standalone Binary**: Runs independently from the `bd` CLI + +## Prerequisites + +Before running the monitor, you must have: + +1. A beads database initialized (run `bd init` in your project) +2. The beads daemon running (run `bd daemon`) + +## Building + +From this directory: + +```bash +go build +``` + +Or using bun (if available): + +```bash +bun run go build +``` + +This creates a `monitor-webui` binary in the current directory. + +## Usage + +### Basic Usage + +Start the monitor on default port 8080: + +```bash +./monitor-webui +``` + +Then open your browser to http://localhost:8080 + +### Custom Port + +Start on a different port: + +```bash +./monitor-webui -port 3000 +``` + +### Bind to All Interfaces + +To access from other machines on your network: + +```bash +./monitor-webui -host 0.0.0.0 -port 8080 +``` + +### Custom Database Path + +If your database is not in the current directory: + +```bash +./monitor-webui -db /path/to/your/beads.db +``` + +### Custom Socket Path + +If you need to specify a custom daemon socket: + +```bash +./monitor-webui -socket /path/to/beads.db.sock +``` + +## Command-Line Flags + +- `-port` - Port for web server (default: 8080) +- `-host` - Host to bind to (default: "localhost") +- `-db` - Path to beads database (optional, will auto-detect) +- `-socket` - Path to daemon socket (optional, will auto-detect) + +## API Endpoints + +The monitor exposes several HTTP endpoints: + +### Web UI +- `GET /` - Main HTML interface +- `GET /static/*` - Static assets (CSS, JavaScript) + +### REST API +- `GET /api/issues` - List all issues as JSON +- `GET /api/issues/:id` - Get specific issue details +- `GET /api/ready` - Get ready work (no blockers) +- `GET /api/stats` - Get issue statistics + +### WebSocket +- `WS /ws` - WebSocket endpoint for real-time updates + +## Features + +### Real-time Updates + +The monitor polls the daemon every 2 seconds for mutation events and broadcasts them to all connected WebSocket clients. This provides instant updates when issues are created, modified, or closed. + +### Responsive Design + +- **Desktop**: Full table view with sortable columns +- **Mobile**: Card-based view optimized for small screens +- **Tablet**: Adapts to medium screen sizes + +### Filtering + +- **Status Filter**: Multi-select for Open, In Progress, and Closed +- **Priority Filter**: Single-select for P1, P2, P3, or All + +### Statistics + +Real-time statistics showing: +- Total issues +- In-progress issues +- Open issues +- Closed issues + +## Development + +### Project Structure + +``` +monitor-webui/ +├── main.go # Main application with HTTP server and RPC client +├── go.mod # Go module dependencies +├── go.sum # (generated) Dependency checksums +├── README.md # This file +└── web/ # Web assets (embedded in binary) + ├── index.html # Main HTML page + └── static/ + ├── css/ + │ └── styles.css # Custom styles + └── js/ + └── app.js # JavaScript application logic +``` + +### Modifying the Web Assets + +The HTML, CSS, and JavaScript files are embedded into the binary using Go's `embed` package. After making changes to files in the `web/` directory, rebuild the binary to see your changes. + +### Extending the API + +To add new API endpoints: + +1. Define a new handler function in `main.go` +2. Register it with `http.HandleFunc()` in the `main()` function +3. Use `daemonClient` to make RPC calls to the daemon +4. Return JSON responses using `json.NewEncoder(w).Encode()` + +## Deployment + +### As a Standalone Service + +You can run the monitor as a systemd service. Example service file: + +```ini +[Unit] +Description=Beads Monitor WebUI +After=network.target + +[Service] +Type=simple +User=youruser +WorkingDirectory=/path/to/your/project +ExecStart=/path/to/monitor-webui -host 0.0.0.0 -port 8080 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Save as `/etc/systemd/system/beads-monitor.service` and enable: + +```bash +sudo systemctl enable beads-monitor +sudo systemctl start beads-monitor +``` + +### Behind a Reverse Proxy + +Example nginx configuration: + +```nginx +server { + listen 80; + server_name monitor.example.com; + + location / { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +## Troubleshooting + +### "No beads database found" + +Make sure you've initialized a beads database with `bd init` or specify the database path with `-db`. + +### "Daemon is not running" + +The monitor requires the daemon to avoid SQLite locking conflicts. Start the daemon first: + +```bash +bd daemon +``` + +### WebSocket disconnects frequently + +Check if there's a reverse proxy or firewall between the client and server that might be closing idle connections. Consider adjusting timeout settings. + +### Port already in use + +If port 8080 is already in use, specify a different port: + +```bash +./monitor-webui -port 3001 +``` + +## Security Considerations + +### Production Deployment + +When deploying to production: + +1. **Restrict Origins**: Update the `CheckOrigin` function in `main.go` to validate WebSocket origins +2. **Use HTTPS**: Deploy behind a reverse proxy with TLS (nginx, Caddy, etc.) +3. **Authentication**: Add authentication middleware if exposing publicly +4. **Firewall**: Use firewall rules to restrict access to trusted networks + +### Current Security Model + +The current implementation: +- Allows WebSocket connections from any origin +- Provides read-only access to issue data +- Does not include authentication +- Connects to local daemon socket only + +This is appropriate for local development but requires additional security measures for production use. + +## License + +Same as the main beads project. diff --git a/examples/monitor-webui/go.mod b/examples/monitor-webui/go.mod new file mode 100644 index 00000000..4989e748 --- /dev/null +++ b/examples/monitor-webui/go.mod @@ -0,0 +1,35 @@ +module github.com/steveyegge/beads/examples/monitor-webui + +go 1.24.0 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/steveyegge/beads v0.0.0 +) + +require ( + github.com/anthropics/anthropic-sdk-go v1.16.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/ncruces/go-sqlite3 v0.29.1 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect +) + +replace github.com/steveyegge/beads => ../.. diff --git a/examples/monitor-webui/go.sum b/examples/monitor-webui/go.sum new file mode 100644 index 00000000..01aad24f --- /dev/null +++ b/examples/monitor-webui/go.sum @@ -0,0 +1,69 @@ +github.com/anthropics/anthropic-sdk-go v1.16.0 h1:nRkOFDqYXsHteoIhjdJr/5dsiKbFF3rflSv8ax50y8o= +github.com/anthropics/anthropic-sdk-go v1.16.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM= +github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/monitor-webui/main.go b/examples/monitor-webui/main.go new file mode 100644 index 00000000..e7921c7c --- /dev/null +++ b/examples/monitor-webui/main.go @@ -0,0 +1,382 @@ +package main + +import ( + "embed" + "encoding/json" + "flag" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" +) + +//go:embed web +var webFiles embed.FS + +var ( + // Command-line flags + port = flag.Int("port", 8080, "Port for web server") + host = flag.String("host", "localhost", "Host to bind to") + dbPath = flag.String("db", "", "Path to beads database (optional, will auto-detect)") + socketPath = flag.String("socket", "", "Path to daemon socket (optional, will auto-detect)") + + // WebSocket upgrader + upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // Allow all origins for simplicity (consider restricting in production) + return true + }, + } + + // WebSocket client management + wsClients = make(map[*websocket.Conn]bool) + wsClientsMu sync.Mutex + wsBroadcast = make(chan []byte, 256) + + // RPC client for daemon communication + daemonClient *rpc.Client +) + +func main() { + flag.Parse() + + // Find database path if not specified + dbPathResolved := *dbPath + if dbPathResolved == "" { + if foundDB := beads.FindDatabasePath(); foundDB != "" { + dbPathResolved = foundDB + } else { + fmt.Fprintf(os.Stderr, "Error: no beads database found\n") + fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to create a database in the current directory\n") + fmt.Fprintf(os.Stderr, "Or specify database path with -db flag\n") + os.Exit(1) + } + } + + // Resolve socket path + socketPathResolved := *socketPath + if socketPathResolved == "" { + socketPathResolved = getSocketPath(dbPathResolved) + } + + // Connect to daemon + if err := connectToDaemon(socketPathResolved, dbPathResolved); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Start WebSocket broadcaster + go handleWebSocketBroadcast() + + // Start mutation polling + go pollMutations() + + // Set up HTTP routes + http.HandleFunc("/", handleIndex) + http.HandleFunc("/api/issues", handleAPIIssues) + http.HandleFunc("/api/issues/", handleAPIIssueDetail) + http.HandleFunc("/api/ready", handleAPIReady) + http.HandleFunc("/api/stats", handleAPIStats) + http.HandleFunc("/ws", handleWebSocket) + + // Serve static files + webFS, err := fs.Sub(webFiles, "web") + if err != nil { + fmt.Fprintf(os.Stderr, "Error accessing web files: %v\n", err) + os.Exit(1) + } + http.Handle("/static/", http.StripPrefix("/", http.FileServer(http.FS(webFS)))) + + addr := fmt.Sprintf("%s:%d", *host, *port) + fmt.Printf("🖥️ bd monitor-webui starting on http://%s\n", addr) + fmt.Printf("📊 Open your browser to view real-time issue tracking\n") + fmt.Printf("🔌 WebSocket endpoint available at ws://%s/ws\n", addr) + fmt.Printf("Press Ctrl+C to stop\n\n") + + if err := http.ListenAndServe(addr, nil); err != nil { + fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) + os.Exit(1) + } +} + +// getSocketPath returns the Unix socket path for the daemon +func getSocketPath(dbPath string) string { + // Use the database directory to determine socket path + dbDir := filepath.Dir(dbPath) + dbName := filepath.Base(dbPath) + socketName := dbName + ".sock" + return filepath.Join(dbDir, ".beads", socketName) +} + +// connectToDaemon establishes connection to the daemon +func connectToDaemon(socketPath, dbPath string) error { + client, err := rpc.TryConnect(socketPath) + if err != nil || client == nil { + return fmt.Errorf("bd monitor-webui requires the daemon to be running\n\n"+ + "The monitor uses the daemon's RPC interface to avoid database locking conflicts.\n"+ + "Please start the daemon first:\n\n"+ + " bd daemon\n\n"+ + "Then start the monitor:\n\n"+ + " %s\n", os.Args[0]) + } + + // Check daemon health + health, err := client.Health() + if err != nil || health.Status != "healthy" { + _ = client.Close() + if err != nil { + return fmt.Errorf("daemon health check failed: %v", err) + } + errMsg := fmt.Sprintf("daemon is not healthy (status: %s)", health.Status) + if health.Error != "" { + errMsg += fmt.Sprintf("\nError: %s", health.Error) + } + return fmt.Errorf("%s\n\nTry restarting the daemon:\n bd daemon --stop\n bd daemon", errMsg) + } + + // Set database path + absDBPath, _ := filepath.Abs(dbPath) + client.SetDatabasePath(absDBPath) + + daemonClient = client + + fmt.Printf("✓ Connected to daemon (version %s)\n", health.Version) + return nil +} + +// handleIndex serves the main HTML page +func handleIndex(w http.ResponseWriter, r *http.Request) { + // Only serve index for root path + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + webFS, err := fs.Sub(webFiles, "web") + if err != nil { + http.Error(w, "Error accessing web files", http.StatusInternalServerError) + return + } + + data, err := fs.ReadFile(webFS, "index.html") + if err != nil { + http.Error(w, "Error reading index.html", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +// handleAPIIssues returns all issues as JSON +func handleAPIIssues(w http.ResponseWriter, r *http.Request) { + var issues []*types.Issue + + if daemonClient == nil { + http.Error(w, "Daemon client not initialized", http.StatusInternalServerError) + return + } + + // Use RPC to get issues from daemon + resp, err := daemonClient.List(&rpc.ListArgs{}) + if err != nil { + http.Error(w, fmt.Sprintf("Error fetching issues via RPC: %v", err), http.StatusInternalServerError) + return + } + + if err := json.Unmarshal(resp.Data, &issues); err != nil { + http.Error(w, fmt.Sprintf("Error unmarshaling issues: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(issues) +} + +// handleAPIIssueDetail returns a single issue's details +func handleAPIIssueDetail(w http.ResponseWriter, r *http.Request) { + // Extract issue ID from URL path (e.g., /api/issues/bd-1) + issueID := r.URL.Path[len("/api/issues/"):] + if issueID == "" { + http.Error(w, "Issue ID required", http.StatusBadRequest) + return + } + + if daemonClient == nil { + http.Error(w, "Daemon client not initialized", http.StatusInternalServerError) + return + } + + var issue *types.Issue + + // Use RPC to get issue from daemon + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID}) + if err != nil { + http.Error(w, fmt.Sprintf("Issue not found: %v", err), http.StatusNotFound) + return + } + + if err := json.Unmarshal(resp.Data, &issue); err != nil { + http.Error(w, fmt.Sprintf("Error unmarshaling issue: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(issue) +} + +// handleAPIReady returns ready work (no blockers) +func handleAPIReady(w http.ResponseWriter, r *http.Request) { + var issues []*types.Issue + + if daemonClient == nil { + http.Error(w, "Daemon client not initialized", http.StatusInternalServerError) + return + } + + // Use RPC to get ready work from daemon + resp, err := daemonClient.Ready(&rpc.ReadyArgs{}) + if err != nil { + http.Error(w, fmt.Sprintf("Error fetching ready work via RPC: %v", err), http.StatusInternalServerError) + return + } + + if err := json.Unmarshal(resp.Data, &issues); err != nil { + http.Error(w, fmt.Sprintf("Error unmarshaling issues: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(issues) +} + +// handleAPIStats returns issue statistics +func handleAPIStats(w http.ResponseWriter, r *http.Request) { + var stats *types.Statistics + + if daemonClient == nil { + http.Error(w, "Daemon client not initialized", http.StatusInternalServerError) + return + } + + // Use RPC to get stats from daemon + resp, err := daemonClient.Stats() + if err != nil { + http.Error(w, fmt.Sprintf("Error fetching statistics via RPC: %v", err), http.StatusInternalServerError) + return + } + + if err := json.Unmarshal(resp.Data, &stats); err != nil { + http.Error(w, fmt.Sprintf("Error unmarshaling statistics: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) +} + +// handleWebSocket upgrades HTTP connection to WebSocket and manages client lifecycle +func handleWebSocket(w http.ResponseWriter, r *http.Request) { + // Upgrade connection to WebSocket + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error upgrading to WebSocket: %v\n", err) + return + } + + // Register client + wsClientsMu.Lock() + wsClients[conn] = true + wsClientsMu.Unlock() + + fmt.Printf("WebSocket client connected (total: %d)\n", len(wsClients)) + + // Handle client disconnection + defer func() { + wsClientsMu.Lock() + delete(wsClients, conn) + wsClientsMu.Unlock() + conn.Close() + fmt.Printf("WebSocket client disconnected (total: %d)\n", len(wsClients)) + }() + + // Keep connection alive and handle client messages + for { + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } +} + +// handleWebSocketBroadcast sends messages to all connected WebSocket clients +func handleWebSocketBroadcast() { + for { + // Wait for message to broadcast + message := <-wsBroadcast + + // Send to all connected clients + wsClientsMu.Lock() + for client := range wsClients { + err := client.WriteMessage(websocket.TextMessage, message) + if err != nil { + // Client disconnected, will be cleaned up by handleWebSocket + fmt.Fprintf(os.Stderr, "Error writing to WebSocket client: %v\n", err) + client.Close() + delete(wsClients, client) + } + } + wsClientsMu.Unlock() + } +} + +// pollMutations polls the daemon for mutations and broadcasts them to WebSocket clients +func pollMutations() { + lastPollTime := int64(0) // Start from beginning + + ticker := time.NewTicker(2 * time.Second) // Poll every 2 seconds + defer ticker.Stop() + + for range ticker.C { + if daemonClient == nil { + continue + } + + // Call GetMutations RPC + resp, err := daemonClient.GetMutations(&rpc.GetMutationsArgs{ + Since: lastPollTime, + }) + if err != nil { + // Daemon might be down or restarting, just skip this poll + continue + } + + var mutations []rpc.MutationEvent + if err := json.Unmarshal(resp.Data, &mutations); err != nil { + fmt.Fprintf(os.Stderr, "Error unmarshaling mutations: %v\n", err) + continue + } + + // Broadcast each mutation to WebSocket clients + for _, mutation := range mutations { + data, _ := json.Marshal(mutation) + wsBroadcast <- data + + // Update last poll time to this mutation's timestamp + mutationTimeMillis := mutation.Timestamp.UnixMilli() + if mutationTimeMillis > lastPollTime { + lastPollTime = mutationTimeMillis + } + } + } +} diff --git a/examples/monitor-webui/monitor-webui b/examples/monitor-webui/monitor-webui new file mode 100755 index 00000000..ee466e51 Binary files /dev/null and b/examples/monitor-webui/monitor-webui differ diff --git a/examples/monitor-webui/web/index.html b/examples/monitor-webui/web/index.html new file mode 100644 index 00000000..ef6c62f5 --- /dev/null +++ b/examples/monitor-webui/web/index.html @@ -0,0 +1,108 @@ + + + + + + bd monitor - Issue Tracker + + + + +
+
+
+ +
+
+

bd monitor

+

Real-time issue tracking dashboard

+
+
+ + Connecting... +
+
+ +
+ +
+

Statistics

+
+
+
-
+
Total Issues
+
+
+
-
+
In Progress
+
+
+
-
+
Open
+
+
+
-
+
Closed
+
+
+
+ +
+ + + +
+ +

Issues

+ + + + + + + + + + + + + + +
IDTitleStatusPriorityTypeAssignee
+ + +
+
+
+ + + + + + + diff --git a/examples/monitor-webui/web/static/css/styles.css b/examples/monitor-webui/web/static/css/styles.css new file mode 100644 index 00000000..e94b1d57 --- /dev/null +++ b/examples/monitor-webui/web/static/css/styles.css @@ -0,0 +1,222 @@ +body { padding: 2rem; } +.header { + margin-bottom: 2rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; +} +.connection-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 0.4rem; + font-size: 1.2rem; +} +.connection-status.connected { + background: #d4edda; + color: #155724; +} +.connection-status.disconnected { + background: #f8d7da; + color: #721c24; +} +.connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} +.connection-dot.connected { + background: #28a745; + animation: pulse 2s infinite; +} +.connection-dot.disconnected { + background: #dc3545; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +.stats { margin-bottom: 2rem; } +.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } +.stat-card { padding: 1rem; background: #f4f5f6; border-radius: 0.4rem; } +.stat-value { font-size: 2.4rem; font-weight: bold; color: #9b4dca; } +.stat-label { font-size: 1.2rem; color: #606c76; } + +/* Loading spinner */ +.spinner { + border: 3px solid #f3f3f3; + border-top: 3px solid #9b4dca; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + margin: 2rem auto; +} +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} +.loading-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.8); + z-index: 999; + justify-content: center; + align-items: center; +} +.loading-overlay.active { + display: flex; +} + +/* Error message */ +.error-message { + display: none; + padding: 1rem; + margin: 1rem 0; + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 0.4rem; + color: #721c24; +} +.error-message.active { + display: block; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: #606c76; +} +.empty-state-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +/* Table styles */ +table { width: 100%; } +tbody tr { cursor: pointer; } +tbody tr:hover { background: #f4f5f6; } +.status-open { color: #0074d9; } +.status-closed { color: #2ecc40; } +.status-in-progress { color: #ff851b; } +.priority-1 { color: #ff4136; font-weight: bold; } +.priority-2 { color: #ff851b; } +.priority-3 { color: #ffdc00; } + +/* Modal styles */ +.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4); } +.modal-content { background-color: #fefefe; margin: 5% auto; padding: 2rem; border-radius: 0.4rem; width: 80%; max-width: 800px; } +.close { color: #aaa; float: right; font-size: 2.8rem; font-weight: bold; line-height: 2rem; cursor: pointer; } +.close:hover, .close:focus { color: #000; } + +.filter-controls { + margin-bottom: 2rem; + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} +.filter-controls label { + flex: 0 0 auto; +} +.filter-controls select { margin-right: 0; } +.filter-controls select[multiple] { + height: auto; + min-height: 100px; +} +.reload-button { + padding: 0.6rem 1.2rem; + background: #9b4dca; + color: white; + border: none; + border-radius: 0.4rem; + cursor: pointer; + font-size: 1.4rem; + transition: background 0.2s; +} +.reload-button:hover { + background: #8b3dba; +} +.reload-button:active { + transform: translateY(1px); +} + +/* Responsive design for mobile */ +@media screen and (max-width: 768px) { + body { padding: 1rem; } + .header { + flex-direction: column; + align-items: flex-start; + } + .connection-status { + margin-top: 1rem; + } + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + .filter-controls { + flex-direction: column; + align-items: stretch; + } + .filter-controls label { + width: 100%; + } + .filter-controls select { + width: 100%; + } + .reload-button { + width: 100%; + } + + /* Hide table, show card view on mobile */ + table { display: none; } + .issues-card-view { display: block; } + + .issue-card { + background: #fff; + border: 1px solid #d1d1d1; + border-radius: 0.4rem; + padding: 1.5rem; + margin-bottom: 1rem; + cursor: pointer; + transition: box-shadow 0.2s; + } + .issue-card:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + .issue-card-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 1rem; + } + .issue-card-id { + font-weight: bold; + color: #9b4dca; + } + .issue-card-title { + font-size: 1.6rem; + margin: 0.5rem 0; + } + .issue-card-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + font-size: 1.2rem; + } + .modal-content { + width: 95%; + margin: 10% auto; + } +} + +@media screen and (min-width: 769px) { + .issues-card-view { display: none; } +} diff --git a/examples/monitor-webui/web/static/js/app.js b/examples/monitor-webui/web/static/js/app.js new file mode 100644 index 00000000..ced777a6 --- /dev/null +++ b/examples/monitor-webui/web/static/js/app.js @@ -0,0 +1,248 @@ +let allIssues = []; +let ws = null; +let wsConnected = false; + +// WebSocket connection +function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = protocol + '//' + window.location.host + '/ws'; + + ws = new WebSocket(wsUrl); + + ws.onopen = function() { + console.log('WebSocket connected'); + wsConnected = true; + updateConnectionStatus(true); + }; + + ws.onmessage = function(event) { + console.log('WebSocket message:', event.data); + const mutation = JSON.parse(event.data); + handleMutation(mutation); + }; + + ws.onerror = function(error) { + console.error('WebSocket error:', error); + wsConnected = false; + updateConnectionStatus(false); + }; + + ws.onclose = function() { + console.log('WebSocket disconnected'); + wsConnected = false; + updateConnectionStatus(false); + // Reconnect after 5 seconds + setTimeout(connectWebSocket, 5000); + }; +} + +// Update connection status indicator +function updateConnectionStatus(connected) { + const statusEl = document.getElementById('connection-status'); + const dotEl = document.getElementById('connection-dot'); + const textEl = document.getElementById('connection-text'); + + if (connected) { + statusEl.className = 'connection-status connected'; + dotEl.className = 'connection-dot connected'; + textEl.textContent = 'Connected'; + } else { + statusEl.className = 'connection-status disconnected'; + dotEl.className = 'connection-dot disconnected'; + textEl.textContent = 'Disconnected'; + } +} + +// Show/hide loading overlay +function setLoading(isLoading) { + const overlay = document.getElementById('loading-overlay'); + if (isLoading) { + overlay.classList.add('active'); + } else { + overlay.classList.remove('active'); + } +} + +// Show error message +function showError(message) { + const errorEl = document.getElementById('error-message'); + errorEl.textContent = message; + errorEl.classList.add('active'); + setTimeout(() => { + errorEl.classList.remove('active'); + }, 5000); +} + +// Handle mutation event +function handleMutation(mutation) { + console.log('Mutation:', mutation.type, mutation.issue_id); + // Refresh data on mutation + loadStats(); + loadIssues(); +} + +// Load statistics +async function loadStats() { + try { + const response = await fetch('/api/stats'); + if (!response.ok) throw new Error('Failed to load statistics'); + const stats = await response.json(); + document.getElementById('stat-total').textContent = stats.total_issues || 0; + document.getElementById('stat-in-progress').textContent = stats.in_progress_issues || 0; + document.getElementById('stat-open').textContent = stats.open_issues || 0; + document.getElementById('stat-closed').textContent = stats.closed_issues || 0; + } catch (error) { + console.error('Error loading statistics:', error); + showError('Failed to load statistics: ' + error.message); + } +} + +// Load all issues +async function loadIssues() { + try { + const response = await fetch('/api/issues'); + if (!response.ok) throw new Error('Failed to load issues'); + allIssues = await response.json(); + renderIssues(allIssues); + } catch (error) { + console.error('Error loading issues:', error); + showError('Failed to load issues: ' + error.message); + document.getElementById('issues-tbody').innerHTML = 'Error loading issues'; + document.getElementById('issues-card-view').innerHTML = '
⚠️

Error loading issues

'; + } +} + +// Render issues table +function renderIssues(issues) { + const tbody = document.getElementById('issues-tbody'); + const cardView = document.getElementById('issues-card-view'); + + if (!issues || issues.length === 0) { + const emptyState = '
📋

No issues found

Create your first issue to get started!

'; + tbody.innerHTML = '' + emptyState + ''; + cardView.innerHTML = emptyState; + return; + } + + // Render table view + tbody.innerHTML = issues.map(issue => { + const statusClass = 'status-' + (issue.status || 'open').toLowerCase().replace('_', '-'); + const priorityClass = 'priority-' + (issue.priority || 2); + return '' + issue.id + '' + issue.title + '' + (issue.status || 'open') + 'P' + (issue.priority || 2) + '' + (issue.issue_type || 'task') + '' + (issue.assignee || '-') + ''; + }).join(''); + + // Render card view for mobile + cardView.innerHTML = issues.map(issue => { + const statusClass = 'status-' + (issue.status || 'open').toLowerCase().replace('_', '-'); + const priorityClass = 'priority-' + (issue.priority || 2); + let html = '
'; + html += '
'; + html += '' + issue.id + ''; + html += 'P' + (issue.priority || 2) + ''; + html += '
'; + html += '

' + issue.title + '

'; + html += '
'; + html += '● ' + (issue.status || 'open') + ''; + html += 'Type: ' + (issue.issue_type || 'task') + ''; + if (issue.assignee) html += '👤 ' + issue.assignee + ''; + html += '
'; + html += '
'; + return html; + }).join(''); +} + +// Filter issues +function filterIssues() { + const statusSelect = document.getElementById('filter-status'); + const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value); + const priorityFilter = document.getElementById('filter-priority').value; + + const filtered = allIssues.filter(issue => { + // If statuses are selected, check if issue status is in the selected list + if (selectedStatuses.length > 0 && !selectedStatuses.includes(issue.status)) return false; + if (priorityFilter && issue.priority !== parseInt(priorityFilter)) return false; + return true; + }); + + renderIssues(filtered); +} + +// Reload all data +function reloadData() { + setLoading(true); + Promise.all([loadStats(), loadIssues()]) + .then(() => { + setLoading(false); + }) + .catch(error => { + console.error('Error reloading data:', error); + setLoading(false); + showError('Failed to reload data: ' + error.message); + }); +} + +// Show issue detail modal +async function showIssueDetail(issueId) { + const modal = document.getElementById('issue-modal'); + const modalTitle = document.getElementById('modal-title'); + const modalBody = document.getElementById('modal-body'); + + modal.style.display = 'block'; + modalTitle.textContent = 'Loading...'; + modalBody.innerHTML = '
'; + + try { + const response = await fetch('/api/issues/' + issueId); + if (!response.ok) throw new Error('Issue not found'); + const issue = await response.json(); + + modalTitle.textContent = issue.id + ': ' + issue.title; + let html = '

Status: ' + issue.status + '

'; + html += '

Priority: P' + issue.priority + '

'; + html += '

Type: ' + issue.issue_type + '

'; + html += '

Assignee: ' + (issue.assignee || 'Unassigned') + '

'; + html += '

Created: ' + new Date(issue.created_at).toLocaleString() + '

'; + html += '

Updated: ' + new Date(issue.updated_at).toLocaleString() + '

'; + if (issue.description) html += '

Description

' + issue.description + '
'; + if (issue.design) html += '

Design

' + issue.design + '
'; + if (issue.notes) html += '

Notes

' + issue.notes + '
'; + if (issue.labels && issue.labels.length > 0) html += '

Labels: ' + issue.labels.join(', ') + '

'; + modalBody.innerHTML = html; + } catch (error) { + console.error('Error loading issue details:', error); + showError('Failed to load issue details: ' + error.message); + modalBody.innerHTML = '
⚠️

Error loading issue details

'; + } +} + +// Close modal +document.querySelector('.close').onclick = function() { + document.getElementById('issue-modal').style.display = 'none'; +}; + +window.onclick = function(event) { + const modal = document.getElementById('issue-modal'); + if (event.target == modal) { + modal.style.display = 'none'; + } +}; + +// Filter event listeners +document.getElementById('filter-status').addEventListener('change', filterIssues); +document.getElementById('filter-priority').addEventListener('change', filterIssues); + +// Reload button listener +document.getElementById('reload-button').addEventListener('click', reloadData); + +// Initial load +connectWebSocket(); +loadStats(); +loadIssues(); + +// Fallback: Refresh every 30 seconds (WebSocket should handle real-time updates) +setInterval(() => { + if (!wsConnected) { + loadStats(); + loadIssues(); + } +}, 30000); diff --git a/internal/rpc/client.go b/internal/rpc/client.go index 54e039cc..e7caf996 100644 --- a/internal/rpc/client.go +++ b/internal/rpc/client.go @@ -267,6 +267,11 @@ func (c *Client) Stats() (*Response, error) { return c.Execute(OpStats, nil) } +// GetMutations retrieves recent mutations from the daemon +func (c *Client) GetMutations(args *GetMutationsArgs) (*Response, error) { + return c.Execute(OpGetMutations, args) +} + // AddDependency adds a dependency via the daemon func (c *Client) AddDependency(args *DepAddArgs) (*Response, error) { return c.Execute(OpDepAdd, args) diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index e4a8d613..b2a5126d 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -33,6 +33,7 @@ const ( OpExport = "export" OpImport = "import" OpEpicStatus = "epic_status" + OpGetMutations = "get_mutations" OpShutdown = "shutdown" ) @@ -315,3 +316,8 @@ type ExportArgs struct { type ImportArgs struct { JSONLPath string `json:"jsonl_path"` // Path to import JSONL file } + +// GetMutationsArgs represents arguments for retrieving recent mutations +type GetMutationsArgs struct { + Since int64 `json:"since"` // Unix timestamp in milliseconds (0 for all recent) +} diff --git a/internal/rpc/server_core.go b/internal/rpc/server_core.go index 7c059ab2..54b588c8 100644 --- a/internal/rpc/server_core.go +++ b/internal/rpc/server_core.go @@ -1,6 +1,7 @@ package rpc import ( + "encoding/json" "fmt" "net" "os" @@ -49,6 +50,10 @@ type Server struct { // Mutation events for event-driven daemon mutationChan chan MutationEvent droppedEvents atomic.Int64 // Counter for dropped mutation events + // Recent mutations buffer for polling (circular buffer, max 100 events) + recentMutations []MutationEvent + recentMutationsMu sync.RWMutex + maxMutationBuffer int } // Mutation event types @@ -93,19 +98,21 @@ func NewServer(socketPath string, store storage.Storage, workspacePath string, d } s := &Server{ - socketPath: socketPath, - workspacePath: workspacePath, - dbPath: dbPath, - storage: store, - shutdownChan: make(chan struct{}), - doneChan: make(chan struct{}), - startTime: time.Now(), - metrics: NewMetrics(), - maxConns: maxConns, - connSemaphore: make(chan struct{}, maxConns), - requestTimeout: requestTimeout, - readyChan: make(chan struct{}), - mutationChan: make(chan MutationEvent, mutationBufferSize), // Configurable buffer + socketPath: socketPath, + workspacePath: workspacePath, + dbPath: dbPath, + storage: store, + shutdownChan: make(chan struct{}), + doneChan: make(chan struct{}), + startTime: time.Now(), + metrics: NewMetrics(), + maxConns: maxConns, + connSemaphore: make(chan struct{}, maxConns), + requestTimeout: requestTimeout, + readyChan: make(chan struct{}), + mutationChan: make(chan MutationEvent, mutationBufferSize), // Configurable buffer + recentMutations: make([]MutationEvent, 0, 100), + maxMutationBuffer: 100, } s.lastActivityTime.Store(time.Now()) return s @@ -113,18 +120,31 @@ func NewServer(socketPath string, store storage.Storage, workspacePath string, d // emitMutation sends a mutation event to the daemon's event-driven loop. // Non-blocking: drops event if channel is full (sync will happen eventually). +// Also stores in recent mutations buffer for polling. func (s *Server) emitMutation(eventType, issueID string) { - select { - case s.mutationChan <- MutationEvent{ + event := MutationEvent{ Type: eventType, IssueID: issueID, Timestamp: time.Now(), - }: + } + + // Send to mutation channel for daemon + select { + case s.mutationChan <- event: // Event sent successfully default: // Channel full, increment dropped events counter s.droppedEvents.Add(1) } + + // Store in recent mutations buffer for polling + s.recentMutationsMu.Lock() + s.recentMutations = append(s.recentMutations, event) + // Keep buffer size limited (circular buffer behavior) + if len(s.recentMutations) > s.maxMutationBuffer { + s.recentMutations = s.recentMutations[1:] + } + s.recentMutationsMu.Unlock() } // MutationChan returns the mutation event channel for the daemon to consume @@ -136,3 +156,36 @@ func (s *Server) MutationChan() <-chan MutationEvent { func (s *Server) ResetDroppedEventsCount() int64 { return s.droppedEvents.Swap(0) } + +// GetRecentMutations returns mutations since the given timestamp +func (s *Server) GetRecentMutations(sinceMillis int64) []MutationEvent { + s.recentMutationsMu.RLock() + defer s.recentMutationsMu.RUnlock() + + var result []MutationEvent + for _, m := range s.recentMutations { + if m.Timestamp.UnixMilli() > sinceMillis { + result = append(result, m) + } + } + return result +} + +// handleGetMutations handles the get_mutations RPC operation +func (s *Server) handleGetMutations(req *Request) Response { + var args GetMutationsArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid arguments: %v", err), + } + } + + mutations := s.GetRecentMutations(args.Since) + data, _ := json.Marshal(mutations) + + return Response{ + Success: true, + Data: data, + } +} diff --git a/internal/rpc/server_mutations_test.go b/internal/rpc/server_mutations_test.go new file mode 100644 index 00000000..fe2000be --- /dev/null +++ b/internal/rpc/server_mutations_test.go @@ -0,0 +1,277 @@ +package rpc + +import ( + "encoding/json" + "testing" + "time" + + "github.com/steveyegge/beads/internal/storage/memory" +) + +func TestEmitMutation(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Emit a mutation + server.emitMutation(MutationCreate, "bd-123") + + // Check that mutation was stored in buffer + mutations := server.GetRecentMutations(0) + if len(mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(mutations)) + } + + if mutations[0].Type != MutationCreate { + t.Errorf("expected type %s, got %s", MutationCreate, mutations[0].Type) + } + + if mutations[0].IssueID != "bd-123" { + t.Errorf("expected issue ID bd-123, got %s", mutations[0].IssueID) + } +} + +func TestGetRecentMutations_EmptyBuffer(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + mutations := server.GetRecentMutations(0) + if len(mutations) != 0 { + t.Errorf("expected empty mutations, got %d", len(mutations)) + } +} + +func TestGetRecentMutations_TimestampFiltering(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Emit mutations with delays + server.emitMutation(MutationCreate, "bd-1") + time.Sleep(10 * time.Millisecond) + + checkpoint := time.Now().UnixMilli() + time.Sleep(10 * time.Millisecond) + + server.emitMutation(MutationUpdate, "bd-2") + server.emitMutation(MutationUpdate, "bd-3") + + // Get mutations after checkpoint + mutations := server.GetRecentMutations(checkpoint) + + if len(mutations) != 2 { + t.Fatalf("expected 2 mutations after checkpoint, got %d", len(mutations)) + } + + // Verify the mutations are bd-2 and bd-3 + ids := make(map[string]bool) + for _, m := range mutations { + ids[m.IssueID] = true + } + + if !ids["bd-2"] || !ids["bd-3"] { + t.Errorf("expected bd-2 and bd-3, got %v", ids) + } + + if ids["bd-1"] { + t.Errorf("bd-1 should be filtered out by timestamp") + } +} + +func TestGetRecentMutations_CircularBuffer(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Emit more than maxMutationBuffer (100) mutations + for i := 0; i < 150; i++ { + server.emitMutation(MutationCreate, "bd-"+string(rune(i))) + time.Sleep(time.Millisecond) // Ensure different timestamps + } + + // Buffer should only keep last 100 + mutations := server.GetRecentMutations(0) + if len(mutations) != 100 { + t.Errorf("expected 100 mutations (circular buffer limit), got %d", len(mutations)) + } + + // First mutation should be from iteration 50 (150-100) + firstID := mutations[0].IssueID + expectedFirstID := "bd-" + string(rune(50)) + if firstID != expectedFirstID { + t.Errorf("expected first mutation to be %s (after circular buffer wraparound), got %s", expectedFirstID, firstID) + } +} + +func TestGetRecentMutations_ConcurrentAccess(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Simulate concurrent writes and reads + done := make(chan bool) + + // Writer goroutine + go func() { + for i := 0; i < 50; i++ { + server.emitMutation(MutationUpdate, "bd-write") + time.Sleep(time.Millisecond) + } + done <- true + }() + + // Reader goroutine + go func() { + for i := 0; i < 50; i++ { + _ = server.GetRecentMutations(0) + time.Sleep(time.Millisecond) + } + done <- true + }() + + // Wait for both to complete + <-done + <-done + + // Verify no race conditions (test will fail with -race flag if there are) + mutations := server.GetRecentMutations(0) + if len(mutations) == 0 { + t.Error("expected some mutations after concurrent access") + } +} + +func TestHandleGetMutations(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Emit some mutations + server.emitMutation(MutationCreate, "bd-1") + time.Sleep(10 * time.Millisecond) + checkpoint := time.Now().UnixMilli() + time.Sleep(10 * time.Millisecond) + server.emitMutation(MutationUpdate, "bd-2") + + // Create RPC request + args := GetMutationsArgs{Since: checkpoint} + argsJSON, _ := json.Marshal(args) + + req := &Request{ + Operation: OpGetMutations, + Args: argsJSON, + } + + // Handle request + resp := server.handleGetMutations(req) + + if !resp.Success { + t.Fatalf("expected successful response, got error: %s", resp.Error) + } + + // Parse response + var mutations []MutationEvent + if err := json.Unmarshal(resp.Data, &mutations); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if len(mutations) != 1 { + t.Errorf("expected 1 mutation, got %d", len(mutations)) + } + + if len(mutations) > 0 && mutations[0].IssueID != "bd-2" { + t.Errorf("expected bd-2, got %s", mutations[0].IssueID) + } +} + +func TestHandleGetMutations_InvalidArgs(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Create RPC request with invalid JSON + req := &Request{ + Operation: OpGetMutations, + Args: []byte("invalid json"), + } + + // Handle request + resp := server.handleGetMutations(req) + + if resp.Success { + t.Error("expected error response for invalid args") + } + + if resp.Error == "" { + t.Error("expected error message") + } +} + +func TestMutationEventTypes(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Test all mutation types + types := []string{ + MutationCreate, + MutationUpdate, + MutationDelete, + MutationComment, + } + + for _, mutationType := range types { + server.emitMutation(mutationType, "bd-test") + } + + mutations := server.GetRecentMutations(0) + if len(mutations) != len(types) { + t.Fatalf("expected %d mutations, got %d", len(types), len(mutations)) + } + + // Verify each type was stored correctly + foundTypes := make(map[string]bool) + for _, m := range mutations { + foundTypes[m.Type] = true + } + + for _, expectedType := range types { + if !foundTypes[expectedType] { + t.Errorf("expected mutation type %s not found", expectedType) + } + } +} + +func TestMutationTimestamps(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + before := time.Now() + server.emitMutation(MutationCreate, "bd-123") + after := time.Now() + + mutations := server.GetRecentMutations(0) + if len(mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(mutations)) + } + + timestamp := mutations[0].Timestamp + if timestamp.Before(before) || timestamp.After(after) { + t.Errorf("mutation timestamp %v is outside expected range [%v, %v]", timestamp, before, after) + } +} + +func TestEmitMutation_NonBlocking(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Don't consume from mutationChan to test non-blocking behavior + // Fill the buffer (default size is 512 from BEADS_MUTATION_BUFFER or default) + for i := 0; i < 600; i++ { + // This should not block even when channel is full + server.emitMutation(MutationCreate, "bd-test") + } + + // Verify mutations were still stored in recent buffer + mutations := server.GetRecentMutations(0) + if len(mutations) == 0 { + t.Error("expected mutations in recent buffer even when channel is full") + } + + // Verify buffer is capped at 100 (maxMutationBuffer) + if len(mutations) > 100 { + t.Errorf("expected at most 100 mutations in buffer, got %d", len(mutations)) + } +} diff --git a/internal/rpc/server_routing_validation_diagnostics.go b/internal/rpc/server_routing_validation_diagnostics.go index 0ef192be..dcbf6bb9 100644 --- a/internal/rpc/server_routing_validation_diagnostics.go +++ b/internal/rpc/server_routing_validation_diagnostics.go @@ -201,6 +201,8 @@ func (s *Server) handleRequest(req *Request) Response { resp = s.handleImport(req) case OpEpicStatus: resp = s.handleEpicStatus(req) + case OpGetMutations: + resp = s.handleGetMutations(req) case OpShutdown: resp = s.handleShutdown(req) default: