diff --git a/docs/ai/design/2026-08-13-feature-agent-registry-sqlite.md b/docs/ai/design/2026-08-13-feature-agent-registry-sqlite.md new file mode 100644 index 00000000..e725763b --- /dev/null +++ b/docs/ai/design/2026-08-13-feature-agent-registry-sqlite.md @@ -0,0 +1,108 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture + +## Architecture Overview + +```mermaid +graph TD + Start["agent start"] --> Registry["AgentRegistry"] + List["agent list/detail/console"] --> Manager["AgentManager.listAgents"] + Manager --> Registry + Registry --> Conn["database/connection"] + Conn --> Schema["database/schema"] + Conn --> DB[("~/.ai-devkit/agents.db")] + Manager -->|live discovery repopulates| Registry + Registry --> Process["process liveness prune"] +``` + +`AgentRegistry` remains the public agent-storage boundary, but SQLite mechanics are isolated in a small database layer modeled after the memory/task packages. `AgentManager`, adapters, and CLI services continue using `register`, `registerBatch`, `lookup`, `list`, `rename`, and `prune`. + +## Data Models + +SQLite database: `~/.ai-devkit/agents.db` + +Table: `agents` + +| Column | Type | Notes | +|---|---|---| +| `name` | TEXT NOT NULL | User-facing agent name | +| `type` | TEXT NOT NULL | Provider type | +| `pid` | INTEGER NOT NULL | Live provider process PID | +| `tmux_session` | TEXT NOT NULL DEFAULT '' | Managed tmux session name, if known | +| `cwd` | TEXT NOT NULL DEFAULT '' | Working directory | +| `started_at` | TEXT NOT NULL | ISO timestamp from first known registry row | +| `session_id` | TEXT NOT NULL DEFAULT '' | Provider session id or `pid-` fallback | +| `session_file_path` | TEXT NOT NULL DEFAULT '' | Provider transcript path when known | +| `updated_at` | TEXT NOT NULL | Last registry update time | + +Constraints: + +- `PRIMARY KEY (type, pid)` to enforce one live row per provider process. +- `UNIQUE(name)` keeps name resolution stable and supports existing rename conflict behavior. + +Legacy JSON behavior: + +- Existing `agents.json` files are ignored rather than imported. +- Running agents are repopulated into SQLite through normal `agent list` discovery and `agent start` registration. +- This avoids resurrecting stale JSON rows after all DB rows have been pruned or stopped. + +## API Design + +Keep the current TypeScript API: + +- `register(entry: RegistryEntry): void` +- `registerBatch(entries: RegistryEntry[]): void` +- `rename(currentName: string, newName: string): void` +- `lookup(name: string): RegistryEntry | null` +- `list(): RegistryEntry[]` +- `prune(): void` +- `isAlive(entry: RegistryEntry): boolean` + +Merge behavior for same `type + pid`: + +- Preserve existing `name` by default. +- Replace `name` when the incoming entry has a non-empty `tmuxSession`; this represents `agent start` registering a managed, user-provided name. +- Preserve existing `tmuxSession` when incoming `tmuxSession` is empty. +- Preserve existing `startedAt`. +- Update `cwd`, `sessionId`, and `sessionFilePath` from incoming values when they are non-empty. + +## Component Breakdown + +- `packages/agent-manager/src/utils/AgentRegistry.ts` + - Own agent-specific behavior: merge/upsert rules, lookup, rename, prune, and mapping between DB rows and `RegistryEntry`. +- `packages/agent-manager/src/database/connection.ts` + - Own SQLite path resolution, directory creation, connection setup, pragmas, query helpers, transactions, and close behavior. +- `packages/agent-manager/src/database/schema.ts` + - Own migration discovery, `user_version` tracking, and applying pending SQL migrations. +- `packages/agent-manager/src/database/migrations/001_initial.sql` + - Create the initial `agents` table. +- `packages/agent-manager/src/database/index.ts` + - Re-export the database boundary for the package. +- `packages/agent-manager/src/AgentManager.ts` + - Continue building registry entries after adapter detection. + - Benefit from PID-aware upsert without major call-site changes. +- Tests + - Update `AgentRegistry` tests from JSON parsing expectations to SQLite behavior. + - Add regression coverage for duplicate PID/name preservation and concurrent registry instances. + +## Design Decisions + +- SQLite over JSON lock files: SQLite provides transactional writes and file locking without a custom lock protocol. +- Separate connection/schema modules over putting all storage mechanics in `AgentRegistry`: follows the existing memory/task package pattern and keeps future migrations localized. +- SQL migration files over embedded DDL: matches the existing memory/task package layout and keeps future schema changes append-only. +- Preserve API shape: reduces blast radius across CLI services, adapters, and tests. +- Ignore legacy JSON rather than importing it: live discovery can repopulate running agents, and avoiding import prevents stale rows from returning after prune/stop. +- `type + pid` primary key: matches live process identity and directly prevents the observed duplicate rows. +- `UNIQUE(name)`: keeps rename and lookup semantics explicit. If a stale row owns a desired name, existing rename/start logic can prune first. + +## Non-Functional Requirements + +- Registry operations must be fast enough for console polling; expected row count is small. +- SQLite initialization must be idempotent. +- Storage writes must be atomic under multiple CLI processes. +- No secrets are stored; all data is local process/session metadata. diff --git a/docs/ai/implementation/2026-08-13-feature-agent-registry-sqlite.md b/docs/ai/implementation/2026-08-13-feature-agent-registry-sqlite.md new file mode 100644 index 00000000..3b2f1942 --- /dev/null +++ b/docs/ai/implementation/2026-08-13-feature-agent-registry-sqlite.md @@ -0,0 +1,79 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Development Setup + +- Active worktree: `.worktrees/feature-agent-registry-sqlite` +- Branch: `feature-agent-registry-sqlite` +- Dependencies: `npm ci` + +## Code Structure + +- `packages/agent-manager/src/utils/AgentRegistry.ts` + - Agent registry behavior and SQLite row mapping. + - Merge/upsert, lookup, list, rename, prune. +- `packages/agent-manager/src/database/connection.ts` + - SQLite connection wrapper, path resolution, pragmas, query helpers, transactions, and close behavior. +- `packages/agent-manager/src/database/schema.ts` + - Migration discovery, schema initialization, and `user_version` management. +- `packages/agent-manager/src/database/migrations/001_initial.sql` + - Initial SQLite table migration. +- `packages/agent-manager/src/database/index.ts` + - Database module exports. +- `packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts` + - Primary unit coverage for storage and merge semantics. +- `packages/agent-manager/src/__tests__/AgentManager.test.ts` + - Manager-level regression coverage for list-driven registry writes. + +## Implementation Notes + +### Core Features + +- Use `better-sqlite3` synchronously; this package is already a dependency of `@ai-devkit/agent-manager`. +- Keep SQLite connection, schema setup, and SQL migrations in `src/database/*`, following the memory/task package pattern. +- Copy `src/database/migrations` into `dist/database/` during package build so runtime schema initialization can load SQL files after SWC compilation. +- Keep `AgentRegistry.default()` and constructor injection for tests. +- Constructor should accept the existing path argument for compatibility. If callers pass `.../agents.json`, derive DB path by replacing `.json` with `.db`; if callers pass another path, use it as the database path unless a JSON extension clearly indicates legacy path intent. +- Initialize schema in the constructor or lazily before the first operation. +- Do not import legacy `agents.json`; running agents repopulate SQLite through normal discovery/start registration. + +### Implemented Files + +- `packages/agent-manager/src/utils/AgentRegistry.ts` now stores rows in SQLite with `PRIMARY KEY (type, pid)` and a unique name constraint. +- `packages/agent-manager/src/database/*` now owns the SQLite connection wrapper, schema initialization, and path derivation. +- `packages/agent-manager/package.json` copies migration SQL files during build, matching the memory/task package build scripts. +- `packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts` covers schema creation, ignored legacy JSON, PID-aware merge, managed-name replacement, prune, rename, and concurrent registry instances. +- `packages/agent-manager/src/__tests__/AgentManager.test.ts` covers the observed custom-name-overwritten-by-fallback regression. + +### Patterns & Best Practices + +- Treat `type + pid` as canonical live identity. +- Preserve user-managed names over generated fallback names. +- Prefer incoming non-empty metadata over empty metadata. +- Keep storage errors explicit. + +## Integration Points + +- `AgentManager.listAgents()` should not need a major rewrite; SQLite upsert fixes duplicate rows underneath it. +- `startAgent()` continues calling `registry.register(entry)` after polling the actual provider PID. +- `killAgent()` continues resolving `tmuxSession` by `lookup(agent.name)`. + +## Error Handling + +- SQLite constraint errors should surface in tests and CLI because they indicate a real storage bug. +- Rename conflict behavior should remain explicit through `RenameConflictError`. + +## Performance Considerations + +- Use primary-key and name indexes/constraints for direct lookup. +- Keep transactions around batch registration. + +## Security Notes + +- Registry data is local process/session metadata only. +- No credential or prompt content is stored in the registry. diff --git a/docs/ai/planning/2026-08-13-feature-agent-registry-sqlite.md b/docs/ai/planning/2026-08-13-feature-agent-registry-sqlite.md new file mode 100644 index 00000000..e53002bc --- /dev/null +++ b/docs/ai/planning/2026-08-13-feature-agent-registry-sqlite.md @@ -0,0 +1,62 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] Milestone 1: SQLite registry storage implemented. +- [x] Milestone 2: Name-preservation and concurrency regressions covered by tests. +- [x] Milestone 3: Focused verification, docs lint, final review, and PR delivered. + +## Task Breakdown + +### Phase 1: Storage Foundation + +- [x] Task 1.1: Replace JSON read/write implementation in `AgentRegistry` with SQLite schema initialization and row mapping. +- [x] Task 1.2: Skip legacy `agents.json` import and let live discovery/start registration repopulate SQLite. +- [x] Task 1.3: Preserve the existing public `AgentRegistry` constructor/API so call sites remain stable. +- [x] Task 1.4: Extract SQLite connection and schema concerns into `src/database/*` following the memory/task package pattern. +- [x] Task 1.5: Move schema DDL into numbered SQL migrations and copy migrations during package build. + +### Phase 2: Merge Semantics + +- [x] Task 2.1: Implement PID-aware upsert that preserves custom names and tmux metadata. +- [x] Task 2.2: Ensure `agent start` style entries with non-empty `tmuxSession` can claim a same-PID generated fallback row. +- [x] Task 2.3: Keep `rename`, `lookup`, `list`, and `prune` semantics compatible with existing CLI behavior. + +### Phase 3: Tests and Validation + +- [x] Task 3.1: Update `AgentRegistry` unit tests for SQLite behavior and ignored legacy JSON. +- [x] Task 3.2: Add `AgentManager` regression tests for repeated `listAgents()` name stability. +- [x] Task 3.3: Run focused tests, typecheck, package lint, AI docs lint, and final git diff review. +- [x] Task 3.4: Commit, push branch, and open/update a PR. + +## Dependencies + +- Existing `better-sqlite3` dependency in `@ai-devkit/agent-manager`. +- Existing process liveness check via `process.kill(pid, 0)`. +- Existing CLI and adapter callers must continue using `AgentRegistry` without redesign. + +## Timeline & Estimates + +- Storage setup: medium risk, targeted to one module. +- Merge semantics: medium risk because name conflict behavior is user-visible. +- Tests and validation: medium effort because several existing tests assert JSON-file details. + +## Risks & Mitigation + +- Risk: existing tests rely on `agents.json` file creation. + - Mitigation: update tests to assert registry behavior rather than storage format where possible. +- Risk: `UNIQUE(name)` conflicts with stale rows. + - Mitigation: keep existing prune-before-start flow and rename conflict checks. +- Risk: pre-upgrade managed tmux metadata exists only in `agents.json`. + - Mitigation: accept this as local ephemeral state; live discovery and new `agent start` calls populate SQLite going forward. + +## Resources Needed + +- Local npm workspace with dependencies installed. +- GitHub CLI or git remote access for PR delivery. diff --git a/docs/ai/requirements/2026-08-13-feature-agent-registry-sqlite.md b/docs/ai/requirements/2026-08-13-feature-agent-registry-sqlite.md new file mode 100644 index 00000000..dfcd1aec --- /dev/null +++ b/docs/ai/requirements/2026-08-13-feature-agent-registry-sqlite.md @@ -0,0 +1,63 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement + +`ai-devkit agent start --name ` can create a correctly named tmux session while `ai-devkit agent list` later shows a generated fallback name such as `ai-devkit-77725`. Controlled reproduction on 2026-08-13 showed two related defects in the JSON registry at `~/.ai-devkit/agents.json`: + +- `AgentManager.listAgents()` can write an adapter-generated fallback entry for a live process before or concurrently with `agent start` writing the user-provided name. +- `AgentRegistry.registerBatch()` upserts by `name` only, so the same live process can have duplicate rows with the same `pid` and different names. +- Multiple commands write through the fixed temp path `agents.json.tmp`, so concurrent `agent start`, `agent list`, `agent detail`, or `agent console` writes can fail with `ENOENT` during `rename()`. + +Affected users are developers supervising local agents through `agent list`, `agent send`, `agent detail`, `agent kill`, and `agent console`. The current workaround is manual registry cleanup or renaming, which is fragile because polling commands can reintroduce generated rows. + +## Goals & Objectives + +- Preserve user-provided agent names and tmux session metadata across repeated list/detail/console polling. +- Prevent duplicate live registry entries for the same agent process identity. +- Make registry writes robust under concurrent short-lived CLI commands and long-running console polling. +- Keep existing public `AgentRegistry` API callers working with minimal call-site churn. +- Ignore existing `agents.json` state and let live discovery/start registration repopulate SQLite, avoiding stale-row resurrection. + +Non-goals: + +- Do not redesign provider detection or session parsing. +- Do not move historical provider session indexes into this registry. +- Do not change print-mode agent storage in `print-agents.json`. +- Do not add a daemon or long-running registry service. + +## User Stories & Use Cases + +- As a developer, I want `agent list` to keep showing `agent-list-debug` after I start an agent with that name, so I can target it reliably with `agent send --id agent-list-debug`. +- As a developer using `agent console`, I want background polling not to overwrite custom names with generated fallback names. +- As a developer running multiple CLI commands, I want registry writes not to fail when commands overlap. +- As a maintainer, I want the registry to enforce one live row per process identity so bugs are caught by storage constraints rather than display ordering. +- As an existing user, I want stale `agents.json` rows not to reappear after the SQLite registry is initialized. + +## Success Criteria + +- `AgentRegistry` stores live agents in SQLite at `~/.ai-devkit/agents.db`. +- Repeated or concurrent registration for the same `type + pid` keeps one row. +- A non-empty existing custom `name` and `tmuxSession` are preserved when incoming detection has only a generated fallback name and empty tmux metadata. +- `agent start --name ` followed by repeated `agent list`, `agent detail`, and `agent console` polling continues to list ``. +- Concurrent registry writes do not use a shared temp file and do not fail with `agents.json.tmp` rename errors. +- Existing `agents.json` entries are left in place but not imported into SQLite. +- Existing tests for start/list/rename/kill/session cache pass, with new regression tests for duplicate PID/name preservation and concurrent writes. + +## Constraints & Assumptions + +- `@ai-devkit/agent-manager` already depends on `better-sqlite3`, so no new runtime storage dependency is required. +- CLI commands are local-only and synchronous registry operations are acceptable. +- The registry is process-local user data under `~/.ai-devkit`; no network or multi-user access is required. +- Live process identity is primarily `type + pid`; `sessionId` and `sessionFilePath` are metadata and may be empty early in startup. +- Generated names follow the adapter pattern `-` and should not replace a user-managed name when the same process already has one. + +## Questions & Open Items + +- Naming policy: preserve any existing name for a same `type + pid` row unless callers explicitly invoke `rename()` or `startAgent()` registers a managed name. Accepted assumption for this feature. +- Legacy cleanup: keep `agents.json` rather than deleting or rewriting it, but do not import it. Accepted assumption for rollback safety and stale-row prevention. diff --git a/docs/ai/testing/2026-08-13-feature-agent-registry-sqlite.md b/docs/ai/testing/2026-08-13-feature-agent-registry-sqlite.md new file mode 100644 index 00000000..1b82b708 --- /dev/null +++ b/docs/ai/testing/2026-08-13-feature-agent-registry-sqlite.md @@ -0,0 +1,76 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy + +## Test Coverage Goals + +- Cover 100% of new/changed `AgentRegistry` behavior. +- Cover the extracted SQLite connection/schema/migration behavior through registry creation and persistence tests. +- Keep existing `AgentManager`, adapter cache, start/rename/kill, and CLI command tests passing. +- Add regressions for the exact observed failure modes: duplicate PID names and concurrent temp-file writes. + +## Unit Tests + +### AgentRegistry + +- [x] Creates SQLite database and schema on first write. +- [x] Ignores existing legacy `agents.json` rows and starts with an empty DB. +- [x] Upserts by `type + pid` rather than by name. +- [x] Preserves existing custom name when incoming detection has generated fallback name and empty `tmuxSession`. +- [x] Lets `agent start` style incoming entries with non-empty `tmuxSession` replace a generated fallback name for the same PID. +- [x] Preserves non-empty `tmuxSession` when incoming detection has empty `tmuxSession`. +- [x] Updates session metadata when incoming `sessionId` and `sessionFilePath` are non-empty. +- [x] `rename()` updates the name and preserves all other fields. +- [x] `rename()` reports not-found and live-name conflict errors. +- [x] `prune()` removes dead PIDs from SQLite. +- [x] Two registry instances can register the same PID without duplicate rows or temp-file failures. + +### AgentManager + +- [x] `listAgents()` preserves a user-managed name when adapter detection emits a generated fallback for the same PID. +- [x] Repeated `listAgents()` calls do not create duplicate registry entries for the same PID. + +## Integration Tests + +- [ ] `startAgent()` can register a managed name after a prior generated fallback row for the same PID. +- [ ] `killAgent()` can find the preserved `tmuxSession` by custom name. +- [ ] Existing Codex/Gemini/Pi adapter registry-cache tests pass with SQLite-backed storage. + +## End-to-End Tests + +- [ ] Manual smoke: start a named Codex agent, run repeated `agent list --json`, confirm the name remains stable. +- [ ] Manual smoke: run overlapping list/detail commands and confirm no temp-file rename error occurs. +- [ ] Manual cleanup: kill repro agents and verify no repro tmux sessions or registry rows remain. + +## Test Data + +- Temporary SQLite DB paths under `fs.mkdtempSync(...)`. +- Legacy JSON fixture used to verify old rows are ignored. +- Live PID uses `process.pid`; dead PID uses a high unlikely PID such as `999999`. + +## Test Reporting & Coverage + +- `npm test --workspace @ai-devkit/agent-manager -- AgentRegistry.test.ts AgentManager.test.ts` +- `npm test --workspace ai-devkit -- agent.service.test.ts agent.test.ts` +- `npm run typecheck --workspace @ai-devkit/agent-manager` +- `npm run lint --workspace @ai-devkit/agent-manager` +- `npx ai-devkit@latest lint --feature agent-registry-sqlite` + +Current note: the full `npm test --workspace @ai-devkit/agent-manager` command is blocked in this sandbox by the unrelated `ClaudePrintAgent.integration.test.ts` path because local `ps` process identity lookup is denied. Focused registry, adapter-cache, CLI service/command, typecheck, build, and lint checks are the required evidence for this feature. + +## Manual Testing + +- Use uniquely named repro agents and clean them up immediately after observing behavior. +- Do not kill or rename unrelated existing user agents. + +## Performance Testing + +- No dedicated benchmark is required. Registry row counts are small; SQLite operations are synchronous and indexed by primary key. + +## Bug Tracking + +- Failures in name preservation, duplicate PID rows, or concurrent write behavior block PR delivery. diff --git a/packages/agent-manager/package.json b/packages/agent-manager/package.json index 647985ac..cccbf666 100644 --- a/packages/agent-manager/package.json +++ b/packages/agent-manager/package.json @@ -12,7 +12,7 @@ } }, "scripts": { - "build": "swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly", + "build": "swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly && cp -r src/database/migrations dist/database/", "dev": "swc src -d dist --strip-leading-paths --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index bd912fb2..cf6273ed 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -340,6 +340,43 @@ describe('AgentManager', () => { expect(registry.list()[0].startedAt).toBe('2026-05-30T00:00:00.000Z'); }); + it('preserves a user-managed name when a fallback row was written later for the same pid', async () => { + registry.register({ + name: 'agent-list-debug', + type: 'codex', + pid: process.pid, + tmuxSession: 'agent-list-debug', + cwd: '/cwd/debug', + startedAt: '2026-05-30T00:00:00.000Z', + sessionId: 'pid-debug', + sessionFilePath: '', + }); + registry.register({ + name: `ai-devkit-${process.pid}`, + type: 'codex', + pid: process.pid, + tmuxSession: '', + cwd: '/cwd/debug', + startedAt: '2026-05-31T00:00:00.000Z', + sessionId: 'pid-debug', + sessionFilePath: '', + }); + + scopedManager.registerAdapter(new MockAdapter('codex', [ + createMockAgent({ name: `ai-devkit-${process.pid}`, type: 'codex', pid: process.pid }), + ])); + + const agents = await scopedManager.listAgents(); + + expect(agents[0].name).toBe('agent-list-debug'); + expect(registry.list()).toHaveLength(1); + expect(registry.list()[0]).toMatchObject({ + name: 'agent-list-debug', + pid: process.pid, + tmuxSession: 'agent-list-debug', + }); + }); + it('writes a fresh startedAt for new entries', async () => { const before = new Date().toISOString(); scopedManager.registerAdapter(new MockAdapter('claude', [ diff --git a/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts b/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts index 0aa7c5cb..867e3951 100644 --- a/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts +++ b/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts @@ -33,29 +33,28 @@ describe('AgentRegistry', () => { }); describe('register', () => { - it('creates the file and parent directory if missing', () => { + it('creates the SQLite database and parent directory if missing', () => { registry.register(makeEntry()); - expect(fs.existsSync(regPath)).toBe(true); - const parsed = JSON.parse(fs.readFileSync(regPath, 'utf8')); - expect(parsed.entries).toHaveLength(1); - expect(parsed.entries[0].name).toBe('agent1'); + expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true); + expect(registry.list()[0].name).toBe('agent1'); }); it('appends a new entry when name is unique', () => { registry.register(makeEntry({ name: 'a' })); - registry.register(makeEntry({ name: 'b' })); + registry.register(makeEntry({ name: 'b', pid: process.ppid })); expect(registry.list()).toHaveLength(2); }); - it('upserts in place when name already exists', () => { - registry.register(makeEntry({ name: 'a', pid: 100 })); - registry.register(makeEntry({ name: 'a', pid: 200 })); + it('upserts in place when type and pid already exist', () => { + registry.register(makeEntry({ name: 'a', pid: process.pid })); + registry.register(makeEntry({ name: 'fallback', pid: process.pid, tmuxSession: '' })); const all = registry.list(); expect(all).toHaveLength(1); - expect(all[0].pid).toBe(200); + expect(all[0].pid).toBe(process.pid); + expect(all[0].name).toBe('a'); }); - it('writes atomically (no leftover .tmp on success)', () => { + it('does not write through the legacy fixed .tmp path', () => { registry.register(makeEntry()); expect(fs.existsSync(`${regPath}.tmp`)).toBe(false); }); @@ -69,16 +68,18 @@ describe('AgentRegistry', () => { it('preserves existing tmuxSession when incoming is empty string', () => { registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' })); - registry.register(makeEntry({ name: 'a', tmuxSession: '', pid: 999 })); + registry.register(makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid })); const saved = registry.lookup('a'); expect(saved?.tmuxSession).toBe('pinned'); - expect(saved?.pid).toBe(999); + expect(saved?.pid).toBe(process.pid); }); - it('replaces tmuxSession when incoming is non-empty', () => { - registry.register(makeEntry({ name: 'a', tmuxSession: 'old' })); - registry.register(makeEntry({ name: 'a', tmuxSession: 'new' })); - expect(registry.lookup('a')?.tmuxSession).toBe('new'); + it('lets a managed start entry replace a generated fallback for the same pid', () => { + registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' })); + registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' })); + expect(registry.lookup('custom-name')?.tmuxSession).toBe('custom-name'); + expect(registry.lookup(`ai-devkit-${process.pid}`)).toBeNull(); + expect(registry.list()).toHaveLength(1); }); }); @@ -88,28 +89,35 @@ describe('AgentRegistry', () => { expect(fs.existsSync(regPath)).toBe(false); }); - it('upserts multiple entries with a single write', () => { - const writeSpy = vi.spyOn(fs, 'writeFileSync'); + it('upserts multiple entries in a single batch', () => { registry.registerBatch([ makeEntry({ name: 'a' }), - makeEntry({ name: 'b' }), - makeEntry({ name: 'c' }), + makeEntry({ name: 'b', pid: process.pid + 1 }), + makeEntry({ name: 'c', pid: process.pid + 2 }), ]); - expect(writeSpy).toHaveBeenCalledTimes(1); - writeSpy.mockRestore(); expect(registry.list()).toHaveLength(3); }); it('applies the tmuxSession merge per entry', () => { registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' })); registry.registerBatch([ - makeEntry({ name: 'a', tmuxSession: '', pid: 7 }), - makeEntry({ name: 'b', tmuxSession: '' }), + makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid }), + makeEntry({ name: 'b', tmuxSession: '', pid: process.pid + 1 }), ]); expect(registry.lookup('a')?.tmuxSession).toBe('pinned'); - expect(registry.lookup('a')?.pid).toBe(7); + expect(registry.lookup('a')?.pid).toBe(process.pid); expect(registry.lookup('b')?.tmuxSession).toBe(''); }); + + it('handles concurrent registry instances without duplicate pid rows', () => { + const other = new AgentRegistry(regPath); + registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' })); + other.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' })); + registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' })); + + expect(registry.list()).toHaveLength(1); + expect(registry.lookup('custom-name')?.pid).toBe(process.pid); + }); }); describe('lookup', () => { @@ -124,20 +132,20 @@ describe('AgentRegistry', () => { }); describe('list', () => { - it('returns empty array when file does not exist', () => { + it('returns empty array when database does not contain entries', () => { expect(registry.list()).toEqual([]); }); - it('returns empty array when file is malformed', () => { + it('ignores existing legacy agents.json entries', () => { + const legacyEntry = makeEntry({ name: 'legacy', tmuxSession: 'legacy' }); fs.mkdirSync(path.dirname(regPath), { recursive: true }); - fs.writeFileSync(regPath, 'not json', 'utf8'); - expect(registry.list()).toEqual([]); - }); + fs.writeFileSync(regPath, JSON.stringify({ entries: [legacyEntry] }), 'utf8'); - it('coerces non-array entries to []', () => { - fs.mkdirSync(path.dirname(regPath), { recursive: true }); - fs.writeFileSync(regPath, JSON.stringify({ entries: 'oops' }), 'utf8'); - expect(registry.list()).toEqual([]); + const legacyRegistry = new AgentRegistry(regPath); + + expect(legacyRegistry.lookup('legacy')).toBeNull(); + expect(legacyRegistry.list()).toEqual([]); + expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true); }); }); @@ -163,10 +171,10 @@ describe('AgentRegistry', () => { it('is a no-op when all entries are alive', () => { registry.register(makeEntry({ pid: process.pid })); - const before = fs.readFileSync(regPath, 'utf8'); + const before = registry.list(); registry.prune(); - const after = fs.readFileSync(regPath, 'utf8'); - expect(after).toBe(before); + const after = registry.list(); + expect(after).toEqual(before); }); it('does nothing when file is missing', () => { @@ -203,7 +211,7 @@ describe('AgentRegistry', () => { it('throws RenameConflictError when new name is already in use by a live entry', () => { registry.register(makeEntry({ name: 'agent-a', pid: process.pid })); - registry.register(makeEntry({ name: 'agent-b', pid: process.pid })); + registry.register(makeEntry({ name: 'agent-b', pid: process.ppid })); expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError); }); @@ -214,7 +222,7 @@ describe('AgentRegistry', () => { expect(registry.lookup('agent-b')?.pid).toBe(process.pid); }); - it('writes atomically (no leftover .tmp on success)', () => { + it('does not create the legacy fixed .tmp path on rename', () => { registry.register(makeEntry({ name: 'old-name', pid: process.pid })); registry.rename('old-name', 'new-name'); expect(fs.existsSync(`${regPath}.tmp`)).toBe(false); diff --git a/packages/agent-manager/src/database/connection.ts b/packages/agent-manager/src/database/connection.ts new file mode 100644 index 00000000..db760c41 --- /dev/null +++ b/packages/agent-manager/src/database/connection.ts @@ -0,0 +1,74 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { homedir } from 'os'; +import { initializeSchema } from './schema.js'; + +export const DEFAULT_AGENT_REGISTRY_DB_PATH = join(homedir(), '.ai-devkit', 'agents.db'); + +export interface DatabaseOptions { + dbPath?: string; + verbose?: boolean; + readonly?: boolean; +} + +export function resolveAgentRegistryDbPath(filePath?: string): string { + if (!filePath) return DEFAULT_AGENT_REGISTRY_DB_PATH; + return filePath.endsWith('.json') ? filePath.replace(/\.json$/, '.db') : filePath; +} + +export class DatabaseConnection { + private db: Database.Database; + private readonly dbPath: string; + + constructor(options: DatabaseOptions = {}) { + this.dbPath = options.dbPath ?? DEFAULT_AGENT_REGISTRY_DB_PATH; + mkdirSync(dirname(this.dbPath), { recursive: true }); + + this.db = new Database(this.dbPath, { + readonly: options.readonly ?? false, + verbose: options.verbose ? console.log : undefined, + }); + + this.configure(); + initializeSchema(this); + } + + private configure(): void { + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + this.db.pragma('synchronous = NORMAL'); + this.db.pragma('busy_timeout = 5000'); + this.db.pragma('mmap_size = 268435456'); + } + + get instance(): Database.Database { + return this.db; + } + + get path(): string { + return this.dbPath; + } + + query(sql: string, params: unknown[] = []): T[] { + return this.db.prepare(sql).all(...params) as T[]; + } + + queryOne(sql: string, params: unknown[] = []): T | undefined { + return this.db.prepare(sql).get(...params) as T | undefined; + } + + execute(sql: string, params: unknown[] = []): Database.RunResult { + return this.db.prepare(sql).run(...params); + } + + transaction(fn: () => T): T { + return this.db.transaction(fn)(); + } + + close(): void { + if (this.db.open) { + this.db.close(); + } + } +} diff --git a/packages/agent-manager/src/database/index.ts b/packages/agent-manager/src/database/index.ts new file mode 100644 index 00000000..aebad308 --- /dev/null +++ b/packages/agent-manager/src/database/index.ts @@ -0,0 +1,7 @@ +export { + DatabaseConnection, + DEFAULT_AGENT_REGISTRY_DB_PATH, + resolveAgentRegistryDbPath, +} from './connection.js'; +export type { DatabaseOptions } from './connection.js'; +export { getSchemaVersion, initializeSchema } from './schema.js'; diff --git a/packages/agent-manager/src/database/migrations/001_initial.sql b/packages/agent-manager/src/database/migrations/001_initial.sql new file mode 100644 index 00000000..415c2b46 --- /dev/null +++ b/packages/agent-manager/src/database/migrations/001_initial.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS agents ( + type TEXT NOT NULL, + pid INTEGER NOT NULL, + name TEXT NOT NULL UNIQUE, + tmux_session TEXT NOT NULL DEFAULT '', + cwd TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + session_file_path TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + PRIMARY KEY (type, pid) +); diff --git a/packages/agent-manager/src/database/schema.ts b/packages/agent-manager/src/database/schema.ts new file mode 100644 index 00000000..e61c76d8 --- /dev/null +++ b/packages/agent-manager/src/database/schema.ts @@ -0,0 +1,62 @@ +import { readFileSync, readdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import type { DatabaseConnection } from './connection.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function getSchemaVersion(db: DatabaseConnection): number { + const result = db.instance.pragma('user_version') as { user_version: number }[]; + return result[0]?.user_version ?? 0; +} + +function setSchemaVersion(db: DatabaseConnection, version: number): void { + db.instance.pragma(`user_version = ${version}`); +} + +function getMigrationsDir(): string { + return join(__dirname, 'migrations'); +} + +interface Migration { + version: number; + path: string; + name: string; +} + +function getMigrationFiles(): Migration[] { + const migrationsDir = getMigrationsDir(); + + let files: string[]; + try { + files = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort(); + } catch { + return []; + } + + return files.map((file) => { + const match = file.match(/^(\d+)_(.+)\.sql$/); + if (!match || !match[1] || !match[2]) { + throw new Error(`Invalid migration filename: ${file}. Expected format: 001_name.sql`); + } + return { + version: parseInt(match[1], 10), + name: match[2], + path: join(migrationsDir, file), + }; + }); +} + +export function initializeSchema(db: DatabaseConnection): void { + const currentVersion = getSchemaVersion(db); + const pendingMigrations = getMigrationFiles().filter((m) => m.version > currentVersion); + + for (const migration of pendingMigrations) { + const sql = readFileSync(migration.path, 'utf-8'); + + db.transaction(() => { + db.instance.exec(sql); + setSchemaVersion(db, migration.version); + }); + } +} diff --git a/packages/agent-manager/src/utils/AgentRegistry.ts b/packages/agent-manager/src/utils/AgentRegistry.ts index 8ed59800..83d25216 100644 --- a/packages/agent-manager/src/utils/AgentRegistry.ts +++ b/packages/agent-manager/src/utils/AgentRegistry.ts @@ -1,7 +1,10 @@ -import fs from 'fs'; import os from 'os'; import path from 'path'; import type { AgentType } from '../adapters/AgentAdapter.js'; +import { + DatabaseConnection, + resolveAgentRegistryDbPath, +} from '../database/index.js'; export class RenameNotFoundError extends Error { constructor(public agentName: string) { @@ -28,8 +31,16 @@ export interface RegistryEntry { sessionFilePath: string; } -interface RegistryFile { - entries: RegistryEntry[]; +interface RegistryRow { + name: string; + type: AgentType; + pid: number; + tmux_session: string; + cwd: string; + started_at: string; + session_id: string; + session_file_path: string; + updated_at: string; } const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json'); @@ -37,10 +48,10 @@ const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json let defaultInstance: AgentRegistry | null = null; export class AgentRegistry { - private filePath: string; + private db: DatabaseConnection; constructor(filePath: string = DEFAULT_REGISTRY_PATH) { - this.filePath = filePath; + this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) }); } static default(): AgentRegistry { @@ -50,32 +61,79 @@ export class AgentRegistry { return defaultInstance; } - private readFile(): RegistryFile { - try { - const raw = fs.readFileSync(this.filePath, 'utf8'); - const parsed = JSON.parse(raw) as RegistryFile; - return { entries: Array.isArray(parsed.entries) ? parsed.entries : [] }; - } catch { - return { entries: [] }; - } - } - - private writeFile(data: RegistryFile): void { - const dir = path.dirname(this.filePath); - fs.mkdirSync(dir, { recursive: true }); - const tmp = `${this.filePath}.tmp`; - fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8'); - fs.renameSync(tmp, this.filePath); + private rowToEntry(row: RegistryRow): RegistryEntry { + return { + name: row.name, + type: row.type, + pid: row.pid, + tmuxSession: row.tmux_session, + cwd: row.cwd, + startedAt: row.started_at, + sessionId: row.session_id, + sessionFilePath: row.session_file_path, + }; } private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry { if (!existing) return incoming; + const incomingIsManaged = Boolean(incoming.tmuxSession); return { - ...incoming, + ...existing, + name: incomingIsManaged ? incoming.name : existing.name, tmuxSession: incoming.tmuxSession || existing.tmuxSession, + cwd: incoming.cwd || existing.cwd, + startedAt: existing.startedAt || incoming.startedAt, + sessionId: incoming.sessionId || existing.sessionId, + sessionFilePath: incoming.sessionFilePath || existing.sessionFilePath, }; } + private findByIdentity(type: AgentType, pid: number): RegistryEntry | undefined { + const row = this.db.queryOne( + 'SELECT * FROM agents WHERE type = ? AND pid = ?', + [type, pid], + ); + return row ? this.rowToEntry(row) : undefined; + } + + private findByName(name: string): RegistryEntry | undefined { + const row = this.db.queryOne('SELECT * FROM agents WHERE name = ?', [name]); + return row ? this.rowToEntry(row) : undefined; + } + + private deleteNameConflict(name: string, type: AgentType, pid: number): void { + const conflict = this.findByName(name); + if (!conflict) return; + if (conflict.type === type && conflict.pid === pid) return; + if (!this.isAlive(conflict)) { + this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]); + } + } + + private insertOrUpdate(entry: RegistryEntry): void { + this.db.instance.prepare(` + INSERT INTO agents ( + type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at + ) + VALUES ( + @type, @pid, @name, @tmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt + ) + ON CONFLICT(type, pid) DO UPDATE SET + name = excluded.name, + tmux_session = excluded.tmux_session, + cwd = excluded.cwd, + started_at = agents.started_at, + session_id = excluded.session_id, + session_file_path = excluded.session_file_path, + updated_at = excluded.updated_at + `).run({ ...entry, updatedAt: new Date().toISOString() }); + } + + private save(entry: RegistryEntry): void { + this.deleteNameConflict(entry.name, entry.type, entry.pid); + this.insertOrUpdate(entry); + } + isAlive(entry: RegistryEntry): boolean { try { process.kill(entry.pid, 0); @@ -86,11 +144,13 @@ export class AgentRegistry { } prune(): void { - const data = this.readFile(); - const live = data.entries.filter((e) => this.isAlive(e)); - if (live.length !== data.entries.length) { - this.writeFile({ entries: live }); - } + const entries = this.list(); + const stale = entries.filter((e) => !this.isAlive(e)); + this.db.transaction(() => { + for (const entry of stale) { + this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]); + } + }); } register(entry: RegistryEntry): void { @@ -99,41 +159,41 @@ export class AgentRegistry { registerBatch(entries: RegistryEntry[]): void { if (entries.length === 0) return; - const data = this.readFile(); - for (const incoming of entries) { - const idx = data.entries.findIndex((e) => e.name === incoming.name); - if (idx >= 0) { - data.entries[idx] = this.mergeEntry(incoming, data.entries[idx]); - } else { - data.entries.push(incoming); + this.db.transaction(() => { + for (const incoming of entries) { + const existing = this.findByIdentity(incoming.type, incoming.pid); + this.save(this.mergeEntry(incoming, existing)); } - } - this.writeFile(data); + }); } rename(currentName: string, newName: string): void { - const data = this.readFile(); - const idx = data.entries.findIndex((e) => e.name === currentName); - if (idx < 0) { + const existing = this.findByName(currentName); + if (!existing) { throw new RenameNotFoundError(currentName); } - const liveEntries = data.entries.filter((e) => this.isAlive(e)); - const conflict = liveEntries.find((e) => e.name === newName); - if (conflict) { + const conflict = this.findByName(newName); + if (conflict && this.isAlive(conflict)) { throw new RenameConflictError(newName); } - const pruned = liveEntries.map((e) => - e.name === currentName ? { ...e, name: newName } : e, - ); - this.writeFile({ entries: pruned }); + + this.db.transaction(() => { + if (conflict) { + this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]); + } + this.db.execute( + 'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?', + [newName, new Date().toISOString(), existing.type, existing.pid], + ); + }); } lookup(name: string): RegistryEntry | null { - const data = this.readFile(); - return data.entries.find((e) => e.name === name) ?? null; + return this.findByName(name) ?? null; } list(): RegistryEntry[] { - return this.readFile().entries; + const rows = this.db.query('SELECT * FROM agents ORDER BY started_at ASC, name ASC'); + return rows.map((row) => this.rowToEntry(row)); } }