triflux 3.2.0-dev.1 → 3.2.0-dev.11

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.
Files changed (53) hide show
  1. package/README.ko.md +26 -18
  2. package/README.md +26 -18
  3. package/bin/triflux.mjs +1614 -1084
  4. package/hooks/hooks.json +12 -0
  5. package/hooks/keyword-rules.json +354 -0
  6. package/hub/bridge.mjs +371 -193
  7. package/hub/hitl.mjs +45 -31
  8. package/hub/pipe.mjs +457 -0
  9. package/hub/router.mjs +422 -161
  10. package/hub/server.mjs +429 -344
  11. package/hub/store.mjs +388 -314
  12. package/hub/team/cli-team-common.mjs +348 -0
  13. package/hub/team/cli-team-control.mjs +393 -0
  14. package/hub/team/cli-team-start.mjs +516 -0
  15. package/hub/team/cli-team-status.mjs +269 -0
  16. package/hub/team/cli.mjs +99 -368
  17. package/hub/team/dashboard.mjs +165 -64
  18. package/hub/team/native-supervisor.mjs +300 -0
  19. package/hub/team/native.mjs +62 -0
  20. package/hub/team/nativeProxy.mjs +534 -0
  21. package/hub/team/orchestrator.mjs +90 -31
  22. package/hub/team/pane.mjs +149 -101
  23. package/hub/team/psmux.mjs +297 -0
  24. package/hub/team/session.mjs +608 -186
  25. package/hub/team/shared.mjs +13 -0
  26. package/hub/team/staleState.mjs +299 -0
  27. package/hub/tools.mjs +140 -53
  28. package/hub/workers/claude-worker.mjs +446 -0
  29. package/hub/workers/codex-mcp.mjs +414 -0
  30. package/hub/workers/factory.mjs +18 -0
  31. package/hub/workers/gemini-worker.mjs +349 -0
  32. package/hub/workers/interface.mjs +41 -0
  33. package/hud/hud-qos-status.mjs +1789 -1732
  34. package/package.json +6 -2
  35. package/scripts/__tests__/keyword-detector.test.mjs +234 -0
  36. package/scripts/hub-ensure.mjs +83 -0
  37. package/scripts/keyword-detector.mjs +272 -0
  38. package/scripts/keyword-rules-expander.mjs +521 -0
  39. package/scripts/lib/keyword-rules.mjs +168 -0
  40. package/scripts/psmux-steering-prototype.sh +368 -0
  41. package/scripts/run.cjs +62 -0
  42. package/scripts/setup.mjs +189 -7
  43. package/scripts/test-tfx-route-no-claude-native.mjs +49 -0
  44. package/scripts/tfx-route-worker.mjs +161 -0
  45. package/scripts/tfx-route.sh +943 -508
  46. package/skills/tfx-auto/SKILL.md +90 -564
  47. package/skills/tfx-auto-codex/SKILL.md +77 -0
  48. package/skills/tfx-codex/SKILL.md +1 -4
  49. package/skills/tfx-doctor/SKILL.md +1 -0
  50. package/skills/tfx-gemini/SKILL.md +1 -4
  51. package/skills/tfx-multi/SKILL.md +296 -0
  52. package/skills/tfx-setup/SKILL.md +1 -4
  53. package/skills/tfx-team/SKILL.md +0 -172
package/hub/store.mjs CHANGED
@@ -1,11 +1,11 @@
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
-
1
+ // hub/store.mjs — SQLite 감사 로그/메타데이터 저장소
2
+ // 실시간 배달 큐는 router/pipe가 담당하고, SQLite는 재생/감사 용도로만 유지한다.
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
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
  let _rndPool = Buffer.alloc(0), _rndOff = 0;
11
11
 
@@ -19,64 +19,82 @@ function pooledRandom(n) {
19
19
  return out;
20
20
  }
21
21
 
