Implementing an RPC monitoring solution with a web-ui as implementation example. (#244)

* bd sync: 2025-10-30 12:12:27

* Working on frontend

* bd sync: 2025-11-06 16:55:55

* feat: finish bd monitor human viewer

* Merge conflicts resolved and added tests

* bd sync: 2025-11-06 17:23:41

* bd sync: 2025-11-06 17:34:52

* feat: Add reload button and multiselect status filter to monitor

- Changed status filter from single select to multiselect with 'Open' selected by default
- Added reload button with visual feedback (hover/active states)
- Updated filterIssues() to handle multiple selected statuses
- Added reloadData() function that reloads both stats and issues
- Improved responsive design for mobile devices
- Filter controls now use flexbox layout with better spacing

* fix: Update monitor statistics to show Total, In Progress, Open, Closed

- Replaced 'Ready to Work' stat with 'In Progress' stat
- Reordered stats to show logical progression: Total -> In Progress -> Open -> Closed
- Updated loadStats() to fetch in-progress count from stats API
- Removed unnecessary separate API call for ready count

* fix: Correct API field names in monitor stats JavaScript

The JavaScript was using incorrect field names (stats.total, stats.by_status)
that don't match the actual types.Statistics struct which uses flat fields
with underscores (total_issues, in_progress_issues, etc).

Fixed by updating loadStats() to use correct field names:
- stats.total -> stats.total_issues
- stats.by_status?.['in-progress'] -> stats.in_progress_issues
- stats.by_status?.open -> stats.open_issues
- stats.by_status?.closed -> stats.closed_issues

Fixes beads-9

* bd sync: 2025-11-06 17:51:24

* bd sync: 2025-11-06 17:56:09

* fix: Make monitor require daemon to prevent SQLite locking

Implemented Option 1 from beads-eel: monitor now requires daemon and never
opens direct SQLite connection.

Changes:
- Added 'monitor' to noDbCommands list in main.go to skip normal DB initialization
- Added validateDaemonForMonitor() PreRun function that:
  - Finds database path using beads.FindDatabasePath()
  - Validates daemon is running and healthy
  - Fails gracefully with clear error message if no daemon
  - Only uses RPC connection, never opens SQLite directly

Benefits:
- Eliminates SQLite locking conflicts between monitor and daemon
- Users can now close/update issues via CLI while monitor runs
- Clear error messages guide users to start daemon first

Fixes beads-eel

* bd sync: 2025-11-06 18:03:50

* docs: Add bd daemons restart subcommand documentation

Added documentation for the 'bd daemons restart' subcommand across all documentation files:

- commands/daemons.md: Added full restart subcommand section with synopsis, description, arguments, flags, and examples
- README.md: Added restart examples to daemon management section
- AGENTS.md: Added restart examples with --json flag for agents

The restart command gracefully stops and starts a specific daemon by workspace path or PID,
useful after upgrading bd or when a daemon needs refreshing.

Fixes beads-11

* bd sync: 2025-11-06 18:13:16

* Separated the web ui from the general monitoring functionality

---------

Co-authored-by: Steve Yegge <stevey@sourcegraph.com>
This commit is contained in:
Markus Flür
2025-11-07 18:49:12 +01:00
committed by GitHub
parent 19da81caea
commit e7f532db93
18 changed files with 1982 additions and 23 deletions

108
AGENTS.md
View File

@@ -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: **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 installs git hooks
- Automatically configures git merge driver for intelligent JSONL merging - Automatically configures git merge driver for intelligent JSONL merging
- No prompts for user input - 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. **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:** **Installation:**
```bash ```bash
# Install the MCP server # Install the MCP server
pip install beads-mcp pip install beads-mcp
@@ -45,6 +47,7 @@ pip install beads-mcp
``` ```
**Benefits:** **Benefits:**
- Native function calls instead of shell commands - Native function calls instead of shell commands
- Automatic workspace detection - Automatic workspace detection
- Better error handling and validation - Better error handling and validation
@@ -52,6 +55,7 @@ pip install beads-mcp
- No need for `--json` flags - No need for `--json` flags
**All bd commands are available as MCP functions** with the prefix `mcp__beads-*__`. For example: **All bd commands are available as MCP functions** with the prefix `mcp__beads-*__`. For example:
- `bd ready``mcp__beads__ready()` - `bd ready``mcp__beads__ready()`
- `bd create``mcp__beads__create(title="...", priority=1)` - `bd create``mcp__beads__create(title="...", priority=1)`
- `bd update``mcp__beads__update(issue_id="bd-42", status="in_progress")` - `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). **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):** **Setup (one-time):**
```bash ```bash
# MCP config in ~/.config/amp/settings.json or Claude Desktop config: # 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):** **How it works (LSP model):**
The single MCP server instance automatically: The single MCP server instance automatically:
1. Checks for local daemon socket (`.beads/bd.sock`) in your current workspace 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 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) 3. Auto-starts the local daemon if not running (with exponential backoff)
4. **Each project gets its own isolated daemon** serving only its database 4. **Each project gets its own isolated daemon** serving only its database
**Architecture:** **Architecture:**
``` ```
MCP Server (one instance) MCP Server (one instance)
@@ -94,6 +101,7 @@ SQLite Databases (complete isolation)
``` ```
**Why per-project daemons?** **Why per-project daemons?**
- ✅ Complete database isolation between projects - ✅ Complete database isolation between projects
- ✅ No cross-project pollution or git worktree conflicts - ✅ No cross-project pollution or git worktree conflicts
- ✅ Simpler mental model: one project = one database = one daemon - ✅ 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`. **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+): **Version Management:** bd automatically handles daemon version mismatches (v0.16.0+):
- When you upgrade bd, old daemons are automatically detected and restarted - When you upgrade bd, old daemons are automatically detected and restarted
- Version compatibility is checked on every connection - Version compatibility is checked on every connection
- No manual intervention required after upgrades - No manual intervention required after upgrades
@@ -111,6 +120,7 @@ SQLite Databases (complete isolation)
**Alternative (not recommended): Multiple MCP Server Instances** **Alternative (not recommended): Multiple MCP Server Instances**
If you must use separate MCP servers: If you must use separate MCP servers:
```json ```json
{ {
"beads-webapp": { "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. ⚠️ **Problem**: AI may select the wrong MCP server for your workspace, causing commands to operate on the wrong database.
### CLI Quick Reference ### 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: **Migration safety:** The system verifies data integrity invariants after migrations:
- **required_config_present**: Ensures issue_prefix and schema_version are set - **required_config_present**: Ensures issue_prefix and schema_version are set
- **foreign_keys_valid**: No orphaned dependencies or labels - **foreign_keys_valid**: No orphaned dependencies or labels
- **issue_count_stable**: Issue count doesn't decrease unexpectedly - **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 /path/to/workspace --json
bd daemons stop 12345 --json # By PID 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 # View daemon logs
bd daemons logs /path/to/workspace -n 100 bd daemons logs /path/to/workspace -n 100
bd daemons logs 12345 -f # Follow mode bd daemons logs 12345 -f # Follow mode
@@ -317,28 +333,65 @@ bd daemons killall --force --json # Force kill if graceful fails
``` ```
**When to use:** **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 - **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 <workspace>` to view daemon logs - **Debugging**: Use `bd daemons logs <workspace>` to view daemon logs
- **Cleanup**: `bd daemons list` auto-removes stale sockets - **Cleanup**: `bd daemons list` auto-removes stale sockets
**Troubleshooting:** **Troubleshooting:**
- **Stale sockets**: `bd daemons list` auto-cleans them - **Stale sockets**: `bd daemons list` auto-cleans them
- **Version mismatch**: `bd daemons killall` then let daemons auto-start on next command - **Version mismatch**: `bd daemons killall` then let daemons auto-start on next command
- **Daemon won't stop**: `bd daemons killall --force` - **Daemon won't stop**: `bd daemons killall --force`
See [commands/daemons.md](commands/daemons.md) for detailed documentation. 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) ### 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. **NEW in v0.16+**: The daemon supports an experimental event-driven mode that replaces 5-second polling with instant reactivity.
**Benefits:** **Benefits:**
-**<500ms latency** (vs ~5000ms with polling) -**<500ms latency** (vs ~5000ms with polling)
- 🔋 **~60% less CPU usage** (no continuous polling) - 🔋 **~60% less CPU usage** (no continuous polling)
- 🎯 **Instant sync** on mutations and file changes - 🎯 **Instant sync** on mutations and file changes
- 🛡️ **Dropped events safety net** prevents data loss - 🛡️ **Dropped events safety net** prevents data loss
**How it works:** **How it works:**
- **FileWatcher** monitors `.beads/issues.jsonl` and `.git/refs/heads` using platform-native APIs: - **FileWatcher** monitors `.beads/issues.jsonl` and `.git/refs/heads` using platform-native APIs:
- Linux: `inotify` - Linux: `inotify`
- macOS: `FSEvents` (via kqueue) - macOS: `FSEvents` (via kqueue)
@@ -364,12 +417,14 @@ bd daemons killall
``` ```
**Available modes:** **Available modes:**
- `poll` (default) - Traditional 5-second polling, stable and battle-tested - `poll` (default) - Traditional 5-second polling, stable and battle-tested
- `events` - New event-driven mode, experimental but thoroughly tested - `events` - New event-driven mode, experimental but thoroughly tested
**Troubleshooting:** **Troubleshooting:**
If the watcher fails to start: If the watcher fails to start:
- Check daemon logs: `bd daemons logs /path/to/workspace -n 100` - Check daemon logs: `bd daemons logs /path/to/workspace -n 100`
- Look for "File watcher unavailable" warnings - Look for "File watcher unavailable" warnings
- Common causes: - Common causes:
@@ -378,16 +433,19 @@ If the watcher fails to start:
- Resource limits - check `ulimit -n` (open file descriptors) - Resource limits - check `ulimit -n` (open file descriptors)
**Fallback behavior:** **Fallback behavior:**
- If `BEADS_DAEMON_MODE=events` but watcher fails, daemon falls back to polling automatically - 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 - Set `BEADS_WATCHER_FALLBACK=false` to disable fallback and require fsnotify
**Disable polling fallback:** **Disable polling fallback:**
```bash ```bash
# Require fsnotify, fail if unavailable # Require fsnotify, fail if unavailable
BEADS_WATCHER_FALLBACK=false BEADS_DAEMON_MODE=events bd daemon start BEADS_WATCHER_FALLBACK=false BEADS_DAEMON_MODE=events bd daemon start
``` ```
**Switch back to polling:** **Switch back to polling:**
```bash ```bash
# Explicitly use polling mode # Explicitly use polling mode
BEADS_DAEMON_MODE=poll bd daemon start BEADS_DAEMON_MODE=poll bd daemon start
@@ -462,12 +520,14 @@ bd import -i issues.jsonl --dedupe-after
**Detection strategies:** **Detection strategies:**
1. **Before creating new issues**: Search for similar existing issues 1. **Before creating new issues**: Search for similar existing issues
```bash ```bash
bd list --json | grep -i "authentication" bd list --json | grep -i "authentication"
bd show bd-41 bd-42 --json # Compare candidates bd show bd-41 bd-42 --json # Compare candidates
``` ```
2. **Periodic duplicate scans**: Review issues by type or priority 2. **Periodic duplicate scans**: Review issues by type or priority
```bash ```bash
bd list --status open --priority 1 --json # High-priority issues bd list --status open --priority 1 --json # High-priority issues
bd list --issue-type bug --json # All bugs bd list --issue-type bug --json # All bugs
@@ -498,18 +558,21 @@ bd show bd-41 --json # Verify merged content
``` ```
**What gets merged:** **What gets merged:**
- ✅ All dependencies from source → target - ✅ All dependencies from source → target
- ✅ Text references updated across ALL issues (descriptions, notes, design, acceptance criteria) - ✅ Text references updated across ALL issues (descriptions, notes, design, acceptance criteria)
- ✅ Source issues closed with "Merged into bd-X" reason - ✅ Source issues closed with "Merged into bd-X" reason
- ❌ Source issue content NOT copied (target keeps its original content) - ❌ Source issue content NOT copied (target keeps its original content)
**Important notes:** **Important notes:**
- Merge preserves target issue completely; only dependencies/references migrate - Merge preserves target issue completely; only dependencies/references migrate
- If source issues have valuable content, manually copy it to target BEFORE merging - 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 - Cannot merge in daemon mode yet (bd-190); use `--no-daemon` flag
- Operation cannot be undone (but git history preserves the original) - Operation cannot be undone (but git history preserves the original)
**Best practices:** **Best practices:**
- Merge early to prevent dependency fragmentation - Merge early to prevent dependency fragmentation
- Choose the oldest or most complete issue as merge target - Choose the oldest or most complete issue as merge target
- Add labels like `duplicate` to source issues before merging (for tracking) - Add labels like `duplicate` to source issues before merging (for tracking)
@@ -550,6 +613,7 @@ beads/
### Git Workflow ### Git Workflow
**Auto-sync provides batching!** bd automatically: **Auto-sync provides batching!** bd automatically:
- **Exports** to JSONL after CRUD operations (30-second debounce for batching) - **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`) - **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) - **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:** **How it works:**
- Beads commits issue updates to `beads-metadata` instead of `main` - Beads commits issue updates to `beads-metadata` instead of `main`
- Uses git worktrees (lightweight checkouts) in `.git/beads-worktrees/` - Uses git worktrees (lightweight checkouts) in `.git/beads-worktrees/`
- Your main working directory is never affected - Your main working directory is never affected
@@ -600,6 +665,7 @@ bd sync --merge
``` ```
**Benefits:** **Benefits:**
- ✅ Works with protected `main` branches - ✅ Works with protected `main` branches
- ✅ No disruption to agent workflows - ✅ No disruption to agent workflows
- ✅ Platform-agnostic (works on any git platform) - ✅ 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]" - Format: "Continue work on bd-X: [issue title]. [Brief context about what's been done and what's next]"
**Example "land the plane" session:** **Example "land the plane" session:**
```bash ```bash
# 1. File remaining work # 1. File remaining work
bd create "Add integration tests for sync" -t task -p 2 --json 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:** **Then provide the user with:**
- Summary of what was completed this session - Summary of what was completed this session
- What issues were filed for follow-up - What issues were filed for follow-up
- Status of quality gates (all passing / issues filed) - Status of quality gates (all passing / issues filed)
@@ -670,6 +738,7 @@ bd sync
``` ```
This immediately: This immediately:
1. Exports pending changes to JSONL (no 30s wait) 1. Exports pending changes to JSONL (no 30s wait)
2. Commits to git 2. Commits to git
3. Pulls from remote 3. Pulls from remote
@@ -677,6 +746,7 @@ This immediately:
5. Pushes to remote 5. Pushes to remote
**Example agent session:** **Example agent session:**
```bash ```bash
# Make multiple changes (batched in 30-second window) # Make multiple changes (batched in 30-second window)
bd create "Fix bug" -p 1 bd create "Fix bug" -p 1
@@ -691,6 +761,7 @@ bd sync
``` ```
**Why this matters:** **Why this matters:**
- Without `bd sync`, changes sit in 30-second debounce window - Without `bd sync`, changes sit in 30-second debounce window
- User might think you pushed but JSONL is still dirty - User might think you pushed but JSONL is still dirty
- `bd sync` forces immediate flush/commit/push - `bd sync` forces immediate flush/commit/push
@@ -703,6 +774,7 @@ bd sync
``` ```
This installs: This installs:
- **pre-commit** - Flushes pending changes immediately before commit (bypasses 30s debounce) - **pre-commit** - Flushes pending changes immediately before commit (bypasses 30s debounce)
- **post-merge** - Imports updated JSONL after pull/merge (guaranteed sync) - **post-merge** - Imports updated JSONL after pull/merge (guaranteed sync)
- **pre-push** - Exports database to JSONL before push (prevents stale JSONL from reaching remote) - **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. 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:** **What you lose without daemon mode:**
- **Auto-sync** - No automatic commit/push of changes (use `bd sync` manually) - **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 - **MCP server** - The beads-mcp server requires daemon mode for multi-repo support
- **Background watching** - No automatic detection of remote changes - **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:** **Solutions for Worktree Users:**
1. **Use `--no-daemon` flag** (recommended): 1. **Use `--no-daemon` flag** (recommended):
```bash ```bash
bd --no-daemon ready bd --no-daemon ready
bd --no-daemon create "Fix bug" -p 1 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): 2. **Disable daemon via environment variable** (for entire worktree session):
```bash ```bash
export BEADS_NO_DAEMON=1 export BEADS_NO_DAEMON=1
bd ready # All commands use direct mode 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:** **Automatic detection:**
bd automatically detects conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) and shows clear resolution steps: bd automatically detects conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) and shows clear resolution steps:
- `bd import` rejects files with conflict markers and shows resolution commands - `bd import` rejects files with conflict markers and shows resolution commands
- `bd validate --checks=conflicts` scans for conflicts in JSONL - `bd validate --checks=conflicts` scans for conflicts in JSONL
**Resolution workflow:** **Resolution workflow:**
```bash ```bash
# After git merge creates conflict in .beads/beads.jsonl # 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 bd import -i .beads/beads.jsonl
# Option 2: Keep our version (local) # Option 2: Keep our version (local)
git checkout --ours .beads/beads.jsonl git checkout --ours .beads/beads.jsonl
bd import -i .beads/beads.jsonl bd import -i .beads/beads.jsonl
# Option 3: Manual resolution in editor # 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. **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:** **What it does:**
- Performs field-level 3-way merging (not line-by-line) - Performs field-level 3-way merging (not line-by-line)
- Matches issues by identity (id + created_at + created_by) - Matches issues by identity (id + created_at + created_by)
- Smart field merging: timestamps→max, dependencies→union, status/priority→3-way - 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) - Automatically configured during `bd init` (both interactive and `--quiet` modes)
**Auto-configuration (happens automatically):** **Auto-configuration (happens automatically):**
```bash ```bash
# During bd init, these are configured: # During bd init, these are configured:
git config merge.beads.driver "bd merge %A %O %L %R" 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`):** **Manual setup (if skipped with `--skip-merge-driver`):**
```bash ```bash
git config merge.beads.driver "bd merge %A %O %L %R" git config merge.beads.driver "bd merge %A %O %L %R"
git config merge.beads.name "bd JSONL merge driver" git config merge.beads.name "bd JSONL merge driver"
@@ -854,6 +934,7 @@ Run `bd stats` to see overall progress.
### 1.0 Milestone ### 1.0 Milestone
We're working toward 1.0. Key blockers tracked in bd. Run: We're working toward 1.0. Key blockers tracked in bd. Run:
```bash ```bash
bd dep tree bd-8 # Show 1.0 epic dependencies 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): **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: The bd daemon respects exclusive locks via `.beads/.exclusive-lock` file. When this lock exists:
- Daemon skips all operations for the locked database - Daemon skips all operations for the locked database
- External tool has complete control over git sync and database operations - External tool has complete control over git sync and database operations
- Stale locks (dead process) are automatically cleaned up - 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. **Use case:** Tools like VibeCoder that need deterministic execution without daemon interference.
See [EXCLUSIVE_LOCK.md](EXCLUSIVE_LOCK.md) for: See [EXCLUSIVE_LOCK.md](EXCLUSIVE_LOCK.md) for:
- Lock file format (JSON schema) - Lock file format (JSON schema)
- Creating and releasing locks (Go/shell examples) - Creating and releasing locks (Go/shell examples)
- Stale lock detection behavior - Stale lock detection behavior
- Integration testing guidance - Integration testing guidance
**Quick example:** **Quick example:**
```bash ```bash
# Create lock # 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 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. **IMPORTANT**: When asked to check GitHub issues or PRs, use command-line tools like `gh` instead of browser/playwright tools.
**Preferred approach:** **Preferred approach:**
```bash ```bash
# List open issues with details # List open issues with details
gh issue list --limit 30 gh issue list --limit 30
@@ -961,12 +1046,14 @@ gh issue view 201
``` ```
**Then provide an in-conversation summary** highlighting: **Then provide an in-conversation summary** highlighting:
- Urgent/critical issues (regressions, bugs, broken builds) - Urgent/critical issues (regressions, bugs, broken builds)
- Common themes or patterns - Common themes or patterns
- Feature requests with high engagement - Feature requests with high engagement
- Items that need immediate attention - Items that need immediate attention
**Why this matters:** **Why this matters:**
- Browser tools consume more tokens and are slower - Browser tools consume more tokens and are slower
- CLI summaries are easier to scan and discuss - CLI summaries are easier to scan and discuss
- Keeps the conversation focused and efficient - Keeps the conversation focused and efficient
@@ -1007,6 +1094,7 @@ git push origin main
``` ```
**What it does:** **What it does:**
- Updates ALL version files (CLI, plugin, MCP server, docs) in one command - Updates ALL version files (CLI, plugin, MCP server, docs) in one command
- Validates semantic versioning format - Validates semantic versioning format
- Shows diff preview - Shows diff preview
@@ -1014,17 +1102,20 @@ git push origin main
- Creates standardized commit message - Creates standardized commit message
**User will typically say:** **User will typically say:**
- "Bump to 0.9.3" - "Bump to 0.9.3"
- "Update version to 1.0.0" - "Update version to 1.0.0"
- "Rev the project to 0.9.4" - "Rev the project to 0.9.4"
- "Increment the version" - "Increment the version"
**You should:** **You should:**
1. Run `./scripts/bump-version.sh <version> --commit` 1. Run `./scripts/bump-version.sh <version> --commit`
2. Push to GitHub 2. Push to GitHub
3. Confirm all versions updated correctly 3. Confirm all versions updated correctly
**Files updated automatically:** **Files updated automatically:**
- `cmd/bd/version.go` - CLI version - `cmd/bd/version.go` - CLI version
- `.claude-plugin/plugin.json` - Plugin version - `.claude-plugin/plugin.json` - Plugin version
- `.claude-plugin/marketplace.json` - Marketplace version - `.claude-plugin/marketplace.json` - Marketplace version
@@ -1053,6 +1144,7 @@ See `scripts/README.md` for more details.
Happy coding! 🔗 Happy coding! 🔗
<!-- bd onboard section --> <!-- bd onboard section -->
## Issue Tracking with bd (beads) ## 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. **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 ### Quick Start
**FIRST TIME?** Just run `bd init` - it auto-imports issues from git: **FIRST TIME?** Just run `bd init` - it auto-imports issues from git:
```bash ```bash
bd init --prefix bd bd init --prefix bd
``` ```
**OSS Contributor?** Use the contributor wizard for fork workflows: **OSS Contributor?** Use the contributor wizard for fork workflows:
```bash ```bash
bd init --contributor # Interactive setup for separate planning repo bd init --contributor # Interactive setup for separate planning repo
``` ```
**Team Member?** Use the team wizard for branch workflows: **Team Member?** Use the team wizard for branch workflows:
```bash ```bash
bd init --team # Interactive setup for team collaboration bd init --team # Interactive setup for team collaboration
``` ```
**Check for ready work:** **Check for ready work:**
```bash ```bash
bd ready --json bd ready --json
``` ```
**Create new issues:** **Create new issues:**
```bash ```bash
bd create "Issue title" -t bug|feature|task -p 0-4 --json bd create "Issue title" -t bug|feature|task -p 0-4 --json
bd create "Issue title" -p 1 --deps discovered-from:bd-123 --json bd create "Issue title" -p 1 --deps discovered-from:bd-123 --json
``` ```
**Claim and update:** **Claim and update:**
```bash ```bash
bd update bd-42 --status in_progress --json bd update bd-42 --status in_progress --json
bd update bd-42 --priority 1 --json bd update bd-42 --priority 1 --json
``` ```
**Complete work:** **Complete work:**
```bash ```bash
bd close bd-42 --reason "Completed" --json bd close bd-42 --reason "Completed" --json
``` ```
@@ -1131,6 +1230,7 @@ bd close bd-42 --reason "Completed" --json
### Auto-Sync ### Auto-Sync
bd automatically syncs with git: bd automatically syncs with git:
- Exports to `.beads/issues.jsonl` after changes (5s debounce) - Exports to `.beads/issues.jsonl` after changes (5s debounce)
- Imports from JSONL when newer (e.g., after `git pull`) - Imports from JSONL when newer (e.g., after `git pull`)
- No manual export/import needed! - No manual export/import needed!
@@ -1144,6 +1244,7 @@ pip install beads-mcp
``` ```
Add to MCP config (e.g., `~/.config/claude/config.json`): Add to MCP config (e.g., `~/.config/claude/config.json`):
```json ```json
{ {
"beads": { "beads": {
@@ -1158,6 +1259,7 @@ Then use `mcp__beads__*` functions instead of CLI commands.
### Managing AI-Generated Planning Documents ### Managing AI-Generated Planning Documents
AI assistants often create planning and design documents during development: AI assistants often create planning and design documents during development:
- PLAN.md, IMPLEMENTATION.md, ARCHITECTURE.md - PLAN.md, IMPLEMENTATION.md, ARCHITECTURE.md
- DESIGN.md, CODEBASE_SUMMARY.md, INTEGRATION_PLAN.md - DESIGN.md, CODEBASE_SUMMARY.md, INTEGRATION_PLAN.md
- TESTING_GUIDE.md, TECHNICAL_DESIGN.md, and similar files - 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** **Best Practice: Use a dedicated directory for these ephemeral files**
**Recommended approach:** **Recommended approach:**
- Create a `history/` directory in the project root - Create a `history/` directory in the project root
- Store ALL AI-generated planning/design docs in `history/` - Store ALL AI-generated planning/design docs in `history/`
- Keep the repository root clean and focused on permanent project files - Keep the repository root clean and focused on permanent project files
- Only access `history/` when explicitly asked to review past planning - Only access `history/` when explicitly asked to review past planning
**Example .gitignore entry (optional):** **Example .gitignore entry (optional):**
``` ```
# AI planning documents (ephemeral) # AI planning documents (ephemeral)
history/ history/
``` ```
**Benefits:** **Benefits:**
- ✅ Clean repository root - ✅ Clean repository root
- ✅ Clear separation between ephemeral and permanent documentation - ✅ Clear separation between ephemeral and permanent documentation
- ✅ Easy to exclude from version control if desired - ✅ Easy to exclude from version control if desired
@@ -1196,4 +1301,5 @@ history/
- ❌ Do NOT clutter repo root with planning documents - ❌ Do NOT clutter repo root with planning documents
For more details, see README.md and QUICKSTART.md. For more details, see README.md and QUICKSTART.md.
<!-- /bd onboard section --> <!-- /bd onboard section -->

View File

@@ -634,6 +634,10 @@ bd daemons health
bd daemons stop /path/to/workspace bd daemons stop /path/to/workspace
bd daemons stop 12345 # By PID 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 # View daemon logs
bd daemons logs /path/to/workspace -n 100 bd daemons logs /path/to/workspace -n 100
bd daemons logs 12345 -f # Follow mode 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. 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 ## Examples
Check out the **[examples/](examples/)** directory for: Check out the **[examples/](examples/)** directory for:

View File

@@ -77,6 +77,30 @@ bd daemons stop 12345
bd daemons stop /Users/me/projects/myapp --json bd daemons stop /Users/me/projects/myapp --json
``` ```
### restart
Restart a specific daemon gracefully.
```bash
bd daemons restart <workspace-path|pid> [--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|pid>` - 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 ### logs
View logs for a specific daemon. 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 health # Check for version mismatches
bd daemons killall # Stop all old daemons bd daemons killall # Stop all old daemons
# Daemons will auto-start with new version on next bd command # Daemons will auto-start with new version on next bd command
# Or restart a specific daemon
bd daemons restart /path/to/workspace
``` ```
### Debugging ### Debugging

View File

@@ -1,10 +1,119 @@
# CLAUDE.md
<!-- bd integration note --> This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**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.
<!-- /bd integration note -->
# 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.

View File

@@ -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 - **[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 - **[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 - **[markdown-to-jsonl/](markdown-to-jsonl/)** - Convert markdown planning docs to bd issues
- **[github-import/](github-import/)** - Import issues from GitHub repositories - **[github-import/](github-import/)** - Import issues from GitHub repositories
- **[git-hooks/](git-hooks/)** - Pre-configured git hooks for automatic export/import - **[git-hooks/](git-hooks/)** - Pre-configured git hooks for automatic export/import

View File

@@ -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.

View File

@@ -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 => ../..

View File

@@ -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=

View File

@@ -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
}
}
}
}

Binary file not shown.

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>bd monitor - Issue Tracker</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.min.css">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
</div>
<div class="header">
<div>
<h1>bd monitor</h1>
<p>Real-time issue tracking dashboard</p>
</div>
<div class="connection-status disconnected" id="connection-status">
<span class="connection-dot disconnected" id="connection-dot"></span>
<span id="connection-text">Connecting...</span>
</div>
</div>
<div class="error-message" id="error-message"></div>
<div class="stats">
<h2>Statistics</h2>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-total">-</div>
<div class="stat-label">Total Issues</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-in-progress">-</div>
<div class="stat-label">In Progress</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-open">-</div>
<div class="stat-label">Open</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-closed">-</div>
<div class="stat-label">Closed</div>
</div>
</div>
</div>
<div class="filter-controls">
<label>
Status (multi-select):
<select id="filter-status" multiple>
<option value="open" selected>Open</option>
<option value="in-progress">In Progress</option>
<option value="closed">Closed</option>
</select>
</label>
<label>
Priority:
<select id="filter-priority">
<option value="">All</option>
<option value="1">P1</option>
<option value="2">P2</option>
<option value="3">P3</option>
</select>
</label>
<button class="reload-button" id="reload-button" title="Reload all data">
🔄 Reload
</button>
</div>
<h2>Issues</h2>
<table id="issues-table">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Status</th>
<th>Priority</th>
<th>Type</th>
<th>Assignee</th>
</tr>
</thead>
<tbody id="issues-tbody">
<tr><td colspan="6"><div class="spinner"></div></td></tr>
</tbody>
</table>
<!-- Mobile card view -->
<div class="issues-card-view" id="issues-card-view">
<div class="spinner"></div>
</div>
<!-- Modal for issue details -->
<div id="issue-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2 id="modal-title">Issue Details</h2>
<div id="modal-body">
<p>Loading...</p>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -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; }
}

View File

@@ -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 = '<tr><td colspan="6" style="text-align: center; color: #721c24;">Error loading issues</td></tr>';
document.getElementById('issues-card-view').innerHTML = '<div class="empty-state"><div class="empty-state-icon">⚠️</div><p>Error loading issues</p></div>';
}
}
// 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 = '<div class="empty-state"><div class="empty-state-icon">📋</div><h3>No issues found</h3><p>Create your first issue to get started!</p></div>';
tbody.innerHTML = '<tr><td colspan="6">' + emptyState + '</td></tr>';
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 '<tr onclick="showIssueDetail(\'' + issue.id + '\')"><td>' + issue.id + '</td><td>' + issue.title + '</td><td class="' + statusClass + '">' + (issue.status || 'open') + '</td><td class="' + priorityClass + '">P' + (issue.priority || 2) + '</td><td>' + (issue.issue_type || 'task') + '</td><td>' + (issue.assignee || '-') + '</td></tr>';
}).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 = '<div class="issue-card" onclick="showIssueDetail(\'' + issue.id + '\')">';
html += '<div class="issue-card-header">';
html += '<span class="issue-card-id">' + issue.id + '</span>';
html += '<span class="' + priorityClass + '">P' + (issue.priority || 2) + '</span>';
html += '</div>';
html += '<h3 class="issue-card-title">' + issue.title + '</h3>';
html += '<div class="issue-card-meta">';
html += '<span class="' + statusClass + '">● ' + (issue.status || 'open') + '</span>';
html += '<span>Type: ' + (issue.issue_type || 'task') + '</span>';
if (issue.assignee) html += '<span>👤 ' + issue.assignee + '</span>';
html += '</div>';
html += '</div>';
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 = '<div class="spinner"></div>';
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 = '<p><strong>Status:</strong> ' + issue.status + '</p>';
html += '<p><strong>Priority:</strong> P' + issue.priority + '</p>';
html += '<p><strong>Type:</strong> ' + issue.issue_type + '</p>';
html += '<p><strong>Assignee:</strong> ' + (issue.assignee || 'Unassigned') + '</p>';
html += '<p><strong>Created:</strong> ' + new Date(issue.created_at).toLocaleString() + '</p>';
html += '<p><strong>Updated:</strong> ' + new Date(issue.updated_at).toLocaleString() + '</p>';
if (issue.description) html += '<h3>Description</h3><pre>' + issue.description + '</pre>';
if (issue.design) html += '<h3>Design</h3><pre>' + issue.design + '</pre>';
if (issue.notes) html += '<h3>Notes</h3><pre>' + issue.notes + '</pre>';
if (issue.labels && issue.labels.length > 0) html += '<p><strong>Labels:</strong> ' + issue.labels.join(', ') + '</p>';
modalBody.innerHTML = html;
} catch (error) {
console.error('Error loading issue details:', error);
showError('Failed to load issue details: ' + error.message);
modalBody.innerHTML = '<div class="empty-state"><div class="empty-state-icon">⚠️</div><p>Error loading issue details</p></div>';
}
}
// 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);

View File

@@ -267,6 +267,11 @@ func (c *Client) Stats() (*Response, error) {
return c.Execute(OpStats, nil) 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 // AddDependency adds a dependency via the daemon
func (c *Client) AddDependency(args *DepAddArgs) (*Response, error) { func (c *Client) AddDependency(args *DepAddArgs) (*Response, error) {
return c.Execute(OpDepAdd, args) return c.Execute(OpDepAdd, args)

View File

@@ -33,6 +33,7 @@ const (
OpExport = "export" OpExport = "export"
OpImport = "import" OpImport = "import"
OpEpicStatus = "epic_status" OpEpicStatus = "epic_status"
OpGetMutations = "get_mutations"
OpShutdown = "shutdown" OpShutdown = "shutdown"
) )
@@ -315,3 +316,8 @@ type ExportArgs struct {
type ImportArgs struct { type ImportArgs struct {
JSONLPath string `json:"jsonl_path"` // Path to import JSONL file 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)
}

View File

@@ -1,6 +1,7 @@
package rpc package rpc
import ( import (
"encoding/json"
"fmt" "fmt"
"net" "net"
"os" "os"
@@ -49,6 +50,10 @@ type Server struct {
// Mutation events for event-driven daemon // Mutation events for event-driven daemon
mutationChan chan MutationEvent mutationChan chan MutationEvent
droppedEvents atomic.Int64 // Counter for dropped mutation events 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 // Mutation event types
@@ -93,19 +98,21 @@ func NewServer(socketPath string, store storage.Storage, workspacePath string, d
} }
s := &Server{ s := &Server{
socketPath: socketPath, socketPath: socketPath,
workspacePath: workspacePath, workspacePath: workspacePath,
dbPath: dbPath, dbPath: dbPath,
storage: store, storage: store,
shutdownChan: make(chan struct{}), shutdownChan: make(chan struct{}),
doneChan: make(chan struct{}), doneChan: make(chan struct{}),
startTime: time.Now(), startTime: time.Now(),
metrics: NewMetrics(), metrics: NewMetrics(),
maxConns: maxConns, maxConns: maxConns,
connSemaphore: make(chan struct{}, maxConns), connSemaphore: make(chan struct{}, maxConns),
requestTimeout: requestTimeout, requestTimeout: requestTimeout,
readyChan: make(chan struct{}), readyChan: make(chan struct{}),
mutationChan: make(chan MutationEvent, mutationBufferSize), // Configurable buffer mutationChan: make(chan MutationEvent, mutationBufferSize), // Configurable buffer
recentMutations: make([]MutationEvent, 0, 100),
maxMutationBuffer: 100,
} }
s.lastActivityTime.Store(time.Now()) s.lastActivityTime.Store(time.Now())
return s 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. // emitMutation sends a mutation event to the daemon's event-driven loop.
// Non-blocking: drops event if channel is full (sync will happen eventually). // 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) { func (s *Server) emitMutation(eventType, issueID string) {
select { event := MutationEvent{
case s.mutationChan <- MutationEvent{
Type: eventType, Type: eventType,
IssueID: issueID, IssueID: issueID,
Timestamp: time.Now(), Timestamp: time.Now(),
}: }
// Send to mutation channel for daemon
select {
case s.mutationChan <- event:
// Event sent successfully // Event sent successfully
default: default:
// Channel full, increment dropped events counter // Channel full, increment dropped events counter
s.droppedEvents.Add(1) 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 // 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 { func (s *Server) ResetDroppedEventsCount() int64 {
return s.droppedEvents.Swap(0) 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,
}
}

View File

@@ -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))
}
}

View File

@@ -201,6 +201,8 @@ func (s *Server) handleRequest(req *Request) Response {
resp = s.handleImport(req) resp = s.handleImport(req)
case OpEpicStatus: case OpEpicStatus:
resp = s.handleEpicStatus(req) resp = s.handleEpicStatus(req)
case OpGetMutations:
resp = s.handleGetMutations(req)
case OpShutdown: case OpShutdown:
resp = s.handleShutdown(req) resp = s.handleShutdown(req)
default: default: