triflux 3.0.0 → 3.1.0-dev.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp.json +8 -3
- package/bin/triflux.mjs +222 -5
- package/hub/hitl.mjs +130 -0
- package/hub/router.mjs +189 -0
- package/hub/schema.sql +80 -0
- package/hub/server.mjs +274 -0
- package/hub/store.mjs +318 -0
- package/hub/tools.mjs +238 -0
- package/hud/hud-qos-status.mjs +26 -7
- package/package.json +56 -51
- package/skills/tfx-hub/SKILL.md +83 -0
package/hub/server.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// hub/server.mjs — Streamable HTTP MCP 서버 진입점
|
|
2
|
+
// Express 없이 Node.js http 모듈 + MCP SDK로 구현
|
|
3
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
|
|
13
|
+
import { createStore } from './store.mjs';
|
|
14
|
+
import { createRouter } from './router.mjs';
|
|
15
|
+
import { createHitlManager } from './hitl.mjs';
|
|
16
|
+
import { createTools } from './tools.mjs';
|
|
17
|
+
|
|
18
|
+
/** initialize 요청 판별 */
|
|
19
|
+
function isInitializeRequest(body) {
|
|
20
|
+
if (body?.method === 'initialize') return true;
|
|
21
|
+
if (Array.isArray(body)) return body.some(m => m.method === 'initialize');
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** HTTP 요청 body JSON 파싱 (1MB 제한) */
|
|
26
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
27
|
+
async function parseBody(req) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let size = 0;
|
|
30
|
+
for await (const chunk of req) {
|
|
31
|
+
size += chunk.length;
|
|
32
|
+
if (size > MAX_BODY_SIZE) throw Object.assign(new Error('Body too large'), { statusCode: 413 });
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
}
|
|
35
|
+
return JSON.parse(Buffer.concat(chunks).toString());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** PID 파일 경로 */
|
|
39
|
+
const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
|
|
40
|
+
const PID_FILE = join(PID_DIR, 'hub.pid');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* tfx-hub 데몬 시작
|
|
44
|
+
* @param {object} opts
|
|
45
|
+
* @param {number} opts.port — 리스닝 포트 (기본 27888)
|
|
46
|
+
* @param {string} opts.dbPath — SQLite DB 경로
|
|
47
|
+
* @param {string} opts.host — 바인드 주소 (기본 127.0.0.1)
|
|
48
|
+
*/
|
|
49
|
+
export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}) {
|
|
50
|
+
if (!dbPath) {
|
|
51
|
+
dbPath = join(PID_DIR, 'state.db');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 코어 모듈 초기화
|
|
55
|
+
const store = createStore(dbPath);
|
|
56
|
+
const router = createRouter(store);
|
|
57
|
+
const hitl = createHitlManager(store);
|
|
58
|
+
const tools = createTools(store, router, hitl);
|
|
59
|
+
|
|
60
|
+
// 세션별 transport 맵
|
|
61
|
+
const transports = new Map();
|
|
62
|
+
|
|
63
|
+
/** 세션당 MCP 서버 생성 (low-level Server — plain JSON Schema 호환) */
|
|
64
|
+
function createMcpForSession() {
|
|
65
|
+
const mcp = new Server(
|
|
66
|
+
{ name: 'tfx-hub', version: '1.0.0' },
|
|
67
|
+
{ capabilities: { tools: {} } },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// tools/list 핸들러
|
|
71
|
+
mcp.setRequestHandler(
|
|
72
|
+
ListToolsRequestSchema,
|
|
73
|
+
async () => ({
|
|
74
|
+
tools: tools.map(t => ({
|
|
75
|
+
name: t.name,
|
|
76
|
+
description: t.description,
|
|
77
|
+
inputSchema: t.inputSchema,
|
|
78
|
+
})),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// tools/call 핸들러
|
|
83
|
+
mcp.setRequestHandler(
|
|
84
|
+
CallToolRequestSchema,
|
|
85
|
+
async (request) => {
|
|
86
|
+
const { name, arguments: args } = request.params;
|
|
87
|
+
const tool = tools.find(t => t.name === name);
|
|
88
|
+
if (!tool) {
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
|
|
91
|
+
isError: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return await tool.handler(args || {});
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return mcp;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// HTTP 서버
|
|
102
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
103
|
+
// CORS (로컬 전용이지만 CLI 클라이언트 호환)
|
|
104
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
105
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
106
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Last-Event-ID');
|
|
107
|
+
|
|
108
|
+
if (req.method === 'OPTIONS') {
|
|
109
|
+
res.writeHead(204);
|
|
110
|
+
return res.end();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// /status — 허브 상태 (브라우저/curl 용)
|
|
114
|
+
if (req.url === '/' || req.url === '/status') {
|
|
115
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
116
|
+
return res.end(JSON.stringify({
|
|
117
|
+
...router.getStatus('hub').data,
|
|
118
|
+
sessions: transports.size,
|
|
119
|
+
pid: process.pid,
|
|
120
|
+
port,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// /mcp — MCP Streamable HTTP 엔드포인트
|
|
125
|
+
if (req.url !== '/mcp') {
|
|
126
|
+
res.writeHead(404);
|
|
127
|
+
return res.end('Not Found');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
132
|
+
|
|
133
|
+
if (req.method === 'POST') {
|
|
134
|
+
const body = await parseBody(req);
|
|
135
|
+
|
|
136
|
+
if (sessionId && transports.has(sessionId)) {
|
|
137
|
+
// 기존 세션
|
|
138
|
+
await transports.get(sessionId).handleRequest(req, res, body);
|
|
139
|
+
} else if (!sessionId && isInitializeRequest(body)) {
|
|
140
|
+
// 새 세션 초기화
|
|
141
|
+
const transport = new StreamableHTTPServerTransport({
|
|
142
|
+
sessionIdGenerator: () => randomUUID(),
|
|
143
|
+
onsessioninitialized: (sid) => { transports.set(sid, transport); },
|
|
144
|
+
});
|
|
145
|
+
transport.onclose = () => {
|
|
146
|
+
if (transport.sessionId) transports.delete(transport.sessionId);
|
|
147
|
+
};
|
|
148
|
+
const mcp = createMcpForSession();
|
|
149
|
+
await mcp.connect(transport);
|
|
150
|
+
await transport.handleRequest(req, res, body);
|
|
151
|
+
} else {
|
|
152
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
153
|
+
res.end(JSON.stringify({
|
|
154
|
+
jsonrpc: '2.0',
|
|
155
|
+
error: { code: -32000, message: 'Bad Request: No valid session ID' },
|
|
156
|
+
id: null,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
} else if (req.method === 'GET') {
|
|
160
|
+
// SSE 스트림 연결
|
|
161
|
+
if (sessionId && transports.has(sessionId)) {
|
|
162
|
+
await transports.get(sessionId).handleRequest(req, res);
|
|
163
|
+
} else {
|
|
164
|
+
res.writeHead(400);
|
|
165
|
+
res.end('Invalid or missing session ID');
|
|
166
|
+
}
|
|
167
|
+
} else if (req.method === 'DELETE') {
|
|
168
|
+
// 세션 종료
|
|
169
|
+
if (sessionId && transports.has(sessionId)) {
|
|
170
|
+
await transports.get(sessionId).handleRequest(req, res);
|
|
171
|
+
} else {
|
|
172
|
+
res.writeHead(400);
|
|
173
|
+
res.end('Invalid or missing session ID');
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
res.writeHead(405);
|
|
177
|
+
res.end('Method Not Allowed');
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error('[tfx-hub] 요청 처리 에러:', error.message);
|
|
181
|
+
if (!res.headersSent) {
|
|
182
|
+
const code = error.statusCode === 413 ? 413
|
|
183
|
+
: error instanceof SyntaxError ? 400 : 500;
|
|
184
|
+
const msg = code === 413 ? 'Body too large'
|
|
185
|
+
: code === 400 ? 'Invalid JSON' : 'Internal server error';
|
|
186
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
187
|
+
res.end(JSON.stringify({
|
|
188
|
+
jsonrpc: '2.0',
|
|
189
|
+
error: { code: code === 500 ? -32603 : -32700, message: msg },
|
|
190
|
+
id: null,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// 스위퍼 시작
|
|
197
|
+
router.startSweeper();
|
|
198
|
+
|
|
199
|
+
// HITL 타임아웃 체크 (10초 주기)
|
|
200
|
+
const hitlTimer = setInterval(() => {
|
|
201
|
+
try { hitl.checkTimeouts(); } catch {}
|
|
202
|
+
}, 10000);
|
|
203
|
+
hitlTimer.unref();
|
|
204
|
+
|
|
205
|
+
// stale 세션 정리 (60초 주기 — transport.onclose 미호출 대비)
|
|
206
|
+
const sessionTimer = setInterval(() => {
|
|
207
|
+
for (const [sid, transport] of transports) {
|
|
208
|
+
try {
|
|
209
|
+
if (transport._writableState?.destroyed || transport._readableState?.destroyed) {
|
|
210
|
+
transports.delete(sid);
|
|
211
|
+
}
|
|
212
|
+
} catch { transports.delete(sid); }
|
|
213
|
+
}
|
|
214
|
+
}, 60000);
|
|
215
|
+
sessionTimer.unref();
|
|
216
|
+
|
|
217
|
+
// PID 파일 기록
|
|
218
|
+
mkdirSync(PID_DIR, { recursive: true });
|
|
219
|
+
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
httpServer.listen(port, host, () => {
|
|
222
|
+
const info = { port, host, dbPath, pid: process.pid, url: `http://${host}:${port}/mcp` };
|
|
223
|
+
|
|
224
|
+
// PID + 포트 기록 (stop/status 용)
|
|
225
|
+
writeFileSync(PID_FILE, JSON.stringify({ pid: process.pid, port, host, url: info.url, started: Date.now() }));
|
|
226
|
+
|
|
227
|
+
console.log(`[tfx-hub] MCP 서버 시작: ${info.url} (PID ${process.pid})`);
|
|
228
|
+
|
|
229
|
+
const stopFn = async () => {
|
|
230
|
+
router.stopSweeper();
|
|
231
|
+
clearInterval(hitlTimer);
|
|
232
|
+
clearInterval(sessionTimer);
|
|
233
|
+
for (const [, transport] of transports) {
|
|
234
|
+
try { await transport.close(); } catch {}
|
|
235
|
+
}
|
|
236
|
+
transports.clear();
|
|
237
|
+
store.close();
|
|
238
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
239
|
+
await new Promise(r => httpServer.close(r));
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
resolve({ ...info, httpServer, store, router, hitl, stop: stopFn });
|
|
243
|
+
});
|
|
244
|
+
httpServer.on('error', reject);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 실행 중인 허브 정보 읽기 */
|
|
249
|
+
export function getHubInfo() {
|
|
250
|
+
if (!existsSync(PID_FILE)) return null;
|
|
251
|
+
try {
|
|
252
|
+
return JSON.parse(readFileSync(PID_FILE, 'utf8'));
|
|
253
|
+
} catch { return null; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// CLI 직접 실행
|
|
257
|
+
const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
|
|
258
|
+
if (selfRun) {
|
|
259
|
+
const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
|
|
260
|
+
const dbPath = process.env.TFX_HUB_DB || undefined;
|
|
261
|
+
|
|
262
|
+
startHub({ port, dbPath }).then((info) => {
|
|
263
|
+
const shutdown = async (sig) => {
|
|
264
|
+
console.log(`\n[tfx-hub] ${sig} 수신, 종료 중...`);
|
|
265
|
+
await info.stop();
|
|
266
|
+
process.exit(0);
|
|
267
|
+
};
|
|
268
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
269
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
270
|
+
}).catch((err) => {
|
|
271
|
+
console.error('[tfx-hub] 시작 실패:', err.message);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
});
|
|
274
|
+
}
|
package/hub/store.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// hub/store.mjs — SQLite WAL 상태 저장소
|
|
2
|
+
// tfx-hub 메시지 버스의 영속 상태를 관리
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
/** UUIDv7 생성 (RFC 9562) */
|
|
12
|
+
export function uuidv7() {
|
|
13
|
+
const now = BigInt(Date.now());
|
|
14
|
+
const buf = randomBytes(16);
|
|
15
|
+
buf[0] = Number((now >> 40n) & 0xffn);
|
|
16
|
+
buf[1] = Number((now >> 32n) & 0xffn);
|
|
17
|
+
buf[2] = Number((now >> 24n) & 0xffn);
|
|
18
|
+
buf[3] = Number((now >> 16n) & 0xffn);
|
|
19
|
+
buf[4] = Number((now >> 8n) & 0xffn);
|
|
20
|
+
buf[5] = Number(now & 0xffn);
|
|
21
|
+
buf[6] = (buf[6] & 0x0f) | 0x70; // version 7
|
|
22
|
+
buf[8] = (buf[8] & 0x3f) | 0x80; // variant 10xx
|
|
23
|
+
const h = buf.toString('hex');
|
|
24
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseJson(str, fallback = null) {
|
|
28
|
+
if (str == null) return fallback;
|
|
29
|
+
try { return JSON.parse(str); } catch { return fallback; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseAgentRow(row) {
|
|
33
|
+
if (!row) return null;
|
|
34
|
+
const { capabilities_json, topics_json, metadata_json, ...rest } = row;
|
|
35
|
+
return { ...rest, capabilities: parseJson(capabilities_json, []), topics: parseJson(topics_json, []), metadata: parseJson(metadata_json, {}) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseMessageRow(row) {
|
|
39
|
+
if (!row) return null;
|
|
40
|
+
const { payload_json, ...rest } = row;
|
|
41
|
+
return { ...rest, payload: parseJson(payload_json, {}) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseHumanRequestRow(row) {
|
|
45
|
+
if (!row) return null;
|
|
46
|
+
const { schema_json, response_json, ...rest } = row;
|
|
47
|
+
return { ...rest, schema: parseJson(schema_json, {}), response: parseJson(response_json, null) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 상태 저장소 생성
|
|
52
|
+
* @param {string} dbPath — SQLite DB 파일 경로
|
|
53
|
+
*/
|
|
54
|
+
export function createStore(dbPath) {
|
|
55
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
56
|
+
const db = new Database(dbPath);
|
|
57
|
+
|
|
58
|
+
// PRAGMA
|
|
59
|
+
db.pragma('journal_mode = WAL');
|
|
60
|
+
db.pragma('synchronous = NORMAL');
|
|
61
|
+
db.pragma('foreign_keys = ON');
|
|
62
|
+
db.pragma('busy_timeout = 5000');
|
|
63
|
+
db.pragma('wal_autocheckpoint = 1000');
|
|
64
|
+
|
|
65
|
+
// 스키마 초기화 (schema.sql 전체 실행 — 주석 포함 안전 처리)
|
|
66
|
+
const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
|
|
67
|
+
db.exec(schemaSQL);
|
|
68
|
+
|
|
69
|
+
// ── 준비된 구문 ──
|
|
70
|
+
|
|
71
|
+
const S = {
|
|
72
|
+
// 에이전트
|
|
73
|
+
upsertAgent: db.prepare(`
|
|
74
|
+
INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, status, metadata_json)
|
|
75
|
+
VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, @status, @metadata_json)
|
|
76
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
77
|
+
cli=excluded.cli, pid=excluded.pid, capabilities_json=excluded.capabilities_json,
|
|
78
|
+
topics_json=excluded.topics_json, last_seen_ms=excluded.last_seen_ms,
|
|
79
|
+
lease_expires_ms=excluded.lease_expires_ms, status=excluded.status, metadata_json=excluded.metadata_json`),
|
|
80
|
+
getAgent: db.prepare('SELECT * FROM agents WHERE agent_id = ?'),
|
|
81
|
+
heartbeat: db.prepare("UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=?"),
|
|
82
|
+
setAgentStatus: db.prepare('UPDATE agents SET status=? WHERE agent_id=?'),
|
|
83
|
+
onlineAgents: db.prepare("SELECT * FROM agents WHERE status != 'offline'"),
|
|
84
|
+
allAgents: db.prepare('SELECT * FROM agents'),
|
|
85
|
+
agentsByTopic: db.prepare("SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'"),
|
|
86
|
+
markStale: db.prepare("UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?"),
|
|
87
|
+
markOffline: db.prepare("UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 60000"),
|
|
88
|
+
|
|
89
|
+
// 메시지
|
|
90
|
+
insertMsg: db.prepare(`
|
|
91
|
+
INSERT INTO messages (id, type, from_agent, to_agent, topic, priority, ttl_ms, created_at_ms, expires_at_ms, correlation_id, trace_id, payload_json, status)
|
|
92
|
+
VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status)`),
|
|
93
|
+
getMsg: db.prepare('SELECT * FROM messages WHERE id=?'),
|
|
94
|
+
getResponse: db.prepare("SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1"),
|
|
95
|
+
getMsgsByTrace: db.prepare('SELECT * FROM messages WHERE trace_id=? ORDER BY created_at_ms'),
|
|
96
|
+
setMsgStatus: db.prepare('UPDATE messages SET status=? WHERE id=?'),
|
|
97
|
+
|
|
98
|
+
// 수신함
|
|
99
|
+
insertInbox: db.prepare('INSERT OR IGNORE INTO message_inbox (message_id, agent_id, attempts) VALUES (?,?,0)'),
|
|
100
|
+
poll: db.prepare(`
|
|
101
|
+
SELECT m.*, i.delivery_id FROM messages m
|
|
102
|
+
JOIN message_inbox i ON m.id=i.message_id
|
|
103
|
+
WHERE i.agent_id=? AND i.delivered_at_ms IS NULL
|
|
104
|
+
AND m.status IN ('queued','delivered') AND m.expires_at_ms > ?
|
|
105
|
+
ORDER BY m.priority DESC, m.created_at_ms ASC LIMIT ?`),
|
|
106
|
+
pollTopics: db.prepare(`
|
|
107
|
+
SELECT m.*, i.delivery_id FROM messages m
|
|
108
|
+
JOIN message_inbox i ON m.id=i.message_id
|
|
109
|
+
WHERE i.agent_id=? AND i.delivered_at_ms IS NULL
|
|
110
|
+
AND m.status IN ('queued','delivered') AND m.expires_at_ms > ?
|
|
111
|
+
AND m.topic IN (SELECT value FROM json_each(?))
|
|
112
|
+
ORDER BY m.priority DESC, m.created_at_ms ASC LIMIT ?`),
|
|
113
|
+
markDelivered: db.prepare('UPDATE message_inbox SET delivered_at_ms=?, attempts=attempts+1 WHERE message_id=? AND agent_id=?'),
|
|
114
|
+
ackInbox: db.prepare('UPDATE message_inbox SET acked_at_ms=? WHERE message_id=? AND agent_id=? AND acked_at_ms IS NULL'),
|
|
115
|
+
tryAckMsg: db.prepare("UPDATE messages SET status='acked' WHERE id=? AND NOT EXISTS (SELECT 1 FROM message_inbox WHERE message_id=? AND acked_at_ms IS NULL)"),
|
|
116
|
+
|
|
117
|
+
// 사용자 입력
|
|
118
|
+
insertHR: db.prepare(`
|
|
119
|
+
INSERT INTO human_requests (request_id, requester_agent, kind, prompt, schema_json, state, deadline_ms, default_action, correlation_id, trace_id, response_json)
|
|
120
|
+
VALUES (@request_id, @requester_agent, @kind, @prompt, @schema_json, @state, @deadline_ms, @default_action, @correlation_id, @trace_id, @response_json)`),
|
|
121
|
+
getHR: db.prepare('SELECT * FROM human_requests WHERE request_id=?'),
|
|
122
|
+
updateHR: db.prepare('UPDATE human_requests SET state=?, response_json=? WHERE request_id=?'),
|
|
123
|
+
pendingHR: db.prepare("SELECT * FROM human_requests WHERE state='pending'"),
|
|
124
|
+
expireHR: db.prepare("UPDATE human_requests SET state='timed_out' WHERE state='pending' AND deadline_ms < ?"),
|
|
125
|
+
|
|
126
|
+
// 데드 레터
|
|
127
|
+
insertDL: db.prepare('INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)'),
|
|
128
|
+
getDL: db.prepare('SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?'),
|
|
129
|
+
|
|
130
|
+
// 스위퍼
|
|
131
|
+
findExpired: db.prepare("SELECT id FROM messages WHERE status IN ('queued','delivered') AND expires_at_ms < ?"),
|
|
132
|
+
|
|
133
|
+
// 메트릭
|
|
134
|
+
urgentDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status IN ('queued','delivered') AND priority >= 7"),
|
|
135
|
+
normalDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status IN ('queued','delivered') AND priority < 7"),
|
|
136
|
+
dlqDepth: db.prepare('SELECT COUNT(*) as cnt FROM dead_letters'),
|
|
137
|
+
onlineCount: db.prepare("SELECT COUNT(*) as cnt FROM agents WHERE status='online'"),
|
|
138
|
+
msgCount: db.prepare('SELECT COUNT(*) as cnt FROM messages'),
|
|
139
|
+
deliveryAvg: db.prepare(`
|
|
140
|
+
SELECT COUNT(*) as total, AVG(i.delivered_at_ms - m.created_at_ms) as avg_ms
|
|
141
|
+
FROM message_inbox i JOIN messages m ON i.message_id=m.id
|
|
142
|
+
WHERE i.delivered_at_ms IS NOT NULL AND i.delivered_at_ms > ? - 300000`),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ── API ──
|
|
146
|
+
|
|
147
|
+
const store = {
|
|
148
|
+
db,
|
|
149
|
+
uuidv7,
|
|
150
|
+
close() { db.close(); },
|
|
151
|
+
|
|
152
|
+
// ── 에이전트 ──
|
|
153
|
+
|
|
154
|
+
registerAgent({ agent_id, cli, pid, capabilities = [], topics = [], heartbeat_ttl_ms = 30000, metadata = {} }) {
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const leaseExpires = now + heartbeat_ttl_ms;
|
|
157
|
+
S.upsertAgent.run({
|
|
158
|
+
agent_id, cli, pid: pid ?? null,
|
|
159
|
+
capabilities_json: JSON.stringify(capabilities),
|
|
160
|
+
topics_json: JSON.stringify(topics),
|
|
161
|
+
last_seen_ms: now, lease_expires_ms: leaseExpires,
|
|
162
|
+
status: 'online', metadata_json: JSON.stringify(metadata),
|
|
163
|
+
});
|
|
164
|
+
return { agent_id, lease_id: uuidv7(), lease_expires_ms: leaseExpires, server_time_ms: now };
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
getAgent(id) { return parseAgentRow(S.getAgent.get(id)); },
|
|
168
|
+
|
|
169
|
+
refreshLease(agentId, ttlMs = 30000) {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
S.heartbeat.run(now, now + ttlMs, agentId);
|
|
172
|
+
return { agent_id: agentId, lease_expires_ms: now + ttlMs };
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
listOnlineAgents() { return S.onlineAgents.all().map(parseAgentRow); },
|
|
176
|
+
listAllAgents() { return S.allAgents.all().map(parseAgentRow); },
|
|
177
|
+
getAgentsByTopic(topic) { return S.agentsByTopic.all(topic).map(parseAgentRow); },
|
|
178
|
+
|
|
179
|
+
sweepStaleAgents() {
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
return { stale: S.markStale.run(now).changes, offline: S.markOffline.run(now).changes };
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// ── 메시지 ──
|
|
185
|
+
|
|
186
|
+
enqueueMessage({ type, from, to, topic, priority = 5, ttl_ms = 300000, payload = {}, trace_id, correlation_id }) {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const id = uuidv7();
|
|
189
|
+
const row = {
|
|
190
|
+
id, type, from_agent: from, to_agent: to, topic, priority, ttl_ms,
|
|
191
|
+
created_at_ms: now, expires_at_ms: now + ttl_ms,
|
|
192
|
+
correlation_id: correlation_id || uuidv7(),
|
|
193
|
+
trace_id: trace_id || uuidv7(),
|
|
194
|
+
payload_json: JSON.stringify(payload), status: 'queued',
|
|
195
|
+
};
|
|
196
|
+
S.insertMsg.run(row);
|
|
197
|
+
return { ...row, payload };
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
getMessage(id) { return parseMessageRow(S.getMsg.get(id)); },
|
|
201
|
+
getResponseByCorrelation(cid) { return parseMessageRow(S.getResponse.get(cid)); },
|
|
202
|
+
getMessagesByTrace(tid) { return S.getMsgsByTrace.all(tid).map(parseMessageRow); },
|
|
203
|
+
updateMessageStatus(id, status) { return S.setMsgStatus.run(status, id).changes > 0; },
|
|
204
|
+
|
|
205
|
+
// ── 수신함 ──
|
|
206
|
+
|
|
207
|
+
deliverToAgent(messageId, agentId) {
|
|
208
|
+
S.insertInbox.run(messageId, agentId);
|
|
209
|
+
S.setMsgStatus.run('delivered', messageId);
|
|
210
|
+
return true;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
deliverToTopic(messageId, topic) {
|
|
214
|
+
const agents = S.agentsByTopic.all(topic);
|
|
215
|
+
return db.transaction(() => {
|
|
216
|
+
for (const a of agents) S.insertInbox.run(messageId, a.agent_id);
|
|
217
|
+
if (agents.length) S.setMsgStatus.run('delivered', messageId);
|
|
218
|
+
return agents.length;
|
|
219
|
+
})();
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
pollForAgent(agentId, { max_messages = 20, include_topics = null, auto_ack = false } = {}) {
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
const rows = (include_topics?.length)
|
|
225
|
+
? S.pollTopics.all(agentId, now, JSON.stringify(include_topics), max_messages)
|
|
226
|
+
: S.poll.all(agentId, now, max_messages);
|
|
227
|
+
|
|
228
|
+
db.transaction(() => {
|
|
229
|
+
for (const r of rows) {
|
|
230
|
+
S.markDelivered.run(now, r.id, agentId);
|
|
231
|
+
if (auto_ack) { S.ackInbox.run(now, r.id, agentId); S.tryAckMsg.run(r.id, r.id); }
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
|
|
235
|
+
// poll = heartbeat (에이전트 등록 TTL 사용, 미등록 시 30초 기본값)
|
|
236
|
+
const agentInfo = S.getAgent.get(agentId);
|
|
237
|
+
const ttl = agentInfo ? (agentInfo.lease_expires_ms - agentInfo.last_seen_ms) || 30000 : 30000;
|
|
238
|
+
S.heartbeat.run(now, now + ttl, agentId);
|
|
239
|
+
return rows.map(parseMessageRow);
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
ackMessages(ids, agentId) {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
return db.transaction(() => {
|
|
245
|
+
let n = 0;
|
|
246
|
+
for (const id of ids) {
|
|
247
|
+
if (S.ackInbox.run(now, id, agentId).changes > 0) { S.tryAckMsg.run(id, id); n++; }
|
|
248
|
+
}
|
|
249
|
+
return n;
|
|
250
|
+
})();
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
// ── 사용자 입력 ──
|
|
254
|
+
|
|
255
|
+
insertHumanRequest({ requester_agent, kind, prompt, requested_schema = {}, deadline_ms, default_action, correlation_id, trace_id }) {
|
|
256
|
+
const rid = uuidv7();
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
const abs = now + deadline_ms;
|
|
259
|
+
S.insertHR.run({
|
|
260
|
+
request_id: rid, requester_agent, kind, prompt,
|
|
261
|
+
schema_json: JSON.stringify(requested_schema),
|
|
262
|
+
state: 'pending', deadline_ms: abs, default_action,
|
|
263
|
+
correlation_id: correlation_id || uuidv7(),
|
|
264
|
+
trace_id: trace_id || uuidv7(),
|
|
265
|
+
response_json: null,
|
|
266
|
+
});
|
|
267
|
+
return { request_id: rid, state: 'pending', deadline_ms: abs };
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
getHumanRequest(id) { return parseHumanRequestRow(S.getHR.get(id)); },
|
|
271
|
+
updateHumanRequest(id, state, resp = null) { return S.updateHR.run(state, resp ? JSON.stringify(resp) : null, id).changes > 0; },
|
|
272
|
+
getPendingHumanRequests() { return S.pendingHR.all().map(parseHumanRequestRow); },
|
|
273
|
+
|
|
274
|
+
// ── 데드 레터 ──
|
|
275
|
+
|
|
276
|
+
moveToDeadLetter(messageId, reason, lastError = null) {
|
|
277
|
+
db.transaction(() => {
|
|
278
|
+
S.setMsgStatus.run('dead_letter', messageId);
|
|
279
|
+
S.insertDL.run(messageId, reason, Date.now(), lastError);
|
|
280
|
+
})();
|
|
281
|
+
return true;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
getDeadLetters(limit = 50) { return S.getDL.all(limit); },
|
|
285
|
+
|
|
286
|
+
// ── 스위퍼 ──
|
|
287
|
+
|
|
288
|
+
sweepExpired() {
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
return db.transaction(() => {
|
|
291
|
+
const expired = S.findExpired.all(now);
|
|
292
|
+
for (const { id } of expired) {
|
|
293
|
+
S.setMsgStatus.run('dead_letter', id);
|
|
294
|
+
S.insertDL.run(id, 'ttl_expired', now, null);
|
|
295
|
+
}
|
|
296
|
+
const hr = S.expireHR.run(now).changes;
|
|
297
|
+
return { messages: expired.length, human_requests: hr };
|
|
298
|
+
})();
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
// ── 메트릭 ──
|
|
302
|
+
|
|
303
|
+
getQueueDepths() {
|
|
304
|
+
return { urgent: S.urgentDepth.get().cnt, normal: S.normalDepth.get().cnt, dlq: S.dlqDepth.get().cnt };
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
getDeliveryStats() {
|
|
308
|
+
const r = S.deliveryAvg.get(Date.now());
|
|
309
|
+
return { total_deliveries: r?.total || 0, avg_delivery_ms: Math.round(r?.avg_ms || 0) };
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
getHubStats() {
|
|
313
|
+
return { online_agents: S.onlineCount.get().cnt, total_messages: S.msgCount.get().cnt, ...store.getQueueDepths() };
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
return store;
|
|
318
|
+
}
|