feature(api): read-only cache MCP tools#136
Merged
jamby77 merged 18 commits intoApr 29, 2026
Merged
Conversation
b999cd6 to
a098313
Compare
4 tasks
54794d1 to
85cd223
Compare
CacheReadonlyService exposes: - listCaches: enumerate __betterdb:caches with hit rate + live status - cacheHealth: discriminated union by cache_type; semantic returns category breakdown + uncertain_hit_rate, agent returns per-tool breakdown - thresholdRecommendation: replicates SemanticCache.thresholdEffectiveness by reading the rolling similarity window directly - toolEffectiveness: replicates AgentCache.toolEffectiveness from the __stats hash - similarityDistribution: 20 fixed buckets of width 0.1 across the 0..2 cosine distance range, with optional category and window-hours filters - recentChanges: thin wrapper over listCacheProposals filtered by cache_name (any status, newest first) Six matching MCP tools registered, plus six GET endpoints on the existing McpController. Per-type dispatch raises InvalidCacheTypeError (HTTP 400 / code INVALID_CACHE_TYPE) when a semantic-only or agent-only tool is called against the wrong cache type. The library methods (thresholdEffectiveness, toolEffectiveness) require SemanticCache.initialize(), which would side-effect-create the FT index, so we read the underlying Valkey state directly instead of spawning a transient cache instance per request.
…types CacheReadonlyService now imports REGISTRY_KEY and heartbeatKeyFor() from @betterdb/shared (added in the parent commit on this branch's base) instead of redefining the literals locally. Ensures the API reader and the cache-package writers reference the same constants. The 12 exported types describing the read-only response shapes (CacheHealth, CacheListEntry, ThresholdRecommendation, etc.) move to a sibling cache-readonly.types.ts file. The service file re-exports them so existing import paths continue to work, but the types now live separately and the service file shrinks back to behavior-only.
…ervice
The local readInt() in CacheReadonlyService duplicated a pattern
already used inside AgentCache.stats() and was a minor utility worth
sharing. Extracted to apps/api/src/common/utils/valkey-fields.ts as
readHashInt(record, field) and the service now imports it.
Drive-by cleanups while in the file:
- Drop two `const health: ... = { ... }; return health;` patterns in
favor of returning the literal directly.
- Collapse the two-step recordedAt + category filter in
similarityDistribution into a single boolean expression.
Picks up the constants added on the parent branch and replaces the remaining 'semantic_cache' / 'agent_cache' string literals in cache-readonly.service.ts and the discriminator types in cache-readonly.types.ts (via typeof SEMANTIC_CACHE / typeof AGENT_CACHE). The unknown-narrowing on parsed.type after the two literal-inequality checks doesn't propagate cleanly when the literals come via const imports; an explicit `as CacheType` at the assignment site keeps control-flow narrowing where it matters and avoids cluttering the checks.
…nstants
cache-readonly.types.ts now exports two const-object enums:
THRESHOLD_RECOMMENDATIONS = { TIGHTEN, LOOSEN, OPTIMAL, INSUFFICIENT_DATA }
TOOL_EFFECTIVENESS_RECOMMENDATIONS = { INCREASE_TTL, OPTIMAL, DECREASE_TTL_OR_DISABLE }
with the existing union types (ThresholdRecommendationKind,
ToolEffectivenessRecommendation) derived from them via
typeof[keyof typeof].
The four threshold-recommendation reasoning templates also move out
of the service, into a THRESHOLD_REASONINGS map of small functions
that take only the relevant context. The "% of foo" formatting is
now expressed once via a local formatPct helper.
CacheReadonlyService imports the consts and the reasoning map and
references them by name, so adding or renaming a recommendation no
longer requires touching the inline strings in two places.
The helper takes a generic Record<string, string>; nothing about it is Valkey-specific. The first caller happens to feed it HGETALL output, but a future caller could feed it process.env or any other string-valued record without surprise. apps/api/src/common/utils/valkey-fields.ts → record-fields.ts readHashInt(raw, field) → readIntField(record, field)
readBaseStats(client, key) constructs ${key}:__stats. listCaches was
passing marker.name; for caches whose discovery-marker name happens
to equal their prefix this is fine, but the protocol allows the two
to differ (e.g. an alias-style registration). Stats would then read
from the wrong key and report zeros even for an active cache.
Adds a regression test where ALIAS != PREFIX and asserts stats are
sourced from PREFIX.
…ranch bugbot LOW: nearMisses is filtered to s.score <= threshold + 0.03 so avgNearMissDelta is constrained to (0, 0.03] by construction. The extra '< 0.03' guard never excluded anything useful but did suppress the loosen recommendation in the boundary case where every near miss sits at exactly threshold + 0.03 (delta avg == 0.03), falling through to 'optimal' despite a high near-miss rate.
85cd223 to
af032f6
Compare
- Add approve/reject/editAndApprove/expireProposals on CacheProposalService
sharing logic between HTTP controller (actor_source='ui') and MCP tools
(actor_source='mcp')
- Add CacheApplyService that runs Valkey work and transitions
approved -> applied|failed with idempotency on re-entry
- Add CacheApplyDispatcher with 4 handlers:
- semantic threshold_adjust: HSET {cache_name}:__config (gated on
threshold_adjust capability advertised in marker)
- agent tool_ttl_adjust: HSET {cache_name}:__tool_policies
- semantic invalidate: FT.SEARCH + DEL
- agent invalidate: SCAN + DEL on tool/key_prefix/session pattern
- Add CacheExpirationCron (5 min cadence, setInterval pattern)
marking past-due pending proposals as expired with system audit
- Add /cache-proposals HTTP controller with 6 endpoints
- Add 5 MCP approval endpoints in mcp.controller.ts
- Extract shared mapCacheProposalErrorToHttp helper used by both
controllers and add typed errors for proposal lifecycle
- Tests: 18 new specs covering approve/reject/edit/expire flows and
dispatcher behaviour
- HIGH: parseFtSearchKeys was iterating with i+=2, missing every other key when FT.SEARCH was called with RETURN 0 (which behaves like NOCONTENT and returns just [count, key1, key2, ...]). Step by 1. - LOW: extract optionalString, optionalFiniteNumber, formatApprovalResult to controller-helpers.ts, share between cache-proposal.controller and mcp.controller - Add regression test exercising RETURN 0 response shape
bugbot HIGH: applyAgentInvalidate's key_prefix branch built a SCAN pattern as `<filter_value>*` without prepending `<cache.name>:`, unlike the tool/session branches. This let a key_prefix invalidation match keys belonging to other caches or unrelated application data on the same Valkey instance. Scope the pattern to the cache namespace and add a regression test.
…lob cache name
bugbot MEDIUM: applySemanticThresholdAdjust, applyAgentToolTtlAdjust,
applySemanticInvalidate were passing cache.name as the first arg to
ApplyFailedError, which the constructor labels as proposalId — that
wrong value was landing in applied_result.details. Plus, the constructor
spread '{ proposalId, ...details }' allowed a stale proposalId in
details to overwrite the explicit one. Pass proposalId through to all
dispatcher methods, attach cacheName separately in details, and put the
explicit proposalId last in the spread so it always wins.
bugbot LOW: tool and session SCAN patterns interpolated cache.name
without escaping while key_prefix did. Apply escapeGlob uniformly to
cache.name across all three branches.
Add /cache-proposals route with Pending and History tabs. Pending cards render four (cache_type, proposal_type) variants with approve, reject, and edit-and-approve flows. Edit hidden on invalidate cards. History table filters by status/cache_name and opens a detail panel with full reasoning, payload, and audit trail. Sidebar shows an unread badge for new pending proposals.
Use a module-level subscription store with useSyncExternalStore so the sidebar badge clears when CacheProposals.markAllRead() runs. Wraps markAllRead in useCallback to avoid firing the page-level effect every render. Drops the unused cacheProposalQueryKeys export.
- proposalSource now reads proposed_by only (was falling through to reviewed_by, mislabelling UI-proposed/MCP-reviewed entries as 'mcp'). - Track unread by last-seen timestamp instead of id, so when the marker proposal is approved/rejected/expired it doesn't inflate the unread count to all remaining (already-seen) pending proposals.
…sion Math.max guards setLastSeenAt so a markAllRead call where the newest pending proposal has a smaller timestamp (e.g. the previous newest was approved by another user) cannot move the marker backward and resurrect already-seen proposals as unread.
## Summary
- Add the **Cache Proposals** tab to Monitor (route `/cache-proposals`)
with Pending and History sub-views.
- **Pending view**: cards render the 4 `(cache_type, proposal_type)`
variants — semantic threshold adjust, agent tool TTL adjust, semantic
invalidate, agent invalidate. Approve is optimistic ("Applying…"),
Reject opens an inline reason input, Edit swaps the proposed value for
an inline numeric input that posts via `/edit-and-approve`. Edit is
hidden on invalidate cards. `estimated_affected > 10000` triggers a warn
visual.
- **History view**: table with status + cache_name filters, source
column, clickable rows opening a detail panel with reasoning, payload,
apply result, and full audit trail.
- **Unread indicator**: sidebar badge on the Cache Proposals nav item,
cleared when the user opens the Pending tab. Persisted in localStorage.
- New helpers: `lib/formatters.ts` (`formatTtlSeconds`, `formatTimeAgo`,
`formatExpiresIn`).
- Component tests (Vitest + RTL) cover all 4 card variants, edit hidden
on invalidate, warn visual at >10000, optimistic Applying state, reject
flow with/without reason, edit-and-approve, and direct approve.
Stacked on #137 — merge after the apply-dispatcher PR lands.
Closes the UI portion of issue-179753285 (Days 7–9). Spec:
`docs/plans/specs/spec-cache-proposals-ui.md`.
## Test plan
- [ ] `pnpm --filter @betterdb/web test` — all web tests pass (175
tests, 27 files).
- [ ] `pnpm --filter @betterdb/web build` — typecheck + Vite build
clean.
- [ ] Manual: visit `/cache-proposals`, verify all 4 card variants
render correctly, approve/reject/edit flows POST to the right endpoint,
history filters work, detail panel opens with audit trail, unread badge
clears on tab visit.
- [ ] Verify no hard-coded hex colours — all status colours use
`var(--chart-*)` / shadcn theme variables.
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Medium risk because it introduces new approve/reject/edit-and-approve
mutation flows that trigger operational changes via new API endpoints,
plus polling/unread state persisted in localStorage.
>
> **Overview**
> Introduces a new `/cache-proposals` page with **Pending** and
**History** views for reviewing agent-submitted cache proposals.
>
> Pending proposals render as actionable cards (approve, reject with
optional reason, and edit+approve for supported proposal types) with
basic validation and “high impact” highlighting; history adds filtering
and a row-click detail sheet that shows payload, apply result, and audit
events.
>
> Adds a `cacheProposalsApi` client plus React Query hooks (including
pending polling and query invalidation) and a sidebar **unread badge**
backed by `localStorage`, along with new time/TTL formatting utilities
and targeted component/unit tests.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1bef37b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
## Summary
Implements the cache proposal approval and apply pipeline for issue
179753285 (cache intelligence v1, advisory mode):
- **CacheProposalService**: new `approve`, `reject`, `editAndApprove`,
`expireProposals`, `getProposalWithAudit` methods, sharing logic between
UI (HTTP) and MCP callers
- **CacheApplyService**: idempotent transition `approved → applied |
failed`,
writes `applied`/`failed` audit
- **CacheApplyDispatcher**: 4 handlers
- semantic + threshold_adjust: `HSET {cache_name}:__config
threshold[:category] <value>`
(gated on `threshold_adjust` capability in discovery marker)
- agent + tool_ttl_adjust: `HSET {cache_name}:__tool_policies <tool>
<json>`
- semantic + invalidate: `FT.SEARCH` + `DEL`
- agent + invalidate: `SCAN` + `DEL` on tool/key_prefix/session patterns
- **CacheExpirationCron**: 5-min `setInterval`, drives
`expireCacheProposalsBefore`, writes `actor_source='system'` audit
- **HTTP controller** under `/cache-proposals`: 6 endpoints (pending,
history,
get, approve, reject, edit-and-approve)
- **MCP controller**: 5 new endpoints for the approval flow
(`actor_source='mcp'`)
- Extracts shared `mapCacheProposalErrorToHttp` helper
Stacked on PR #136 (`feature/cache-mcp-readonly-tools`).
## Test plan
- [x] Unit tests pass: 87 cache-related specs (18 new, 69 pre-existing)
- [x] `tsc --noEmit` clean
- [x] `eslint` clean
- [ ] Manual e2e: propose → approve → verify Valkey HSET (left for the
integration-tests spec)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Adds new proposal approval/edit/apply flows that can mutate Valkey
state (HSET/DEL/SCAN/FT.SEARCH) and introduces background expiration, so
mistakes could cause unintended cache invalidations or config changes.
>
> **Overview**
> Implements an end-to-end **cache proposal lifecycle** beyond creation:
proposals can now be listed, fetched with audit, **approved/rejected**,
and (for some types) **edited then approved**, with consistent audit
logging and error-to-HTTP mapping.
>
> Introduces an idempotent apply pipeline: `CacheApplyService`
transitions `approved → applied|failed` and records `applied_result`,
while `CacheApplyDispatcher` executes the concrete Valkey mutations for
`semantic_cache` threshold updates, `agent_cache` tool TTL policy
updates, and both cache types’ invalidations (including safeguards like
cache-type mismatch, capability checks, and key-pattern scoping).
>
> Adds a periodic `CacheExpirationCron` to expire overdue pending
proposals, wires the new controller/module providers, extends MCP with
endpoints for listing/approving/rejecting/editing proposals, and adds
unit tests covering dispatcher behavior and approval/edit/expiration
semantics.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
af5cf8b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The 6 read-only MCP tools from
spec-cache-mcp-readonly-tools.md. Stacked on #134 (cache proposal data model + service); base branch is `feature/cache-proposal-data-model` so it'll auto-rebase to `master` when #134 merges.Per-type dispatch surfaces `INVALID_CACHE_TYPE` (HTTP 400) when a semantic-only or agent-only tool is called against the wrong cache type. `CACHE_NOT_FOUND` (HTTP 404) when the cache isn't in the discovery markers.
The cache-library methods (`thresholdEffectiveness`, `toolEffectiveness`) require `initialize()` which side-effect-creates the FT search index. Calling them from an HTTP request would mutate the cache as a side effect of an inspection. The service reads the underlying Valkey keys directly instead.
Test plan
Note
Medium Risk
Adds new endpoints and background expiry plus code that can delete cache keys and modify runtime cache config/TTL policies; mistakes could cause unintended invalidations or incorrect proposal state transitions.
Overview
Adds a cache proposal review + execution pipeline: new
CacheProposalControllerendpoints to list pending/history, fetch details with audit, andapprove/reject/edit-and-approve;CacheProposalServicenow enforces pending/expiry rules, records audit events withactor_source, supports proposal expiry, and (optionally) triggers apply viaCacheApplyService.Implements proposal application to Valkey via
CacheApplyDispatcher(semantic threshold overrides, agent tool TTL policy updates, and both cache types’ invalidate flows), including safer error propagation (ApplyFailedErrorw/ proposalId) and metrics likeactualAffected/duration.Introduces
CacheReadonlyServiceplus new MCP HTTP endpoints and@betterdb/mcptools for cache listing/health, threshold recommendations, tool effectiveness, similarity distributions, and recent changes, and adds a web “Cache Proposals” page with unread badge/polling, pending cards (approve/reject/edit), and history/detail views.Reviewed by Cursor Bugbot for commit 1f6442e. Bugbot is set up for automated code reviews on this repo. Configure here.