triflux 2.5.1 → 3.1.0-dev.1

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/bin/triflux.mjs CHANGED
@@ -1,9 +1,9 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // triflux CLI — setup, doctor, version
3
3
  import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync } from "fs";
4
4
  import { join, dirname } from "path";
5
5
  import { homedir } from "os";
6
- import { execSync } from "child_process";
6
+ import { execSync, spawn } from "child_process";
7
7
 
8
8
  const PKG_ROOT = dirname(dirname(new URL(import.meta.url).pathname)).replace(/^\/([A-Z]:)/, "$1");
9
9
  const CLAUDE_DIR = join(homedir(), ".claude");
@@ -145,7 +145,7 @@ function checkCliCrossShell(cmd, installHint) {
145
145
  return 1;
146
146
  }
147
147
  if (bashMissing) {
148
- warn("bash에서 미발견 — cli-route.sh 실행 불가");
148
+ warn("bash에서 미발견 — tfx-route.sh 실행 불가");
149
149
  info('→ ~/.bashrc에 추가: export PATH="$PATH:$APPDATA/npm"');
150
150
  return 1;
151
151
  }
@@ -158,9 +158,9 @@ function cmdSetup() {
158
158
  console.log(`\n${BOLD}triflux setup${RESET}\n`);
159
159
 
160
160
  syncFile(
161
- join(PKG_ROOT, "scripts", "cli-route.sh"),
162
- join(CLAUDE_DIR, "scripts", "cli-route.sh"),
163
- "cli-route.sh"
161
+ join(PKG_ROOT, "scripts", "tfx-route.sh"),
162
+ join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
163
+ "tfx-route.sh"
164
164
  );
165
165
 
166
166
  syncFile(
@@ -175,6 +175,18 @@ function cmdSetup() {
175
175
  "notion-read.mjs"
176
176
  );
177
177
 
178
+ syncFile(
179
+ join(PKG_ROOT, "scripts", "tfx-route-post.mjs"),
180
+ join(CLAUDE_DIR, "scripts", "tfx-route-post.mjs"),
181
+ "tfx-route-post.mjs"
182
+ );
183
+
184
+ syncFile(
185
+ join(PKG_ROOT, "scripts", "tfx-batch-stats.mjs"),
186
+ join(CLAUDE_DIR, "scripts", "tfx-batch-stats.mjs"),
187
+ "tfx-batch-stats.mjs"
188
+ );
189
+
178
190
  // 스킬 동기화 (~/.claude/skills/{name}/SKILL.md)
179
191
  const skillsSrc = join(PKG_ROOT, "skills");
180
192
  const skillsDst = join(CLAUDE_DIR, "skills");
@@ -323,9 +335,9 @@ function cmdDoctor(options = {}) {
323
335
  if (fix) {
324
336
  section("Auto Fix");
325
337
  syncFile(
326
- join(PKG_ROOT, "scripts", "cli-route.sh"),
327
- join(CLAUDE_DIR, "scripts", "cli-route.sh"),
328
- "cli-route.sh"
338
+ join(PKG_ROOT, "scripts", "tfx-route.sh"),
339
+ join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
340
+ "tfx-route.sh"
329
341
  );
330
342
  syncFile(
331
343
  join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
@@ -375,9 +387,9 @@ function cmdDoctor(options = {}) {
375
387
 
376
388
  let issues = 0;
377
389
 
378
- // 1. cli-route.sh
379
- section("cli-route.sh");
380
- const routeSh = join(CLAUDE_DIR, "scripts", "cli-route.sh");
390
+ // 1. tfx-route.sh
391
+ section("tfx-route.sh");
392
+ const routeSh = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
381
393
  if (existsSync(routeSh)) {
382
394
  const ver = getVersion(routeSh);
383
395
  ok(`설치됨 ${ver ? `${DIM}v${ver}${RESET}` : ""}`);
@@ -676,7 +688,7 @@ function cmdUpdate() {
676
688
  return;
677
689
  }
678
690
 
679
- // 3. setup 재실행 (cli-route.sh, HUD, 스킬 동기화)
691
+ // 3. setup 재실행 (tfx-route.sh, HUD, 스킬 동기화)
680
692
  if (updated) {
681
693
  console.log("");
682
694
  // 업데이트 후 새 버전 읽기
@@ -742,10 +754,10 @@ function cmdList() {
742
754
  }
743
755
 
744
756
  function cmdVersion() {
745
- const routeVer = getVersion(join(CLAUDE_DIR, "scripts", "cli-route.sh"));
757
+ const routeVer = getVersion(join(CLAUDE_DIR, "scripts", "tfx-route.sh"));
746
758
  const hudVer = getVersion(join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"));
747
759
  console.log(`\n ${AMBER}${BOLD}⬡ triflux${RESET} ${WHITE_BRIGHT}v${PKG.version}${RESET}`);
748
- if (routeVer) console.log(` ${GRAY}cli-route${RESET} v${routeVer}`);
760
+ if (routeVer) console.log(` ${GRAY}tfx-route${RESET} v${routeVer}`);
749
761
  if (hudVer) console.log(` ${GRAY}hud${RESET} v${hudVer}`);
750
762
  console.log("");
751
763
  }
@@ -801,6 +813,7 @@ ${updateNotice}
801
813
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
802
814
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 버전으로 업데이트${RESET}
803
815
  ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
816
+ ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
804
817
  ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
805
818
  ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
806
819
 
@@ -817,6 +830,132 @@ ${updateNotice}
817
830
  `);
818
831
  }
819
832
 
833
+ // ── hub 서브커맨드 ──
834
+
835
+ const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
836
+ const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
837
+
838
+ function cmdHub() {
839
+ const sub = process.argv[3] || "status";
840
+
841
+ switch (sub) {
842
+ case "start": {
843
+ // 이미 실행 중인지 확인
844
+ if (existsSync(HUB_PID_FILE)) {
845
+ try {
846
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
847
+ process.kill(info.pid, 0); // 프로세스 존재 확인
848
+ console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
849
+ return;
850
+ } catch {
851
+ // PID 파일 있지만 프로세스 없음 — 정리
852
+ try { unlinkSync(HUB_PID_FILE); } catch {}
853
+ }
854
+ }
855
+
856
+ const portArg = process.argv.indexOf("--port");
857
+ const port = portArg !== -1 ? process.argv[portArg + 1] : "27888";
858
+ const serverPath = join(PKG_ROOT, "hub", "server.mjs");
859
+
860
+ if (!existsSync(serverPath)) {
861
+ fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
862
+ return;
863
+ }
864
+
865
+ const child = spawn(process.execPath, [serverPath], {
866
+ env: { ...process.env, TFX_HUB_PORT: port },
867
+ stdio: "ignore",
868
+ detached: true,
869
+ });
870
+ child.unref();
871
+
872
+ // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
873
+ let started = false;
874
+ const deadline = Date.now() + 3000;
875
+ while (Date.now() < deadline) {
876
+ if (existsSync(HUB_PID_FILE)) { started = true; break; }
877
+ execSync("node -e \"setTimeout(()=>{},100)\"", { stdio: "ignore", timeout: 500 });
878
+ }
879
+
880
+ if (started) {
881
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
882
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
883
+ console.log(` URL: ${AMBER}${info.url}${RESET}`);
884
+ console.log(` PID: ${info.pid}`);
885
+ console.log(` DB: ${DIM}${HUB_PID_DIR}/state.db${RESET}\n`);
886
+ } else {
887
+ // 직접 포그라운드 모드로 안내
888
+ console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`);
889
+ console.log(` ${DIM}TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`);
890
+ }
891
+ break;
892
+ }
893
+
894
+ case "stop": {
895
+ if (!existsSync(HUB_PID_FILE)) {
896
+ console.log(`\n ${DIM}hub 미실행${RESET}\n`);
897
+ return;
898
+ }
899
+ try {
900
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
901
+ process.kill(info.pid, "SIGTERM");
902
+ try { unlinkSync(HUB_PID_FILE); } catch {}
903
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`);
904
+ } catch (e) {
905
+ try { unlinkSync(HUB_PID_FILE); } catch {}
906
+ console.log(`\n ${DIM}hub 프로세스 없음 — PID 파일 정리됨${RESET}\n`);
907
+ }
908
+ break;
909
+ }
910
+
911
+ case "status": {
912
+ if (!existsSync(HUB_PID_FILE)) {
913
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
914
+ return;
915
+ }
916
+ try {
917
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
918
+ process.kill(info.pid, 0); // 생존 확인
919
+ const uptime = Date.now() - info.started;
920
+ const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
921
+ : uptime < 3600000 ? `${Math.round(uptime / 60000)}분`
922
+ : `${Math.round(uptime / 3600000)}시간`;
923
+
924
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
925
+ console.log(` URL: ${info.url}`);
926
+ console.log(` PID: ${info.pid}`);
927
+ console.log(` Uptime: ${uptimeStr}`);
928
+
929
+ // HTTP 상태 조회 시도
930
+ try {
931
+ const statusUrl = info.url.replace("/mcp", "/status");
932
+ const result = execSync(`curl -s "${statusUrl}" 2>/dev/null`, { encoding: "utf8", timeout: 3000 });
933
+ const data = JSON.parse(result);
934
+ if (data.hub) {
935
+ console.log(` State: ${data.hub.state}`);
936
+ }
937
+ if (data.sessions !== undefined) {
938
+ console.log(` Sessions: ${data.sessions}`);
939
+ }
940
+ } catch {}
941
+
942
+ console.log("");
943
+ } catch {
944
+ try { unlinkSync(HUB_PID_FILE); } catch {}
945
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
946
+ }
947
+ break;
948
+ }
949
+
950
+ default:
951
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
952
+ console.log(` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`);
953
+ console.log(` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`);
954
+ console.log(` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`);
955
+ console.log(` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}\n`);
956
+ }
957
+ }
958
+
820
959
  // ── 메인 ──
821
960
 
822
961
  const cmd = process.argv[2] || "help";
@@ -831,6 +970,7 @@ switch (cmd) {
831
970
  }
832
971
  case "update": cmdUpdate(); break;
833
972
  case "list": case "ls": cmdList(); break;
973
+ case "hub": cmdHub(); break;
834
974
  case "notion-read": case "nr": {
835
975
  const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
836
976
  const nrArgs = process.argv.slice(3).map(a => `"${a}"`).join(" ");
package/hub/hitl.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // hub/hitl.mjs — Human-in-the-Loop 매니저
2
+ // 사용자 입력 요청/응답, 타임아웃 자동 처리
3
+ import { uuidv7 } from './store.mjs';
4
+
5
+ /**
6
+ * HITL 매니저 생성
7
+ * @param {object} store — createStore() 반환 객체
8
+ */
9
+ export function createHitlManager(store) {
10
+ return {
11
+ /**
12
+ * 사용자에게 입력 요청 생성
13
+ * 터미널에 알림 출력 후 pending 상태로 저장
14
+ */
15
+ requestHumanInput({
16
+ requester_agent, kind, prompt, requested_schema = {},
17
+ deadline_ms, default_action, channel_preference = 'terminal',
18
+ correlation_id, trace_id,
19
+ }) {
20
+ const result = store.insertHumanRequest({
21
+ requester_agent, kind, prompt, requested_schema,
22
+ deadline_ms, default_action,
23
+ correlation_id, trace_id,
24
+ });
25
+
26
+ // 터미널 알림 (stderr — stdout은 MCP 용)
27
+ const kindLabel = { captcha: 'CAPTCHA', approval: '승인', credential: '자격증명', choice: '선택', text: '텍스트' };
28
+ process.stderr.write(
29
+ `\n[tfx-hub] 사용자 입력 요청 (${kindLabel[kind] || kind})\n` +
30
+ ` 요청자: ${requester_agent}\n` +
31
+ ` 내용: ${prompt}\n` +
32
+ ` ID: ${result.request_id}\n` +
33
+ ` 제한: ${Math.round(deadline_ms / 1000)}초\n\n`,
34
+ );
35
+
36
+ return { ok: true, data: result };
37
+ },
38
+
39
+ /**
40
+ * 사용자 입력 응답 제출
41
+ * 유효성 검증 → 상태 업데이트 → 요청자에게 응답 메시지 전달
42
+ */
43
+ submitHumanInput({ request_id, action, content = null, submitted_by = 'human' }) {
44
+ // 요청 조회
45
+ const hr = store.getHumanRequest(request_id);
46
+ if (!hr) {
47
+ return { ok: false, error: { code: 'NOT_FOUND', message: `요청 없음: ${request_id}` } };
48
+ }
49
+ if (hr.state !== 'pending') {
50
+ return { ok: false, error: { code: 'ALREADY_HANDLED', message: `이미 처리됨: ${hr.state}` } };
51
+ }
52
+
53
+ // 상태 매핑
54
+ const stateMap = { accept: 'accepted', decline: 'declined', cancel: 'cancelled' };
55
+ const newState = stateMap[action];
56
+ if (!newState) {
57
+ return { ok: false, error: { code: 'INVALID_ACTION', message: `잘못된 action: ${action}` } };
58
+ }
59
+
60
+ // DB 업데이트
61
+ store.updateHumanRequest(request_id, newState, content);
62
+
63
+ // 요청자에게 응답 메시지 전달
64
+ let forwardedMessageId = null;
65
+ if (action === 'accept' || action === 'decline') {
66
+ const msg = store.enqueueMessage({
67
+ type: 'human_response',
68
+ from: 'hub:hitl',
69
+ to: hr.requester_agent,
70
+ topic: 'human.response',
71
+ priority: 7, // urgent — 사용자 블로킹 해소
72
+ ttl_ms: 300000,
73
+ payload: { request_id, action, content, submitted_by },
74
+ correlation_id: hr.correlation_id,
75
+ trace_id: hr.trace_id,
76
+ });
77
+ store.deliverToAgent(msg.id, hr.requester_agent);
78
+ forwardedMessageId = msg.id;
79
+ }
80
+
81
+ return {
82
+ ok: true,
83
+ data: { request_id, new_state: newState, forwarded_message_id: forwardedMessageId },
84
+ };
85
+ },
86
+
87
+ /**
88
+ * 만료된 요청 자동 처리
89
+ * deadline 초과 시 default_action 적용
90
+ */
91
+ checkTimeouts() {
92
+ const pending = store.getPendingHumanRequests();
93
+ const now = Date.now();
94
+ let processed = 0;
95
+
96
+ for (const hr of pending) {
97
+ if (hr.deadline_ms > now) continue;
98
+
99
+ // default_action 적용
100
+ if (hr.default_action === 'timeout_continue') {
101
+ store.updateHumanRequest(hr.request_id, 'timed_out', null);
102
+ // 요청자에게 타임아웃 알림
103
+ const msg = store.enqueueMessage({
104
+ type: 'human_response',
105
+ from: 'hub:hitl',
106
+ to: hr.requester_agent,
107
+ topic: 'human.response',
108
+ priority: 5,
109
+ ttl_ms: 300000,
110
+ payload: { request_id: hr.request_id, action: 'timeout_continue', content: null },
111
+ correlation_id: hr.correlation_id,
112
+ trace_id: hr.trace_id,
113
+ });
114
+ store.deliverToAgent(msg.id, hr.requester_agent);
115
+ } else {
116
+ // decline 또는 cancel
117
+ store.updateHumanRequest(hr.request_id, 'timed_out', null);
118
+ }
119
+ processed++;
120
+ }
121
+
122
+ return processed;
123
+ },
124
+
125
+ /** 대기 중인 요청 목록 */
126
+ getPendingRequests() {
127
+ return store.getPendingHumanRequests();
128
+ },
129
+ };
130
+ }
package/hub/router.mjs ADDED
@@ -0,0 +1,189 @@
1
+ // hub/router.mjs — Actor mailbox 라우터 + QoS 스케줄러
2
+ // 메시지 라우팅, ask/publish/handoff 처리, TTL 정리
3
+ import { uuidv7 } from './store.mjs';
4
+
5
+ /**
6
+ * 라우터 생성
7
+ * @param {object} store — createStore() 반환 객체
8
+ */
9
+ export function createRouter(store) {
10
+ let sweepTimer = null;
11
+ let staleTimer = null;
12
+
13
+ const router = {
14
+ /**
15
+ * 메시지를 대상에게 라우팅
16
+ * "topic:XXX" → 토픽 구독자 전체 fanout
17
+ * 직접 agent_id → 1:1 배달
18
+ * @returns {number} 배달된 에이전트 수
19
+ */
20
+ route(msg) {
21
+ const to = msg.to_agent ?? msg.to;
22
+ if (to.startsWith('topic:')) {
23
+ return store.deliverToTopic(msg.id, to.slice(6));
24
+ }
25
+ store.deliverToAgent(msg.id, to);
26
+ return 1;
27
+ },
28
+
29
+ /**
30
+ * ask — 질문 요청 (request/reply 패턴)
31
+ * await_response_ms > 0 이면 짧은 폴링으로 응답 대기
32
+ * 0 이면 티켓(correlation_id) 즉시 반환
33
+ */
34
+ async handleAsk({
35
+ from, to, topic, question, context_refs,
36
+ payload = {}, priority = 5, ttl_ms = 300000,
37
+ await_response_ms = 0, trace_id, correlation_id,
38
+ }) {
39
+ const cid = correlation_id || uuidv7();
40
+ const tid = trace_id || uuidv7();
41
+
42
+ const msg = store.enqueueMessage({
43
+ type: 'request', from, to, topic, priority, ttl_ms,
44
+ payload: { question, context_refs, ...payload },
45
+ correlation_id: cid, trace_id: tid,
46
+ });
47
+ router.route(msg);
48
+
49
+ // 티켓 모드: 즉시 반환
50
+ if (await_response_ms <= 0) {
51
+ return {
52
+ ok: true,
53
+ data: { request_message_id: msg.id, correlation_id: cid, trace_id: tid, state: 'queued' },
54
+ };
55
+ }
56
+
57
+ // 짧은 폴링 대기 (최대 30초 제한)
58
+ const deadline = Date.now() + Math.min(await_response_ms, 30000);
59
+ while (Date.now() < deadline) {
60
+ const resp = store.getResponseByCorrelation(cid);
61
+ if (resp) {
62
+ return {
63
+ ok: true,
64
+ data: { request_message_id: msg.id, correlation_id: cid, trace_id: tid, state: 'answered', response: resp.payload },
65
+ };
66
+ }
67
+ await new Promise(r => setTimeout(r, 100));
68
+ }
69
+
70
+ // 타임아웃 — 티켓 반환
71
+ return {
72
+ ok: true,
73
+ data: { request_message_id: msg.id, correlation_id: cid, trace_id: tid, state: 'delivered' },
74
+ };
75
+ },
76
+
77
+ /**
78
+ * publish — 이벤트/응답 발행
79
+ * correlation_id 존재 시 response 타입, 없으면 event 타입
80
+ */
81
+ handlePublish({
82
+ from, to, topic, priority = 5, ttl_ms = 300000,
83
+ payload = {}, trace_id, correlation_id,
84
+ }) {
85
+ const type = correlation_id ? 'response' : 'event';
86
+ const msg = store.enqueueMessage({
87
+ type, from, to, topic, priority, ttl_ms, payload,
88
+ correlation_id: correlation_id || uuidv7(),
89
+ trace_id: trace_id || uuidv7(),
90
+ });
91
+ const fanout = router.route(msg);
92
+ return {
93
+ ok: true,
94
+ data: { message_id: msg.id, fanout_count: fanout, expires_at_ms: msg.expires_at_ms },
95
+ };
96
+ },
97
+
98
+ /**
99
+ * handoff — 작업 인계
100
+ * acceptance_criteria, context_refs 포함 가능
101
+ */
102
+ handleHandoff({
103
+ from, to, topic, task, acceptance_criteria, context_refs,
104
+ priority = 5, ttl_ms = 600000, trace_id, correlation_id,
105
+ }) {
106
+ const msg = store.enqueueMessage({
107
+ type: 'handoff', from, to, topic, priority, ttl_ms,
108
+ payload: { task, acceptance_criteria, context_refs },
109
+ correlation_id: correlation_id || uuidv7(),
110
+ trace_id: trace_id || uuidv7(),
111
+ });
112
+ router.route(msg);
113
+ return {
114
+ ok: true,
115
+ data: { handoff_message_id: msg.id, state: 'queued', assigned_to: to },
116
+ };
117
+ },
118
+
119
+ // ── 스위퍼 ──
120
+
121
+ /** 주기적 만료 정리 시작 (1초: 메시지, 60초: 비활성 에이전트) */
122
+ startSweeper() {
123
+ if (sweepTimer) return;
124
+ sweepTimer = setInterval(() => {
125
+ try { store.sweepExpired(); } catch { /* 무시 */ }
126
+ }, 1000);
127
+ staleTimer = setInterval(() => {
128
+ try { store.sweepStaleAgents(); } catch { /* 무시 */ }
129
+ }, 60000);
130
+ sweepTimer.unref();
131
+ staleTimer.unref();
132
+ },
133
+
134
+ /** 정리 중지 */
135
+ stopSweeper() {
136
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
137
+ if (staleTimer) { clearInterval(staleTimer); staleTimer = null; }
138
+ },
139
+
140
+ // ── 상태 조회 ──
141
+
142
+ /**
143
+ * 허브/에이전트/큐/트레이스 상태 조회
144
+ * @param {'hub'|'agent'|'queue'|'trace'} scope
145
+ */
146
+ getStatus(scope = 'hub', { agent_id, trace_id, include_metrics = true } = {}) {
147
+ const data = {};
148
+
149
+ if (scope === 'hub' || scope === 'queue') {
150
+ data.hub = {
151
+ state: 'healthy',
152
+ uptime_ms: process.uptime() * 1000 | 0,
153
+ db_wal_mode: true,
154
+ };
155
+ if (include_metrics) {
156
+ const depths = store.getQueueDepths();
157
+ const stats = store.getDeliveryStats();
158
+ data.queues = {
159
+ urgent_depth: depths.urgent,
160
+ normal_depth: depths.normal,
161
+ dlq_depth: depths.dlq,
162
+ p95_delivery_ms: stats.avg_delivery_ms,
163
+ timeout_rate: 0,
164
+ };
165
+ }
166
+ }
167
+
168
+ if (scope === 'agent' && agent_id) {
169
+ const agent = store.getAgent(agent_id);
170
+ if (agent) {
171
+ data.agent = {
172
+ agent_id: agent.agent_id,
173
+ status: agent.status,
174
+ pending: 0,
175
+ last_seen_ms: agent.last_seen_ms,
176
+ };
177
+ }
178
+ }
179
+
180
+ if (scope === 'trace' && trace_id) {
181
+ data.trace = store.getMessagesByTrace(trace_id);
182
+ }
183
+
184
+ return { ok: true, data };
185
+ },
186
+ };
187
+
188
+ return router;
189
+ }
package/hub/schema.sql ADDED
@@ -0,0 +1,80 @@
1
+ -- tfx-hub 상태 저장소 스키마
2
+ -- SQLite WAL 모드 기반 메시지 버스
3
+
4
+ -- 에이전트 등록 테이블
5
+ CREATE TABLE IF NOT EXISTS agents (
6
+ agent_id TEXT PRIMARY KEY,
7
+ cli TEXT NOT NULL CHECK (cli IN ('codex','gemini','claude','other')),
8
+ pid INTEGER,
9
+ capabilities_json TEXT NOT NULL DEFAULT '[]',
10
+ topics_json TEXT NOT NULL DEFAULT '[]',
11
+ last_seen_ms INTEGER NOT NULL,
12
+ lease_expires_ms INTEGER NOT NULL,
13
+ status TEXT NOT NULL CHECK (status IN ('online','stale','offline')),
14
+ metadata_json TEXT NOT NULL DEFAULT '{}'
15
+ );
16
+
17
+ -- 메시지 테이블
18
+ CREATE TABLE IF NOT EXISTS messages (
19
+ id TEXT PRIMARY KEY,
20
+ type TEXT NOT NULL CHECK (type IN ('request','response','event','handoff','human_request','human_response','system')),
21
+ from_agent TEXT NOT NULL,
22
+ to_agent TEXT NOT NULL,
23
+ topic TEXT NOT NULL,
24
+ priority INTEGER NOT NULL CHECK (priority BETWEEN 1 AND 9),
25
+ ttl_ms INTEGER NOT NULL,
26
+ created_at_ms INTEGER NOT NULL,
27
+ expires_at_ms INTEGER NOT NULL,
28
+ correlation_id TEXT NOT NULL,
29
+ trace_id TEXT NOT NULL,
30
+ payload_json TEXT NOT NULL DEFAULT '{}',
31
+ status TEXT NOT NULL CHECK (status IN ('queued','delivered','acked','expired','dead_letter'))
32
+ );
33
+
34
+ -- 메시지 수신함 (배달 추적)
35
+ CREATE TABLE IF NOT EXISTS message_inbox (
36
+ delivery_id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ message_id TEXT NOT NULL,
38
+ agent_id TEXT NOT NULL,
39
+ delivered_at_ms INTEGER,
40
+ acked_at_ms INTEGER,
41
+ attempts INTEGER NOT NULL DEFAULT 0,
42
+ UNIQUE(message_id, agent_id),
43
+ FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE
44
+ );
45
+
46
+ -- 사용자 입력 요청 테이블
47
+ CREATE TABLE IF NOT EXISTS human_requests (
48
+ request_id TEXT PRIMARY KEY,
49
+ requester_agent TEXT NOT NULL,
50
+ kind TEXT NOT NULL CHECK (kind IN ('captcha','approval','credential','choice','text')),
51
+ prompt TEXT NOT NULL,
52
+ schema_json TEXT NOT NULL DEFAULT '{}',
53
+ state TEXT NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','timed_out')),
54
+ deadline_ms INTEGER NOT NULL,
55
+ default_action TEXT NOT NULL CHECK (default_action IN ('decline','cancel','timeout_continue')),
56
+ correlation_id TEXT NOT NULL,
57
+ trace_id TEXT NOT NULL,
58
+ response_json TEXT
59
+ );
60
+
61
+ -- 데드 레터 큐
62
+ CREATE TABLE IF NOT EXISTS dead_letters (
63
+ message_id TEXT PRIMARY KEY,
64
+ reason TEXT NOT NULL,
65
+ failed_at_ms INTEGER NOT NULL,
66
+ last_error TEXT
67
+ );
68
+
69
+ -- 인덱스
70
+ CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status);
71
+ CREATE INDEX IF NOT EXISTS idx_messages_to_agent ON messages(to_agent, status);
72
+ CREATE INDEX IF NOT EXISTS idx_messages_correlation ON messages(correlation_id);
73
+ CREATE INDEX IF NOT EXISTS idx_messages_trace ON messages(trace_id);
74
+ CREATE INDEX IF NOT EXISTS idx_messages_expires ON messages(expires_at_ms);
75
+ CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority DESC, created_at_ms ASC);
76
+ CREATE INDEX IF NOT EXISTS idx_inbox_agent ON message_inbox(agent_id, delivered_at_ms);
77
+ CREATE INDEX IF NOT EXISTS idx_inbox_message ON message_inbox(message_id);
78
+ CREATE INDEX IF NOT EXISTS idx_human_requests_state ON human_requests(state);
79
+ CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
80
+ CREATE INDEX IF NOT EXISTS idx_agents_lease ON agents(lease_expires_ms);