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 CHANGED
@@ -1,3 +1,8 @@
1
- {
2
- "mcpServers": {}
3
- }
1
+ {
2
+ "mcpServers": {
3
+ "tfx-hub": {
4
+ "type": "url",
5
+ "url": "http://127.0.0.1:27888/mcp"
6
+ }
7
+ }
8
+ }
package/bin/triflux.mjs CHANGED
@@ -3,7 +3,7 @@
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");
@@ -221,6 +221,13 @@ function cmdSetup() {
221
221
  }
222
222
  }
223
223
 
224
+ // hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
225
+ if (existsSync(join(PKG_ROOT, "hub", "server.mjs"))) {
226
+ const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
227
+ autoRegisterMcp(defaultHubUrl);
228
+ console.log("");
229
+ }
230
+
224
231
  // HUD statusLine 설정
225
232
  console.log(`${CYAN}[HUD 설정]${RESET}`);
226
233
  const settingsPath = join(CLAUDE_DIR, "settings.json");
@@ -578,7 +585,9 @@ function cmdDoctor(options = {}) {
578
585
  }
579
586
 
580
587
  function cmdUpdate() {
581
- console.log(`\n${BOLD}triflux update${RESET}\n`);
588
+ const isDev = process.argv.includes("--dev");
589
+ const tagLabel = isDev ? ` ${YELLOW}@dev${RESET}` : "";
590
+ console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
582
591
 
583
592
  // 1. 설치 방식 감지
584
593
  const pluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
@@ -648,17 +657,19 @@ function cmdUpdate() {
648
657
  break;
649
658
  }
650
659
  case "npm-global": {
651
- const result = execSync("npm update -g triflux", {
660
+ const npmCmd = isDev ? "npm install -g triflux@dev" : "npm update -g triflux";
661
+ const result = execSync(npmCmd, {
652
662
  encoding: "utf8",
653
663
  timeout: 60000,
654
664
  stdio: ["pipe", "pipe", "ignore"],
655
665
  }).trim().split(/\r?\n/)[0];
656
- ok(`npm update -g — ${result || "완료"}`);
666
+ ok(`${isDev ? "npm install -g @dev" : "npm update -g"} — ${result || "완료"}`);
657
667
  updated = true;
658
668
  break;
659
669
  }
660
670
  case "npm-local": {
661
- const result = execSync("npm update triflux", {
671
+ const npmLocalCmd = isDev ? "npm install triflux@dev" : "npm update triflux";
672
+ const result = execSync(npmLocalCmd, {
662
673
  encoding: "utf8",
663
674
  timeout: 60000,
664
675
  cwd: process.cwd(),
@@ -812,7 +823,9 @@ ${updateNotice}
812
823
  ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
813
824
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
814
825
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 버전으로 업데이트${RESET}
826
+ ${DIM} --dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
815
827
  ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
828
+ ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
816
829
  ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
817
830
  ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
818
831
 
@@ -829,6 +842,209 @@ ${updateNotice}
829
842
  `);
830
843
  }
831
844
 
845
+ // ── hub 서브커맨드 ──
846
+
847
+ const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
848
+ const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
849
+
850
+ // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
851
+ function autoRegisterMcp(mcpUrl) {
852
+ section("MCP 자동 등록");
853
+
854
+ // Codex — codex mcp add
855
+ if (which("codex")) {
856
+ try {
857
+ // 이미 등록됐는지 확인
858
+ const list = execSync("codex mcp list 2>&1", { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
859
+ if (list.includes("tfx-hub")) {
860
+ ok("Codex: 이미 등록됨");
861
+ } else {
862
+ execSync(`codex mcp add tfx-hub --url ${mcpUrl}`, { timeout: 10000, stdio: "ignore" });
863
+ ok("Codex: MCP 등록 완료");
864
+ }
865
+ } catch {
866
+ // mcp list/add 미지원 → 설정 파일 직접 수정
867
+ try {
868
+ const codexDir = join(homedir(), ".codex");
869
+ const configFile = join(codexDir, "config.json");
870
+ let config = {};
871
+ if (existsSync(configFile)) config = JSON.parse(readFileSync(configFile, "utf8"));
872
+ if (!config.mcpServers) config.mcpServers = {};
873
+ if (!config.mcpServers["tfx-hub"]) {
874
+ config.mcpServers["tfx-hub"] = { url: mcpUrl };
875
+ if (!existsSync(codexDir)) mkdirSync(codexDir, { recursive: true });
876
+ writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
877
+ ok("Codex: config.json에 등록 완료");
878
+ } else {
879
+ ok("Codex: 이미 등록됨");
880
+ }
881
+ } catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
882
+ }
883
+ } else {
884
+ info("Codex: 미설치 (건너뜀)");
885
+ }
886
+
887
+ // Gemini — settings.json 직접 수정
888
+ if (which("gemini")) {
889
+ try {
890
+ const geminiDir = join(homedir(), ".gemini");
891
+ const settingsFile = join(geminiDir, "settings.json");
892
+ let settings = {};
893
+ if (existsSync(settingsFile)) settings = JSON.parse(readFileSync(settingsFile, "utf8"));
894
+ if (!settings.mcpServers) settings.mcpServers = {};
895
+ if (!settings.mcpServers["tfx-hub"]) {
896
+ settings.mcpServers["tfx-hub"] = { url: mcpUrl };
897
+ if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
898
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
899
+ ok("Gemini: settings.json에 등록 완료");
900
+ } else {
901
+ ok("Gemini: 이미 등록됨");
902
+ }
903
+ } catch (e) { warn(`Gemini 등록 실패: ${e.message}`); }
904
+ } else {
905
+ info("Gemini: 미설치 (건너뜀)");
906
+ }
907
+
908
+ // Claude — 프로젝트 .mcp.json에 등록 (오케스트레이터용)
909
+ try {
910
+ const mcpJsonPath = join(PKG_ROOT, ".mcp.json");
911
+ let mcpJson = {};
912
+ if (existsSync(mcpJsonPath)) mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
913
+ if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
914
+ if (!mcpJson.mcpServers["tfx-hub"]) {
915
+ mcpJson.mcpServers["tfx-hub"] = { type: "url", url: mcpUrl };
916
+ writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
917
+ ok("Claude: .mcp.json에 등록 완료");
918
+ } else {
919
+ ok("Claude: 이미 등록됨");
920
+ }
921
+ } catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
922
+ }
923
+
924
+ function cmdHub() {
925
+ const sub = process.argv[3] || "status";
926
+
927
+ switch (sub) {
928
+ case "start": {
929
+ // 이미 실행 중인지 확인
930
+ if (existsSync(HUB_PID_FILE)) {
931
+ try {
932
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
933
+ process.kill(info.pid, 0); // 프로세스 존재 확인
934
+ console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
935
+ return;
936
+ } catch {
937
+ // PID 파일 있지만 프로세스 없음 — 정리
938
+ try { unlinkSync(HUB_PID_FILE); } catch {}
939
+ }
940
+ }
941
+
942
+ const portArg = process.argv.indexOf("--port");
943
+ const port = portArg !== -1 ? process.argv[portArg + 1] : "27888";
944
+ const serverPath = join(PKG_ROOT, "hub", "server.mjs");
945
+
946
+ if (!existsSync(serverPath)) {
947
+ fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
948
+ return;
949
+ }
950
+
951
+ const child = spawn(process.execPath, [serverPath], {
952
+ env: { ...process.env, TFX_HUB_PORT: port },
953
+ stdio: "ignore",
954
+ detached: true,
955
+ });
956
+ child.unref();
957
+
958
+ // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
959
+ let started = false;
960
+ const deadline = Date.now() + 3000;
961
+ while (Date.now() < deadline) {
962
+ if (existsSync(HUB_PID_FILE)) { started = true; break; }
963
+ execSync("node -e \"setTimeout(()=>{},100)\"", { stdio: "ignore", timeout: 500 });
964
+ }
965
+
966
+ if (started) {
967
+ const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
968
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
969
+ console.log(` URL: ${AMBER}${hubInfo.url}${RESET}`);
970
+ console.log(` PID: ${hubInfo.pid}`);
971
+ console.log(` DB: ${DIM}${HUB_PID_DIR}/state.db${RESET}`);
972
+ console.log("");
973
+ autoRegisterMcp(hubInfo.url);
974
+ console.log("");
975
+ } else {
976
+ // 직접 포그라운드 모드로 안내
977
+ console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`);
978
+ console.log(` ${DIM}TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`);
979
+ }
980
+ break;
981
+ }
982
+
983
+ case "stop": {
984
+ if (!existsSync(HUB_PID_FILE)) {
985
+ console.log(`\n ${DIM}hub 미실행${RESET}\n`);
986
+ return;
987
+ }
988
+ try {
989
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
990
+ process.kill(info.pid, "SIGTERM");
991
+ try { unlinkSync(HUB_PID_FILE); } catch {}
992
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`);
993
+ } catch (e) {
994
+ try { unlinkSync(HUB_PID_FILE); } catch {}
995
+ console.log(`\n ${DIM}hub 프로세스 없음 — PID 파일 정리됨${RESET}\n`);
996
+ }
997
+ break;
998
+ }
999
+
1000
+ case "status": {
1001
+ if (!existsSync(HUB_PID_FILE)) {
1002
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
1003
+ return;
1004
+ }
1005
+ try {
1006
+ const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
1007
+ process.kill(info.pid, 0); // 생존 확인
1008
+ const uptime = Date.now() - info.started;
1009
+ const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
1010
+ : uptime < 3600000 ? `${Math.round(uptime / 60000)}분`
1011
+ : `${Math.round(uptime / 3600000)}시간`;
1012
+
1013
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
1014
+ console.log(` URL: ${info.url}`);
1015
+ console.log(` PID: ${info.pid}`);
1016
+ console.log(` Uptime: ${uptimeStr}`);
1017
+
1018
+ // HTTP 상태 조회 시도
1019
+ try {
1020
+ const statusUrl = info.url.replace("/mcp", "/status");
1021
+ const result = execSync(`curl -s "${statusUrl}"`, { encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "ignore"] });
1022
+ const data = JSON.parse(result);
1023
+ if (data.hub) {
1024
+ console.log(` State: ${data.hub.state}`);
1025
+ }
1026
+ if (data.sessions !== undefined) {
1027
+ console.log(` Sessions: ${data.sessions}`);
1028
+ }
1029
+ } catch {}
1030
+
1031
+ console.log("");
1032
+ } catch {
1033
+ try { unlinkSync(HUB_PID_FILE); } catch {}
1034
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
1035
+ }
1036
+ break;
1037
+ }
1038
+
1039
+ default:
1040
+ console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
1041
+ console.log(` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`);
1042
+ console.log(` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`);
1043
+ console.log(` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`);
1044
+ console.log(` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}\n`);
1045
+ }
1046
+ }
1047
+
832
1048
  // ── 메인 ──
833
1049
 
834
1050
  const cmd = process.argv[2] || "help";
@@ -843,6 +1059,7 @@ switch (cmd) {
843
1059
  }
844
1060
  case "update": cmdUpdate(); break;
845
1061
  case "list": case "ls": cmdList(); break;
1062
+ case "hub": cmdHub(); break;
846
1063
  case "notion-read": case "nr": {
847
1064
  const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
848
1065
  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);