- 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
621 lines
20 KiB
JavaScript
621 lines
20 KiB
JavaScript
/**
|
|
* Gravity Web Server — Express REST API + WebSocket
|
|
*
|
|
* Antigravity IDE 여러 인스턴스를 CDP로 연결하고
|
|
* 웹 브라우저에서 세션을 전환하며 채팅할 수 있게 합니다.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const http = require('http');
|
|
const path = require('path');
|
|
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();
|
|
|
|
// ─── WebSocket Setup ────────────────────────────────────
|
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
|
|
/** @type {Map<WebSocket, {activeSessionId: string|null}>} */
|
|
const wsClients = new Map();
|
|
|
|
wss.on('connection', (ws) => {
|
|
console.log('[WS] 클라이언트 연결');
|
|
wsClients.set(ws, { activeSessionId: null });
|
|
|
|
ws.on('message', async (raw) => {
|
|
try {
|
|
const msg = JSON.parse(raw.toString());
|
|
await handleWsMessage(ws, msg);
|
|
} catch (err) {
|
|
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
const state = wsClients.get(ws);
|
|
if (state?.activeSessionId) {
|
|
stopSessionPolling(state.activeSessionId);
|
|
// 스크린캠스트도 중지
|
|
const session = sessionManager.getSession(state.activeSessionId);
|
|
if (session) session.client.stopScreencast();
|
|
}
|
|
wsClients.delete(ws);
|
|
console.log('[WS] 클라이언트 연결 해제');
|
|
});
|
|
|
|
// 초기 세션 목록 전송
|
|
ws.send(JSON.stringify({
|
|
type: 'sessions_list',
|
|
sessions: sessionManager.listSessions(),
|
|
}));
|
|
});
|
|
|
|
/**
|
|
* WebSocket 메시지 핸들러
|
|
*/
|
|
async function handleWsMessage(ws, msg) {
|
|
const state = wsClients.get(ws);
|
|
|
|
switch (msg.type) {
|
|
case 'switch_session': {
|
|
// 기존 세션 폴링 중지
|
|
if (state.activeSessionId) {
|
|
stopSessionPolling(state.activeSessionId);
|
|
}
|
|
|
|
const session = sessionManager.getSession(msg.sessionId);
|
|
if (!session) {
|
|
ws.send(JSON.stringify({ type: 'error', message: '세션을 찾을 수 없습니다' }));
|
|
return;
|
|
}
|
|
|
|
state.activeSessionId = msg.sessionId;
|
|
|
|
// 이 세션의 CDP 폴링 시작
|
|
startSessionPolling(session, ws);
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'session_switched',
|
|
sessionId: msg.sessionId,
|
|
session: {
|
|
id: session.id,
|
|
name: session.name,
|
|
status: session.status,
|
|
title: session.title,
|
|
},
|
|
}));
|
|
break;
|
|
}
|
|
|
|
case 'send_message': {
|
|
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
|
|
if (!session) {
|
|
ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' }));
|
|
return;
|
|
}
|
|
|
|
const result = await session.client.sendMessage(msg.text);
|
|
ws.send(JSON.stringify({
|
|
type: 'message_sent',
|
|
sessionId: session.id,
|
|
...result,
|
|
}));
|
|
break;
|
|
}
|
|
|
|
case 'get_screenshot': {
|
|
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
|
|
if (!session) {
|
|
ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' }));
|
|
return;
|
|
}
|
|
|
|
const data = await session.client.captureScreenshot();
|
|
if (data) {
|
|
ws.send(JSON.stringify({
|
|
type: 'screenshot',
|
|
sessionId: session.id,
|
|
data: data,
|
|
}));
|
|
}
|
|
break;
|
|
}
|
|
|
|
// ─── Phase 2: Screencast & Remote Input ────────────
|
|
|
|
case 'start_screencast': {
|
|
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
|
|
if (!session) {
|
|
ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' }));
|
|
return;
|
|
}
|
|
|
|
// 스크린캠스트 프레임 콜백 등록
|
|
session.client.onScreencastFrame = (frame) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'screencast_frame',
|
|
sessionId: session.id,
|
|
data: frame.data,
|
|
metadata: frame.metadata,
|
|
}));
|
|
}
|
|
};
|
|
|
|
const ok = await session.client.startScreencast(msg.options || {});
|
|
ws.send(JSON.stringify({
|
|
type: 'screencast_started',
|
|
sessionId: session.id,
|
|
success: ok,
|
|
}));
|
|
break;
|
|
}
|
|
|
|
case 'stop_screencast': {
|
|
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
|
|
if (session) {
|
|
await session.client.stopScreencast();
|
|
}
|
|
ws.send(JSON.stringify({
|
|
type: 'screencast_stopped',
|
|
sessionId: msg.sessionId || state.activeSessionId,
|
|
}));
|
|
break;
|
|
}
|
|
|
|
case 'input_event': {
|
|
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
|
|
if (session) {
|
|
await session.client.dispatchInput(msg.event);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'click_action': {
|
|
// 채팅 탭에서 Proceed/Cancel 등 버튼 클릭
|
|
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
|
|
if (!session) {
|
|
ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' }));
|
|
return;
|
|
}
|
|
|
|
const { x, y } = msg;
|
|
try {
|
|
// mousePressed + mouseReleased = 클릭
|
|
await session.client.dispatchInput({
|
|
type: 'mouse', action: 'mousePressed', x, y, button: 'left', clickCount: 1,
|
|
});
|
|
await session.client.dispatchInput({
|
|
type: 'mouse', action: 'mouseReleased', x, y, button: 'left', clickCount: 1,
|
|
});
|
|
ws.send(JSON.stringify({ type: 'action_clicked', success: true, label: msg.label }));
|
|
} catch (err) {
|
|
ws.send(JSON.stringify({ type: 'error', message: `버튼 클릭 실패: ${err.message}` }));
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
ws.send(JSON.stringify({ type: 'error', message: `알 수 없는 메시지 타입: ${msg.type}` }));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 세션의 CDP 채팅 폴링을 시작하고 결과를 ws 클라이언트에 전송
|
|
*/
|
|
function startSessionPolling(session, ws) {
|
|
session.client.onChatUpdate = (messages) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'chat_update',
|
|
sessionId: session.id,
|
|
messages: messages,
|
|
timestamp: Date.now(),
|
|
}));
|
|
}
|
|
};
|
|
session.client.startPolling(1000);
|
|
}
|
|
|
|
/**
|
|
* 세션 폴링 중지
|
|
*/
|
|
function stopSessionPolling(sessionId) {
|
|
const session = sessionManager.getSession(sessionId);
|
|
if (session) {
|
|
session.client.stopPolling();
|
|
session.client.onChatUpdate = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 세션 목록 변경을 모든 WS 클라이언트에 브로드캐스트
|
|
*/
|
|
function broadcastSessions() {
|
|
const payload = JSON.stringify({
|
|
type: 'sessions_list',
|
|
sessions: sessionManager.listSessions(),
|
|
});
|
|
for (const [ws] of wsClients) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(payload);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모든 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();
|
|
|
|
app.get('/api/bridge/health', async (req, res) => {
|
|
try {
|
|
const health = await bridge.checkHealth();
|
|
res.json(health);
|
|
} catch (e) {
|
|
res.json({ available: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/bridge/sessions', async (req, res) => {
|
|
try {
|
|
const data = await bridge.getSessions();
|
|
res.json(data);
|
|
} catch (e) {
|
|
res.status(502).json({ error: 'Bridge 연결 실패: ' + e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/bridge/cascades', async (req, res) => {
|
|
try {
|
|
const data = await bridge.getCascades();
|
|
res.json(data);
|
|
} catch (e) {
|
|
res.status(502).json({ error: 'Bridge 연결 실패: ' + e.message });
|
|
}
|
|
});
|
|
|
|
// 단일 세션 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;
|
|
const result = await bridge.sendMessage(message, sessionId);
|
|
res.json(result);
|
|
} catch (e) {
|
|
res.status(502).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/bridge/accept', async (req, res) => {
|
|
try {
|
|
const result = await bridge.sendAction('acceptStep');
|
|
res.json(result);
|
|
} catch (e) {
|
|
res.status(502).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/bridge/reject', async (req, res) => {
|
|
try {
|
|
const result = await bridge.sendAction('rejectStep');
|
|
res.json(result);
|
|
} catch (e) {
|
|
res.status(502).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/bridge/accept-terminal', async (req, res) => {
|
|
try {
|
|
const result = await bridge.sendAction('acceptTerminal');
|
|
res.json(result);
|
|
} catch (e) {
|
|
res.status(502).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/bridge/trajectory/:id', async (req, res) => {
|
|
try {
|
|
const data = await bridge.getTrajectory(req.params.id);
|
|
res.json(data);
|
|
} catch (e) {
|
|
res.status(502).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/bridge/action', async (req, res) => {
|
|
try {
|
|
const { action } = req.body;
|
|
const result = await bridge.sendAction(action);
|
|
res.json(result);
|
|
} catch (e) {
|
|
res.status(502).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// 승인/거절 통합 엔드포인트: 모든 유형을 순차 시도
|
|
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 (레거시) ──────────────────────────────
|
|
|
|
app.get('/api/sessions', (req, res) => {
|
|
res.json(sessionManager.listSessions());
|
|
});
|
|
|
|
app.post('/api/sessions', async (req, res) => {
|
|
const { name, host = 'localhost', cdpPort = 9000 } = req.body;
|
|
|
|
if (!name) {
|
|
return res.status(400).json({ error: 'name is required' });
|
|
}
|
|
|
|
const session = await sessionManager.addSession(name, host, cdpPort);
|
|
broadcastSessions();
|
|
|
|
res.status(201).json({
|
|
id: session.id,
|
|
name: session.name,
|
|
host: session.host,
|
|
cdpPort: session.cdpPort,
|
|
status: session.status,
|
|
title: session.title,
|
|
error: session.error || null,
|
|
});
|
|
});
|
|
|
|
app.delete('/api/sessions/:id', async (req, res) => {
|
|
const removed = await sessionManager.removeSession(req.params.id);
|
|
if (!removed) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
broadcastSessions();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post('/api/sessions/:id/reconnect', async (req, res) => {
|
|
const result = await sessionManager.reconnectSession(req.params.id);
|
|
broadcastSessions();
|
|
res.json(result);
|
|
});
|
|
|
|
app.get('/api/sessions/:id/status', (req, res) => {
|
|
const session = sessionManager.getSession(req.params.id);
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
res.json({
|
|
id: session.id,
|
|
status: session.status,
|
|
title: session.title,
|
|
});
|
|
});
|
|
|
|
// Health
|
|
app.get('/api/health', (req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
uptime: process.uptime(),
|
|
sessions: sessionManager.listSessions().length,
|
|
});
|
|
});
|
|
|
|
// SPA 폴백
|
|
app.get('*', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
|
});
|
|
|
|
// ─── Startup ────────────────────────────────────────────
|
|
|
|
server.listen(PORT, () => {
|
|
console.log('═'.repeat(50));
|
|
console.log(` Gravity Web 서버 시작`);
|
|
console.log(` http://localhost:${PORT}`);
|
|
console.log('═'.repeat(50));
|
|
|
|
// Auto Discovery 시작
|
|
const discovery = new AutoDiscovery({
|
|
host: 'localhost',
|
|
intervalMs: 15000,
|
|
onDiscovered: async (instance) => {
|
|
// 이미 같은 포트의 세션이 있는지 확인
|
|
const existing = sessionManager.listSessions().find(
|
|
s => s.host === 'localhost' && s.cdpPort === instance.port
|
|
);
|
|
if (existing) return;
|
|
|
|
// 자동 세션 생성
|
|
try {
|
|
const session = await sessionManager.addSession(
|
|
`Antigravity :${instance.port}`,
|
|
'localhost',
|
|
instance.port
|
|
);
|
|
|
|
console.log(`[AutoDiscovery] 자동 세션 생성: ${session.name} (${session.id})`);
|
|
|
|
// 모든 WS 클라이언트에 세션 목록 업데이트
|
|
broadcastToAll({
|
|
type: 'sessions_list',
|
|
sessions: sessionManager.listSessions(),
|
|
});
|
|
|
|
// 자동 발견 알림
|
|
broadcastToAll({
|
|
type: 'auto_discovered',
|
|
session: {
|
|
id: session.id,
|
|
name: session.name,
|
|
host: 'localhost',
|
|
port: instance.port,
|
|
status: session.status,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(`[AutoDiscovery] 세션 생성 실패:`, err.message);
|
|
}
|
|
},
|
|
onLost: (port) => {
|
|
// 해당 포트의 세션 상태 업데이트
|
|
const sessions = sessionManager.listSessions();
|
|
const lost = sessions.find(s => s.cdpPort === port && s.host === 'localhost');
|
|
if (lost) {
|
|
console.log(`[AutoDiscovery] 세션 소실: ${lost.name}`);
|
|
broadcastToAll({
|
|
type: 'session_lost',
|
|
sessionId: lost.id,
|
|
port,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
discovery.start();
|
|
});
|
|
|
|
/**
|
|
* 모든 WS 클라이언트에 메시지 브로드캐스트
|
|
*/
|
|
function broadcastToAll(data) {
|
|
const json = JSON.stringify(data);
|
|
for (const [ws] of wsClients) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(json);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', async () => {
|
|
console.log('\n[Server] 종료 중...');
|
|
await sessionManager.cleanup();
|
|
server.close(() => {
|
|
console.log('[Server] 종료 완료');
|
|
process.exit(0);
|
|
});
|
|
});
|