feat(server,frontend): real-time sync architecture with message accumulator

- Add message-accumulator.js: cascades diff-based message accumulation
- Add 3-second cascade polling with broadcastToAll (was undefined!)
- Add /api/bridge/approve endpoint: tries accept/reject Step→Command→Terminal
- Add persistent approve/reject buttons in chat header toolbar
- Frontend: loadSessionMessages (trajectory + accumulated), applyNewMessages (WS push)
- Status change detection: _prevStatusKey tracking prevents unnecessary re-renders
- actionInProgress flag prevents DOM replacement during button fetch
- Known issues: Trajectory 341 hard limit, Cascade no command-approval state
This commit is contained in:
2026-03-08 14:05:59 +09:00
parent e7521433cb
commit 1060476113
16 changed files with 940 additions and 209 deletions

View File

@@ -12,12 +12,20 @@ const { WebSocketServer, WebSocket } = require('ws');
const SessionManager = require('./session-manager');
const { AutoDiscovery } = require('./auto-discover');
const BridgeClient = require('./bridge-client');
const MessageAccumulator = require('./message-accumulator');
const PORT = process.env.PORT || 3300;
const app = express();
const server = http.createServer(app);
app.use(express.json());
// 개발 모드: 캐시 비활성화
app.use((req, res, next) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
next();
});
app.use(express.static(path.join(__dirname, '..', 'public')));
const sessionManager = new SessionManager();
@@ -253,6 +261,18 @@ function broadcastSessions() {
}
}
/**
* 모든 WS 클라이언트에 메시지 브로드캐스트 (Bridge 이벤트 전달용)
*/
function broadcastToAll(msg) {
const payload = JSON.stringify(msg);
for (const [ws] of wsClients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
}
}
// ─── Bridge API 프록시 ──────────────────────────────────
const bridge = new BridgeClient();
@@ -284,6 +304,22 @@ app.get('/api/bridge/cascades', async (req, res) => {
}
});
// 단일 세션 cascades (실시간 갱신용 — 응답 크기 최소화)
app.get('/api/bridge/cascades/:sessionId', async (req, res) => {
try {
const data = await bridge.getCascades();
const cascades = data.cascades || data;
const session = cascades[req.params.sessionId];
if (!session) {
res.json({ status: 'not_found' });
} else {
res.json(session);
}
} catch (e) {
res.status(502).json({ error: 'Bridge 연결 실패: ' + e.message });
}
});
app.post('/api/bridge/send', async (req, res) => {
try {
const { message, sessionId } = req.body;
@@ -296,7 +332,7 @@ app.post('/api/bridge/send', async (req, res) => {
app.post('/api/bridge/accept', async (req, res) => {
try {
const result = await bridge.acceptStep();
const result = await bridge.sendAction('acceptStep');
res.json(result);
} catch (e) {
res.status(502).json({ error: e.message });
@@ -305,7 +341,7 @@ app.post('/api/bridge/accept', async (req, res) => {
app.post('/api/bridge/reject', async (req, res) => {
try {
const result = await bridge.rejectStep();
const result = await bridge.sendAction('rejectStep');
res.json(result);
} catch (e) {
res.status(502).json({ error: e.message });
@@ -314,7 +350,7 @@ app.post('/api/bridge/reject', async (req, res) => {
app.post('/api/bridge/accept-terminal', async (req, res) => {
try {
const result = await bridge.acceptTerminal();
const result = await bridge.sendAction('acceptTerminal');
res.json(result);
} catch (e) {
res.status(502).json({ error: e.message });
@@ -339,9 +375,92 @@ app.post('/api/bridge/action', async (req, res) => {
res.status(502).json({ error: e.message });
}
});
bridge.connectWs((msg) => {
broadcastToAll({ type: 'bridge_event', ...msg });
// 승인/거절 통합 엔드포인트: 모든 유형을 순차 시도
app.post('/api/bridge/approve', async (req, res) => {
const { type } = req.body; // 'accept' or 'reject'
const actions = type === 'accept'
? ['acceptStep', 'acceptCommand', 'acceptTerminal']
: ['rejectStep', 'rejectCommand', 'rejectTerminal'];
for (const action of actions) {
try {
const result = await bridge.sendAction(action);
console.log(`[Approve] ${action} 성공`);
return res.json({ success: true, action, result });
} catch (_) {
// 이 타입은 안 맞음 → 다음 시도
}
}
res.status(502).json({ error: '승인/거절 실패: 대기 중인 요청 없음' });
});
const accumulator = new MessageAccumulator();
// 메시지 누적 API
app.get('/api/bridge/messages/:sessionId', async (req, res) => {
// 요청 시점에 즉시 최신 cascade 반영
try {
const cascData = await bridge.getCascades();
const cascades = cascData.cascades || cascData;
const sid = req.params.sessionId;
if (cascades[sid]) {
accumulator.processCascade(sid, cascades[sid]);
}
} catch (_) { }
const messages = accumulator.getMessages(req.params.sessionId);
const status = accumulator.getStatus(req.params.sessionId);
res.json({ messages, ...status });
});
bridge.connectWs(async (msg) => {
console.log(`[Bridge Event] ${msg.type} session=${msg.sessionId || 'N/A'}`);
broadcastToAll({ type: 'bridge_event', ...msg });
// step_changed/state_changed → cascade 스냅샷 diff → 새 메시지 push
if (msg.type === 'step_changed' || msg.type === 'state_changed') {
await pollAndPushCascades(msg.sessionId);
}
});
// 주기적 cascade polling (WS 이벤트 누락 보완, 3초 간격)
setInterval(async () => {
if (!bridge.connected) return;
await pollAndPushCascades();
}, 3000);
async function pollAndPushCascades(specificSessionId) {
try {
const cascData = await bridge.getCascades();
const cascades = cascData.cascades || cascData;
for (const [sessionId, cascade] of Object.entries(cascades)) {
if (specificSessionId && sessionId !== specificSessionId) continue;
const result = accumulator.processCascade(sessionId, cascade);
if (result && result.newMessages) {
const status = accumulator._computeStatus(cascade);
console.log(`[Accumulator] ${sessionId.substring(0, 8)}: +${result.newMessages.length} messages (${status.isRunning ? 'running' : status.isBlocking ? 'blocking' : 'idle'})`);
broadcastToAll({
type: 'new_messages',
sessionId,
messages: result.newMessages,
...status,
});
} else if (result) {
// 메시지 변경 없어도 상태 변경은 push
const status = accumulator._computeStatus(cascade);
broadcastToAll({
type: 'new_messages',
sessionId,
messages: null,
...status,
});
}
}
} catch (e) {
// 연결 안 됨 — 무시
}
}
// ─── CDP REST API (레거시) ──────────────────────────────