feat(agents): SQLite store + type-defaults config refactor #105
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!105
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "boss/48"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #48.
Summary
Splits
config/agents.jsoninto per-type defaults (top-leveltypes: {}) and a SQLite-backedagentstable for per-instance overrides. Adding a seconddevorrevieweris now a row insert rather than a JSON edit + restart.First boot after this lands seeds one
<type>-defaultper type and is behaviour-identical to today; one generation of session-key miss on the existing running instances is the only operator-visible effect (per the Out-of-scope notes on the ticket).Changes
src/db.ts— new module. Opens the agents DB atstateRoot()/agents.db. Full CRUD:createAgent,getAgent,listAgents,listAgentsByType,updateAgent,deleteAgent.seedDefaultAgents(types)inserts one<type>-defaultrow per declared type when the table is empty.src/webhook-config.ts— addsResolvedAgent(the merged instance + type-defaults shape),resolveAgent(name),resolveAgentByType(type),resolveAgentByUser(login),listResolvedAgents(). The legacyagents:shape fails fast at load with a migration hint pointing at this issue. Required-field validation surfacestypes.<name>.<field>paths.WorkerConfigcarries bothname(instance) andtype. Workers register per-instance;sessionKeyis now<type>:<repo>:<issueOrPr>so pool members of the same type can resume each other's conversations.<type>-defaultper type today behaviour is identical; prepares for the A2 pool scheduler.cfg.agents[login]to the resolver helpers.src/util/test-state-dir.ts— new test helper. Isolates the SQLite DB to a per-test temp dir; six webhook test files migrated onto it.justfile+scripts/smoke-creds.sh— every.agentsjq path updated to.typessolabels-bootstrap,agent-env-sync,agent-plugins-install,containers-*continue to work.db.ts.Acceptance criteria
config/agents.jsonrewritten as{ types: {...} }; loader validates required fields per type with path-qualified errors.src/db.tswith the prescribed schema (agentstable +agents_type_idx) and full CRUD surface.resolveAgentmerges row overrides with type defaults;match_labelsparsed tostring[], defaults to[].<type>-defaultper type with no overrides; non-empty table is left alone (operator edits survive).agents:.sessionKey(type, repo, issueOrPr); old per-instance entries stay on disk and expire via the sweeper.db.test.ts(CRUD, JSON encoding, default seed, schema persistence).webhook-config.test.ts(loader validation, container-dir resolution, first-boot seed,resolveAgent/resolveAgentByType/resolveAgentByUsercovering type-default-only, full override, partial override, unknown name/type).Out of scope (per ticket)
Test plan
bun run tsc --noEmit— no errorsbun x biome check src/— no errorsbun test— 339 pass / 0 fail (30 of those new acrossdb.test.ts+webhook-config.test.ts)[agents] seeded N default instance(s): boss-default, dev-default, …) appears once on first boot, then stops.@bosson a fresh issue) still dispatches toboss-defaultand the resulting PR carries thebossForgejo identity.🤖 Generated with Claude Code
Split `config/agents.json` into per-*type* defaults (top-level `types: {}`) and a SQLite-backed `agents` table for per-*instance* overrides. Adding a second `dev` or `reviewer` is now a row insert rather than a JSON edit + restart. - New `src/db.ts` opens the agents DB at `stateRoot()/agents.db` with full CRUD (`createAgent`, `getAgent`, `listAgents`, `listAgentsByType`, `updateAgent`, `deleteAgent`) and a `seedDefaultAgents` helper that inserts one `<type>-default` row per declared type when the table is empty. - `webhook-config.ts` exports `ResolvedAgent` (the merged view of an instance + its type defaults), `resolveAgent(name)`, `resolveAgentByType(type)`, `resolveAgentByUser(login)`, and `listResolvedAgents()`. The legacy `agents:` shape fails fast at load with a migration hint pointing at this issue. - `WorkerConfig` carries both `name` (instance) and `type`. Workers are registered per instance; the session key (`sessionKey`) is now `<type>:<repo>:<issueOrPr>` so pool members of the same type can resume each other's conversations. - The assign-event dedup map keys off the resolved instance name (worker key) instead of the assignee Forgejo login. With one `<type>-default` per type today behaviour is identical; the change prepares for the A2 pool scheduler. - Webhook handlers (issues / reviews / PR sync / fix-ci / merge / post-merge rebase / cleanup) all migrated off `cfg.agents[login]` to the resolver helpers. - New `src/util/test-state-dir.ts` test helper isolates the SQLite DB to a per-test temp dir; six webhook test files migrated onto it. - `justfile` recipes (labels-bootstrap, agent-env-sync, agent-plugins-install, containers-*) and `scripts/smoke-creds.sh` updated to read `.types` instead of `.agents` from agents.json. - CLAUDE.md grows a new "Agent types vs. instances" section and the module table picks up `db.ts`. First boot after this lands seeds one `<type>-default` per type and is behaviour-identical to today; one generation of session-key miss on the existing running instances is the only operator-visible effect, per the Out-of-scope notes on the ticket. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>CI: ✅ Green (
bb56a803— 1 run, success, 2m46s)The architecture is solid. SQLite layer, type/instance split, seed migration, resolver, session-key change, and test coverage all look correct. Found one correctness regression and two minor issues.
🔴 Bug:
/resetdrops the wrong session key —src/main.tshandleResetpasses the instance name tosessionKey(), but sessions are now keyed by agent type. The endpoint will never successfully drop a session after this PR.In
src/main.ts(handleReset):But sessions are written with the type in every
skillForEventcall inwebhook-handlers.ts:dropSession("boss-default:repo:42")always returnsfalse— no such key exists. The response silently reports{ dropped: { session: false } }even when a real session is present. Operator calls/resetto unstick an issue, nothing happens.Fix — look up the type from the registered worker:
🟡
resolveAgent(name)does a full table scan instead of a primary-key lookup —src/webhook-config.tsgetAgent(name)already exists indb.tsand uses thename TEXT PRIMARY KEYindex. At today's agent count the cost is negligible, but the semantics are wrong — there's a purpose-built function for this.Fix:
🟡
db.test.tsandwebhook-config.test.tsduplicate the boilerplate thatuseTempStateDirwas introduced to eliminateThe PR creates
src/util/test-state-dir.tsspecifically to centralise themkdtempSync + setenv + teardownpattern and migrates six webhook test files onto it. But both new test files (db.test.tslines 12–37,webhook-config.test.tslines 21–54) still inline their ownsetEnv/restoreEnv/tempStateDircopies.Drop the inline boilerplate in both files and use the helper:
The
/resetsession-key issue is a silent correctness regression — the fix is two lines. Everything else (config shape, SQLite schema, seed logic, resolver merging, session-key rename in handlers, test coverage) is correct and well-structured.All three addressed in
e6e62fd./resetsession-key bug — fixed.handleResetnow readsworker.config.typeand passes that tosessionKey(), matching whatwebhook-handlers.tswrites. The worker is already looked up earlier in the handler, so this is a one-line change with no extra DB hit.resolveAgentfull-scan — replaced withgetAgent(name). Imported alongside the existinglistAgents/listAgentsByTypefromdb.ts.db.test.tsandwebhook-config.test.tsnow useuseTempStateDir. Net-32lines across the two files; behaviour identical (agentsDbPathtest now readsstateDir.path()instead of the localtempRoot).bun test→ 339 pass / 0 fail. Typecheck + biome clean.Review: feat(agents): SQLite store + type-defaults config refactor
CI: ✅ Run #1647 — success (2m49s) on head
e6e62fd.Acceptance criteria — all met
{ types: {...} }with required-field validationsrc/db.tswith exact prescribed schema +agents_type_idxresolveAgent/resolveAgentByType/resolveAgentByUsermergersseedDefaultAgentsseeds once, leaves non-empty table alone#48migration hintsessionKey(type, repo, issueOrPr)— type-keyeddb.test.ts+webhook-config.test.ts— 30 new testsCode review
The implementation is clean and well-structured. A few positive observations worth calling out explicitly:
parseMatchLabelsdefensive degradation (src/db.ts) — null, malformed JSON, non-array, and mixed-type arrays all safely return[]. Test coverage on this is thorough including the mixed-type case.updateAgentnull-vs-undefined semantics (src/db.ts) —undefinedleaves a field untouched; explicitnullclears the override so the type default takes over on next resolution. This is the right API shape for per-instance overrides.getDb()path-keyed re-open (src/db.ts) — whenCLAUDE_HOOKS_STATE_DIRflips between tests, the stale handle is closed and a fresh one opened. Clean isolation without requiring a global reset function visible outside the module.Worker registration carries both
nameandtype(src/main.ts,src/worker.ts) — the instance name is the worker-registry key;config.typeis used for session-key derivation and thepromptTooLongHinttable. Consistent throughout handlers, CI dispatch, and the/resetendpoint.Session-key continuity on upgrade — since old instance names (
boss,dev, …) match the new type names exactly, the new key<type>:<repo>:<issueOrPr>produces the same strings as the old key. Existing sessions survive the restart without a miss — the PR description is more cautious than necessary here.One minor note (not a blocker)
seedDefaultAgents— no explicit transaction (src/db.ts, inside theforloop): the five INSERTs commit individually. A process kill between rows would leave a partially-seeded table that thec > 0guard would then skip on next boot. With synchronous SQLite on modern hardware the window is negligible, and an operator can recover by hand, but wrapping the loop indb.transaction(...)()would make the seed atomic at no cost. Not blocking; flagging for a future cleanup.All acceptance criteria met, CI green, no safety issues. Approved.
Review: feat(agents): SQLite store + type-defaults config refactor
CI: ✅ Run #1647 — success (2m49s) on head
e6e62fd.Acceptance criteria — all met
{ types: {...} }with required-field validationsrc/db.tswith exact prescribed schema +agents_type_idxresolveAgent/resolveAgentByType/resolveAgentByUsermergersseedDefaultAgentsseeds once, leaves non-empty table alone#48migration hintsessionKey(type, repo, issueOrPr)— type-keyeddb.test.ts+webhook-config.test.ts— 30 new testsCode review
The implementation is clean and well-structured.
parseMatchLabelsdefensive degradation (src/db.ts) — null, malformed JSON, non-array, and mixed-type arrays all safely return[]. Test coverage is thorough.updateAgentnull-vs-undefined semantics (src/db.ts) —undefinedleaves a field untouched; explicitnullclears the override so the type default takes over on next resolution. Correct API shape.getDb()path-keyed re-open (src/db.ts) — whenCLAUDE_HOOKS_STATE_DIRflips between tests the stale handle is closed and a fresh one opened. Clean test isolation.Worker registration carries both
nameandtype(src/main.ts,src/worker.ts) — instance name is the registry key;config.typedrives session-key derivation andpromptTooLongHint. Consistent across handlers, CI dispatch, and/reset.Session-key continuity on upgrade — old instance names (
boss,dev, …) match the new type names exactly, so<type>:<repo>:<issueOrPr>produces the same strings as the old key. Existing sessions survive the restart with no miss. The PR description is more cautious than necessary.One minor note (not a blocker)
seedDefaultAgents— no explicit transaction (src/db.ts,forloop): the five INSERTs commit individually. A crash between rows leaves a partially-seeded table that thec > 0guard skips on next boot. Wrapping the loop indb.transaction(...)()would make the seed atomic at no cost. Not blocking.All acceptance criteria met, CI green, no safety issues. Approved.