Split internal/rpc/server.go into 8 focused modules (bd-215)
Refactored monolithic 2238-line server.go into 8 files with clear responsibilities: - server_core.go: Server type, NewServer (115 lines) - server_lifecycle_conn.go: Start/Stop/connection handling (248 lines) - server_cache_storage.go: Storage caching and eviction (286 lines) - server_routing_validation_diagnostics.go: Request routing/validation (384 lines) - server_issues_epics.go: Issue CRUD operations (506 lines) - server_labels_deps_comments.go: Labels/deps/comments (199 lines) - server_compact.go: Compaction operations (287 lines) - server_export_import_auto.go: Export/import operations (293 lines) Improvements: - Replaced RWMutex.TryLock with atomic.Bool for portable single-flight guard - Added default storage close in Stop() to prevent FD leaks - All methods remain on *Server receiver (no behavior changes) - Each file <510 LOC for better maintainability - All tests pass, daemon verified working Amp-Thread-ID: https://ampcode.com/threads/T-92d481ad-1bda-4ecd-bcf5-874a1889db30 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -64,7 +64,7 @@
|
||||
{"id":"bd-156","title":"bd create files issues in wrong project when multiple beads databases exist","description":"When working in a directory with a beads database (e.g., /Users/stevey/src/wyvern/.beads/wy.db), bd create can file issues in a different project's database instead of the current directory's database.\n\n## Steps to reproduce:\n1. Have multiple beads projects (e.g., ~/src/wyvern with wy.db, ~/vibecoder with vc.db)\n2. cd ~/src/wyvern\n3. Run bd create --title \"Test\" --type bug\n4. Observe issue created with wrong prefix (e.g., vc-1 instead of wy-1)\n\n## Expected behavior:\nbd create should respect the current working directory and use the beads database in that directory (.beads/ folder).\n\n## Actual behavior:\nbd create appears to use a different project's database, possibly the last accessed or a global default.\n\n## Impact:\nThis can cause issues to be filed in completely wrong projects, polluting unrelated issue trackers.\n\n## Suggested fix:\n- Always check for .beads/ directory in current working directory first\n- Add --project flag to explicitly specify which database to use\n- Show which project/database is being used in command output\n- Add validation/confirmation when creating issues if current directory doesn't match database project","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-10-26T17:02:30.578817-07:00","updated_at":"2025-10-26T17:08:43.009159-07:00","closed_at":"2025-10-26T17:08:43.009159-07:00","labels":["cli","project-context"]}
|
||||
{"id":"bd-157","title":"Implement \"bd daemons health\" subcommand","description":"Add health check command that pings each daemon and reports responsiveness. Should detect and report stale sockets, version mismatches, unresponsive daemons.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T17:09:51.138682-07:00","updated_at":"2025-10-26T17:47:47.958834-07:00","closed_at":"2025-10-26T17:47:47.958834-07:00","dependencies":[{"issue_id":"bd-157","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:09:51.140111-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-158","title":"Implement \"bd daemons list\" subcommand","description":"Create the \"bd daemons list\" command that displays all running daemons in a table with: workspace path, PID, version, socket path, uptime, last activity, exclusive lock status. Include --json flag.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T17:09:51.140442-07:00","updated_at":"2025-10-26T17:47:47.929666-07:00","closed_at":"2025-10-26T17:47:47.929666-07:00","dependencies":[{"issue_id":"bd-158","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:09:51.150077-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-159","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-26T17:58:15.41007-07:00","updated_at":"2025-10-27T21:12:26.911977-07:00","closed_at":"2025-10-27T21:12:26.911977-07:00"}
|
||||
{"id":"bd-159","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-26T17:58:15.41007-07:00","updated_at":"2025-10-27T20:17:24.88576-07:00","closed_at":"2025-10-27T20:17:24.88576-07:00"}
|
||||
{"id":"bd-16","title":"Add lifecycle safety docs and tests for UnderlyingDB() method","description":"The new UnderlyingDB() method exposes the raw *sql.DB connection for extensions like VC to create their own tables. While database/sql is concurrency-safe, there are lifecycle and misuse risks that need documentation and testing.\n\n**What needs to be done:**\n\n1. **Enhanced documentation** - Expand UnderlyingDB() comments to warn:\n - Callers MUST NOT call Close() on returned DB\n - Do NOT change pool/driver settings (SetMaxOpenConns, SetConnMaxIdleTime)\n - Do NOT modify SQLite PRAGMAs (WAL mode, journal, etc.)\n - Expect errors after Storage.Close() - use contexts\n - Keep write transactions short to avoid blocking core storage\n\n2. **Add lifecycle tracking** - Implement closed flag:\n - Add atomic.Bool closed field to SQLiteStorage\n - Set flag in Close(), clear in New()\n - Optional: Add IsClosed() bool method\n\n3. **Add safety tests** (run with -race):\n - TestUnderlyingDB_ConcurrentAccess - N goroutines using UnderlyingDB() during normal storage ops\n - TestUnderlyingDB_AfterClose - Verify operations fail cleanly after storage closed\n - TestUnderlyingDB_CreateExtensionTables - Create VC table with FK to issues, verify FK enforcement\n - TestUnderlyingDB_LongTxDoesNotCorrupt - Ensure long read tx doesn't block writes indefinitely\n\n**Why this matters:**\nVC will use this to create tables in the same database. Need to ensure production-ready safety without over-engineering.\n\n**Estimated effort:** S+S+S = M total (1-3h)","design":"Oracle recommends \"simple path\": enhanced docs + minimal guardrails + focused tests. See oracle output for detailed rationale on concurrency safety, lifecycle risks, and when to consider advanced path (wrapping interface).","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-22T17:07:56.812983-07:00","updated_at":"2025-10-25T23:15:33.476053-07:00","closed_at":"2025-10-22T20:10:52.636372-07:00"}
|
||||
{"id":"bd-160","title":"Add database schema versioning","description":"Store beads version in SQLite database for version compatibility checking.\n\nImplementation:\n- Add metadata table with schema_version field (or use PRAGMA user_version)\n- Set on database creation (bd init)\n- Daemon validates on startup: schema version matches daemon version\n- Fail with clear error if mismatch: \"Database schema v0.17.5 but daemon is v0.18.0\"\n- Provide migration guidance in error message\n\nSchema version format:\n- Use semver (0.17.5)\n- Store in metadata table: CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)\n- Alternative: PRAGMA user_version (integer only)\n\nBenefits:\n- Detect version mismatches before corruption\n- Enable auto-migration in future\n- Clear error messages for users","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T19:41:11.098628-07:00","updated_at":"2025-10-26T19:41:11.115761-07:00","closed_at":"2025-10-26T19:04:07.843634-07:00","dependencies":[{"issue_id":"bd-160","depends_on_id":"bd-159","type":"parent-child","created_at":"2025-10-26T18:06:07.569191-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-161","title":"Update AGENTS.md and README.md with \"bd daemons\" documentation","description":"Document the new \"bd daemons\" command and all subcommands in AGENTS.md and README.md. Include examples and troubleshooting guidance.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-26T19:41:11.099254-07:00","updated_at":"2025-10-26T19:41:11.099254-07:00","dependencies":[{"issue_id":"bd-161","depends_on_id":"bd-159","type":"parent-child","created_at":"2025-10-26T18:06:07.570687-07:00","created_by":"daemon"}]}
|
||||
@@ -118,16 +118,16 @@
|
||||
{"id":"bd-204","title":"Update LINTING.md with current baseline","description":"After cleanup, document the remaining acceptable baseline in LINTING.md so we can track regression.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T18:53:14.531787-07:00","updated_at":"2025-10-27T18:53:14.531787-07:00","closed_at":"2025-10-27T18:37:08.880971-07:00"}
|
||||
{"id":"bd-205","title":"Update LINTING.md with current baseline","description":"After cleanup, document the remaining acceptable baseline in LINTING.md so we can track regression.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T18:53:14.532297-07:00","updated_at":"2025-10-27T18:53:14.532297-07:00"}
|
||||
{"id":"bd-206","title":"Import fails when updating open issue to closed without setting closed_at","description":"When importing JSONL, if an issue exists in the database as \"open\" but the JSONL has it as \"closed\", the import tries to UPDATE status to \"closed\" without setting the closed_at timestamp. This violates the CHECK constraint: (status = 'closed') = (closed_at IS NOT NULL).\n\n**Steps to reproduce:**\n1. Have issue bd-X in database with status=\"open\", closed_at=NULL\n2. JSONL has same issue with status=\"closed\", closed_at=\"2025-10-27T...\"\n3. Run bd import -i .beads/beads.jsonl --resolve-collisions\n4. Error: \"constraint failed: CHECK constraint failed: (status = 'closed') = (closed_at IS NOT NULL) (275)\"\n\n**Expected behavior:**\nImport should update both status AND closed_at when transitioning to closed.\n\n**Actual behavior:**\nImport only updates status field, leaving closed_at=NULL, violating constraint.\n\n**Observed during:**\nSyncing two workspaces where collision resolution remapped bd-45. One workspace had it open, the other had it closed. Import tried to update open→closed but didn't copy closed_at from JSONL.\n\n**Impact:**\n- Prevents successful import when JSONL has closed issues that DB has as open\n- Blocks multi-workspace sync scenarios\n- Forces manual database rebuilds\n\n**Suggested fix:**\nIn import code, when updating an issue, if status changes to \"closed\", ensure closed_at is set from JSONL. Similarly, if status changes from \"closed\" to \"open\", ensure closed_at is cleared.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T18:58:42.228957-07:00","updated_at":"2025-10-27T19:06:37.684793-07:00","closed_at":"2025-10-27T19:06:37.684793-07:00"}
|
||||
{"id":"bd-207","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T19:36:16.764891-07:00","updated_at":"2025-10-27T20:52:52.642685-07:00","closed_at":"2025-10-27T19:54:31.429989-07:00"}
|
||||
{"id":"bd-208","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:21:42.565266-07:00","updated_at":"2025-10-27T20:21:42.565266-07:00","closed_at":"2025-10-27T20:17:24.88576-07:00"}
|
||||
{"id":"bd-209","title":"Code Health \u0026 Technical Debt Cleanup","description":"Comprehensive codebase cleanup to remove dead code, refactor monolithic files, deduplicate utilities, and improve maintainability. Based on ultrathink code health analysis conducted 2025-10-27.\n\nGoals:\n- Remove ~1,500 LOC of dead/unreachable code\n- Split 2 monolithic files (server.go 2,273 LOC, sqlite.go 2,136 LOC) into focused modules\n- Deduplicate scattered utility functions (normalizeLabels, BD_DEBUG checks)\n- Consolidate test coverage (2,019 LOC of collision tests)\n- Improve code navigation and reduce merge conflicts\n\nImpact: Reduces codebase by ~6-8%, improves maintainability, faster CI/CD\n\nEstimated Effort: 11 days across 4 phases","acceptance_criteria":"- All unreachable code identified by `deadcode` analyzer is removed\n- RPC server split into \u003c500 LOC files with clear responsibilities\n- Duplicate utility functions centralized\n- Test coverage maintained or improved\n- All tests passing\n- Documentation updated","status":"open","priority":2,"issue_type":"epic","created_at":"2025-10-27T20:29:37.837243-07:00","updated_at":"2025-10-27T20:29:37.837243-07:00","labels":["cleanup","epic"]}
|
||||
{"id":"bd-207","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:32:12.154712-07:00","updated_at":"2025-10-27T20:43:08.946027-07:00","closed_at":"2025-10-27T20:43:08.946027-07:00"}
|
||||
{"id":"bd-208","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:39:22.221849-07:00","updated_at":"2025-10-27T20:39:22.236222-07:00","closed_at":"2025-10-27T19:54:31.429989-07:00"}
|
||||
{"id":"bd-209","title":"Code Health \u0026 Technical Debt Cleanup","description":"Comprehensive codebase cleanup to remove dead code, refactor monolithic files, deduplicate utilities, and improve maintainability. Based on ultrathink code health analysis conducted 2025-10-27.\n\nGoals:\n- Remove ~1,500 LOC of dead/unreachable code\n- Split 2 monolithic files (server.go 2,273 LOC, sqlite.go 2,136 LOC) into focused modules\n- Deduplicate scattered utility functions (normalizeLabels, BD_DEBUG checks)\n- Consolidate test coverage (2,019 LOC of collision tests)\n- Improve code navigation and reduce merge conflicts\n\nImpact: Reduces codebase by ~6-8%, improves maintainability, faster CI/CD\n\nEstimated Effort: 11 days across 4 phases","acceptance_criteria":"- All unreachable code identified by `deadcode` analyzer is removed\n- RPC server split into \u003c500 LOC files with clear responsibilities\n- Duplicate utility functions centralized\n- Test coverage maintained or improved\n- All tests passing\n- Documentation updated","status":"open","priority":2,"issue_type":"epic","created_at":"2025-10-27T20:39:22.22227-07:00","updated_at":"2025-10-27T20:39:22.236674-07:00","labels":["cleanup","epic"]}
|
||||
{"id":"bd-21","title":"Fix bd sync prefix mismatch error message suggesting non-existent flag","description":"GH #103: bd sync suggests using --rename-on-import flag that doesn't exist. Need to either implement the flag or fix the error message to suggest the correct workflow.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-22T17:54:24.473508-07:00","updated_at":"2025-10-25T23:15:33.481941-07:00","closed_at":"2025-10-22T17:57:46.973029-07:00"}
|
||||
{"id":"bd-210","title":"Delete cmd/bd/import_phases.go - entire file is dead code","description":"The file `cmd/bd/import_phases.go` (377 LOC) contains 7 functions that are all unreachable according to the deadcode analyzer. This appears to be an abandoned import refactoring that was never completed or has been replaced by the current implementation in `import.go`.\n\nUnreachable functions:\n- `getOrCreateStore` (line 15)\n- `handlePrefixMismatch` (line 43)\n- `handleCollisions` (line 87)\n- `upsertIssues` (line 155)\n- `importDependencies` (line 240)\n- `importLabels` (line 281)\n- `importComments` (line 316)\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n# Shows all 7 functions as unreachable\n```\n\nNo external callers found via:\n```bash\ngrep -r \"getOrCreateStore\\|handlePrefixMismatch\\|handleCollisions\\|upsertIssues\" cmd/bd/*.go\n# Only matches within import_phases.go itself\n```\n\nImpact: Removes 377 LOC, simplifies import logic","acceptance_criteria":"- Delete `cmd/bd/import_phases.go`\n- Verify all tests still pass: `go test ./cmd/bd/...`\n- Verify import functionality works: test `bd import` command\n- Run deadcode analyzer to confirm no new unreachable code","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-27T20:30:19.931508-07:00","updated_at":"2025-10-27T20:34:54.268126-07:00","closed_at":"2025-10-27T20:34:54.268126-07:00","labels":["cleanup","dead-code","phase-1"],"dependencies":[{"issue_id":"bd-210","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:19.933155-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-211","title":"Remove deprecated rename functions from import_shared.go","description":"The file `cmd/bd/import_shared.go` contains deprecated and unreachable rename functions (~100 LOC) that are no longer used. The active implementation has moved to `internal/importer/importer.go`.\n\nFunctions to remove:\n- `renameImportedIssuePrefixes` (line 262) - wrapper function, unused\n- `renameImportedIssuePrefixesOld` (line 267) - marked Deprecated, 70 LOC\n- `replaceIDReferences` (line 332) - only called by deprecated function\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n# Shows these as unreachable\n```\n\nThe actual implementation is in:\n- `internal/importer/importer.go` - `RenameImportedIssuePrefixes`\n\nImpact: Removes ~100 LOC of deprecated code","acceptance_criteria":"- Remove lines 262-340 from `cmd/bd/import_shared.go`\n- Verify no callers exist: `grep -r \"renameImportedIssuePrefixes\\|replaceIDReferences\" cmd/bd/`\n- All tests pass: `go test ./cmd/bd/...`\n- Import with rename works: `bd import --rename-on-import`","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-27T20:30:19.948413-07:00","updated_at":"2025-10-27T20:38:13.769416-07:00","closed_at":"2025-10-27T20:38:13.769416-07:00","labels":["cleanup","dead-code","phase-1"],"dependencies":[{"issue_id":"bd-211","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:19.950023-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-212","title":"Delete skipped tests for \"old buggy behavior\"","description":"Three test functions are permanently skipped with comments indicating they test behavior that was fixed in GH#120. These tests will never run again and should be deleted.\n\nTest functions to remove:\n\n1. `cmd/bd/import_collision_test.go:228`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\n2. `cmd/bd/import_collision_test.go:505`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\n3. `internal/storage/sqlite/collision_test.go:919`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\nImpact: Removes ~150 LOC of permanently skipped tests","acceptance_criteria":"- Delete the 3 test functions entirely (~150 LOC total)\n- Update test file comments to reference GH#120 fix if needed\n- All remaining tests pass: `go test ./...`\n- No reduction in meaningful test coverage (these test fixed bugs)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-27T20:30:19.961185-07:00","updated_at":"2025-10-27T20:52:40.36471-07:00","closed_at":"2025-10-27T20:52:40.36471-07:00","labels":["cleanup","dead-code","phase-1","test-cleanup"],"dependencies":[{"issue_id":"bd-212","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:19.962815-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-213","title":"Remove unreachable RPC methods","description":"Several RPC server and client methods are unreachable and should be removed:\n\nServer methods (internal/rpc/server.go):\n- `Server.GetLastImportTime` (line 2116)\n- `Server.SetLastImportTime` (line 2123)\n- `Server.findJSONLPath` (line 2255)\n\nClient methods (internal/rpc/client.go):\n- `Client.Import` (line 311) - RPC import not used (daemon uses autoimport)\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n```\n\nImpact: Removes ~80 LOC of unused RPC code","acceptance_criteria":"- Remove the 4 unreachable methods (~80 LOC total)\n- Verify no callers: `grep -r \"GetLastImportTime\\|SetLastImportTime\\|findJSONLPath\" .`\n- All tests pass: `go test ./internal/rpc/...`\n- Daemon functionality works: test daemon start/stop/operations","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:19.962209-07:00","updated_at":"2025-10-27T20:52:40.366402-07:00","closed_at":"2025-10-27T20:52:40.366402-07:00","labels":["cleanup","dead-code","phase-1","rpc"],"dependencies":[{"issue_id":"bd-213","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:19.965239-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-214","title":"Remove unreachable utility functions","description":"Several small utility functions are unreachable:\n\nFiles to clean:\n1. `internal/storage/sqlite/hash.go` - `computeIssueContentHash` (line 17)\n - Check if entire file can be deleted if only contains this function\n\n2. `internal/config/config.go` - `FileUsed` (line 151)\n - Delete unused config helper\n\n3. `cmd/bd/git_sync_test.go` - `verifyIssueOpen` (line 300)\n - Delete dead test helper\n\n4. `internal/compact/haiku.go` - `HaikuClient.SummarizeTier2` (line 81)\n - Tier 2 summarization not implemented\n - Options: implement feature OR delete method\n\nImpact: Removes 50-100 LOC depending on decisions","acceptance_criteria":"- Remove unreachable functions\n- If entire files can be deleted (like hash.go), delete them\n- For SummarizeTier2: decide to implement or delete, document decision\n- All tests pass: `go test ./...`\n- Verify no callers exist for each function","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:19.963392-07:00","updated_at":"2025-10-27T20:44:57.549063-07:00","closed_at":"2025-10-27T20:44:57.549063-07:00","labels":["cleanup","dead-code","phase-1"],"dependencies":[{"issue_id":"bd-214","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:19.968126-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-215","title":"Split internal/rpc/server.go into focused modules","description":"The file `internal/rpc/server.go` is 2,273 lines with 50+ methods, making it difficult to navigate and prone to merge conflicts. Split into 8 focused files with clear responsibilities.\n\nCurrent structure: Single 2,273-line file with:\n- Connection handling\n- Request routing\n- All 40+ RPC method implementations\n- Storage caching\n- Health checks \u0026 metrics\n- Cleanup loops\n\nTarget structure:\n```\ninternal/rpc/\n├── server.go # Core server, connection handling (~300 lines)\n├── methods_issue.go # Issue operations (~400 lines)\n├── methods_deps.go # Dependency operations (~200 lines)\n├── methods_labels.go # Label operations (~150 lines)\n├── methods_ready.go # Ready work queries (~150 lines)\n├── methods_compact.go # Compaction operations (~200 lines)\n├── methods_comments.go # Comment operations (~150 lines)\n├── storage_cache.go # Storage caching logic (~300 lines)\n└── health.go # Health \u0026 metrics (~200 lines)\n```\n\nMigration strategy:\n1. Create new files with appropriate methods\n2. Keep `server.go` as main file with core server logic\n3. Test incrementally after each file split\n4. Final verification with full test suite","acceptance_criteria":"- All 50 methods split into appropriate files\n- Each file \u003c500 LOC\n- All methods remain on `*Server` receiver (no behavior change)\n- All tests pass: `go test ./internal/rpc/...`\n- Verify daemon works: start daemon, run operations, check health\n- Update internal documentation if needed\n- No change to public API","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-27T20:30:47.870646-07:00","updated_at":"2025-10-27T20:30:47.870646-07:00","labels":["architecture","phase-2","refactor"],"dependencies":[{"issue_id":"bd-215","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:47.872396-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-212","title":"Delete skipped tests for \"old buggy behavior\"","description":"Three test functions are permanently skipped with comments indicating they test behavior that was fixed in GH#120. These tests will never run again and should be deleted.\n\nTest functions to remove:\n\n1. `cmd/bd/import_collision_test.go:228`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\n2. `cmd/bd/import_collision_test.go:505`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\n3. `internal/storage/sqlite/collision_test.go:919`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\nImpact: Removes ~150 LOC of permanently skipped tests","acceptance_criteria":"- Delete the 3 test functions entirely (~150 LOC total)\n- Update test file comments to reference GH#120 fix if needed\n- All remaining tests pass: `go test ./...`\n- No reduction in meaningful test coverage (these test fixed bugs)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-27T20:30:19.961185-07:00","updated_at":"2025-10-27T20:30:19.961185-07:00","labels":["cleanup","dead-code","phase-1","test-cleanup"],"dependencies":[{"issue_id":"bd-212","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:19.962815-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-213","title":"Remove unreachable RPC methods","description":"Several RPC server and client methods are unreachable and should be removed:\n\nServer methods (internal/rpc/server.go):\n- `Server.GetLastImportTime` (line 2116)\n- `Server.SetLastImportTime` (line 2123)\n- `Server.findJSONLPath` (line 2255)\n\nClient methods (internal/rpc/client.go):\n- `Client.Import` (line 311) - RPC import not used (daemon uses autoimport)\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n```\n\nImpact: Removes ~80 LOC of unused RPC code","acceptance_criteria":"- Remove the 4 unreachable methods (~80 LOC total)\n- Verify no callers: `grep -r \"GetLastImportTime\\|SetLastImportTime\\|findJSONLPath\" .`\n- All tests pass: `go test ./internal/rpc/...`\n- Daemon functionality works: test daemon start/stop/operations","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:19.962209-07:00","updated_at":"2025-10-27T20:30:19.962209-07:00","labels":["cleanup","dead-code","phase-1","rpc"],"dependencies":[{"issue_id":"bd-213","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:19.965239-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-214","title":"Remove unreachable utility functions","description":"Several small utility functions are unreachable:\n\nFiles to clean:\n1. `internal/storage/sqlite/hash.go` - `computeIssueContentHash` (line 17)\n - Check if entire file can be deleted if only contains this function\n\n2. `internal/config/config.go` - `FileUsed` (line 151)\n - Delete unused config helper\n\n3. `cmd/bd/git_sync_test.go` - `verifyIssueOpen` (line 300)\n - Delete dead test helper\n\n4. `internal/compact/haiku.go` - `HaikuClient.SummarizeTier2` (line 81)\n - Tier 2 summarization not implemented\n - Options: implement feature OR delete method\n\nImpact: Removes 50-100 LOC depending on decisions","acceptance_criteria":"- Remove unreachable functions\n- If entire files can be deleted (like hash.go), delete them\n- For SummarizeTier2: decide to implement or delete, document decision\n- All tests pass: `go test ./...`\n- Verify no callers exist for each function","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:19.963392-07:00","updated_at":"2025-10-27T20:30:19.963392-07:00","labels":["cleanup","dead-code","phase-1"],"dependencies":[{"issue_id":"bd-214","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:19.968126-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-215","title":"Split internal/rpc/server.go into focused modules","description":"The file `internal/rpc/server.go` is 2,273 lines with 50+ methods, making it difficult to navigate and prone to merge conflicts. Split into 8 focused files with clear responsibilities.\n\nCurrent structure: Single 2,273-line file with:\n- Connection handling\n- Request routing\n- All 40+ RPC method implementations\n- Storage caching\n- Health checks \u0026 metrics\n- Cleanup loops\n\nTarget structure:\n```\ninternal/rpc/\n├── server.go # Core server, connection handling (~300 lines)\n├── methods_issue.go # Issue operations (~400 lines)\n├── methods_deps.go # Dependency operations (~200 lines)\n├── methods_labels.go # Label operations (~150 lines)\n├── methods_ready.go # Ready work queries (~150 lines)\n├── methods_compact.go # Compaction operations (~200 lines)\n├── methods_comments.go # Comment operations (~150 lines)\n├── storage_cache.go # Storage caching logic (~300 lines)\n└── health.go # Health \u0026 metrics (~200 lines)\n```\n\nMigration strategy:\n1. Create new files with appropriate methods\n2. Keep `server.go` as main file with core server logic\n3. Test incrementally after each file split\n4. Final verification with full test suite","acceptance_criteria":"- All 50 methods split into appropriate files\n- Each file \u003c500 LOC\n- All methods remain on `*Server` receiver (no behavior change)\n- All tests pass: `go test ./internal/rpc/...`\n- Verify daemon works: start daemon, run operations, check health\n- Update internal documentation if needed\n- No change to public API","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-27T20:30:47.870646-07:00","updated_at":"2025-10-27T21:09:03.4525-07:00","closed_at":"2025-10-27T21:09:03.4525-07:00","labels":["architecture","phase-2","refactor"],"dependencies":[{"issue_id":"bd-215","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:47.872396-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-216","title":"Extract SQLite migrations into separate files","description":"The file `internal/storage/sqlite/sqlite.go` is 2,136 lines and contains 11 sequential migrations alongside core storage logic. Extract migrations into a versioned system.\n\nCurrent issues:\n- 11 migration functions mixed with core logic\n- Hard to see migration history\n- Sequential migrations slow database open\n- No clear migration versioning\n\nMigration functions to extract:\n- `migrateDirtyIssuesTable()`\n- `migrateIssueCountersTable()`\n- `migrateExternalRefColumn()`\n- `migrateCompositeIndexes()`\n- `migrateClosedAtConstraint()`\n- `migrateCompactionColumns()`\n- `migrateSnapshotsTable()`\n- `migrateCompactionConfig()`\n- `migrateCompactedAtCommitColumn()`\n- `migrateExportHashesTable()`\n- Plus 1 more (11 total)\n\nTarget structure:\n```\ninternal/storage/sqlite/\n├── sqlite.go # Core storage (~800 lines)\n├── schema.go # Table definitions (~200 lines)\n├── migrations.go # Migration orchestration (~200 lines)\n└── migrations/ # Individual migrations\n ├── 001_initial_schema.go\n ├── 002_dirty_issues.go\n ├── 003_issue_counters.go\n [... through 011_export_hashes.go]\n```\n\nBenefits:\n- Clear migration history\n- Each migration self-contained\n- Easier to review migration changes in PRs\n- Future migrations easier to add","acceptance_criteria":"- All 11 migrations extracted to separate files\n- Migration version tracking in database\n- Migrations run in order on fresh database\n- Existing databases upgrade correctly\n- All tests pass: `go test ./internal/storage/sqlite/...`\n- Database initialization time unchanged or improved\n- Add migration rollback capability (optional, nice-to-have)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:47.870671-07:00","updated_at":"2025-10-27T20:30:47.870671-07:00","labels":["database","phase-2","refactor"],"dependencies":[{"issue_id":"bd-216","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:30:47.875564-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-217","title":"Extract normalizeLabels to shared utility package","description":"The `normalizeLabels` function appears in multiple locations with identical implementation. Extract to a shared utility package.\n\nCurrent locations:\n- `internal/rpc/server.go:37` (53 lines) - full implementation\n- `cmd/bd/list.go:50-52` - uses the server version (needs to use new shared version)\n\nFunction purpose:\n- Trims whitespace from labels\n- Removes empty strings\n- Deduplicates labels\n- Preserves order\n\nTarget structure:\n```\ninternal/util/\n├── strings.go # String utilities\n └── NormalizeLabels([]string) []string\n```\n\nImpact: DRY principle, single source of truth, easier to test","acceptance_criteria":"- Create `internal/util/strings.go` with `NormalizeLabels`\n- Add comprehensive unit tests in `internal/util/strings_test.go`\n- Update `internal/rpc/server.go` to import and use `util.NormalizeLabels`\n- Update `cmd/bd/list.go` to import and use `util.NormalizeLabels`\n- Remove duplicate implementations\n- All tests pass: `go test ./...`\n- Verify label normalization works: test `bd list --label` commands","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:31:19.078622-07:00","updated_at":"2025-10-27T20:31:19.078622-07:00","labels":["deduplication","phase-3","refactor"],"dependencies":[{"issue_id":"bd-217","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:31:19.08015-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-218","title":"Centralize BD_DEBUG logging into debug package","description":"The codebase has 43 scattered instances of `if os.Getenv(\"BD_DEBUG\") != \"\"` debug checks across 6 files. Centralize into a debug logging package.\n\nCurrent locations:\n- `cmd/bd/main.go` - 15 checks\n- `cmd/bd/autoflush.go` - 6 checks\n- `cmd/bd/nodb.go` - 4 checks\n- `internal/rpc/server.go` - 2 checks\n- `internal/rpc/client.go` - 5 checks\n- `cmd/bd/daemon_autostart.go` - 11 checks\n\nTarget structure:\n```\ninternal/debug/\n└── debug.go\n```\n\nBenefits:\n- Centralized debug logging\n- Easier to add structured logging later\n- Testable (can mock debug output)\n- Consistent debug message format\n\nImpact: Removes 43 scattered checks, improves code clarity","acceptance_criteria":"- Create `internal/debug/debug.go` with `Enabled`, `Logf`, `Printf`\n- Add unit tests in `internal/debug/debug_test.go` (test with/without BD_DEBUG)\n- Replace all 43 instances of `os.Getenv(\"BD_DEBUG\")` checks with `debug.Logf()`\n- Verify debug output works: run with `BD_DEBUG=1 bd status`\n- All tests pass: `go test ./...`\n- No behavior change (output identical to before)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:31:19.089078-07:00","updated_at":"2025-10-27T20:31:19.089078-07:00","labels":["deduplication","logging","phase-3","refactor"]}
|
||||
@@ -137,17 +137,11 @@
|
||||
{"id":"bd-221","title":"Audit and consolidate collision test coverage","description":"The codebase has 2,019 LOC of collision detection tests across 3 files. Run coverage analysis to identify redundant test cases and consolidate.\n\nTest files:\n- `cmd/bd/import_collision_test.go` - 974 LOC\n- `cmd/bd/autoimport_collision_test.go` - 750 LOC\n- `cmd/bd/import_collision_regression_test.go` - 295 LOC\n\nTotal: 2,019 LOC of collision tests\n\nAnalysis steps:\n1. Run coverage analysis\n2. Identify redundant tests\n3. Document findings\n\nConsolidation strategy:\n- Keep regression tests for critical bugs\n- Merge overlapping table-driven tests\n- Remove redundant edge case tests covered elsewhere\n- Ensure all collision scenarios still tested\n\nExpected outcome: Reduce to ~1,200 LOC (save ~800 lines) while maintaining coverage\n\nImpact: Faster test runs, easier maintenance, clearer test intent","acceptance_criteria":"- Coverage analysis completed and documented\n- Redundant tests identified (~800 LOC estimated)\n- Consolidated test suite maintains or improves coverage\n- All remaining tests pass: `go test ./cmd/bd/...`\n- Test run time unchanged or faster\n- Document which tests were removed and why\n- Coverage percentage maintained: `go test -cover ./cmd/bd/` shows same %","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:32:00.130855-07:00","updated_at":"2025-10-27T20:32:00.130855-07:00","labels":["phase-4","test-cleanup"],"dependencies":[{"issue_id":"bd-221","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:32:00.132251-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-222","title":"Update documentation after code health cleanup","description":"Update all documentation to reflect code structure changes after cleanup phases complete.\n\nDocumentation to update:\n1. **AGENTS.md** - Update file structure references\n2. **CONTRIBUTING.md** (if exists) - Update build/test instructions\n3. **Code comments** - Update any outdated references\n4. **Package documentation** - Update godoc for reorganized packages\n\nNew documentation to add:\n1. **internal/util/README.md** - Document shared utilities\n2. **internal/debug/README.md** - Document debug logging\n3. **internal/rpc/README.md** - Document new file organization\n4. **internal/storage/sqlite/migrations/README.md** - Migration system docs\n\nImpact: Keeps documentation in sync with code","acceptance_criteria":"- All documentation references to deleted files removed\n- New package READMEs written\n- Code comments updated for reorganized code\n- Migration guide for developers (if needed)\n- Architecture diagrams updated (if they exist)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:32:00.141028-07:00","updated_at":"2025-10-27T20:32:00.141028-07:00","labels":["documentation","phase-4"],"dependencies":[{"issue_id":"bd-222","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:32:00.1423-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-223","title":"Run final validation and cleanup checks","description":"Final validation pass to ensure all cleanup objectives met and no regressions introduced.\n\nValidation checklist:\n1. Dead code verification: `go run golang.org/x/tools/cmd/deadcode@latest -test ./...`\n2. Test coverage: `go test -cover ./...`\n3. Build verification: `go build ./cmd/bd/`\n4. Linting: `golangci-lint run`\n5. Integration tests\n6. Metrics verification\n7. Git clean check\n\nFinal metrics to report:\n- LOC removed: ~____\n- Files deleted: ____\n- Files created: ____\n- Test coverage: ____%\n- Build time: ____ (before/after)\n- Test run time: ____ (before/after)\n\nImpact: Confirms all cleanup objectives achieved successfully","acceptance_criteria":"- Zero unreachable functions per deadcode analyzer\n- All tests pass: `go test ./...`\n- Test coverage maintained or improved\n- Builds cleanly: `go build ./...`\n- Linting shows improvements\n- Integration tests all pass\n- LOC reduction target achieved (~2,500 LOC)\n- No unintended behavior changes\n- Git commit messages document all changes","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-27T20:32:00.14166-07:00","updated_at":"2025-10-27T20:32:00.14166-07:00","labels":["phase-4","validation"],"dependencies":[{"issue_id":"bd-223","depends_on_id":"bd-209","type":"parent-child","created_at":"2025-10-27T20:32:00.144113-07:00","created_by":"daemon"}]}
|
||||
{"id":"bd-224","title":"Remove deprecated rename functions from import_shared.go","description":"The file `cmd/bd/import_shared.go` contains deprecated and unreachable rename functions (~100 LOC) that are no longer used. The active implementation has moved to `internal/importer/importer.go`.\n\nFunctions to remove:\n- `renameImportedIssuePrefixes` (line 262) - wrapper function, unused\n- `renameImportedIssuePrefixesOld` (line 267) - marked Deprecated, 70 LOC\n- `replaceIDReferences` (line 332) - only called by deprecated function\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n# Shows these as unreachable\n```\n\nThe actual implementation is in:\n- `internal/importer/importer.go` - `RenameImportedIssuePrefixes`\n\nImpact: Removes ~100 LOC of deprecated code","acceptance_criteria":"- Remove lines 262-340 from `cmd/bd/import_shared.go`\n- Verify no callers exist: `grep -r \"renameImportedIssuePrefixes\\|replaceIDReferences\" cmd/bd/`\n- All tests pass: `go test ./cmd/bd/...`\n- Import with rename works: `bd import --rename-on-import`","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-27T20:36:42.488154-07:00","updated_at":"2025-10-27T20:44:57.54834-07:00","closed_at":"2025-10-27T20:44:57.54834-07:00","labels":["cleanup","dead-code","phase-1"]}
|
||||
{"id":"bd-225","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:38:57.751516-07:00","updated_at":"2025-10-27T21:12:31.956217-07:00","closed_at":"2025-10-27T21:12:31.956217-07:00"}
|
||||
{"id":"bd-224","title":"Remove deprecated rename functions from import_shared.go","description":"The file `cmd/bd/import_shared.go` contains deprecated and unreachable rename functions (~100 LOC) that are no longer used. The active implementation has moved to `internal/importer/importer.go`.\n\nFunctions to remove:\n- `renameImportedIssuePrefixes` (line 262) - wrapper function, unused\n- `renameImportedIssuePrefixesOld` (line 267) - marked Deprecated, 70 LOC\n- `replaceIDReferences` (line 332) - only called by deprecated function\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n# Shows these as unreachable\n```\n\nThe actual implementation is in:\n- `internal/importer/importer.go` - `RenameImportedIssuePrefixes`\n\nImpact: Removes ~100 LOC of deprecated code","acceptance_criteria":"- Remove lines 262-340 from `cmd/bd/import_shared.go`\n- Verify no callers exist: `grep -r \"renameImportedIssuePrefixes\\|replaceIDReferences\" cmd/bd/`\n- All tests pass: `go test ./cmd/bd/...`\n- Import with rename works: `bd import --rename-on-import`","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-27T20:36:42.488154-07:00","updated_at":"2025-10-27T20:48:12.577634-07:00","closed_at":"2025-10-27T20:48:12.577643-07:00","labels":["cleanup","dead-code","phase-1"]}
|
||||
{"id":"bd-225","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:38:57.751516-07:00","updated_at":"2025-10-27T20:48:52.004689-07:00","closed_at":"2025-10-27T20:48:52.004689-07:00"}
|
||||
{"id":"bd-226","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:38:57.752024-07:00","updated_at":"2025-10-27T20:38:57.752024-07:00","closed_at":"2025-10-27T20:17:24.88576-07:00"}
|
||||
{"id":"bd-227","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:52:52.630581-07:00","updated_at":"2025-10-27T20:52:52.630581-07:00","closed_at":"2025-10-27T20:17:24.88576-07:00"}
|
||||
{"id":"bd-228","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:52:55.750036-07:00","updated_at":"2025-10-27T20:52:55.750036-07:00","closed_at":"2025-10-27T20:17:24.88576-07:00"}
|
||||
{"id":"bd-229","title":"Remove unreachable utility functions","description":"Several small utility functions are unreachable:\n\nFiles to clean:\n1. `internal/storage/sqlite/hash.go` - `computeIssueContentHash` (line 17)\n - Check if entire file can be deleted if only contains this function\n\n2. `internal/config/config.go` - `FileUsed` (line 151)\n - Delete unused config helper\n\n3. `cmd/bd/git_sync_test.go` - `verifyIssueOpen` (line 300)\n - Delete dead test helper\n\n4. `internal/compact/haiku.go` - `HaikuClient.SummarizeTier2` (line 81)\n - Tier 2 summarization not implemented\n - Options: implement feature OR delete method\n\nImpact: Removes 50-100 LOC depending on decisions","acceptance_criteria":"- Remove unreachable functions\n- If entire files can be deleted (like hash.go), delete them\n- For SummarizeTier2: decide to implement or delete, document decision\n- All tests pass: `go test ./...`\n- Verify no callers exist for each function","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T20:54:52.832836-07:00","updated_at":"2025-10-27T20:54:52.832836-07:00","closed_at":"2025-10-27T20:44:57.549063-07:00"}
|
||||
{"id":"bd-227","title":"Remove unreachable utility functions","description":"Several small utility functions are unreachable:\n\nFiles to clean:\n1. `internal/storage/sqlite/hash.go` - `computeIssueContentHash` (line 17)\n - Check if entire file can be deleted if only contains this function\n\n2. `internal/config/config.go` - `FileUsed` (line 151)\n - Delete unused config helper\n\n3. `cmd/bd/git_sync_test.go` - `verifyIssueOpen` (line 300)\n - Delete dead test helper\n\n4. `internal/compact/haiku.go` - `HaikuClient.SummarizeTier2` (line 81)\n - Tier 2 summarization not implemented\n - Options: implement feature OR delete method\n\nImpact: Removes 50-100 LOC depending on decisions","acceptance_criteria":"- Remove unreachable functions\n- If entire files can be deleted (like hash.go), delete them\n- For SummarizeTier2: decide to implement or delete, document decision\n- All tests pass: `go test ./...`\n- Verify no callers exist for each function","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T20:48:12.56596-07:00","updated_at":"2025-10-27T20:48:12.56596-07:00","closed_at":"2025-10-27T20:44:57.549063-07:00","labels":["cleanup","dead-code","phase-1"]}
|
||||
{"id":"bd-23","title":"Update Claude Code marketplace plugin","description":"Update the beads plugin in the Claude Code marketplace to the latest version. This may help resolve some of the open GitHub issues related to marketplace installation and compatibility (#54, #112).\n\nShould include:\n- Latest beads version\n- Updated documentation\n- Any new features or bug fixes","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T22:29:11.293161-07:00","updated_at":"2025-10-25T23:15:33.483625-07:00","closed_at":"2025-10-23T22:27:37.671065-07:00"}
|
||||
{"id":"bd-230","title":"Delete skipped tests for \"old buggy behavior\"","description":"Three test functions are permanently skipped with comments indicating they test behavior that was fixed in GH#120. These tests will never run again and should be deleted.\n\nTest functions to remove:\n\n1. `cmd/bd/import_collision_test.go:228`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\n2. `cmd/bd/import_collision_test.go:505`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\n3. `internal/storage/sqlite/collision_test.go:919`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\nImpact: Removes ~150 LOC of permanently skipped tests","acceptance_criteria":"- Delete the 3 test functions entirely (~150 LOC total)\n- Update test file comments to reference GH#120 fix if needed\n- All remaining tests pass: `go test ./...`\n- No reduction in meaningful test coverage (these test fixed bugs)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-27T20:54:52.833277-07:00","updated_at":"2025-10-28T04:12:19Z"}
|
||||
{"id":"bd-231","title":"Remove unreachable RPC methods","description":"Several RPC server and client methods are unreachable and should be removed:\n\nServer methods (internal/rpc/server.go):\n- `Server.GetLastImportTime` (line 2116)\n- `Server.SetLastImportTime` (line 2123)\n- `Server.findJSONLPath` (line 2255)\n\nClient methods (internal/rpc/client.go):\n- `Client.Import` (line 311) - RPC import not used (daemon uses autoimport)\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n```\n\nImpact: Removes ~80 LOC of unused RPC code","acceptance_criteria":"- Remove the 4 unreachable methods (~80 LOC total)\n- Verify no callers: `grep -r \"GetLastImportTime\\|SetLastImportTime\\|findJSONLPath\" .`\n- All tests pass: `go test ./internal/rpc/...`\n- Daemon functionality works: test daemon start/stop/operations","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:54:52.833581-07:00","updated_at":"2025-10-27T20:54:52.833581-07:00"}
|
||||
{"id":"bd-232","title":"Remove unreachable utility functions","description":"Several small utility functions are unreachable:\n\nFiles to clean:\n1. `internal/storage/sqlite/hash.go` - `computeIssueContentHash` (line 17)\n - Check if entire file can be deleted if only contains this function\n\n2. `internal/config/config.go` - `FileUsed` (line 151)\n - Delete unused config helper\n\n3. `cmd/bd/git_sync_test.go` - `verifyIssueOpen` (line 300)\n - Delete dead test helper\n\n4. `internal/compact/haiku.go` - `HaikuClient.SummarizeTier2` (line 81)\n - Tier 2 summarization not implemented\n - Options: implement feature OR delete method\n\nImpact: Removes 50-100 LOC depending on decisions","acceptance_criteria":"- Remove unreachable functions\n- If entire files can be deleted (like hash.go), delete them\n- For SummarizeTier2: decide to implement or delete, document decision\n- All tests pass: `go test ./...`\n- Verify no callers exist for each function","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:54:52.83391-07:00","updated_at":"2025-10-27T20:54:52.83391-07:00"}
|
||||
{"id":"bd-233","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-27T20:54:52.834284-07:00","updated_at":"2025-10-27T20:54:52.834284-07:00","closed_at":"2025-10-27T20:17:24.88576-07:00"}
|
||||
{"id":"bd-24","title":"Git worktree support - ensure --no-daemon works correctly","description":"Users working with git worktrees report that beads MCP commits to the wrong branch (main instead of worktree branch). \n\n**Root cause:** Git worktrees share the same .beads directory, so the daemon/MCP server doesn't know which branch the worktree has checked out.\n\n**Current state:**\n- Daemon mode: Cannot work properly with worktrees (fundamental limitation - shared .beads, unknown branch)\n- CLI with --no-daemon: Should work but needs verification\n\n**Action items:**\n1. Test and verify --no-daemon works correctly in worktrees\n2. Document the limitation in README/AGENTS.md\n3. Add clear error/warning when using daemon with worktrees\n4. Consider if MCP server can detect worktree usage and auto-disable daemon\n\n**Workaround for users:**\nUse --no-daemon flag when working in worktrees, or set BEADS_AUTO_DAEMON=false\n\n**Related:** GH issue #55","notes":"**Implementation completed with oracle review improvements:**\n\n1. **Robust detection** - Changed from substring check to canonical git-dir vs git-common-dir comparison\n2. **Correct warning gate** - Now only checks isGitWorktree() and only called when daemon is actually connected\n3. **Complete coverage** - Added warning to `bd daemon` command start path\n4. **Better UX** - Shows shared database path and clarifies BEADS_AUTO_START_DAEMON behavior\n5. **Improved tests** - Validates canonical detection method and path truncation\n\n**Files changed:**\n- cmd/bd/worktree.go - Improved detection and warning\n- cmd/bd/worktree_test.go - Better test coverage\n- cmd/bd/main.go - Warning after daemon connection (2 places)\n- cmd/bd/daemon.go - Warning when starting daemon\n- README.md \u0026 AGENTS.md - Documented limitations and solutions","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-10-22T22:38:52.438601-07:00","updated_at":"2025-10-25T23:15:33.484321-07:00","closed_at":"2025-10-22T22:51:45.464598-07:00"}
|
||||
{"id":"bd-25","title":"Investigate jujutsu integration for beads","description":"Research and document how beads could integrate with jujutsu (jj), the next-generation VCS. Key areas to explore:\n- How jj's operation model differs from git (immutable operations, working-copy-as-commit)\n- JSONL sync strategy with jj's conflict resolution model\n- Daemon compatibility with jj's more frequent rewrites\n- Whether auto-import/export needs changes for jj workflows\n- Example configurations and documentation updates needed","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-23T09:23:23.582009-07:00","updated_at":"2025-10-25T23:15:33.484928-07:00"}
|
||||
{"id":"bd-26","title":"Add description parameter to bd update command and MCP server","description":"Issue #114 and #122 show that agents expect to update issue descriptions. Currently only create supports description. Need to add --description flag to CLI update command and description parameter to MCP update_issue tool.","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-10-23T10:46:00.481647-07:00","updated_at":"2025-10-25T23:15:33.485577-07:00","closed_at":"2025-10-23T10:52:49.825354-07:00"}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
286
internal/rpc/server_cache_storage.go
Normal file
286
internal/rpc/server_cache_storage.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
// StorageCacheEntry holds a cached storage with metadata for eviction
|
||||
type StorageCacheEntry struct {
|
||||
store storage.Storage
|
||||
lastAccess time.Time
|
||||
dbMtime time.Time // DB file modification time for detecting external changes
|
||||
}
|
||||
|
||||
// runCleanupLoop periodically evicts stale storage connections and checks memory pressure
|
||||
func (s *Server) runCleanupLoop() {
|
||||
s.cleanupTicker = time.NewTicker(5 * time.Minute)
|
||||
defer s.cleanupTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.cleanupTicker.C:
|
||||
s.checkMemoryPressure()
|
||||
s.evictStaleStorage()
|
||||
case <-s.shutdownChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkMemoryPressure monitors memory usage and triggers aggressive eviction if needed
|
||||
func (s *Server) checkMemoryPressure() {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
// Memory thresholds (configurable via env var)
|
||||
const defaultThresholdMB = 500
|
||||
thresholdMB := defaultThresholdMB
|
||||
if env := os.Getenv("BEADS_DAEMON_MEMORY_THRESHOLD_MB"); env != "" {
|
||||
var threshold int
|
||||
if _, err := fmt.Sscanf(env, "%d", &threshold); err == nil && threshold > 0 {
|
||||
thresholdMB = threshold
|
||||
}
|
||||
}
|
||||
|
||||
allocMB := m.Alloc / 1024 / 1024
|
||||
// #nosec G115 - safe conversion of positive value
|
||||
if allocMB > uint64(thresholdMB) {
|
||||
fmt.Fprintf(os.Stderr, "Warning: High memory usage detected (%d MB), triggering aggressive cache eviction\n", allocMB)
|
||||
s.aggressiveEviction()
|
||||
runtime.GC() // Suggest garbage collection
|
||||
}
|
||||
}
|
||||
|
||||
// aggressiveEviction evicts 50% of cached storage to reduce memory pressure
|
||||
func (s *Server) aggressiveEviction() {
|
||||
toClose := []storage.Storage{}
|
||||
|
||||
s.cacheMu.Lock()
|
||||
|
||||
if len(s.storageCache) == 0 {
|
||||
s.cacheMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Build sorted list by last access
|
||||
type cacheItem struct {
|
||||
path string
|
||||
entry *StorageCacheEntry
|
||||
}
|
||||
items := make([]cacheItem, 0, len(s.storageCache))
|
||||
for path, entry := range s.storageCache {
|
||||
items = append(items, cacheItem{path, entry})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].entry.lastAccess.Before(items[j].entry.lastAccess)
|
||||
})
|
||||
|
||||
// Evict oldest 50%
|
||||
numToEvict := len(items) / 2
|
||||
for i := 0; i < numToEvict; i++ {
|
||||
toClose = append(toClose, items[i].entry.store)
|
||||
delete(s.storageCache, items[i].path)
|
||||
}
|
||||
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
// Close without holding lock
|
||||
for _, store := range toClose {
|
||||
if err := store.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close evicted storage: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictStaleStorage removes idle connections and enforces cache size limits
|
||||
func (s *Server) evictStaleStorage() {
|
||||
now := time.Now()
|
||||
toClose := []storage.Storage{}
|
||||
|
||||
s.cacheMu.Lock()
|
||||
|
||||
// First pass: evict TTL-expired entries
|
||||
for path, entry := range s.storageCache {
|
||||
if now.Sub(entry.lastAccess) > s.cacheTTL {
|
||||
toClose = append(toClose, entry.store)
|
||||
delete(s.storageCache, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: enforce max cache size with LRU eviction
|
||||
if len(s.storageCache) > s.maxCacheSize {
|
||||
// Build sorted list of entries by lastAccess
|
||||
type cacheItem struct {
|
||||
path string
|
||||
entry *StorageCacheEntry
|
||||
}
|
||||
items := make([]cacheItem, 0, len(s.storageCache))
|
||||
for path, entry := range s.storageCache {
|
||||
items = append(items, cacheItem{path, entry})
|
||||
}
|
||||
|
||||
// Sort by lastAccess (oldest first) with sort.Slice
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].entry.lastAccess.Before(items[j].entry.lastAccess)
|
||||
})
|
||||
|
||||
// Evict oldest entries until we're under the limit
|
||||
numToEvict := len(s.storageCache) - s.maxCacheSize
|
||||
for i := 0; i < numToEvict && i < len(items); i++ {
|
||||
toClose = append(toClose, items[i].entry.store)
|
||||
delete(s.storageCache, items[i].path)
|
||||
s.metrics.RecordCacheEviction()
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock before closing to avoid holding lock during Close
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
// Close connections synchronously
|
||||
for _, store := range toClose {
|
||||
if err := store.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close evicted storage: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getStorageForRequest returns the appropriate storage for the request
|
||||
// If req.Cwd is set, it finds the database for that directory
|
||||
// Otherwise, it uses the default storage
|
||||
func (s *Server) getStorageForRequest(req *Request) (storage.Storage, error) {
|
||||
// If no cwd specified, use default storage
|
||||
if req.Cwd == "" {
|
||||
return s.storage, nil
|
||||
}
|
||||
|
||||
// Find database for this cwd (to get the canonical repo root)
|
||||
dbPath := s.findDatabaseForCwd(req.Cwd)
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("no .beads database found for path: %s", req.Cwd)
|
||||
}
|
||||
|
||||
// Canonicalize key to repo root (parent of .beads directory)
|
||||
beadsDir := filepath.Dir(dbPath)
|
||||
repoRoot := filepath.Dir(beadsDir)
|
||||
|
||||
// Check cache first with write lock (to avoid race on lastAccess update)
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
if entry, ok := s.storageCache[repoRoot]; ok {
|
||||
// Check if DB file has been modified externally
|
||||
info, err := os.Stat(dbPath)
|
||||
if err == nil && !info.ModTime().Equal(entry.dbMtime) {
|
||||
// DB file changed - evict stale cache entry
|
||||
// Remove from cache first to prevent concurrent access
|
||||
delete(s.storageCache, repoRoot)
|
||||
atomic.AddInt64(&s.cacheMisses, 1)
|
||||
// Close storage after removing from cache (safe now)
|
||||
// Unlock briefly to avoid blocking during Close()
|
||||
s.cacheMu.Unlock()
|
||||
if err := entry.store.Close(); err != nil {
|
||||
// Log but don't fail - we'll reopen anyway
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close stale cached storage: %v\n", err)
|
||||
}
|
||||
s.cacheMu.Lock()
|
||||
// Fall through to reopen
|
||||
} else if err == nil {
|
||||
// Cache hit - DB file unchanged
|
||||
entry.lastAccess = time.Now()
|
||||
atomic.AddInt64(&s.cacheHits, 1)
|
||||
return entry.store, nil
|
||||
} else {
|
||||
// Stat failed - evict and reopen
|
||||
// Remove from cache first to prevent concurrent access
|
||||
delete(s.storageCache, repoRoot)
|
||||
atomic.AddInt64(&s.cacheMisses, 1)
|
||||
// Close storage after removing from cache
|
||||
s.cacheMu.Unlock()
|
||||
if err := entry.store.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close cached storage: %v\n", err)
|
||||
}
|
||||
s.cacheMu.Lock()
|
||||
// Fall through to reopen
|
||||
}
|
||||
} else {
|
||||
atomic.AddInt64(&s.cacheMisses, 1)
|
||||
}
|
||||
|
||||
// Open storage
|
||||
store, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database at %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
// Get mtime for the newly opened DB
|
||||
info, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
// If we can't stat, still cache it but with zero mtime (will invalidate on next check)
|
||||
info = nil
|
||||
}
|
||||
|
||||
mtime := time.Time{}
|
||||
if info != nil {
|
||||
mtime = info.ModTime()
|
||||
}
|
||||
|
||||
// Cache it with current timestamp and mtime
|
||||
s.storageCache[repoRoot] = &StorageCacheEntry{
|
||||
store: store,
|
||||
lastAccess: time.Now(),
|
||||
dbMtime: mtime,
|
||||
}
|
||||
|
||||
// Enforce LRU immediately to prevent FD spikes between cleanup ticks
|
||||
needEvict := len(s.storageCache) > s.maxCacheSize
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
if needEvict {
|
||||
s.evictStaleStorage()
|
||||
}
|
||||
|
||||
// Re-acquire lock for defer
|
||||
s.cacheMu.Lock()
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// findDatabaseForCwd walks up from cwd to find .beads/*.db
|
||||
func (s *Server) findDatabaseForCwd(cwd string) string {
|
||||
dir, err := filepath.Abs(cwd)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Walk up directory tree
|
||||
for {
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
||||
// Found .beads/ directory, look for *.db files
|
||||
matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
|
||||
if err == nil && len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Move up one directory
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
// Reached filesystem root
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
287
internal/rpc/server_compact.go
Normal file
287
internal/rpc/server_compact.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/compact"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleCompact(req *Request) Response {
|
||||
var args CompactArgs
|
||||
if err := json.Unmarshal(req.Args, &args); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid compact args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get storage: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: "compact requires SQLite storage",
|
||||
}
|
||||
}
|
||||
|
||||
config := &compact.Config{
|
||||
APIKey: args.APIKey,
|
||||
Concurrency: args.Workers,
|
||||
DryRun: args.DryRun,
|
||||
}
|
||||
if config.Concurrency <= 0 {
|
||||
config.Concurrency = 5
|
||||
}
|
||||
|
||||
compactor, err := compact.New(sqliteStore, args.APIKey, config)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create compactor: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
startTime := time.Now()
|
||||
|
||||
if args.IssueID != "" {
|
||||
if !args.Force {
|
||||
eligible, reason, err := sqliteStore.CheckEligibility(ctx, args.IssueID, args.Tier)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to check eligibility: %v", err),
|
||||
}
|
||||
}
|
||||
if !eligible {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("%s is not eligible for Tier %d compaction: %s", args.IssueID, args.Tier, reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issue, err := sqliteStore.GetIssue(ctx, args.IssueID)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
originalSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria)
|
||||
|
||||
if args.DryRun {
|
||||
result := CompactResponse{
|
||||
Success: true,
|
||||
IssueID: args.IssueID,
|
||||
OriginalSize: originalSize,
|
||||
Reduction: "70-80%",
|
||||
DryRun: true,
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
if args.Tier == 1 {
|
||||
err = compactor.CompactTier1(ctx, args.IssueID)
|
||||
} else {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: "Tier 2 compaction not yet implemented",
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("compaction failed: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
issueAfter, _ := sqliteStore.GetIssue(ctx, args.IssueID)
|
||||
compactedSize := 0
|
||||
if issueAfter != nil {
|
||||
compactedSize = len(issueAfter.Description)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
result := CompactResponse{
|
||||
Success: true,
|
||||
IssueID: args.IssueID,
|
||||
OriginalSize: originalSize,
|
||||
CompactedSize: compactedSize,
|
||||
Reduction: fmt.Sprintf("%.1f%%", float64(originalSize-compactedSize)/float64(originalSize)*100),
|
||||
Duration: duration.String(),
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
if args.All {
|
||||
var candidates []*sqlite.CompactionCandidate
|
||||
|
||||
switch args.Tier {
|
||||
case 1:
|
||||
tier1, err := sqliteStore.GetTier1Candidates(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get Tier 1 candidates: %v", err),
|
||||
}
|
||||
}
|
||||
candidates = tier1
|
||||
case 2:
|
||||
tier2, err := sqliteStore.GetTier2Candidates(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get Tier 2 candidates: %v", err),
|
||||
}
|
||||
}
|
||||
candidates = tier2
|
||||
default:
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid tier: %d (must be 1 or 2)", args.Tier),
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
result := CompactResponse{
|
||||
Success: true,
|
||||
Results: []CompactResult{},
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
issueIDs := make([]string, len(candidates))
|
||||
for i, c := range candidates {
|
||||
issueIDs[i] = c.IssueID
|
||||
}
|
||||
|
||||
batchResults, err := compactor.CompactTier1Batch(ctx, issueIDs)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("batch compaction failed: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]CompactResult, 0, len(batchResults))
|
||||
for _, r := range batchResults {
|
||||
result := CompactResult{
|
||||
IssueID: r.IssueID,
|
||||
Success: r.Err == nil,
|
||||
OriginalSize: r.OriginalSize,
|
||||
CompactedSize: r.CompactedSize,
|
||||
}
|
||||
if r.Err != nil {
|
||||
result.Error = r.Err.Error()
|
||||
} else if r.OriginalSize > 0 && r.CompactedSize > 0 {
|
||||
result.Reduction = fmt.Sprintf("%.1f%%", float64(r.OriginalSize-r.CompactedSize)/float64(r.OriginalSize)*100)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
response := CompactResponse{
|
||||
Success: true,
|
||||
Results: results,
|
||||
Duration: duration.String(),
|
||||
DryRun: args.DryRun,
|
||||
}
|
||||
data, _ := json.Marshal(response)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: "must specify --all or --id",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCompactStats(req *Request) Response {
|
||||
var args CompactStatsArgs
|
||||
if err := json.Unmarshal(req.Args, &args); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid compact stats args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get storage: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: "compact stats requires SQLite storage",
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
|
||||
tier1, err := sqliteStore.GetTier1Candidates(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get Tier 1 candidates: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
tier2, err := sqliteStore.GetTier2Candidates(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get Tier 2 candidates: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
stats := CompactStatsData{
|
||||
Tier1Candidates: len(tier1),
|
||||
Tier2Candidates: len(tier2),
|
||||
Tier1MinAge: "30 days",
|
||||
Tier2MinAge: "90 days",
|
||||
TotalClosed: 0, // Could query for this but not critical
|
||||
}
|
||||
|
||||
result := CompactResponse{
|
||||
Success: true,
|
||||
Stats: &stats,
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
112
internal/rpc/server_core.go
Normal file
112
internal/rpc/server_core.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
)
|
||||
|
||||
// ServerVersion is the version of this RPC server
|
||||
// This should match the bd CLI version for proper compatibility checks
|
||||
// It's set dynamically by daemon.go from cmd/bd/version.go before starting the server
|
||||
var ServerVersion = "0.0.0" // Placeholder; overridden by daemon startup
|
||||
|
||||
const (
|
||||
statusUnhealthy = "unhealthy"
|
||||
)
|
||||
|
||||
// Server represents the RPC server that runs in the daemon
|
||||
type Server struct {
|
||||
socketPath string
|
||||
workspacePath string // Absolute path to workspace root
|
||||
dbPath string // Absolute path to database file
|
||||
storage storage.Storage // Default storage (for backward compat)
|
||||
listener net.Listener
|
||||
mu sync.RWMutex
|
||||
shutdown bool
|
||||
shutdownChan chan struct{}
|
||||
stopOnce sync.Once
|
||||
doneChan chan struct{} // closed when Start() cleanup is complete
|
||||
// Per-request storage routing with eviction support
|
||||
storageCache map[string]*StorageCacheEntry // repoRoot -> entry
|
||||
cacheMu sync.RWMutex
|
||||
maxCacheSize int
|
||||
cacheTTL time.Duration
|
||||
cleanupTicker *time.Ticker
|
||||
// Health and metrics
|
||||
startTime time.Time
|
||||
lastActivityTime atomic.Value // time.Time - last request timestamp
|
||||
cacheHits int64
|
||||
cacheMisses int64
|
||||
metrics *Metrics
|
||||
// Connection limiting
|
||||
maxConns int
|
||||
activeConns int32 // atomic counter
|
||||
connSemaphore chan struct{}
|
||||
// Request timeout
|
||||
requestTimeout time.Duration
|
||||
// Ready channel signals when server is listening
|
||||
readyChan chan struct{}
|
||||
// Auto-import single-flight guard
|
||||
importInProgress atomic.Bool
|
||||
}
|
||||
|
||||
// NewServer creates a new RPC server
|
||||
func NewServer(socketPath string, store storage.Storage, workspacePath string, dbPath string) *Server {
|
||||
// Parse config from env vars
|
||||
maxCacheSize := 50 // default
|
||||
if env := os.Getenv("BEADS_DAEMON_MAX_CACHE_SIZE"); env != "" {
|
||||
// Parse as integer
|
||||
var size int
|
||||
if _, err := fmt.Sscanf(env, "%d", &size); err == nil && size > 0 {
|
||||
maxCacheSize = size
|
||||
}
|
||||
}
|
||||
|
||||
cacheTTL := 30 * time.Minute // default
|
||||
if env := os.Getenv("BEADS_DAEMON_CACHE_TTL"); env != "" {
|
||||
if ttl, err := time.ParseDuration(env); err == nil && ttl > 0 {
|
||||
cacheTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
maxConns := 100 // default
|
||||
if env := os.Getenv("BEADS_DAEMON_MAX_CONNS"); env != "" {
|
||||
var conns int
|
||||
if _, err := fmt.Sscanf(env, "%d", &conns); err == nil && conns > 0 {
|
||||
maxConns = conns
|
||||
}
|
||||
}
|
||||
|
||||
requestTimeout := 30 * time.Second // default
|
||||
if env := os.Getenv("BEADS_DAEMON_REQUEST_TIMEOUT"); env != "" {
|
||||
if timeout, err := time.ParseDuration(env); err == nil && timeout > 0 {
|
||||
requestTimeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
socketPath: socketPath,
|
||||
workspacePath: workspacePath,
|
||||
dbPath: dbPath,
|
||||
storage: store,
|
||||
storageCache: make(map[string]*StorageCacheEntry),
|
||||
maxCacheSize: maxCacheSize,
|
||||
cacheTTL: cacheTTL,
|
||||
shutdownChan: make(chan struct{}),
|
||||
doneChan: make(chan struct{}),
|
||||
startTime: time.Now(),
|
||||
metrics: NewMetrics(),
|
||||
maxConns: maxConns,
|
||||
connSemaphore: make(chan struct{}, maxConns),
|
||||
requestTimeout: requestTimeout,
|
||||
readyChan: make(chan struct{}),
|
||||
}
|
||||
s.lastActivityTime.Store(time.Now())
|
||||
return s
|
||||
}
|
||||
288
internal/rpc/server_export_import_auto.go
Normal file
288
internal/rpc/server_export_import_auto.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/steveyegge/beads/internal/autoimport"
|
||||
"github.com/steveyegge/beads/internal/importer"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// handleExport handles the export operation
|
||||
func (s *Server) handleExport(req *Request) Response {
|
||||
var exportArgs ExportArgs
|
||||
if err := json.Unmarshal(req.Args, &exportArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid export args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get storage: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
|
||||
// Get all issues
|
||||
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get issues: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by ID for consistent output
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].ID < issues[j].ID
|
||||
})
|
||||
|
||||
// Populate dependencies for all issues (avoid N+1)
|
||||
allDeps, err := store.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get dependencies: %v", err),
|
||||
}
|
||||
}
|
||||
for _, issue := range issues {
|
||||
issue.Dependencies = allDeps[issue.ID]
|
||||
}
|
||||
|
||||
// Populate labels for all issues
|
||||
for _, issue := range issues {
|
||||
labels, err := store.GetLabels(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get labels for %s: %v", issue.ID, err),
|
||||
}
|
||||
}
|
||||
issue.Labels = labels
|
||||
}
|
||||
|
||||
// Populate comments for all issues
|
||||
for _, issue := range issues {
|
||||
comments, err := store.GetIssueComments(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get comments for %s: %v", issue.ID, err),
|
||||
}
|
||||
}
|
||||
issue.Comments = comments
|
||||
}
|
||||
|
||||
// Create temp file for atomic write
|
||||
dir := filepath.Dir(exportArgs.JSONLPath)
|
||||
base := filepath.Base(exportArgs.JSONLPath)
|
||||
tempFile, err := os.CreateTemp(dir, base+".tmp.*")
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create temp file: %v", err),
|
||||
}
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
defer func() {
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
}()
|
||||
|
||||
// Write JSONL
|
||||
encoder := json.NewEncoder(tempFile)
|
||||
exportedIDs := make([]string, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
if err := encoder.Encode(issue); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to encode issue %s: %v", issue.ID, err),
|
||||
}
|
||||
}
|
||||
exportedIDs = append(exportedIDs, issue.ID)
|
||||
}
|
||||
|
||||
// Close temp file before rename
|
||||
_ = tempFile.Close()
|
||||
|
||||
// Atomic replace
|
||||
if err := os.Rename(tempPath, exportArgs.JSONLPath); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to replace JSONL file: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Set appropriate file permissions (0600: rw-------)
|
||||
if err := os.Chmod(exportArgs.JSONLPath, 0600); err != nil {
|
||||
// Non-fatal, just log
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to set file permissions: %v\n", err)
|
||||
}
|
||||
|
||||
// Clear dirty flags for exported issues
|
||||
if err := store.ClearDirtyIssuesByID(ctx, exportedIDs); err != nil {
|
||||
// Non-fatal, just log
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to clear dirty flags: %v\n", err)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"exported_count": len(exportedIDs),
|
||||
"path": exportArgs.JSONLPath,
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// handleImport handles the import operation
|
||||
func (s *Server) handleImport(req *Request) Response {
|
||||
var importArgs ImportArgs
|
||||
if err := json.Unmarshal(req.Args, &importArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid import args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The actual import logic is complex and lives in cmd/bd/import.go
|
||||
// For now, we'll return an error suggesting to use direct mode
|
||||
// In the future, we can refactor the import logic into a shared package
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: "import via daemon not yet implemented, use --no-daemon flag",
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndAutoImportIfStale checks if JSONL is newer than last import and triggers auto-import
|
||||
// This fixes bd-132: daemon shows stale data after git pull
|
||||
func (s *Server) checkAndAutoImportIfStale(req *Request) error {
|
||||
// Get storage for this request
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get storage: %w", err)
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
|
||||
// Get database path from storage
|
||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
return fmt.Errorf("storage is not SQLiteStorage")
|
||||
}
|
||||
dbPath := sqliteStore.Path()
|
||||
|
||||
// Fast path: Check if JSONL is stale using cheap mtime check
|
||||
// This avoids reading/hashing JSONL on every request
|
||||
isStale, err := autoimport.CheckStaleness(ctx, store, dbPath)
|
||||
if err != nil || !isStale {
|
||||
return err
|
||||
}
|
||||
|
||||
// Single-flight guard: Only allow one import at a time
|
||||
// If import is already running, skip and let the request proceed
|
||||
if !s.importInProgress.CompareAndSwap(false, true) {
|
||||
if os.Getenv("BD_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "Debug: auto-import already in progress, skipping\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer s.importInProgress.Store(false)
|
||||
|
||||
// Double-check staleness after acquiring lock (another goroutine may have imported)
|
||||
isStale, err = autoimport.CheckStaleness(ctx, store, dbPath)
|
||||
if err != nil || !isStale {
|
||||
return err
|
||||
}
|
||||
|
||||
if os.Getenv("BD_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "Debug: daemon detected stale JSONL, auto-importing...\n")
|
||||
}
|
||||
|
||||
// Perform actual import
|
||||
notify := autoimport.NewStderrNotifier(os.Getenv("BD_DEBUG") != "")
|
||||
|
||||
importFunc := func(ctx context.Context, issues []*types.Issue) (created, updated int, idMapping map[string]string, err error) {
|
||||
// Use the importer package to perform the actual import
|
||||
result, err := importer.ImportIssues(ctx, dbPath, store, issues, importer.Options{
|
||||
ResolveCollisions: true, // Auto-resolve collisions for auto-import
|
||||
RenameOnImport: true, // Auto-rename prefix mismatches
|
||||
// Note: SkipPrefixValidation is false by default, so we validate and rename
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
return result.Created, result.Updated, result.IDMapping, nil
|
||||
}
|
||||
|
||||
onChanged := func(needsFullExport bool) {
|
||||
// When IDs are remapped, trigger export so JSONL reflects the new IDs
|
||||
if needsFullExport {
|
||||
// Use a goroutine to avoid blocking the import
|
||||
go func() {
|
||||
if err := s.triggerExport(ctx, store, dbPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to export after auto-import: %v\n", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return autoimport.AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, onChanged)
|
||||
}
|
||||
|
||||
// triggerExport exports all issues to JSONL after auto-import remaps IDs
|
||||
func (s *Server) triggerExport(ctx context.Context, store storage.Storage, dbPath string) error {
|
||||
// Find JSONL path using database directory
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
pattern := filepath.Join(dbDir, "*.jsonl")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
var jsonlPath string
|
||||
if err == nil && len(matches) > 0 {
|
||||
jsonlPath = matches[0]
|
||||
} else {
|
||||
jsonlPath = filepath.Join(dbDir, "issues.jsonl")
|
||||
}
|
||||
|
||||
// Get all issues from storage
|
||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
return fmt.Errorf("storage is not SQLiteStorage")
|
||||
}
|
||||
|
||||
// Export to JSONL (this will update the file with remapped IDs)
|
||||
allIssues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch issues for export: %w", err)
|
||||
}
|
||||
|
||||
// Write to JSONL file
|
||||
// Note: We reuse the export logic from the daemon's existing export mechanism
|
||||
// For now, this is a simple implementation - could be refactored to share with cmd/bd
|
||||
file, err := os.Create(jsonlPath) // #nosec G304 - controlled path from config
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create JSONL file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, issue := range allIssues {
|
||||
if err := encoder.Encode(issue); err != nil {
|
||||
return fmt.Errorf("failed to encode issue %s: %w", issue.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
506
internal/rpc/server_issues_epics.go
Normal file
506
internal/rpc/server_issues_epics.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// normalizeLabels trims whitespace, removes empty strings, and deduplicates labels
|
||||
func normalizeLabels(ss []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func strValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func updatesFromArgs(a UpdateArgs) map[string]interface{} {
|
||||
u := map[string]interface{}{}
|
||||
if a.Title != nil {
|
||||
u["title"] = *a.Title
|
||||
}
|
||||
if a.Description != nil {
|
||||
u["description"] = *a.Description
|
||||
}
|
||||
if a.Status != nil {
|
||||
u["status"] = *a.Status
|
||||
}
|
||||
if a.Priority != nil {
|
||||
u["priority"] = *a.Priority
|
||||
}
|
||||
if a.Design != nil {
|
||||
u["design"] = a.Design
|
||||
}
|
||||
if a.AcceptanceCriteria != nil {
|
||||
u["acceptance_criteria"] = a.AcceptanceCriteria
|
||||
}
|
||||
if a.Notes != nil {
|
||||
u["notes"] = a.Notes
|
||||
}
|
||||
if a.Assignee != nil {
|
||||
u["assignee"] = a.Assignee
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) handleCreate(req *Request) Response {
|
||||
var createArgs CreateArgs
|
||||
if err := json.Unmarshal(req.Args, &createArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid create args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
var design, acceptance, assignee *string
|
||||
if createArgs.Design != "" {
|
||||
design = &createArgs.Design
|
||||
}
|
||||
if createArgs.AcceptanceCriteria != "" {
|
||||
acceptance = &createArgs.AcceptanceCriteria
|
||||
}
|
||||
if createArgs.Assignee != "" {
|
||||
assignee = &createArgs.Assignee
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
ID: createArgs.ID,
|
||||
Title: createArgs.Title,
|
||||
Description: createArgs.Description,
|
||||
IssueType: types.IssueType(createArgs.IssueType),
|
||||
Priority: createArgs.Priority,
|
||||
Design: strValue(design),
|
||||
AcceptanceCriteria: strValue(acceptance),
|
||||
Assignee: strValue(assignee),
|
||||
Status: types.StatusOpen,
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
if err := store.CreateIssue(ctx, issue, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels if specified
|
||||
for _, label := range createArgs.Labels {
|
||||
if err := store.AddLabel(ctx, issue.ID, label, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to add label %s: %v", label, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add dependencies if specified
|
||||
for _, depSpec := range createArgs.Dependencies {
|
||||
depSpec = strings.TrimSpace(depSpec)
|
||||
if depSpec == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var depType types.DependencyType
|
||||
var dependsOnID string
|
||||
|
||||
if strings.Contains(depSpec, ":") {
|
||||
parts := strings.SplitN(depSpec, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid dependency format '%s', expected 'type:id' or 'id'", depSpec),
|
||||
}
|
||||
}
|
||||
depType = types.DependencyType(strings.TrimSpace(parts[0]))
|
||||
dependsOnID = strings.TrimSpace(parts[1])
|
||||
} else {
|
||||
depType = types.DepBlocks
|
||||
dependsOnID = depSpec
|
||||
}
|
||||
|
||||
if !depType.IsValid() {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)", depType),
|
||||
}
|
||||
}
|
||||
|
||||
dep := &types.Dependency{
|
||||
IssueID: issue.ID,
|
||||
DependsOnID: dependsOnID,
|
||||
Type: depType,
|
||||
}
|
||||
if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to add dependency %s -> %s: %v", issue.ID, dependsOnID, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(issue)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdate(req *Request) Response {
|
||||
var updateArgs UpdateArgs
|
||||
if err := json.Unmarshal(req.Args, &updateArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid update args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
updates := updatesFromArgs(updateArgs)
|
||||
if len(updates) == 0 {
|
||||
return Response{Success: true}
|
||||
}
|
||||
|
||||
if err := store.UpdateIssue(ctx, updateArgs.ID, updates, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to update issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
issue, err := store.GetIssue(ctx, updateArgs.ID)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get updated issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(issue)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleClose(req *Request) Response {
|
||||
var closeArgs CloseArgs
|
||||
if err := json.Unmarshal(req.Args, &closeArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid close args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to close issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
issue, _ := store.GetIssue(ctx, closeArgs.ID)
|
||||
data, _ := json.Marshal(issue)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleList(req *Request) Response {
|
||||
var listArgs ListArgs
|
||||
if err := json.Unmarshal(req.Args, &listArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid list args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
filter := types.IssueFilter{
|
||||
Limit: listArgs.Limit,
|
||||
}
|
||||
if listArgs.Status != "" {
|
||||
status := types.Status(listArgs.Status)
|
||||
filter.Status = &status
|
||||
}
|
||||
if listArgs.IssueType != "" {
|
||||
issueType := types.IssueType(listArgs.IssueType)
|
||||
filter.IssueType = &issueType
|
||||
}
|
||||
if listArgs.Assignee != "" {
|
||||
filter.Assignee = &listArgs.Assignee
|
||||
}
|
||||
if listArgs.Priority != nil {
|
||||
filter.Priority = listArgs.Priority
|
||||
}
|
||||
// Normalize and apply label filters
|
||||
labels := normalizeLabels(listArgs.Labels)
|
||||
labelsAny := normalizeLabels(listArgs.LabelsAny)
|
||||
// Support both old single Label and new Labels array
|
||||
if len(labels) > 0 {
|
||||
filter.Labels = labels
|
||||
} else if listArgs.Label != "" {
|
||||
filter.Labels = []string{strings.TrimSpace(listArgs.Label)}
|
||||
}
|
||||
if len(labelsAny) > 0 {
|
||||
filter.LabelsAny = labelsAny
|
||||
}
|
||||
if len(listArgs.IDs) > 0 {
|
||||
ids := normalizeLabels(listArgs.IDs)
|
||||
if len(ids) > 0 {
|
||||
filter.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against excessive ID lists to avoid SQLite parameter limits
|
||||
const maxIDs = 1000
|
||||
if len(filter.IDs) > maxIDs {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("--id flag supports at most %d issue IDs, got %d", maxIDs, len(filter.IDs)),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
issues, err := store.SearchIssues(ctx, listArgs.Query, filter)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to list issues: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Populate labels for each issue
|
||||
for _, issue := range issues {
|
||||
labels, _ := store.GetLabels(ctx, issue.ID)
|
||||
issue.Labels = labels
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(issues)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleShow(req *Request) Response {
|
||||
var showArgs ShowArgs
|
||||
if err := json.Unmarshal(req.Args, &showArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid show args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
issue, err := store.GetIssue(ctx, showArgs.ID)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get issue: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Populate labels, dependencies, and dependents
|
||||
labels, _ := store.GetLabels(ctx, issue.ID)
|
||||
deps, _ := store.GetDependencies(ctx, issue.ID)
|
||||
dependents, _ := store.GetDependents(ctx, issue.ID)
|
||||
|
||||
// Create detailed response with related data
|
||||
type IssueDetails struct {
|
||||
*types.Issue
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Dependencies []*types.Issue `json:"dependencies,omitempty"`
|
||||
Dependents []*types.Issue `json:"dependents,omitempty"`
|
||||
}
|
||||
|
||||
details := &IssueDetails{
|
||||
Issue: issue,
|
||||
Labels: labels,
|
||||
Dependencies: deps,
|
||||
Dependents: dependents,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(details)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(req *Request) Response {
|
||||
var readyArgs ReadyArgs
|
||||
if err := json.Unmarshal(req.Args, &readyArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid ready args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
wf := types.WorkFilter{
|
||||
Status: types.StatusOpen,
|
||||
Priority: readyArgs.Priority,
|
||||
Limit: readyArgs.Limit,
|
||||
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
|
||||
}
|
||||
if readyArgs.Assignee != "" {
|
||||
wf.Assignee = &readyArgs.Assignee
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
issues, err := store.GetReadyWork(ctx, wf)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get ready work: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(issues)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleStats(req *Request) Response {
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
stats, err := store.GetStatistics(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get statistics: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(stats)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleEpicStatus(req *Request) Response {
|
||||
var epicArgs EpicStatusArgs
|
||||
if err := json.Unmarshal(req.Args, &epicArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid epic status args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
epics, err := store.GetEpicsEligibleForClosure(ctx)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get epic status: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if epicArgs.EligibleOnly {
|
||||
filtered := []*types.EpicStatus{}
|
||||
for _, epic := range epics {
|
||||
if epic.EligibleForClose {
|
||||
filtered = append(filtered, epic)
|
||||
}
|
||||
}
|
||||
epics = filtered
|
||||
}
|
||||
|
||||
data, err := json.Marshal(epics)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to marshal epics: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
199
internal/rpc/server_labels_deps_comments.go
Normal file
199
internal/rpc/server_labels_deps_comments.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func (s *Server) handleDepAdd(req *Request) Response {
|
||||
var depArgs DepAddArgs
|
||||
if err := json.Unmarshal(req.Args, &depArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid dep add args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
dep := &types.Dependency{
|
||||
IssueID: depArgs.FromID,
|
||||
DependsOnID: depArgs.ToID,
|
||||
Type: types.DependencyType(depArgs.DepType),
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to add dependency: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
return Response{Success: true}
|
||||
}
|
||||
|
||||
// Generic handler for simple store operations with standard error handling
|
||||
func (s *Server) handleSimpleStoreOp(req *Request, argsPtr interface{}, argDesc string,
|
||||
opFunc func(context.Context, storage.Storage, string) error) Response {
|
||||
if err := json.Unmarshal(req.Args, argsPtr); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid %s args: %v", argDesc, err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
if err := opFunc(ctx, store, s.reqActor(req)); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to %s: %v", argDesc, err),
|
||||
}
|
||||
}
|
||||
|
||||
return Response{Success: true}
|
||||
}
|
||||
|
||||
func (s *Server) handleDepRemove(req *Request) Response {
|
||||
var depArgs DepRemoveArgs
|
||||
return s.handleSimpleStoreOp(req, &depArgs, "dep remove", func(ctx context.Context, store storage.Storage, actor string) error {
|
||||
return store.RemoveDependency(ctx, depArgs.FromID, depArgs.ToID, actor)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLabelAdd(req *Request) Response {
|
||||
var labelArgs LabelAddArgs
|
||||
return s.handleSimpleStoreOp(req, &labelArgs, "label add", func(ctx context.Context, store storage.Storage, actor string) error {
|
||||
return store.AddLabel(ctx, labelArgs.ID, labelArgs.Label, actor)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLabelRemove(req *Request) Response {
|
||||
var labelArgs LabelRemoveArgs
|
||||
return s.handleSimpleStoreOp(req, &labelArgs, "label remove", func(ctx context.Context, store storage.Storage, actor string) error {
|
||||
return store.RemoveLabel(ctx, labelArgs.ID, labelArgs.Label, actor)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleCommentList(req *Request) Response {
|
||||
var commentArgs CommentListArgs
|
||||
if err := json.Unmarshal(req.Args, &commentArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid comment list args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
comments, err := store.GetIssueComments(ctx, commentArgs.ID)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to list comments: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(comments)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCommentAdd(req *Request) Response {
|
||||
var commentArgs CommentAddArgs
|
||||
if err := json.Unmarshal(req.Args, &commentArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid comment add args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
comment, err := store.AddIssueComment(ctx, commentArgs.ID, commentArgs.Author, commentArgs.Text)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to add comment: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(comment)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleBatch(req *Request) Response {
|
||||
var batchArgs BatchArgs
|
||||
if err := json.Unmarshal(req.Args, &batchArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid batch args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]BatchResult, 0, len(batchArgs.Operations))
|
||||
|
||||
for _, op := range batchArgs.Operations {
|
||||
subReq := &Request{
|
||||
Operation: op.Operation,
|
||||
Args: op.Args,
|
||||
Actor: req.Actor,
|
||||
RequestID: req.RequestID,
|
||||
Cwd: req.Cwd, // Pass through context
|
||||
ClientVersion: req.ClientVersion, // Pass through version for compatibility checks
|
||||
}
|
||||
|
||||
resp := s.handleRequest(subReq)
|
||||
|
||||
results = append(results, BatchResult(resp))
|
||||
|
||||
if !resp.Success {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
batchResp := BatchResponse{Results: results}
|
||||
data, _ := json.Marshal(batchResp)
|
||||
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
255
internal/rpc/server_lifecycle_conn.go
Normal file
255
internal/rpc/server_lifecycle_conn.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
)
|
||||
|
||||
// Start starts the RPC server and listens for connections
|
||||
func (s *Server) Start(_ context.Context) error {
|
||||
if err := s.ensureSocketDir(); err != nil {
|
||||
return fmt.Errorf("failed to ensure socket directory: %w", err)
|
||||
}
|
||||
|
||||
if err := s.removeOldSocket(); err != nil {
|
||||
return fmt.Errorf("failed to remove old socket: %w", err)
|
||||
}
|
||||
|
||||
listener, err := listenRPC(s.socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize RPC listener: %w", err)
|
||||
}
|
||||
s.listener = listener
|
||||
|
||||
// Set socket permissions to 0600 for security (owner only)
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(s.socketPath, 0600); err != nil {
|
||||
_ = listener.Close()
|
||||
return fmt.Errorf("failed to set socket permissions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Store listener under lock
|
||||
s.mu.Lock()
|
||||
s.listener = listener
|
||||
s.mu.Unlock()
|
||||
|
||||
// Signal that server is ready to accept connections
|
||||
close(s.readyChan)
|
||||
|
||||
go s.handleSignals()
|
||||
go s.runCleanupLoop()
|
||||
|
||||
// Ensure cleanup is signaled when this function returns
|
||||
defer close(s.doneChan)
|
||||
|
||||
// Accept connections using listener
|
||||
for {
|
||||
// Get listener under lock
|
||||
s.mu.RLock()
|
||||
listener := s.listener
|
||||
s.mu.RUnlock()
|
||||
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
shutdown := s.shutdown
|
||||
s.mu.Unlock()
|
||||
if shutdown {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to accept connection: %w", err)
|
||||
}
|
||||
|
||||
// Try to acquire connection slot (non-blocking)
|
||||
select {
|
||||
case s.connSemaphore <- struct{}{}:
|
||||
// Acquired slot, handle connection
|
||||
s.metrics.RecordConnection()
|
||||
go func(c net.Conn) {
|
||||
defer func() { <-s.connSemaphore }() // Release slot
|
||||
atomic.AddInt32(&s.activeConns, 1)
|
||||
defer atomic.AddInt32(&s.activeConns, -1)
|
||||
s.handleConnection(c)
|
||||
}(conn)
|
||||
default:
|
||||
// Max connections reached, reject immediately
|
||||
s.metrics.RecordRejectedConnection()
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitReady waits for the server to be ready to accept connections
|
||||
func (s *Server) WaitReady() <-chan struct{} {
|
||||
return s.readyChan
|
||||
}
|
||||
|
||||
// Stop stops the RPC server and cleans up resources
|
||||
func (s *Server) Stop() error {
|
||||
var err error
|
||||
s.stopOnce.Do(func() {
|
||||
s.mu.Lock()
|
||||
s.shutdown = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// Signal cleanup goroutine to stop
|
||||
close(s.shutdownChan)
|
||||
|
||||
// Close all cached storage connections outside lock
|
||||
s.cacheMu.Lock()
|
||||
stores := make([]storage.Storage, 0, len(s.storageCache))
|
||||
for _, entry := range s.storageCache {
|
||||
stores = append(stores, entry.store)
|
||||
}
|
||||
s.storageCache = make(map[string]*StorageCacheEntry)
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
// Close stores without holding lock
|
||||
for _, store := range stores {
|
||||
if closeErr := store.Close(); closeErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close storage: %v\n", closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Close default storage
|
||||
if s.storage != nil {
|
||||
if closeErr := s.storage.Close(); closeErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close default storage: %v\n", closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Close listener under lock
|
||||
s.mu.Lock()
|
||||
listener := s.listener
|
||||
s.listener = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if listener != nil {
|
||||
if closeErr := listener.Close(); closeErr != nil {
|
||||
err = fmt.Errorf("failed to close listener: %w", closeErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if removeErr := s.removeOldSocket(); removeErr != nil {
|
||||
err = fmt.Errorf("failed to remove socket: %w", removeErr)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for Start() goroutine to finish cleanup (with timeout)
|
||||
select {
|
||||
case <-s.doneChan:
|
||||
// Cleanup completed
|
||||
case <-time.After(5 * time.Second):
|
||||
// Timeout waiting for cleanup - continue anyway
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) ensureSocketDir() error {
|
||||
dir := filepath.Dir(s.socketPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
// Best-effort tighten permissions if directory already existed
|
||||
_ = os.Chmod(dir, 0700) // #nosec G302 - 0700 is secure (user-only access)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) removeOldSocket() error {
|
||||
if _, err := os.Stat(s.socketPath); err == nil {
|
||||
// Socket exists - check if it's stale before removing
|
||||
// Try to connect to see if a daemon is actually using it
|
||||
conn, err := dialRPC(s.socketPath, 500*time.Millisecond)
|
||||
if err == nil {
|
||||
// Socket is active - another daemon is running
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("socket %s is in use by another daemon", s.socketPath)
|
||||
}
|
||||
|
||||
// Socket is stale - safe to remove
|
||||
if err := os.Remove(s.socketPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSignals() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, serverSignals...)
|
||||
<-sigChan
|
||||
_ = s.Stop()
|
||||
}
|
||||
|
||||
func (s *Server) handleConnection(conn net.Conn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
|
||||
for {
|
||||
// Set read deadline for the next request
|
||||
if err := conn.SetReadDeadline(time.Now().Add(s.requestTimeout)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
resp := Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid request: %v", err),
|
||||
}
|
||||
s.writeResponse(writer, resp)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set write deadline for the response
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(s.requestTimeout)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
resp := s.handleRequest(&req)
|
||||
s.writeResponse(writer, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeResponse(writer *bufio.Writer, resp Response) {
|
||||
data, _ := json.Marshal(resp)
|
||||
_, _ = writer.Write(data)
|
||||
_ = writer.WriteByte('\n')
|
||||
_ = writer.Flush()
|
||||
}
|
||||
|
||||
func (s *Server) handleShutdown(_ *Request) Response {
|
||||
// Schedule shutdown in a goroutine so we can return a response first
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond) // Give time for response to be sent
|
||||
if err := s.Stop(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error during shutdown: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: json.RawMessage(`{"message":"Daemon shutting down"}`),
|
||||
}
|
||||
}
|
||||
384
internal/rpc/server_routing_validation_diagnostics.go
Normal file
384
internal/rpc/server_routing_validation_diagnostics.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// checkVersionCompatibility validates client version against server version
|
||||
// Returns error if versions are incompatible
|
||||
func (s *Server) checkVersionCompatibility(clientVersion string) error {
|
||||
// Allow empty client version (old clients before this feature)
|
||||
if clientVersion == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize versions to semver format (add 'v' prefix if missing)
|
||||
serverVer := ServerVersion
|
||||
if !strings.HasPrefix(serverVer, "v") {
|
||||
serverVer = "v" + serverVer
|
||||
}
|
||||
clientVer := clientVersion
|
||||
if !strings.HasPrefix(clientVer, "v") {
|
||||
clientVer = "v" + clientVer
|
||||
}
|
||||
|
||||
// Validate versions are valid semver
|
||||
if !semver.IsValid(serverVer) || !semver.IsValid(clientVer) {
|
||||
// If either version is invalid, allow connection (dev builds, etc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract major versions
|
||||
serverMajor := semver.Major(serverVer)
|
||||
clientMajor := semver.Major(clientVer)
|
||||
|
||||
// Major version must match
|
||||
if serverMajor != clientMajor {
|
||||
cmp := semver.Compare(serverVer, clientVer)
|
||||
if cmp < 0 {
|
||||
// Daemon is older - needs upgrade
|
||||
return fmt.Errorf("incompatible major versions: client %s, daemon %s. Daemon is older; upgrade and restart daemon: 'bd daemon --stop && bd daemon'",
|
||||
clientVersion, ServerVersion)
|
||||
}
|
||||
// Daemon is newer - client needs upgrade
|
||||
return fmt.Errorf("incompatible major versions: client %s, daemon %s. Client is older; upgrade the bd CLI to match the daemon's major version",
|
||||
clientVersion, ServerVersion)
|
||||
}
|
||||
|
||||
// Compare full versions - daemon should be >= client for backward compatibility
|
||||
cmp := semver.Compare(serverVer, clientVer)
|
||||
if cmp < 0 {
|
||||
// Server is older than client within same major version - may be missing features
|
||||
return fmt.Errorf("version mismatch: daemon %s is older than client %s. Upgrade and restart daemon: 'bd daemon --stop && bd daemon'",
|
||||
ServerVersion, clientVersion)
|
||||
}
|
||||
|
||||
// Client is same version or older - OK (daemon supports backward compat within major version)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDatabaseBinding validates that the client is connecting to the correct daemon
|
||||
// Returns error if ExpectedDB is set and doesn't match the daemon's database path
|
||||
func (s *Server) validateDatabaseBinding(req *Request) error {
|
||||
// If client doesn't specify ExpectedDB, allow but log warning (old clients)
|
||||
if req.ExpectedDB == "" {
|
||||
// Log warning for audit trail
|
||||
fmt.Fprintf(os.Stderr, "Warning: Client request without database binding validation (old client or missing ExpectedDB)\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// For multi-database daemons: If a cwd is provided, verify the client expects
|
||||
// the database that would be selected for that cwd
|
||||
var daemonDB string
|
||||
if req.Cwd != "" {
|
||||
// Use the database discovery logic to find which DB would be used
|
||||
dbPath := s.findDatabaseForCwd(req.Cwd)
|
||||
if dbPath != "" {
|
||||
daemonDB = dbPath
|
||||
} else {
|
||||
// No database found for cwd, will fall back to default storage
|
||||
daemonDB = s.storage.Path()
|
||||
}
|
||||
} else {
|
||||
// No cwd provided, use default storage
|
||||
daemonDB = s.storage.Path()
|
||||
}
|
||||
|
||||
// Normalize both paths for comparison (resolve symlinks, clean paths)
|
||||
expectedPath, err := filepath.EvalSymlinks(req.ExpectedDB)
|
||||
if err != nil {
|
||||
// If we can't resolve expected path, use it as-is
|
||||
expectedPath = filepath.Clean(req.ExpectedDB)
|
||||
}
|
||||
daemonPath, err := filepath.EvalSymlinks(daemonDB)
|
||||
if err != nil {
|
||||
// If we can't resolve daemon path, use it as-is
|
||||
daemonPath = filepath.Clean(daemonDB)
|
||||
}
|
||||
|
||||
// Compare paths
|
||||
if expectedPath != daemonPath {
|
||||
return fmt.Errorf("database mismatch: client expects %s but daemon serves %s. Wrong daemon connection - check socket path",
|
||||
req.ExpectedDB, daemonDB)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleRequest(req *Request) Response {
|
||||
// Track request timing
|
||||
start := time.Now()
|
||||
|
||||
// Defer metrics recording to ensure it always happens
|
||||
defer func() {
|
||||
latency := time.Since(start)
|
||||
s.metrics.RecordRequest(req.Operation, latency)
|
||||
}()
|
||||
|
||||
// Validate database binding (skip for health/metrics to allow diagnostics)
|
||||
if req.Operation != OpHealth && req.Operation != OpMetrics {
|
||||
if err := s.validateDatabaseBinding(req); err != nil {
|
||||
s.metrics.RecordError(req.Operation)
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check version compatibility (skip for ping/health to allow version checks)
|
||||
if req.Operation != OpPing && req.Operation != OpHealth {
|
||||
if err := s.checkVersionCompatibility(req.ClientVersion); err != nil {
|
||||
s.metrics.RecordError(req.Operation)
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stale JSONL and auto-import if needed (bd-160)
|
||||
// Skip for write operations that will trigger export anyway
|
||||
// Skip for import operation itself to avoid recursion
|
||||
if req.Operation != OpPing && req.Operation != OpHealth && req.Operation != OpMetrics &&
|
||||
req.Operation != OpImport && req.Operation != OpExport {
|
||||
if err := s.checkAndAutoImportIfStale(req); err != nil {
|
||||
// Log warning but continue - don't fail the request
|
||||
fmt.Fprintf(os.Stderr, "Warning: staleness check failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last activity timestamp
|
||||
s.lastActivityTime.Store(time.Now())
|
||||
|
||||
var resp Response
|
||||
switch req.Operation {
|
||||
case OpPing:
|
||||
resp = s.handlePing(req)
|
||||
case OpStatus:
|
||||
resp = s.handleStatus(req)
|
||||
case OpHealth:
|
||||
resp = s.handleHealth(req)
|
||||
case OpMetrics:
|
||||
resp = s.handleMetrics(req)
|
||||
case OpCreate:
|
||||
resp = s.handleCreate(req)
|
||||
case OpUpdate:
|
||||
resp = s.handleUpdate(req)
|
||||
case OpClose:
|
||||
resp = s.handleClose(req)
|
||||
case OpList:
|
||||
resp = s.handleList(req)
|
||||
case OpShow:
|
||||
resp = s.handleShow(req)
|
||||
case OpReady:
|
||||
resp = s.handleReady(req)
|
||||
case OpStats:
|
||||
resp = s.handleStats(req)
|
||||
case OpDepAdd:
|
||||
resp = s.handleDepAdd(req)
|
||||
case OpDepRemove:
|
||||
resp = s.handleDepRemove(req)
|
||||
case OpLabelAdd:
|
||||
resp = s.handleLabelAdd(req)
|
||||
case OpLabelRemove:
|
||||
resp = s.handleLabelRemove(req)
|
||||
case OpCommentList:
|
||||
resp = s.handleCommentList(req)
|
||||
case OpCommentAdd:
|
||||
resp = s.handleCommentAdd(req)
|
||||
case OpBatch:
|
||||
resp = s.handleBatch(req)
|
||||
|
||||
case OpCompact:
|
||||
resp = s.handleCompact(req)
|
||||
case OpCompactStats:
|
||||
resp = s.handleCompactStats(req)
|
||||
case OpExport:
|
||||
resp = s.handleExport(req)
|
||||
case OpImport:
|
||||
resp = s.handleImport(req)
|
||||
case OpEpicStatus:
|
||||
resp = s.handleEpicStatus(req)
|
||||
case OpShutdown:
|
||||
resp = s.handleShutdown(req)
|
||||
default:
|
||||
s.metrics.RecordError(req.Operation)
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("unknown operation: %s", req.Operation),
|
||||
}
|
||||
}
|
||||
|
||||
// Record error if request failed
|
||||
if !resp.Success {
|
||||
s.metrics.RecordError(req.Operation)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// Adapter helpers
|
||||
func (s *Server) reqCtx(_ *Request) context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func (s *Server) reqActor(req *Request) string {
|
||||
if req != nil && req.Actor != "" {
|
||||
return req.Actor
|
||||
}
|
||||
return "daemon"
|
||||
}
|
||||
|
||||
// Handler implementations
|
||||
|
||||
func (s *Server) handlePing(_ *Request) Response {
|
||||
data, _ := json.Marshal(PingResponse{
|
||||
Message: "pong",
|
||||
Version: ServerVersion,
|
||||
})
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(_ *Request) Response {
|
||||
// Get last activity timestamp
|
||||
lastActivity := s.lastActivityTime.Load().(time.Time)
|
||||
|
||||
// Check for exclusive lock
|
||||
lockActive := false
|
||||
lockHolder := ""
|
||||
if s.workspacePath != "" {
|
||||
if skip, holder, _ := types.ShouldSkipDatabase(s.workspacePath); skip {
|
||||
lockActive = true
|
||||
lockHolder = holder
|
||||
}
|
||||
}
|
||||
|
||||
statusResp := StatusResponse{
|
||||
Version: ServerVersion,
|
||||
WorkspacePath: s.workspacePath,
|
||||
DatabasePath: s.dbPath,
|
||||
SocketPath: s.socketPath,
|
||||
PID: os.Getpid(),
|
||||
UptimeSeconds: time.Since(s.startTime).Seconds(),
|
||||
LastActivityTime: lastActivity.Format(time.RFC3339),
|
||||
ExclusiveLockActive: lockActive,
|
||||
ExclusiveLockHolder: lockHolder,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(statusResp)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(req *Request) Response {
|
||||
start := time.Now()
|
||||
|
||||
// Get memory stats for health response
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
store, err := s.getStorageForRequest(req)
|
||||
if err != nil {
|
||||
data, _ := json.Marshal(HealthResponse{
|
||||
Status: "unhealthy",
|
||||
Version: ServerVersion,
|
||||
Uptime: time.Since(s.startTime).Seconds(),
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
})
|
||||
return Response{
|
||||
Success: false,
|
||||
Data: data,
|
||||
Error: fmt.Sprintf("storage error: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
healthCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status := "healthy"
|
||||
dbError := ""
|
||||
|
||||
_, pingErr := store.GetStatistics(healthCtx)
|
||||
dbResponseMs := time.Since(start).Seconds() * 1000
|
||||
|
||||
if pingErr != nil {
|
||||
status = statusUnhealthy
|
||||
dbError = pingErr.Error()
|
||||
} else if dbResponseMs > 500 {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
s.cacheMu.RLock()
|
||||
cacheSize := len(s.storageCache)
|
||||
s.cacheMu.RUnlock()
|
||||
|
||||
// Check version compatibility
|
||||
compatible := true
|
||||
if req.ClientVersion != "" {
|
||||
if err := s.checkVersionCompatibility(req.ClientVersion); err != nil {
|
||||
compatible = false
|
||||
}
|
||||
}
|
||||
|
||||
health := HealthResponse{
|
||||
Status: status,
|
||||
Version: ServerVersion,
|
||||
ClientVersion: req.ClientVersion,
|
||||
Compatible: compatible,
|
||||
Uptime: time.Since(s.startTime).Seconds(),
|
||||
CacheSize: cacheSize,
|
||||
CacheHits: atomic.LoadInt64(&s.cacheHits),
|
||||
CacheMisses: atomic.LoadInt64(&s.cacheMisses),
|
||||
DBResponseTime: dbResponseMs,
|
||||
ActiveConns: atomic.LoadInt32(&s.activeConns),
|
||||
MaxConns: s.maxConns,
|
||||
MemoryAllocMB: m.Alloc / 1024 / 1024,
|
||||
}
|
||||
|
||||
if dbError != "" {
|
||||
health.Error = dbError
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(health)
|
||||
return Response{
|
||||
Success: status != "unhealthy",
|
||||
Data: data,
|
||||
Error: dbError,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleMetrics(_ *Request) Response {
|
||||
s.cacheMu.RLock()
|
||||
cacheSize := len(s.storageCache)
|
||||
s.cacheMu.RUnlock()
|
||||
|
||||
snapshot := s.metrics.Snapshot(
|
||||
atomic.LoadInt64(&s.cacheHits),
|
||||
atomic.LoadInt64(&s.cacheMisses),
|
||||
cacheSize,
|
||||
int(atomic.LoadInt32(&s.activeConns)),
|
||||
)
|
||||
|
||||
data, _ := json.Marshal(snapshot)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user