22
- /** UUIDv7 생성 (RFC 9562) */
22
+ /** UUIDv7 생성 (RFC 9562, 단조 증가 보장) */
23
+ let _lastMs = 0n;
24
+ let _seq = 0;
23
25
  export function uuidv7() {
24
- const now = BigInt(Date.now());
26
+ let now = BigInt(Date.now());
27
+ if (now <= _lastMs) {
28
+ _seq++;
29
+ if (_seq > 0xfff) {
30
+ now = _lastMs + 1n;
31
+ _seq = 0;
32
+ }
33
+ } else {
34
+ _seq = 0;
35
+ }
36
+ _lastMs = now;
25
37
  const buf = pooledRandom(16);
26
- buf[0] = Number((now >> 40n) & 0xffn);
27
- buf[1] = Number((now >> 32n) & 0xffn);
28
- buf[2] = Number((now >> 24n) & 0xffn);
29
- buf[3] = Number((now >> 16n) & 0xffn);
30
- buf[4] = Number((now >> 8n) & 0xffn);
31
- buf[5] = Number(now & 0xffn);
32
- buf[6] = (buf[6] & 0x0f) | 0x70; // version 7
33
- buf[8] = (buf[8] & 0x3f) | 0x80; // variant 10xx
34
- const h = buf.toString('hex');
35
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
36
- }
37
-
38
- function parseJson(str, fallback = null) {
39
- if (str == null) return fallback;
40
- try { return JSON.parse(str); } catch { return fallback; }
41
- }
42
-
43
- function parseAgentRow(row) {
44
- if (!row) return null;
45
- const { capabilities_json, topics_json, metadata_json, ...rest } = row;
46
- return { ...rest, capabilities: parseJson(capabilities_json, []), topics: parseJson(topics_json, []), metadata: parseJson(metadata_json, {}) };
47
- }
48
-
49
- function parseMessageRow(row) {
50
- if (!row) return null;
51
- const { payload_json, ...rest } = row;
52
- return { ...rest, payload: parseJson(payload_json, {}) };
53
- }
54
-
55
- function parseHumanRequestRow(row) {
56
- if (!row) return null;
57
- const { schema_json, response_json, ...rest } = row;
58
- return { ...rest, schema: parseJson(schema_json, {}), response: parseJson(response_json, null) };
59
- }
60
-
61
- /**
62
- * 상태 저장소 생성
63
- * @param {string} dbPath — SQLite DB 파일 경로
64
- */
65
- export function createStore(dbPath) {
66
- mkdirSync(dirname(dbPath), { recursive: true });
67
- const db = new Database(dbPath);
68
-
69
- // PRAGMA
70
- db.pragma('journal_mode = WAL');
71
- db.pragma('synchronous = NORMAL');
72
- db.pragma('foreign_keys = ON');
73
- db.pragma('busy_timeout = 5000');
74
- db.pragma('wal_autocheckpoint = 1000');
75
-
76
- // 스키마 초기화 (schema.sql 전체 실행 — 주석 포함 안전 처리)
77
- const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
38
+ buf[0] = Number((now >> 40n) & 0xffn);
39
+ buf[1] = Number((now >> 32n) & 0xffn);
40
+ buf[2] = Number((now >> 24n) & 0xffn);
41
+ buf[3] = Number((now >> 16n) & 0xffn);
42
+ buf[4] = Number((now >> 8n) & 0xffn);
43
+ buf[5] = Number(now & 0xffn);
44
+ buf[6] = ((_seq >> 8) & 0x0f) | 0x70;
45
+ buf[7] = _seq & 0xff;
46
+ buf[8] = (buf[8] & 0x3f) | 0x80;
47
+ const h = buf.toString('hex');
48
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
49
+ }
50
+
51
+ function parseJson(str, fallback = null) {
52
+ if (str == null) return fallback;
53
+ try { return JSON.parse(str); } catch { return fallback; }
54
+ }
55
+
56
+ function parseAgentRow(row) {
57
+ if (!row) return null;
58
+ const { capabilities_json, topics_json, metadata_json, ...rest } = row;
59
+ return {
60
+ ...rest,
61
+ capabilities: parseJson(capabilities_json, []),
62
+ topics: parseJson(topics_json, []),
63
+ metadata: parseJson(metadata_json, {}),
64
+ };
65
+ }
78
66
 
