Skip to content

Commit 76ac9e0

Browse files
committed
feature(api): add cache proposal approve/reject/edit + apply dispatcher
- 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
1 parent 54794d1 commit 76ac9e0

11 files changed

Lines changed: 1618 additions & 80 deletions
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {
2+
CacheApplyDispatcher,
3+
} from '../cache-apply.dispatcher';
4+
import { CacheResolverService, type ResolvedCache } from '../cache-resolver.service';
5+
import type { ConnectionRegistry } from '../../connections/connection-registry.service';
6+
import { ApplyFailedError } from '../errors';
7+
import type { StoredCacheProposal } from '@betterdb/shared';
8+
9+
class FakeClient {
10+
public hsets: Array<{ key: string; field: string; value: string }> = [];
11+
public deletes: string[] = [];
12+
13+
async hset(key: string, field: string, value: string): Promise<number> {
14+
this.hsets.push({ key, field, value });
15+
return 1;
16+
}
17+
18+
async del(...keys: string[]): Promise<number> {
19+
this.deletes.push(...keys);
20+
return keys.length;
21+
}
22+
23+
async scan(cursor: string, _match: string, _pattern: string, _count: string, _n: number): Promise<[string, string[]]> {
24+
return [cursor === '0' ? '0' : '0', []];
25+
}
26+
27+
async call(): Promise<unknown> {
28+
return [0];
29+
}
30+
}
31+
32+
const buildDispatcher = (cache: ResolvedCache, client: FakeClient) => {
33+
const registry = {
34+
get: () => ({ getClient: () => client }),
35+
} as unknown as ConnectionRegistry;
36+
const resolver = {
37+
resolveCacheByName: async () => cache,
38+
} as unknown as CacheResolverService;
39+
return new CacheApplyDispatcher(registry, resolver);
40+
};
41+
42+
const SEMANTIC_CACHE: ResolvedCache = {
43+
name: 'sc:prod',
44+
type: 'semantic_cache',
45+
prefix: 'sc:prod',
46+
capabilities: ['threshold_adjust'],
47+
protocol_version: 1,
48+
live: true,
49+
};
50+
51+
const AGENT_CACHE: ResolvedCache = {
52+
name: 'ac:prod',
53+
type: 'agent_cache',
54+
prefix: 'ac:prod',
55+
capabilities: [],
56+
protocol_version: 1,
57+
live: true,
58+
};
59+
60+
const proposal = (overrides: Partial<StoredCacheProposal>): StoredCacheProposal =>
61+
({
62+
id: 'p1',
63+
connection_id: 'c1',
64+
cache_name: 'sc:prod',
65+
cache_type: 'semantic_cache',
66+
proposal_type: 'threshold_adjust',
67+
proposal_payload: { category: null, current_threshold: 0.1, new_threshold: 0.5 },
68+
reasoning: 'r',
69+
status: 'approved',
70+
proposed_by: 'u',
71+
proposed_at: 0,
72+
reviewed_by: null,
73+
reviewed_at: null,
74+
applied_at: null,
75+
applied_result: null,
76+
expires_at: 0,
77+
...overrides,
78+
} as StoredCacheProposal);
79+
80+
describe('CacheApplyDispatcher', () => {
81+
it('semantic threshold_adjust writes HSET to {cache_name}:__config', async () => {
82+
const client = new FakeClient();
83+
const dispatcher = buildDispatcher(SEMANTIC_CACHE, client);
84+
await dispatcher.dispatch(proposal({}));
85+
expect(client.hsets).toEqual([
86+
{ key: 'sc:prod:__config', field: 'threshold', value: '0.5' },
87+
]);
88+
});
89+
90+
it('semantic threshold_adjust with category writes namespaced field', async () => {
91+
const client = new FakeClient();
92+
const dispatcher = buildDispatcher(SEMANTIC_CACHE, client);
93+
await dispatcher.dispatch(
94+
proposal({
95+
proposal_payload: { category: 'support', current_threshold: 0.1, new_threshold: 0.7 },
96+
}),
97+
);
98+
expect(client.hsets[0]).toEqual({
99+
key: 'sc:prod:__config',
100+
field: 'threshold:support',
101+
value: '0.7',
102+
});
103+
});
104+
105+
it('semantic threshold_adjust fails when capability missing', async () => {
106+
const client = new FakeClient();
107+
const dispatcher = buildDispatcher({ ...SEMANTIC_CACHE, capabilities: [] }, client);
108+
await expect(dispatcher.dispatch(proposal({}))).rejects.toBeInstanceOf(ApplyFailedError);
109+
expect(client.hsets).toEqual([]);
110+
});
111+
112+
it('agent tool_ttl_adjust writes JSON policy to {cache_name}:__tool_policies', async () => {
113+
const client = new FakeClient();
114+
const dispatcher = buildDispatcher(AGENT_CACHE, client);
115+
await dispatcher.dispatch(
116+
proposal({
117+
cache_name: 'ac:prod',
118+
cache_type: 'agent_cache',
119+
proposal_type: 'tool_ttl_adjust',
120+
proposal_payload: {
121+
tool_name: 'search_index',
122+
current_ttl_seconds: 60,
123+
new_ttl_seconds: 600,
124+
},
125+
}),
126+
);
127+
expect(client.hsets).toEqual([
128+
{
129+
key: 'ac:prod:__tool_policies',
130+
field: 'search_index',
131+
value: JSON.stringify({ ttl: 600 }),
132+
},
133+
]);
134+
});
135+
136+
it('fails when cache type changed since proposal creation', async () => {
137+
const client = new FakeClient();
138+
const dispatcher = buildDispatcher(AGENT_CACHE, client);
139+
await expect(
140+
dispatcher.dispatch(
141+
proposal({ cache_name: 'ac:prod', cache_type: 'semantic_cache' }),
142+
),
143+
).rejects.toBeInstanceOf(ApplyFailedError);
144+
});
145+
});

0 commit comments

Comments
 (0)