Skip to content

Commit bbdf82c

Browse files
amingclawdevclaude
andcommitted
feat: stateService Phase A+B — HTTP CRUD + SSE broadcast
Phase A: /api/state/* routes (read, write, session CRUD, language pref) Phase B: SSE subscribe endpoint with topic filtering + EventBus broadcast 74/74 tests pass. No breaking changes — additive only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 646435b commit bbdf82c

4 files changed

Lines changed: 1127 additions & 0 deletions

File tree

server/routes/stateRoutes.js

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* stateRoutes.js — HTTP CRUD + SSE routes for stateService.
3+
*
4+
* Phase A Endpoints:
5+
* A1 (Read):
6+
* GET /api/state/:agentId — Full snapshot for agent
7+
* GET /api/state/:agentId/* — Value at dot-path (wildcard)
8+
*
9+
* A2 (Write):
10+
* POST /api/state/:agentId/set — { path, value }
11+
* POST /api/state/:agentId/merge — { path, partial }
12+
* DELETE /api/state/:agentId — { path }
13+
*
14+
* A3 (Session CRUD):
15+
* GET /api/state/sessions/:agentId — List sessions
16+
* GET /api/state/sessions/:agentId/:sessionId — Get single session
17+
* POST /api/state/sessions/:agentId — Create session { name }
18+
* DELETE /api/state/sessions/:agentId/:sessionId — Delete session
19+
* POST /api/state/sessions/:agentId/switch — { sessionId }
20+
*
21+
* A4 (Language):
22+
* GET /api/state/app/language — Get language
23+
* POST /api/state/app/language — Set language { language }
24+
*
25+
* Phase B Endpoints:
26+
* B1 (SSE Subscribe):
27+
* GET /api/state/subscribe?topics=sessions,language — SSE stream
28+
*/
29+
30+
const express = require('express');
31+
const router = express.Router();
32+
const { StateService } = require('../services/stateService');
33+
34+
function getStateService() {
35+
return StateService.getInstance();
36+
}
37+
38+
// ── B1: SSE Subscribe Endpoint (must be before :agentId wildcard) ──
39+
40+
router.get('/subscribe', (req, res) => {
41+
const svc = getStateService();
42+
43+
// Parse topics from query param
44+
const topicsParam = req.query.topics || '';
45+
const topics = new Set(
46+
topicsParam.split(',').map(t => t.trim()).filter(Boolean)
47+
);
48+
49+
// Set SSE headers
50+
res.writeHead(200, {
51+
'Content-Type': 'text/event-stream',
52+
'Cache-Control': 'no-cache',
53+
'Connection': 'keep-alive',
54+
'X-Accel-Buffering': 'no' // Disable nginx buffering if present
55+
});
56+
57+
// Send initial :ok comment
58+
res.write(':ok\n\n');
59+
60+
// Initialize SSE broadcast wiring if not already done
61+
svc.initSSEBroadcast();
62+
63+
// Create connection object
64+
const connection = { res, topics };
65+
svc.addSSEConnection(connection);
66+
67+
// Heartbeat every 15s
68+
const heartbeatInterval = setInterval(() => {
69+
try {
70+
res.write(':heartbeat\n\n');
71+
} catch (err) {
72+
console.error('[stateService:sse] Heartbeat write error:', err.message);
73+
clearInterval(heartbeatInterval);
74+
svc.removeSSEConnection(connection);
75+
}
76+
}, 15000);
77+
78+
// Clean up on client disconnect
79+
req.on('close', () => {
80+
clearInterval(heartbeatInterval);
81+
svc.removeSSEConnection(connection);
82+
});
83+
});
84+
85+
// ── A4: Language preference (must be before :agentId wildcard) ──
86+
87+
router.get('/app/language', (req, res) => {
88+
try {
89+
const svc = getStateService();
90+
const language = svc.getLanguage();
91+
res.json({ success: true, language });
92+
} catch (err) {
93+
res.status(500).json({ success: false, message: err.message });
94+
}
95+
});
96+
97+
router.post('/app/language', (req, res) => {
98+
try {
99+
const { language } = req.body || {};
100+
if (!language) {
101+
return res.status(400).json({ success: false, message: 'Missing required field: language' });
102+
}
103+
const svc = getStateService();
104+
const ok = svc.setLanguage(language);
105+
if (!ok) {
106+
return res.status(400).json({ success: false, message: 'Invalid language. Must be "en" or "zh-CN".' });
107+
}
108+
res.json({ success: true, language });
109+
} catch (err) {
110+
res.status(500).json({ success: false, message: err.message });
111+
}
112+
});
113+
114+
// ── A3: Session CRUD (must be before :agentId wildcard) ──
115+
116+
router.get('/sessions/:agentId', (req, res) => {
117+
try {
118+
const svc = getStateService();
119+
const result = svc.listSessions(req.params.agentId);
120+
res.json({ success: true, data: result });
121+
} catch (err) {
122+
res.status(500).json({ success: false, message: err.message });
123+
}
124+
});
125+
126+
router.get('/sessions/:agentId/:sessionId', (req, res) => {
127+
try {
128+
const svc = getStateService();
129+
const session = svc.getSession(req.params.agentId, req.params.sessionId);
130+
if (!session) {
131+
return res.status(404).json({ success: false, message: 'Session not found' });
132+
}
133+
res.json({ success: true, data: session });
134+
} catch (err) {
135+
res.status(500).json({ success: false, message: err.message });
136+
}
137+
});
138+
139+
router.post('/sessions/:agentId', (req, res) => {
140+
try {
141+
const { name } = req.body || {};
142+
const svc = getStateService();
143+
const session = svc.createSession(req.params.agentId, name);
144+
res.json({ success: true, data: session });
145+
} catch (err) {
146+
res.status(500).json({ success: false, message: err.message });
147+
}
148+
});
149+
150+
router.delete('/sessions/:agentId/:sessionId', (req, res) => {
151+
try {
152+
const svc = getStateService();
153+
const deleted = svc.deleteSession(req.params.agentId, req.params.sessionId);
154+
if (!deleted) {
155+
return res.status(404).json({ success: false, message: 'Session not found' });
156+
}
157+
res.json({ success: true });
158+
} catch (err) {
159+
res.status(500).json({ success: false, message: err.message });
160+
}
161+
});
162+
163+
router.post('/sessions/:agentId/switch', (req, res) => {
164+
try {
165+
const { sessionId } = req.body || {};
166+
if (!sessionId) {
167+
return res.status(400).json({ success: false, message: 'Missing required field: sessionId' });
168+
}
169+
const svc = getStateService();
170+
const switched = svc.switchSession(req.params.agentId, sessionId);
171+
if (!switched) {
172+
return res.status(404).json({ success: false, message: 'Session not found' });
173+
}
174+
res.json({ success: true });
175+
} catch (err) {
176+
res.status(500).json({ success: false, message: err.message });
177+
}
178+
});
179+
180+
// ── A2: State write routes (must be before :agentId GET wildcard) ──
181+
182+
router.post('/:agentId/set', (req, res) => {
183+
try {
184+
const { path, value } = req.body || {};
185+
if (!path) {
186+
return res.status(400).json({ success: false, message: 'Missing required field: path' });
187+
}
188+
if (value === undefined) {
189+
return res.status(400).json({ success: false, message: 'Missing required field: value' });
190+
}
191+
const svc = getStateService();
192+
const fullPath = `${req.params.agentId}.${path}`;
193+
svc.set(fullPath, value);
194+
res.json({ success: true });
195+
} catch (err) {
196+
res.status(500).json({ success: false, message: err.message });
197+
}
198+
});
199+
200+
router.post('/:agentId/merge', (req, res) => {
201+
try {
202+
const { path, partial } = req.body || {};
203+
if (!path) {
204+
return res.status(400).json({ success: false, message: 'Missing required field: path' });
205+
}
206+
if (!partial || typeof partial !== 'object') {
207+
return res.status(400).json({ success: false, message: 'Missing or invalid field: partial (must be an object)' });
208+
}
209+
const svc = getStateService();
210+
const fullPath = `${req.params.agentId}.${path}`;
211+
svc.merge(fullPath, partial);
212+
res.json({ success: true });
213+
} catch (err) {
214+
res.status(500).json({ success: false, message: err.message });
215+
}
216+
});
217+
218+
// ── A2: State delete (uses body.path) ──
219+
220+
router.delete('/:agentId', (req, res) => {
221+
try {
222+
const { path } = req.body || {};
223+
if (!path) {
224+
return res.status(400).json({ success: false, message: 'Missing required field: path' });
225+
}
226+
const svc = getStateService();
227+
const fullPath = `${req.params.agentId}.${path}`;
228+
const deleted = svc.delete(fullPath);
229+
res.json({ success: true, deleted });
230+
} catch (err) {
231+
res.status(500).json({ success: false, message: err.message });
232+
}
233+
});
234+
235+
// ── A1: State read routes ──
236+
237+
router.get('/:agentId', (req, res) => {
238+
try {
239+
const svc = getStateService();
240+
const data = svc.snapshot(req.params.agentId);
241+
res.json({ success: true, data });
242+
} catch (err) {
243+
res.status(500).json({ success: false, message: err.message });
244+
}
245+
});
246+
247+
// Wildcard: GET /state/:agentId/some/dot/path
248+
router.get('/:agentId/*', (req, res) => {
249+
try {
250+
const svc = getStateService();
251+
// req.params[0] captures the wildcard portion (e.g. "sessions" or "config/model")
252+
const subPath = req.params[0].replace(/\//g, '.');
253+
const fullPath = `${req.params.agentId}.${subPath}`;
254+
const data = svc.get(fullPath);
255+
res.json({ success: true, data: data !== undefined ? data : null });
256+
} catch (err) {
257+
res.status(500).json({ success: false, message: err.message });
258+
}
259+
});
260+
261+
module.exports = router;

0 commit comments

Comments
 (0)