79
- // 스키마 버전 체크 — 불필요한 재실행 방지
67
+ function parseMessageRow(row) {
68
+ if (!row) return null;
69
+ const { payload_json, ...rest } = row;
70
+ return { ...rest, payload: parseJson(payload_json, {}) };
71
+ }
72
+
73
+ function parseHumanRequestRow(row) {
74
+ if (!row) return null;
75
+ const { schema_json, response_json, ...rest } = row;
76
+ return {
77
+ ...rest,
78
+ schema: parseJson(schema_json, {}),
79
+ response: parseJson(response_json, null),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * 저장소 생성
85
+ * @param {string} dbPath
86
+ */
87
+ export function createStore(dbPath) {
88
+ mkdirSync(dirname(dbPath), { recursive: true });
89
+ const db = new Database(dbPath);
90
+
91
+ db.pragma('journal_mode = WAL');
92
+ db.pragma('synchronous = NORMAL');
93
+ db.pragma('foreign_keys = ON');
94
+ db.pragma('busy_timeout = 5000');
95
+ db.pragma('wal_autocheckpoint = 1000');
96
+
97
+ const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
80
98
  db.exec("CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)");
81
99
  const SCHEMA_VERSION = '1';
82
100
  const curVer = (() => {
@@ -87,254 +105,310 @@ export function createStore(dbPath) {
87
105
  db.exec(schemaSQL);
88
106
  db.prepare("INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
89
107
  }
90
-
91
- // ── 준비된 구문 ──
92
-
93
- const S = {
94
- // 에이전트
95
- upsertAgent: db.prepare(`
96
- INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, status, metadata_json)
97
- VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, @status, @metadata_json)
98
- ON CONFLICT(agent_id) DO UPDATE SET
99
- cli=excluded.cli, pid=excluded.pid, capabilities_json=excluded.capabilities_json,
100
- topics_json=excluded.topics_json, last_seen_ms=excluded.last_seen_ms,
101
- lease_expires_ms=excluded.lease_expires_ms, status=excluded.status, metadata_json=excluded.metadata_json`),
102
- getAgent: db.prepare('SELECT * FROM agents WHERE agent_id = ?'),
103
- heartbeat: db.prepare("UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=?"),
104
- setAgentStatus: db.prepare('UPDATE agents SET status=? WHERE agent_id=?'),
105
- onlineAgents: db.prepare("SELECT * FROM agents WHERE status != 'offline'"),
106
- allAgents: db.prepare('SELECT * FROM agents'),
107
- agentsByTopic: db.prepare("SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'"),
108
- markStale: db.prepare("UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?"),
109
- markOffline: db.prepare("UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000"),
110
-
111
- // 메시지
112
- insertMsg: db.prepare(`
113
- 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)
114
- VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status)`),
115
- getMsg: db.prepare('SELECT * FROM messages WHERE id=?'),
116
- getResponse: db.prepare("SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1"),
117
- getMsgsByTrace: db.prepare('SELECT * FROM messages WHERE trace_id=? ORDER BY created_at_ms'),
118
- setMsgStatus: db.prepare('UPDATE messages SET status=? WHERE id=?'),
119
-
120
- // 수신함
121
- insertInbox: db.prepare('INSERT OR IGNORE INTO message_inbox (message_id, agent_id, attempts) VALUES (?,?,0)'),
122
- poll: db.prepare(`
123
- SELECT m.*, i.delivery_id FROM messages m
124
- JOIN message_inbox i ON m.id=i.message_id
125
- WHERE i.agent_id=? AND i.delivered_at_ms IS NULL
126
- AND m.status IN ('queued','delivered') AND m.expires_at_ms > ?
127
- ORDER BY m.priority DESC, m.created_at_ms ASC LIMIT ?`),
128
- pollTopics: db.prepare(`
129
- SELECT m.*, i.delivery_id FROM messages m
130
- JOIN message_inbox i ON m.id=i.message_id
131
- WHERE i.agent_id=? AND i.delivered_at_ms IS NULL
132
- AND m.status IN ('queued','delivered') AND m.expires_at_ms > ?
133
- AND m.topic IN (SELECT value FROM json_each(?))
134
- ORDER BY m.priority DESC, m.created_at_ms ASC LIMIT ?`),
135
- markDelivered: db.prepare('UPDATE message_inbox SET delivered_at_ms=?, attempts=attempts+1 WHERE message_id=? AND agent_id=?'),
136
- ackInbox: db.prepare('UPDATE message_inbox SET acked_at_ms=? WHERE message_id=? AND agent_id=? AND acked_at_ms IS NULL'),
137
- 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)"),
138
-
139
- // 사용자 입력
140
- insertHR: db.prepare(`
141
- INSERT INTO human_requests (request_id, requester_agent, kind, prompt, schema_json, state, deadline_ms, default_action, correlation_id, trace_id, response_json)
142
- VALUES (@request_id, @requester_agent, @kind, @prompt, @schema_json, @state, @deadline_ms, @default_action, @correlation_id, @trace_id, @response_json)`),
143
- getHR: db.prepare('SELECT * FROM human_requests WHERE request_id=?'),
144
- updateHR: db.prepare('UPDATE human_requests SET state=?, response_json=? WHERE request_id=?'),
145
- pendingHR: db.prepare("SELECT * FROM human_requests WHERE state='pending'"),
146
- expireHR: db.prepare("UPDATE human_requests SET state='timed_out' WHERE state='pending' AND deadline_ms < ?"),
147
-
148
- // 데드 레터
149
- insertDL: db.prepare('INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)'),
150
- getDL: db.prepare('SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?'),
151
-
152
- // 스위퍼
153
- findExpired: db.prepare("SELECT id FROM messages WHERE status IN ('queued','delivered') AND expires_at_ms < ?"),
154
-
155
- // 메트릭
156
- urgentDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status IN ('queued','delivered') AND priority >= 7"),
157
- normalDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status IN ('queued','delivered') AND priority < 7"),
158
- dlqDepth: db.prepare('SELECT COUNT(*) as cnt FROM dead_letters'),
159
- onlineCount: db.prepare("SELECT COUNT(*) as cnt FROM agents WHERE status='online'"),
160
- msgCount: db.prepare('SELECT COUNT(*) as cnt FROM messages'),
161
- deliveryAvg: db.prepare(`
162
- SELECT COUNT(*) as total, AVG(i.delivered_at_ms - m.created_at_ms) as avg_ms
163
- FROM message_inbox i JOIN messages m ON i.message_id=m.id
164
- WHERE i.delivered_at_ms IS NOT NULL AND i.delivered_at_ms > ? - 300000`),
165
- };
166
-
167
- // ── API ──
168
-
169
- const store = {
170
- db,
171
- uuidv7,
172
- close() { db.close(); },
173
-
174
- // ── 에이전트 ──
175
-
176
- registerAgent({ agent_id, cli, pid, capabilities = [], topics = [], heartbeat_ttl_ms = 30000, metadata = {} }) {
177
- const now = Date.now();
178
- const leaseExpires = now + heartbeat_ttl_ms;
179
- S.upsertAgent.run({
180
- agent_id, cli, pid: pid ?? null,
181
- capabilities_json: JSON.stringify(capabilities),
182
- topics_json: JSON.stringify(topics),
183
- last_seen_ms: now, lease_expires_ms: leaseExpires,
184
- status: 'online', metadata_json: JSON.stringify(metadata),
185
- });
186
- return { agent_id, lease_id: uuidv7(), lease_expires_ms: leaseExpires, server_time_ms: now };
187
- },
188
-
189
- getAgent(id) { return parseAgentRow(S.getAgent.get(id)); },
190
-
191
- refreshLease(agentId, ttlMs = 30000) {
192
- const now = Date.now();
193
- S.heartbeat.run(now, now + ttlMs, agentId);
194
- return { agent_id: agentId, lease_expires_ms: now + ttlMs };
195
- },
196
-
197
- listOnlineAgents() { return S.onlineAgents.all().map(parseAgentRow); },
198
- listAllAgents() { return S.allAgents.all().map(parseAgentRow); },
199
- getAgentsByTopic(topic) { return S.agentsByTopic.all(topic).map(parseAgentRow); },
200
-
201
- sweepStaleAgents() {
202
- const now = Date.now();
203
- return { stale: S.markStale.run(now).changes, offline: S.markOffline.run(now).changes };
204
- },
205
-
206
- // ── 메시지 ──
207
-
208
- enqueueMessage({ type, from, to, topic, priority = 5, ttl_ms = 300000, payload = {}, trace_id, correlation_id }) {
209
- const now = Date.now();
210
- const id = uuidv7();
211
- const row = {
212
- id, type, from_agent: from, to_agent: to, topic, priority, ttl_ms,
213
- created_at_ms: now, expires_at_ms: now + ttl_ms,
214
- correlation_id: correlation_id || uuidv7(),
215
- trace_id: trace_id || uuidv7(),
216
- payload_json: JSON.stringify(payload), status: 'queued',
217
- };
218
- S.insertMsg.run(row);
219
- return { ...row, payload };
220
- },
221
-
222
- getMessage(id) { return parseMessageRow(S.getMsg.get(id)); },
223
- getResponseByCorrelation(cid) { return parseMessageRow(S.getResponse.get(cid)); },
224
- getMessagesByTrace(tid) { return S.getMsgsByTrace.all(tid).map(parseMessageRow); },
225
- updateMessageStatus(id, status) { return S.setMsgStatus.run(status, id).changes > 0; },
226
-
227
- // ── 수신함 ──
228
-
229
- deliverToAgent(messageId, agentId) {
230
- S.insertInbox.run(messageId, agentId);
231
- S.setMsgStatus.run('delivered', messageId);
232
- return true;
233
- },
234
-
235
- deliverToTopic(messageId, topic) {
236
- const agents = S.agentsByTopic.all(topic);
237
- return db.transaction(() => {
238
- for (const a of agents) S.insertInbox.run(messageId, a.agent_id);
239
- if (agents.length) S.setMsgStatus.run('delivered', messageId);
240
- return agents.length;
241
- })();
242
- },
243
-
244
- pollForAgent(agentId, { max_messages = 20, include_topics = null, auto_ack = false } = {}) {
245
- const now = Date.now();
246
- const rows = (include_topics?.length)
247
- ? S.pollTopics.all(agentId, now, JSON.stringify(include_topics), max_messages)
248
- : S.poll.all(agentId, now, max_messages);
249
-
250
- db.transaction(() => {
251
- for (const r of rows) {
252
- S.markDelivered.run(now, r.id, agentId);
253
- if (auto_ack) { S.ackInbox.run(now, r.id, agentId); S.tryAckMsg.run(r.id, r.id); }
254
- }
255
- })();
256
-
257
- // poll = heartbeat (에이전트 등록 TTL 사용, 미등록 시 30초 기본값)
258
- const agentInfo = S.getAgent.get(agentId);
259
- const ttl = agentInfo ? (agentInfo.lease_expires_ms - agentInfo.last_seen_ms) || 30000 : 30000;
260
- S.heartbeat.run(now, now + ttl, agentId);
261
- return rows.map(parseMessageRow);
262
- },
263
-
264
- ackMessages(ids, agentId) {
265
- const now = Date.now();
266
- return db.transaction(() => {
267
- let n = 0;
268
- for (const id of ids) {
269
- if (S.ackInbox.run(now, id, agentId).changes > 0) { S.tryAckMsg.run(id, id); n++; }
270
- }
271
- return n;
272
- })();
273
- },
274
-
275
- // ── 사용자 입력 ──
276
-
277
- insertHumanRequest({ requester_agent, kind, prompt, requested_schema = {}, deadline_ms, default_action, correlation_id, trace_id }) {
278
- const rid = uuidv7();
279
- const now = Date.now();
280
- const abs = now + deadline_ms;
281
- S.insertHR.run({
282
- request_id: rid, requester_agent, kind, prompt,
283
- schema_json: JSON.stringify(requested_schema),
284
- state: 'pending', deadline_ms: abs, default_action,
285
- correlation_id: correlation_id || uuidv7(),
286
- trace_id: trace_id || uuidv7(),
287
- response_json: null,
288
- });
289
- return { request_id: rid, state: 'pending', deadline_ms: abs };
290
- },
291
-
292
- getHumanRequest(id) { return parseHumanRequestRow(S.getHR.get(id)); },
293
- updateHumanRequest(id, state, resp = null) { return S.updateHR.run(state, resp ? JSON.stringify(resp) : null, id).changes > 0; },
294
- getPendingHumanRequests() { return S.pendingHR.all().map(parseHumanRequestRow); },
295
-
296
- // ── 데드 레터 ──
297
-
298
- moveToDeadLetter(messageId, reason, lastError = null) {
299
- db.transaction(() => {
300
- S.setMsgStatus.run('dead_letter', messageId);
301
- S.insertDL.run(messageId, reason, Date.now(), lastError);
302
- })();
303
- return true;
304
- },
305
-
306
- getDeadLetters(limit = 50) { return S.getDL.all(limit); },
307
-
308
- // ── 스위퍼 ──
309
-
310
- sweepExpired() {
311
- const now = Date.now();
312
- return db.transaction(() => {
313
- const expired = S.findExpired.all(now);
314
- for (const { id } of expired) {
315
- S.setMsgStatus.run('dead_letter', id);
316
- S.insertDL.run(id, 'ttl_expired', now, null);
317
- }
318
- const hr = S.expireHR.run(now).changes;
319
- return { messages: expired.length, human_requests: hr };
320
- })();
321
- },
322
-
323
- // ── 메트릭 ──
324
-
325
- getQueueDepths() {
326
- return { urgent: S.urgentDepth.get().cnt, normal: S.normalDepth.get().cnt, dlq: S.dlqDepth.get().cnt };
327
- },
328
-
329
- getDeliveryStats() {
330
- const r = S.deliveryAvg.get(Date.now());
331
- return { total_deliveries: r?.total || 0, avg_delivery_ms: Math.round(r?.avg_ms || 0) };
332
- },
333
-
334
- getHubStats() {
335
- return { online_agents: S.onlineCount.get().cnt, total_messages: S.msgCount.get().cnt, ...store.getQueueDepths() };
336
- },
337
- };
338
-
339
- return store;
340
- }
108
+
109
+ const S = {
110
+ upsertAgent: db.prepare(`
111
+ INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, status, metadata_json)
112
+ VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, @status, @metadata_json)
113
+ ON CONFLICT(agent_id) DO UPDATE SET
114
+ cli=excluded.cli,
115
+ pid=excluded.pid,
116
+ capabilities_json=excluded.capabilities_json,
117
+ topics_json=excluded.topics_json,
118
+ last_seen_ms=excluded.last_seen_ms,
119
+ lease_expires_ms=excluded.lease_expires_ms,
120
+ status=excluded.status,
121
+ metadata_json=excluded.metadata_json`),
122
+ getAgent: db.prepare('SELECT * FROM agents WHERE agent_id = ?'),
123
+ setAgentTopics: db.prepare('UPDATE agents SET topics_json=?, last_seen_ms=? WHERE agent_id=?'),
124
+ heartbeat: db.prepare("UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=?"),
125
+ setAgentStatus: db.prepare('UPDATE agents SET status=? WHERE agent_id=?'),
126
+ onlineAgents: db.prepare("SELECT * FROM agents WHERE status != 'offline'"),
127
+ allAgents: db.prepare('SELECT * FROM agents'),
128
+ agentsByTopic: db.prepare("SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'"),
129
+ markStale: db.prepare("UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?"),
130
+ markOffline: db.prepare("UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000"),
131
+
132
+ insertAuditMessage: db.prepare(`
133
+ 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)
134
+ VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status)`),
135
+ getMsg: db.prepare('SELECT * FROM messages WHERE id=?'),
136
+ getResponse: db.prepare("SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1"),
137
+ getMsgsByTrace: db.prepare('SELECT * FROM messages WHERE trace_id=? ORDER BY created_at_ms'),
138
+ setMsgStatus: db.prepare('UPDATE messages SET status=? WHERE id=?'),
139
+ recentAgentMessages: db.prepare(`
140
+ SELECT * FROM messages
141
+ WHERE to_agent=?
142
+ ORDER BY created_at_ms DESC
143
+ LIMIT ?`),
144
+ recentAgentMessagesWithTopics: db.prepare(`
145
+ SELECT * FROM messages
146
+ WHERE to_agent=?
147
+ OR (
148
+ substr(to_agent, 1, 6)='topic:'
149
+ AND topic IN (SELECT value FROM json_each(?))
150
+ )
151
+ ORDER BY created_at_ms DESC
152
+ LIMIT ?`),
153
+
154
+ insertHR: db.prepare(`
155
+ INSERT INTO human_requests (request_id, requester_agent, kind, prompt, schema_json, state, deadline_ms, default_action, correlation_id, trace_id, response_json)
156
+ VALUES (@request_id, @requester_agent, @kind, @prompt, @schema_json, @state, @deadline_ms, @default_action, @correlation_id, @trace_id, @response_json)`),
157
+ getHR: db.prepare('SELECT * FROM human_requests WHERE request_id=?'),
158
+ updateHR: db.prepare('UPDATE human_requests SET state=?, response_json=? WHERE request_id=?'),
159
+ pendingHR: db.prepare("SELECT * FROM human_requests WHERE state='pending'"),
160
+ expireHR: db.prepare("UPDATE human_requests SET state='timed_out' WHERE state='pending' AND deadline_ms < ?"),
161
+
162
+ insertDL: db.prepare('INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)'),
163
+ getDL: db.prepare('SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?'),
164
+
165
+ findExpired: db.prepare("SELECT id FROM messages WHERE status='queued' AND expires_at_ms < ?"),
166
+ urgentDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority >= 7"),
167
+ normalDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority < 7"),
168
+ onlineCount: db.prepare("SELECT COUNT(*) as cnt FROM agents WHERE status='online'"),
169
+ msgCount: db.prepare('SELECT COUNT(*) as cnt FROM messages'),
170
+ dlqDepth: db.prepare('SELECT COUNT(*) as cnt FROM dead_letters'),
171
+ ackedRecent: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='acked' AND created_at_ms > ? - 300000"),
172
+ };
173
+
174
+ function clampMaxMessages(value, fallback = 20) {
175
+ const num = Number(value);
176
+ if (!Number.isFinite(num)) return fallback;
177
+ return Math.max(1, Math.min(Math.trunc(num), 100));
178
+ }
179
+
180
+ const store = {
181
+ db,
182
+ uuidv7,
183
+
184
+ close() {
185
+ db.close();
186
+ },
187
+
188
+ registerAgent({ agent_id, cli, pid, capabilities = [], topics = [], heartbeat_ttl_ms = 30000, metadata = {} }) {
189
+ const now = Date.now();
190
+ const leaseExpires = now + heartbeat_ttl_ms;
191
+ S.upsertAgent.run({
192
+ agent_id,
193
+ cli,
194
+ pid: pid ?? null,
195
+ capabilities_json: JSON.stringify(capabilities),
196
+ topics_json: JSON.stringify(topics),
197
+ last_seen_ms: now,
198
+ lease_expires_ms: leaseExpires,
199
+ status: 'online',
200
+ metadata_json: JSON.stringify(metadata),
201
+ });
202
+ return { agent_id, lease_id: uuidv7(), lease_expires_ms: leaseExpires, server_time_ms: now };
203
+ },
204
+
205
+ getAgent(id) {
206
+ return parseAgentRow(S.getAgent.get(id));
207
+ },
208
+
209
+ refreshLease(agentId, ttlMs = 30000) {
210
+ const now = Date.now();
211
+ S.heartbeat.run(now, now + ttlMs, agentId);
212
+ return { agent_id: agentId, lease_expires_ms: now + ttlMs, server_time_ms: now };
213
+ },
214
+
215
+ updateAgentTopics(agentId, topics = []) {
216
+ const now = Date.now();
217
+ return S.setAgentTopics.run(JSON.stringify(topics), now, agentId).changes > 0;
218
+ },
219
+
220
+ listOnlineAgents() {
221
+ return S.onlineAgents.all().map(parseAgentRow);
222
+ },
223
+
224
+ listAllAgents() {
225
+ return S.allAgents.all().map(parseAgentRow);
226
+ },
227
+
228
+ getAgentsByTopic(topic) {
229
+ return S.agentsByTopic.all(topic).map(parseAgentRow);
230
+ },
231
+
232
+ sweepStaleAgents() {
233
+ const now = Date.now();
234
+ return {
235
+ stale: S.markStale.run(now).changes,
236
+ offline: S.markOffline.run(now).changes,
237
+ };
238
+ },
239
+
240
+ updateAgentStatus(agentId, status) {
241
+ return S.setAgentStatus.run(status, agentId).changes > 0;
242
+ },
243
+
244
+ auditLog({ type, from, to, topic, priority = 5, ttl_ms = 300000, payload = {}, trace_id, correlation_id, status = 'queued' }) {
245
+ const now = Date.now();
246
+ const row = {
247
+ id: uuidv7(),
248
+ type,
249
+ from_agent: from,
250
+ to_agent: to,
251
+ topic,
252
+ priority,
253
+ ttl_ms,
254
+ created_at_ms: now,
255
+ expires_at_ms: now + ttl_ms,
256
+ correlation_id: correlation_id || uuidv7(),
257
+ trace_id: trace_id || uuidv7(),
258
+ payload_json: JSON.stringify(payload),
259
+ status,
260
+ };
261
+ S.insertAuditMessage.run(row);
262
+ return { ...row, payload };
263
+ },
264
+
265
+ // 하위 호환: 기존 enqueueMessage 호출은 auditLog로 위임한다.
266
+ enqueueMessage(args) {
267
+ return store.auditLog(args);
268
+ },
269
+
270
+ getMessage(id) {
271
+ return parseMessageRow(S.getMsg.get(id));
272
+ },
273
+
274
+ getResponseByCorrelation(cid) {
275
+ return parseMessageRow(S.getResponse.get(cid));
276
+ },
277
+
278
+ getMessagesByTrace(tid) {
279
+ return S.getMsgsByTrace.all(tid).map(parseMessageRow);
280
+ },
281
+
282
+ updateMessageStatus(id, status) {
283
+ return S.setMsgStatus.run(status, id).changes > 0;
284
+ },
285
+
286
+ getAuditMessagesForAgent(agentId, { max_messages = 20, include_topics = null } = {}) {
287
+ const limit = clampMaxMessages(max_messages);
288
+ const topics = Array.isArray(include_topics) && include_topics.length
289
+ ? include_topics
290
+ : (store.getAgent(agentId)?.topics || []);
291
+
292
+ const rows = topics.length
293
+ ? S.recentAgentMessagesWithTopics.all(agentId, JSON.stringify(topics), limit)
294
+ : S.recentAgentMessages.all(agentId, limit);
295
+
296
+ return rows.map(parseMessageRow);
297
+ },
298
+
299
+ // 하위 호환: 실시간 수신함 대신 감사 로그 재생 결과를 반환한다.
300
+ deliverToAgent(messageId, agentId) {
301
+ return !!store.getMessage(messageId) && !!agentId;
302
+ },
303
+
304
+ deliverToTopic(messageId, topic) {
305
+ void messageId;
306
+ return store.getAgentsByTopic(topic).length;
307
+ },
308
+
309
+ pollForAgent(agentId, { max_messages = 20, include_topics = null } = {}) {
310
+ return store.getAuditMessagesForAgent(agentId, {
311
+ max_messages,
312
+ include_topics,
313
+ });
314
+ },
315
+
316
+ ackMessages() {
317
+ return 0;
318
+ },
319
+
320
+ insertHumanRequest({ requester_agent, kind, prompt, requested_schema = {}, deadline_ms, default_action, correlation_id, trace_id }) {
321
+ const requestId = uuidv7();
322
+ const now = Date.now();
323
+ const deadlineAt = now + deadline_ms;
324
+ S.insertHR.run({
325
+ request_id: requestId,
326
+ requester_agent,
327
+ kind,
328
+ prompt,
329
+ schema_json: JSON.stringify(requested_schema),
330
+ state: 'pending',
331
+ deadline_ms: deadlineAt,
332
+ default_action,
333
+ correlation_id: correlation_id || uuidv7(),
334
+ trace_id: trace_id || uuidv7(),
335
+ response_json: null,
336
+ });
337
+ return { request_id: requestId, state: 'pending', deadline_ms: deadlineAt };
338
+ },
339
+
340
+ getHumanRequest(id) {
341
+ return parseHumanRequestRow(S.getHR.get(id));
342
+ },
343
+
344
+ updateHumanRequest(id, state, resp = null) {
345
+ return S.updateHR.run(state, resp ? JSON.stringify(resp) : null, id).changes > 0;
346
+ },
347
+
348
+ getPendingHumanRequests() {
349
+ return S.pendingHR.all().map(parseHumanRequestRow);
350
+ },
351
+
352
+ expireHumanRequests() {
353
+ return S.expireHR.run(Date.now()).changes;
354
+ },
355
+
356
+ moveToDeadLetter(messageId, reason, lastError = null) {
357
+ db.transaction(() => {
358
+ S.setMsgStatus.run('dead_letter', messageId);
359
+ S.insertDL.run(messageId, reason, Date.now(), lastError);
360
+ })();
361
+ return true;
362
+ },
363
+
364
+ getDeadLetters(limit = 50) {
365
+ return S.getDL.all(limit);
366
+ },
367
+
368
+ sweepExpired() {
369
+ const now = Date.now();
370
+ return db.transaction(() => {
371
+ const expired = S.findExpired.all(now);
372
+ for (const { id } of expired) {
373
+ S.setMsgStatus.run('dead_letter', id);
374
+ S.insertDL.run(id, 'ttl_expired', now, null);
375
+ }
376
+ const humanRequests = S.expireHR.run(now).changes;
377
+ return { messages: expired.length, human_requests: humanRequests };
378
+ })();
379
+ },
380
+
381
+ getQueueDepths() {
382
+ return {
383
+ urgent: S.urgentDepth.get().cnt,
384
+ normal: S.normalDepth.get().cnt,
385
+ dlq: S.dlqDepth.get().cnt,
386
+ };
387
+ },
388
+
389
+ getDeliveryStats() {
390
+ return {
391
+ total_deliveries: S.ackedRecent.get(Date.now()).cnt,
392
+ avg_delivery_ms: 0,
393
+ };
394
+ },
395
+
396
+ getHubStats() {
397
+ return {
398
+ online_agents: S.onlineCount.get().cnt,
399
+ total_messages: S.msgCount.get().cnt,
400
+ ...store.getQueueDepths(),
401
+ };
402
+ },
403
+
404
+ getAuditStats() {
405
+ return {
406
+ online_agents: S.onlineCount.get().cnt,
407
+ total_messages: S.msgCount.get().cnt,
408
+ dlq: S.dlqDepth.get().cnt,
409
+ };
410
+ },
411
+ };
412
+
413
+ return store;
414
+ }