Skip to content

Commit 5f92d90

Browse files
fullsend-ai-coder[bot]gabemonteroclaude
authored
feat(#3304): add HITL approval store backed by cacheService (#3566)
* feat(#3304): add HITL approval store backed by cacheService Implement BackendApprovalStore for human-in-the-loop tool call approval state (task 1.10). The store uses Backstage cacheService with a 10-minute TTL for request-scoped approval lifecycle. The store supports: - Creating pending approval requests when the inference loop pauses on a tool call with requireApproval: true - Approving requests with optional parameter edits - Rejecting requests - Deleting expired or resolved requests Follows the same cache-backed pattern as ConversationAgentCache (task 1.8) and RateLimiter (task 1.9). Handles both string and auto-deserialized object cache backends. Wired into the boost backend plugin init and exported from the package public API. 16 tests covering all operations and edge cases (corrupt entries, duplicate resolution, unknown IDs). Closes #3304 * fix: update API report for BackendApprovalStore exports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move ApprovalRequest and ApprovalStatus types to boost-common Frontend will need these types for the HITL approval dialog. BackendApprovalStore now imports from boost-common instead of defining the types locally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Co-authored-by: gabemontero <gmontero@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4f4a38e commit 5f92d90

8 files changed

Lines changed: 508 additions & 0 deletions

File tree

workspaces/boost/plugins/boost-backend/report.api.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
```ts
66
import type { AgenticProvider } from '@red-hat-developer-hub/backstage-plugin-boost-common';
77
import type { AgentRecord } from '@red-hat-developer-hub/backstage-plugin-boost-common';
8+
import type { ApprovalRequest } from '@red-hat-developer-hub/backstage-plugin-boost-common';
89
import { BackendFeature } from '@backstage/backend-plugin-api';
910
import type { CacheService } from '@backstage/backend-plugin-api';
1011
import type { ConversationDetails } from '@red-hat-developer-hub/backstage-plugin-boost-common';
@@ -90,6 +91,26 @@ export interface AuthorizeLifecycleActionOptions {
9091
permissions: PermissionsService;
9192
}
9293

94+
// @public
95+
export class BackendApprovalStore {
96+
constructor(options: BackendApprovalStoreOptions);
97+
approve(
98+
requestId: string,
99+
resolvedArgs?: string,
100+
): Promise<ApprovalRequest | undefined>;
101+
create(request: ApprovalRequest): Promise<void>;
102+
delete(requestId: string): Promise<void>;
103+
get(requestId: string): Promise<ApprovalRequest | undefined>;
104+
reject(requestId: string): Promise<ApprovalRequest | undefined>;
105+
static readonly TTL_MS: number;
106+
}
107+
108+
// @public
109+
export interface BackendApprovalStoreOptions {
110+
cache: CacheService;
111+
logger: LoggerService;
112+
}
113+
93114
// @public
94115
export const BOOST_CONFIG_SCHEMA_VERSION = 1;
95116

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type {
18+
CacheService,
19+
LoggerService,
20+
} from '@backstage/backend-plugin-api';
21+
import type { ApprovalRequest } from '@red-hat-developer-hub/backstage-plugin-boost-common';
22+
import { BackendApprovalStore } from './BackendApprovalStore';
23+
24+
function createMockLogger(): LoggerService {
25+
return {
26+
info: jest.fn(),
27+
warn: jest.fn(),
28+
error: jest.fn(),
29+
debug: jest.fn(),
30+
child: jest.fn().mockReturnThis(),
31+
};
32+
}
33+
34+
function createMockCache(): CacheService {
35+
const store = new Map<string, unknown>();
36+
const cache: CacheService = {
37+
get: jest.fn(async (key: string) => store.get(key)) as CacheService['get'],
38+
set: jest.fn(async (key: string, value: unknown) => {
39+
store.set(key, value);
40+
}),
41+
delete: jest.fn(async (key: string) => {
42+
store.delete(key);
43+
}),
44+
withOptions: jest.fn().mockReturnThis(),
45+
};
46+
return cache;
47+
}
48+
49+
function createPendingRequest(
50+
overrides?: Partial<ApprovalRequest>,
51+
): ApprovalRequest {
52+
return {
53+
requestId: 'req-1',
54+
conversationId: 'conv-1',
55+
toolCallId: 'tc-1',
56+
toolName: 'deploy-service',
57+
args: '{"service":"my-app","env":"production"}',
58+
status: 'pending',
59+
userRef: 'user:default/developer',
60+
createdAt: '2026-01-01T00:00:00.000Z',
61+
message: 'Deploy my-app to production?',
62+
...overrides,
63+
};
64+
}
65+
66+
describe('BackendApprovalStore', () => {
67+
let cache: CacheService;
68+
let store: BackendApprovalStore;
69+
70+
beforeEach(() => {
71+
cache = createMockCache();
72+
store = new BackendApprovalStore({
73+
cache,
74+
logger: createMockLogger(),
75+
});
76+
});
77+
78+
it('uses cacheService withOptions for namespace isolation', () => {
79+
expect(cache.withOptions).toHaveBeenCalledWith({
80+
defaultTtl: BackendApprovalStore.TTL_MS,
81+
});
82+
});
83+
84+
it('has a 10-minute TTL', () => {
85+
expect(BackendApprovalStore.TTL_MS).toBe(10 * 60 * 1000);
86+
});
87+
88+
describe('create and get', () => {
89+
it('stores and retrieves an approval request', async () => {
90+
const request = createPendingRequest();
91+
await store.create(request);
92+
const result = await store.get('req-1');
93+
expect(result).toEqual(request);
94+
});
95+
96+
it('returns undefined for unknown request', async () => {
97+
const result = await store.get('unknown');
98+
expect(result).toBeUndefined();
99+
});
100+
101+
it('handles auto-deserialized object from cache backend', async () => {
102+
const request = createPendingRequest();
103+
// Simulate a cache backend that auto-deserializes JSON
104+
(cache.get as jest.Mock).mockResolvedValueOnce(request);
105+
const result = await store.get('req-1');
106+
expect(result).toEqual(request);
107+
});
108+
109+
it('returns undefined for corrupt cache entry', async () => {
110+
(cache.get as jest.Mock).mockResolvedValueOnce('not-valid-json{');
111+
const result = await store.get('req-1');
112+
expect(result).toBeUndefined();
113+
});
114+
115+
it('returns undefined for non-approval object in cache', async () => {
116+
(cache.get as jest.Mock).mockResolvedValueOnce({ unrelated: true });
117+
const result = await store.get('req-1');
118+
expect(result).toBeUndefined();
119+
});
120+
});
121+
122+
describe('approve', () => {
123+
it('approves a pending request with original args', async () => {
124+
await store.create(createPendingRequest());
125+
const result = await store.approve('req-1');
126+
127+
expect(result).toBeDefined();
128+
expect(result!.status).toBe('approved');
129+
expect(result!.resolvedAt).toBeDefined();
130+
expect(result!.resolvedArgs).toBe(
131+
'{"service":"my-app","env":"production"}',
132+
);
133+
});
134+
135+
it('approves with edited arguments', async () => {
136+
await store.create(createPendingRequest());
137+
const editedArgs = '{"service":"my-app","env":"staging"}';
138+
const result = await store.approve('req-1', editedArgs);
139+
140+
expect(result).toBeDefined();
141+
expect(result!.status).toBe('approved');
142+
expect(result!.resolvedArgs).toBe(editedArgs);
143+
});
144+
145+
it('returns undefined for unknown request', async () => {
146+
const result = await store.approve('unknown');
147+
expect(result).toBeUndefined();
148+
});
149+
150+
it('returns undefined for already-approved request', async () => {
151+
await store.create(createPendingRequest());
152+
await store.approve('req-1');
153+
const result = await store.approve('req-1');
154+
expect(result).toBeUndefined();
155+
});
156+
157+
it('returns undefined for already-rejected request', async () => {
158+
await store.create(createPendingRequest());
159+
await store.reject('req-1');
160+
const result = await store.approve('req-1');
161+
expect(result).toBeUndefined();
162+
});
163+
});
164+
165+
describe('reject', () => {
166+
it('rejects a pending request', async () => {
167+
await store.create(createPendingRequest());
168+
const result = await store.reject('req-1');
169+
170+
expect(result).toBeDefined();
171+
expect(result!.status).toBe('rejected');
172+
expect(result!.resolvedAt).toBeDefined();
173+
expect(result!.resolvedArgs).toBeUndefined();
174+
});
175+
176+
it('returns undefined for unknown request', async () => {
177+
const result = await store.reject('unknown');
178+
expect(result).toBeUndefined();
179+
});
180+
181+
it('returns undefined for already-resolved request', async () => {
182+
await store.create(createPendingRequest());
183+
await store.approve('req-1');
184+
const result = await store.reject('req-1');
185+
expect(result).toBeUndefined();
186+
});
187+
});
188+
189+
describe('delete', () => {
190+
it('removes an approval request', async () => {
191+
await store.create(createPendingRequest());
192+
await store.delete('req-1');
193+
const result = await store.get('req-1');
194+
expect(result).toBeUndefined();
195+
});
196+
});
197+
});

0 commit comments

Comments
 (0)