@triflux/remote 10.0.1 → 10.25.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/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +68 -7
- package/hub/server.mjs +2181 -701
- package/hub/store-adapter.mjs +101 -37
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +84 -17
- package/hub/team/build-worker-prompt.mjs +42 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +219 -0
- package/hub/team/claude-native-bridge.mjs +784 -0
- package/hub/team/claude-session-projection.mjs +65 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +154 -33
- package/hub/team/cli/commands/start/parse-args.mjs +89 -12
- package/hub/team/cli/commands/start/start-headless.mjs +127 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +45 -17
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +55 -1
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +21 -119
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +241 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2065 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/launcher-template.mjs +23 -13
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +74 -35
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -15
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1067 -264
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +69 -53
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +62 -34
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +605 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1526 -198
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +288 -0
- package/hub/team/tui-lite.mjs +234 -146
- package/hub/team/tui-remote-adapter.mjs +21 -37
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -45
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +936 -449
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +518 -64
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +474 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +663 -337
- package/hub/workers/factory.mjs +100 -21
- package/hub/workers/gemini-worker.mjs +179 -86
- package/hub/workers/interface.mjs +23 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +22 -16
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +179 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
package/hub/server.mjs
CHANGED
|
@@ -1,74 +1,130 @@
|
|
|
1
1
|
// hub/server.mjs — HTTP MCP + REST bridge + Named Pipe 서버 진입점
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import { createAdaptiveEngine } from
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
2
|
+
|
|
3
|
+
import { execSync as execSyncHub } from "node:child_process";
|
|
4
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { createServer as createHttpServer } from "node:http";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
16
|
+
import { performance } from "node:perf_hooks";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
|
+
import {
|
|
22
|
+
CallToolRequestSchema,
|
|
23
|
+
ListToolsRequestSchema,
|
|
24
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
+
import { createModuleLogger } from "../scripts/lib/logger.mjs";
|
|
26
|
+
import { broker as brokerInstance, reloadBroker } from "@triflux/core/hub/account-broker.mjs";
|
|
27
|
+
import { createAdaptiveEngine } from "@triflux/core/hub/adaptive.mjs";
|
|
28
|
+
import { createAssignCallbackServer } from "@triflux/core/hub/assign-callbacks.mjs";
|
|
29
|
+
import { DelegatorService } from "@triflux/core/hub/delegator/index.mjs";
|
|
30
|
+
import { createHitlManager } from "@triflux/core/hub/hitl.mjs";
|
|
31
|
+
import {
|
|
32
|
+
cleanupOrphanNodeProcesses,
|
|
33
|
+
cleanupOrphanRuntimeProcesses,
|
|
34
|
+
cleanupStaleFsmonitorDaemons,
|
|
35
|
+
} from "@triflux/core/hub/lib/process-utils.mjs";
|
|
36
|
+
import * as spawnTrace from "@triflux/core/hub/lib/spawn-trace.mjs";
|
|
37
|
+
import {
|
|
38
|
+
recordRequest,
|
|
39
|
+
recordWorker,
|
|
40
|
+
snapshot as traceSnapshot,
|
|
41
|
+
} from "@triflux/core/hub/lib/trace-recorder.mjs";
|
|
42
|
+
import { logQuotaRefreshFailures } from "@triflux/core/hub/middleware/quota-middleware.mjs";
|
|
43
|
+
import { wrapRequestHandler } from "@triflux/core/hub/middleware/request-logger.mjs";
|
|
44
|
+
import { createPipeServer } from "./pipe.mjs";
|
|
45
|
+
import { createRouter } from "@triflux/core/hub/router.mjs";
|
|
46
|
+
import { createAdaptiveFingerprintService } from "@triflux/core/hub/session-fingerprint.mjs";
|
|
47
|
+
import {
|
|
48
|
+
acquireLock,
|
|
49
|
+
getVersionHash,
|
|
50
|
+
isServerHealthy,
|
|
51
|
+
readState,
|
|
52
|
+
releaseLock,
|
|
53
|
+
writeState,
|
|
54
|
+
} from "@triflux/core/hub/state.mjs";
|
|
55
|
+
import { createStoreAdapter } from "./store-adapter.mjs";
|
|
56
|
+
import { createGitPreflight } from "./team/git-preflight.mjs";
|
|
57
|
+
import { nativeProxy } from "./team/nativeProxy.mjs";
|
|
58
|
+
import { createSwarmLocks } from "./team/swarm-locks.mjs";
|
|
59
|
+
import { createSynapseRegistry } from "./team/synapse-registry.mjs";
|
|
60
|
+
import { registerTeamBridge } from "@triflux/core/hub/team-bridge.mjs";
|
|
61
|
+
import { createTools } from "./tools.mjs";
|
|
62
|
+
import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
|
|
30
63
|
|
|
31
64
|
registerTeamBridge(nativeProxy);
|
|
32
65
|
|
|
33
|
-
const hubLog = createModuleLogger(
|
|
66
|
+
const hubLog = createModuleLogger("hub");
|
|
67
|
+
const brokerDiagnosticsAttached = new WeakSet();
|
|
34
68
|
|
|
35
69
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
36
|
-
const PUBLIC_PATHS = new Set([
|
|
37
|
-
const RATE_LIMIT_MAX = 100;
|
|
70
|
+
const PUBLIC_PATHS = new Set(["/", "/status", "/health", "/healthz"]);
|
|
71
|
+
const RATE_LIMIT_MAX = 100; // requests per window
|
|
38
72
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute sliding window
|
|
39
|
-
const LOOPBACK_REMOTE_ADDRESSES = new Set([
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
73
|
+
const LOOPBACK_REMOTE_ADDRESSES = new Set([
|
|
74
|
+
"127.0.0.1",
|
|
75
|
+
"::1",
|
|
76
|
+
"::ffff:127.0.0.1",
|
|
77
|
+
]);
|
|
78
|
+
const ALLOWED_ORIGIN_RE =
|
|
79
|
+
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
80
|
+
const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
81
|
+
const PUBLIC_DIR = resolve(join(PROJECT_ROOT, "hub", "public"));
|
|
82
|
+
const CACHE_DIR = join(homedir(), ".claude", "cache");
|
|
83
|
+
const HUB_DEFAULT_PORT = 27888;
|
|
84
|
+
const BATCH_EVENTS_PATH = join(CACHE_DIR, "batch-events.jsonl");
|
|
85
|
+
const SV_ACCUMULATOR_PATH = join(CACHE_DIR, "sv-accumulator.json");
|
|
86
|
+
const CODEX_RATE_LIMITS_CACHE_PATH = join(
|
|
87
|
+
CACHE_DIR,
|
|
88
|
+
"codex-rate-limits-cache.json",
|
|
89
|
+
);
|
|
90
|
+
const GEMINI_QUOTA_CACHE_PATH = join(CACHE_DIR, "gemini-quota-cache.json");
|
|
91
|
+
const CLAUDE_USAGE_CACHE_PATH = join(CACHE_DIR, "claude-usage-cache.json");
|
|
49
92
|
const AIMD_WINDOW_MS = 30 * 60 * 1000;
|
|
50
93
|
const AIMD_INITIAL_BATCH_SIZE = 3;
|
|
51
94
|
const AIMD_MIN_BATCH_SIZE = 1;
|
|
52
95
|
const AIMD_MAX_BATCH_SIZE = 10;
|
|
53
|
-
const
|
|
96
|
+
const SYNAPSE_VALID_OPS = new Set([
|
|
97
|
+
"checkout",
|
|
98
|
+
"rebase",
|
|
99
|
+
"cherry-pick",
|
|
100
|
+
"reset",
|
|
101
|
+
"stash-pop",
|
|
102
|
+
"worktree-remove",
|
|
103
|
+
]);
|
|
104
|
+
const HUB_IDLE_TIMEOUT_DEFAULT_MS = 0; // 0 = 영구 실행 (idle shutdown 비활성). TFX_HUB_IDLE_TIMEOUT_MS 환경변수로 오버라이드 가능
|
|
54
105
|
const HUB_IDLE_SWEEP_DEFAULT_MS = 60 * 1000;
|
|
55
106
|
const STATIC_CONTENT_TYPES = Object.freeze({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
107
|
+
".html": "text/html",
|
|
108
|
+
".css": "text/css",
|
|
109
|
+
".js": "application/javascript",
|
|
110
|
+
".png": "image/png",
|
|
60
111
|
});
|
|
61
112
|
|
|
62
113
|
// IP-based sliding window rate limiter (in-memory, no external deps)
|
|
63
114
|
// Each entry is an array of request timestamps within the current window.
|
|
64
115
|
const rateLimitMap = new Map();
|
|
65
116
|
|
|
66
|
-
function formatHostForUrl(host =
|
|
67
|
-
return String(host).includes(
|
|
117
|
+
function formatHostForUrl(host = "127.0.0.1") {
|
|
118
|
+
return String(host).includes(":") ? `[${host}]` : host;
|
|
68
119
|
}
|
|
69
120
|
|
|
70
121
|
function buildHubUrl(host, port) {
|
|
71
|
-
return `http://${formatHostForUrl(host ||
|
|
122
|
+
return `http://${formatHostForUrl(host || "127.0.0.1")}:${port}/mcp`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseHubPort(value) {
|
|
126
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
127
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
72
128
|
}
|
|
73
129
|
|
|
74
130
|
function isPidAlive(pid, killFn = process.kill) {
|
|
@@ -82,24 +138,45 @@ function isPidAlive(pid, killFn = process.kill) {
|
|
|
82
138
|
}
|
|
83
139
|
}
|
|
84
140
|
|
|
85
|
-
async function tryReuseExistingHub({
|
|
141
|
+
export async function tryReuseExistingHub({
|
|
86
142
|
port,
|
|
87
|
-
|
|
143
|
+
portSpecified = Number.isFinite(Number(port)) && Number(port) > 0,
|
|
144
|
+
host = "127.0.0.1",
|
|
88
145
|
readCurrentState = readState,
|
|
89
146
|
readInfo = getHubInfo,
|
|
90
147
|
checkHealth = isServerHealthy,
|
|
91
148
|
killFn = process.kill,
|
|
149
|
+
detectPeer = detectLivePeer,
|
|
150
|
+
log = hubLog,
|
|
92
151
|
} = {}) {
|
|
93
152
|
const existing = readCurrentState();
|
|
94
153
|
const existingPort = Number(existing?.port);
|
|
95
|
-
|
|
154
|
+
const requestedPort = parseHubPort(port) ?? HUB_DEFAULT_PORT;
|
|
155
|
+
const livePeer = detectPeer();
|
|
156
|
+
const livePidPort = parseHubPort(livePeer?.port);
|
|
157
|
+
if (
|
|
158
|
+
!isPidAlive(existing?.pid, killFn) ||
|
|
159
|
+
!Number.isFinite(existingPort) ||
|
|
160
|
+
existingPort <= 0
|
|
161
|
+
) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (existingPort !== requestedPort) {
|
|
165
|
+
log.warn(
|
|
166
|
+
{
|
|
167
|
+
requestedPort,
|
|
168
|
+
existingPort,
|
|
169
|
+
pid: livePeer.pid,
|
|
170
|
+
livePidPort,
|
|
171
|
+
},
|
|
172
|
+
"hub.port_mismatch_not_reusing_live_pid",
|
|
173
|
+
);
|
|
96
174
|
return null;
|
|
97
175
|
}
|
|
98
|
-
if (Number.isFinite(Number(port)) && existingPort !== Number(port)) return null;
|
|
99
176
|
if (!(await checkHealth(existingPort))) return null;
|
|
100
177
|
|
|
101
178
|
const info = readInfo() ?? existing;
|
|
102
|
-
const infoHost = typeof info?.host ===
|
|
179
|
+
const infoHost = typeof info?.host === "string" ? info.host : host;
|
|
103
180
|
return {
|
|
104
181
|
reused: true,
|
|
105
182
|
external: true,
|
|
@@ -125,80 +202,330 @@ function checkRateLimit(ip) {
|
|
|
125
202
|
}
|
|
126
203
|
|
|
127
204
|
function isInitializeRequest(body) {
|
|
128
|
-
if (body?.method ===
|
|
129
|
-
if (Array.isArray(body))
|
|
205
|
+
if (body?.method === "initialize") return true;
|
|
206
|
+
if (Array.isArray(body))
|
|
207
|
+
return body.some((message) => message.method === "initialize");
|
|
130
208
|
return false;
|
|
131
209
|
}
|
|
132
210
|
|
|
211
|
+
function parseTfxClientHeader(value) {
|
|
212
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
213
|
+
const client = String(raw || "unknown").toLowerCase();
|
|
214
|
+
return ["codex", "claude", "gemini"].includes(client) ? client : "unknown";
|
|
215
|
+
}
|
|
216
|
+
|
|
133
217
|
async function parseBody(req) {
|
|
134
218
|
const chunks = [];
|
|
135
219
|
let size = 0;
|
|
136
220
|
for await (const chunk of req) {
|
|
137
221
|
size += chunk.length;
|
|
138
222
|
if (size > MAX_BODY_SIZE) {
|
|
139
|
-
throw Object.assign(new Error(
|
|
223
|
+
throw Object.assign(new Error("Body too large"), { statusCode: 413 });
|
|
140
224
|
}
|
|
141
225
|
chunks.push(chunk);
|
|
142
226
|
}
|
|
143
227
|
return JSON.parse(Buffer.concat(chunks).toString());
|
|
144
228
|
}
|
|
145
229
|
|
|
146
|
-
const PID_DIR = join(homedir(),
|
|
147
|
-
const PID_FILE = join(PID_DIR,
|
|
148
|
-
const TOKEN_FILE = join(homedir(),
|
|
230
|
+
const PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
231
|
+
const PID_FILE = join(PID_DIR, "hub.pid");
|
|
232
|
+
const TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
|
|
233
|
+
|
|
234
|
+
function readHubPidFile(
|
|
235
|
+
pidFilePath = PID_FILE,
|
|
236
|
+
{ exists = existsSync, readFile = readFileSync } = {},
|
|
237
|
+
) {
|
|
238
|
+
if (!exists(pidFilePath)) {
|
|
239
|
+
return { exists: false, info: null, error: null };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
return {
|
|
244
|
+
exists: true,
|
|
245
|
+
info: JSON.parse(readFile(pidFilePath, "utf8")),
|
|
246
|
+
error: null,
|
|
247
|
+
};
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return { exists: true, info: null, error };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function resolveHubPort(env = process.env, opts = {}) {
|
|
254
|
+
void opts;
|
|
255
|
+
const envPort = parseHubPort(env?.TFX_HUB_PORT);
|
|
256
|
+
if (envPort) return envPort;
|
|
257
|
+
return HUB_DEFAULT_PORT;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function detectLivePeer(
|
|
261
|
+
pidFilePath = PID_FILE,
|
|
262
|
+
{ exists = existsSync, readFile = readFileSync, killFn = process.kill } = {},
|
|
263
|
+
) {
|
|
264
|
+
const state = readHubPidFile(pidFilePath, { exists, readFile });
|
|
265
|
+
const info = state.info ?? null;
|
|
266
|
+
const pid = Number(info?.pid);
|
|
267
|
+
const port = Number(info?.port);
|
|
268
|
+
const base = {
|
|
269
|
+
alive: false,
|
|
270
|
+
pid: Number.isFinite(pid) && pid > 0 ? pid : undefined,
|
|
271
|
+
port: Number.isFinite(port) && port > 0 ? port : undefined,
|
|
272
|
+
version: typeof info?.version === "string" ? info.version : undefined,
|
|
273
|
+
host: typeof info?.host === "string" ? info.host : undefined,
|
|
274
|
+
url: typeof info?.url === "string" ? info.url : undefined,
|
|
275
|
+
reason: "missing",
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (!state.exists) return base;
|
|
279
|
+
if (state.error) return { ...base, reason: "invalid_json" };
|
|
280
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
281
|
+
return { ...base, reason: "invalid_pid" };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (isPidAlive(pid, killFn)) {
|
|
285
|
+
return {
|
|
286
|
+
alive: true,
|
|
287
|
+
pid,
|
|
288
|
+
port: base.port,
|
|
289
|
+
version: base.version,
|
|
290
|
+
host: base.host,
|
|
291
|
+
url: base.url,
|
|
292
|
+
reason: "alive",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { ...base, reason: "dead" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function cleanStaleHubPid(
|
|
300
|
+
pidFilePath = PID_FILE,
|
|
301
|
+
{
|
|
302
|
+
exists = existsSync,
|
|
303
|
+
readFile = readFileSync,
|
|
304
|
+
unlink = unlinkSync,
|
|
305
|
+
killFn = process.kill,
|
|
306
|
+
} = {},
|
|
307
|
+
) {
|
|
308
|
+
const peer = detectLivePeer(pidFilePath, { exists, readFile, killFn });
|
|
309
|
+
if (peer.alive) {
|
|
310
|
+
return { cleaned: false, reason: "alive", pid: peer.pid };
|
|
311
|
+
}
|
|
312
|
+
if (peer.reason === "missing") {
|
|
313
|
+
return { cleaned: false, reason: "missing" };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
unlink(pidFilePath);
|
|
318
|
+
return {
|
|
319
|
+
cleaned: true,
|
|
320
|
+
reason: peer.reason === "dead" ? "stale_pid" : peer.reason,
|
|
321
|
+
pid: peer.pid,
|
|
322
|
+
};
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
cleaned: false,
|
|
326
|
+
reason: "unlink_failed",
|
|
327
|
+
pid: peer.pid,
|
|
328
|
+
error,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function syncHubMcpSettingsIfAvailable({ hubUrl }) {
|
|
334
|
+
try {
|
|
335
|
+
const mod = await import(
|
|
336
|
+
new URL("../scripts/sync-hub-mcp-settings.mjs", import.meta.url)
|
|
337
|
+
);
|
|
338
|
+
if (typeof mod?.syncHubMcpSettings === "function") {
|
|
339
|
+
await mod.syncHubMcpSettings({ hubUrl });
|
|
340
|
+
} else {
|
|
341
|
+
hubLog.warn({ hubUrl }, "hub.mcp_sync_missing_export");
|
|
342
|
+
}
|
|
343
|
+
if (typeof mod?.syncCodexHubUrl === "function") {
|
|
344
|
+
await mod.syncCodexHubUrl({ hubUrl });
|
|
345
|
+
}
|
|
346
|
+
const allowProjectSync =
|
|
347
|
+
process.env.TFX_PROJECT_MCP_SYNC === "1" ||
|
|
348
|
+
!(
|
|
349
|
+
process.env.NODE_ENV === "test" ||
|
|
350
|
+
process.env.CI === "true" ||
|
|
351
|
+
process.env.TFX_TEST === "1"
|
|
352
|
+
);
|
|
353
|
+
if (allowProjectSync && typeof mod?.syncProjectMcpJson === "function") {
|
|
354
|
+
await mod.syncProjectMcpJson({ hubUrl, projectRoot: PROJECT_ROOT });
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
const message = error?.message || String(error);
|
|
358
|
+
if (error?.code === "ERR_MODULE_NOT_FOUND") {
|
|
359
|
+
hubLog.warn({ hubUrl, err: message }, "hub.mcp_sync_missing");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
hubLog.warn({ hubUrl, err: message }, "hub.mcp_sync_skipped");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function findListeningPidByPort(
|
|
367
|
+
port,
|
|
368
|
+
{ platform = process.platform, execSync = execSyncHub } = {},
|
|
369
|
+
) {
|
|
370
|
+
const targetPort = parseHubPort(port);
|
|
371
|
+
if (!targetPort) return null;
|
|
372
|
+
|
|
373
|
+
const execOptions = {
|
|
374
|
+
encoding: "utf8",
|
|
375
|
+
timeout: 5000,
|
|
376
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
377
|
+
windowsHide: true,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
if (platform === "win32") {
|
|
382
|
+
const output = execSync("netstat -ano -p tcp", execOptions);
|
|
383
|
+
for (const line of output.split(/\r?\n/)) {
|
|
384
|
+
const match = line
|
|
385
|
+
.trim()
|
|
386
|
+
.match(/^(TCP|UDP)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)$/i);
|
|
387
|
+
if (!match) continue;
|
|
388
|
+
const [, protocol, localAddress, , state, pidText] = match;
|
|
389
|
+
if (protocol.toUpperCase() !== "TCP") continue;
|
|
390
|
+
if (state.toUpperCase() !== "LISTENING") continue;
|
|
391
|
+
if (localAddress.split(":").pop() !== String(targetPort)) continue;
|
|
392
|
+
const pid = Number.parseInt(pidText, 10);
|
|
393
|
+
if (Number.isFinite(pid) && pid > 0) return pid;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const output = execSync(
|
|
400
|
+
`lsof -nP -iTCP:${targetPort} -sTCP:LISTEN -t`,
|
|
401
|
+
execOptions,
|
|
402
|
+
);
|
|
403
|
+
const pid = Number.parseInt(output.trim().split(/\r?\n/)[0] ?? "", 10);
|
|
404
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
405
|
+
} catch {
|
|
406
|
+
const output = execSync(`ss -ltnp 'sport = :${targetPort}'`, execOptions);
|
|
407
|
+
const match = output.match(/pid=(\d+)/);
|
|
408
|
+
const pid = Number.parseInt(match?.[1] ?? "", 10);
|
|
409
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function resolvePortInUse({
|
|
417
|
+
port,
|
|
418
|
+
host,
|
|
419
|
+
version,
|
|
420
|
+
pidFilePath = PID_FILE,
|
|
421
|
+
detectPeer = detectLivePeer,
|
|
422
|
+
cleanPid = cleanStaleHubPid,
|
|
423
|
+
lookupListeningPid = findListeningPidByPort,
|
|
424
|
+
} = {}) {
|
|
425
|
+
const peer = detectPeer(pidFilePath);
|
|
426
|
+
const ownerPid = lookupListeningPid(port);
|
|
427
|
+
const ownerHint = `pid=${ownerPid ?? "unknown"}`;
|
|
428
|
+
if (peer.alive) {
|
|
429
|
+
const peerPort = Number(peer.port);
|
|
430
|
+
const sameHub =
|
|
431
|
+
peerPort === Number(port) &&
|
|
432
|
+
typeof peer.version === "string" &&
|
|
433
|
+
peer.version === version;
|
|
434
|
+
if (sameHub) {
|
|
435
|
+
return {
|
|
436
|
+
action: "reuse",
|
|
437
|
+
peer,
|
|
438
|
+
url: peer.url ?? buildHubUrl(peer.host || host, peerPort),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const peerDetails = [
|
|
443
|
+
`hub.pid pid=${peer.pid ?? "unknown"}`,
|
|
444
|
+
`hub.pid port=${peerPort || "unknown"}`,
|
|
445
|
+
`hub.pid version=${peer.version ?? "unknown"}`,
|
|
446
|
+
ownerHint,
|
|
447
|
+
].join(", ");
|
|
448
|
+
return {
|
|
449
|
+
action: "error",
|
|
450
|
+
message: `Hub 포트 ${port}이 이미 사용 중입니다 (${peerDetails}). fallback port는 허용되지 않습니다. \`tfx hub stop\` 후 재시도하세요.`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const cleaned = cleanPid(pidFilePath);
|
|
455
|
+
if (cleaned.cleaned) {
|
|
456
|
+
return { action: "retry", cleaned, peer };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
action: "error",
|
|
461
|
+
message: `Hub 포트 ${port}이 이미 사용 중입니다 (${ownerHint}). fallback port는 허용되지 않습니다. \`tfx hub stop\` 후 재시도하세요. (PID file: ${pidFilePath})`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
149
464
|
|
|
150
465
|
function isPublicPath(path) {
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
466
|
+
return (
|
|
467
|
+
PUBLIC_PATHS.has(path) ||
|
|
468
|
+
path === "/dashboard" ||
|
|
469
|
+
path === "/api/qos-stats" ||
|
|
470
|
+
path.startsWith("/public/")
|
|
471
|
+
);
|
|
155
472
|
}
|
|
156
473
|
|
|
157
474
|
function isAllowedOrigin(origin) {
|
|
158
475
|
return origin && ALLOWED_ORIGIN_RE.test(origin);
|
|
159
476
|
}
|
|
160
477
|
|
|
161
|
-
function getRequestPath(url =
|
|
478
|
+
function getRequestPath(url = "/") {
|
|
162
479
|
try {
|
|
163
|
-
return new URL(url,
|
|
480
|
+
return new URL(url, "http://127.0.0.1").pathname;
|
|
164
481
|
} catch {
|
|
165
|
-
return String(url).replace(/\?.*/,
|
|
482
|
+
return String(url).replace(/\?.*/, "") || "/";
|
|
166
483
|
}
|
|
167
484
|
}
|
|
168
485
|
|
|
169
486
|
function isLoopbackRemoteAddress(remoteAddress) {
|
|
170
|
-
return
|
|
487
|
+
return (
|
|
488
|
+
typeof remoteAddress === "string" &&
|
|
489
|
+
LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress)
|
|
490
|
+
);
|
|
171
491
|
}
|
|
172
492
|
|
|
173
493
|
function extractBearerToken(req) {
|
|
174
|
-
const authHeader =
|
|
175
|
-
|
|
494
|
+
const authHeader =
|
|
495
|
+
typeof req.headers.authorization === "string"
|
|
496
|
+
? req.headers.authorization
|
|
497
|
+
: "";
|
|
498
|
+
return authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
|
176
499
|
}
|
|
177
500
|
|
|
178
501
|
function writeJson(res, statusCode, body, headers = {}) {
|
|
179
502
|
res.writeHead(statusCode, {
|
|
180
|
-
|
|
503
|
+
"Content-Type": "application/json",
|
|
181
504
|
...headers,
|
|
182
505
|
});
|
|
183
506
|
res.end(JSON.stringify(body));
|
|
184
507
|
}
|
|
185
508
|
|
|
186
509
|
function applyCorsHeaders(req, res) {
|
|
187
|
-
const origin =
|
|
510
|
+
const origin =
|
|
511
|
+
typeof req.headers.origin === "string" ? req.headers.origin : "";
|
|
188
512
|
if (origin) {
|
|
189
|
-
res.setHeader(
|
|
513
|
+
res.setHeader("Vary", "Origin");
|
|
190
514
|
}
|
|
191
515
|
if (!isAllowedOrigin(origin)) return false;
|
|
192
516
|
|
|
193
|
-
res.setHeader(
|
|
194
|
-
res.setHeader(
|
|
195
|
-
res.setHeader(
|
|
517
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
518
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
519
|
+
res.setHeader(
|
|
520
|
+
"Access-Control-Allow-Headers",
|
|
521
|
+
"Content-Type, Authorization, mcp-session-id, Last-Event-ID",
|
|
522
|
+
);
|
|
196
523
|
return true;
|
|
197
524
|
}
|
|
198
525
|
|
|
199
526
|
function safeTokenCompare(a, b) {
|
|
200
|
-
const ha = createHash(
|
|
201
|
-
const hb = createHash(
|
|
527
|
+
const ha = createHash("sha256").update(a).digest();
|
|
528
|
+
const hb = createHash("sha256").update(b).digest();
|
|
202
529
|
return timingSafeEqual(ha, hb);
|
|
203
530
|
}
|
|
204
531
|
|
|
@@ -215,30 +542,83 @@ function isAuthorizedRequest(req, path, hubToken) {
|
|
|
215
542
|
function resolveTeamStatusCode(result) {
|
|
216
543
|
if (result?.ok) return 200;
|
|
217
544
|
const code = result?.error?.code;
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
545
|
+
if (
|
|
546
|
+
code === "TEAM_NOT_FOUND" ||
|
|
547
|
+
code === "TASK_NOT_FOUND" ||
|
|
548
|
+
code === "TASKS_DIR_NOT_FOUND"
|
|
549
|
+
)
|
|
550
|
+
return 404;
|
|
551
|
+
if (code === "CLAIM_CONFLICT" || code === "MTIME_CONFLICT") return 409;
|
|
552
|
+
if (
|
|
553
|
+
code === "INVALID_TEAM_NAME" ||
|
|
554
|
+
code === "INVALID_TASK_ID" ||
|
|
555
|
+
code === "INVALID_TEXT" ||
|
|
556
|
+
code === "INVALID_FROM" ||
|
|
557
|
+
code === "INVALID_STATUS"
|
|
558
|
+
)
|
|
559
|
+
return 400;
|
|
221
560
|
return 500;
|
|
222
561
|
}
|
|
223
562
|
|
|
224
563
|
function resolvePipelineStatusCode(result) {
|
|
225
564
|
if (result?.ok) return 200;
|
|
226
|
-
if (result?.error ===
|
|
227
|
-
if (result?.error ===
|
|
565
|
+
if (result?.error === "pipeline_not_found") return 404;
|
|
566
|
+
if (result?.error === "hub_db_not_found") return 503;
|
|
567
|
+
return 400;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function resolveSendInputStatusCode(result) {
|
|
571
|
+
if (result?.ok) return 200;
|
|
572
|
+
const code = result?.error?.code;
|
|
573
|
+
if (code === "CONDUCTOR_REGISTRY_NOT_AVAILABLE") return 503;
|
|
574
|
+
if (code === "CONDUCTOR_SESSION_NOT_FOUND") return 404;
|
|
575
|
+
if (code === "SEND_INPUT_FAILED") return 409;
|
|
228
576
|
return 400;
|
|
229
577
|
}
|
|
230
578
|
|
|
579
|
+
function normalizeBridgePayload(payload) {
|
|
580
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
581
|
+
return {};
|
|
582
|
+
}
|
|
583
|
+
return payload;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function normalizeHandoffBody(body) {
|
|
587
|
+
const payload = normalizeBridgePayload(body?.payload);
|
|
588
|
+
return {
|
|
589
|
+
...payload,
|
|
590
|
+
...body,
|
|
591
|
+
from: body?.from,
|
|
592
|
+
to: body?.to,
|
|
593
|
+
payload,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function normalizePublishBody(body) {
|
|
598
|
+
const payload = normalizeBridgePayload(body?.payload);
|
|
599
|
+
const type = body?.type || body?.message_type || "event";
|
|
600
|
+
return {
|
|
601
|
+
...payload,
|
|
602
|
+
...body,
|
|
603
|
+
from: body?.from,
|
|
604
|
+
to: body?.to,
|
|
605
|
+
type,
|
|
606
|
+
message_type: type,
|
|
607
|
+
payload,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
231
611
|
function safeReadJsonFile(filePath) {
|
|
232
612
|
try {
|
|
233
613
|
if (!existsSync(filePath)) return null;
|
|
234
|
-
return JSON.parse(readFileSync(filePath,
|
|
614
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
235
615
|
} catch {
|
|
236
616
|
return null;
|
|
237
617
|
}
|
|
238
618
|
}
|
|
239
619
|
|
|
240
620
|
function parsePositiveInt(value, fallback) {
|
|
241
|
-
const parsed = Number.parseInt(String(value ??
|
|
621
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
242
622
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
243
623
|
}
|
|
244
624
|
|
|
@@ -246,7 +626,7 @@ function readRecentAimdEvents(now = Date.now()) {
|
|
|
246
626
|
try {
|
|
247
627
|
if (!existsSync(BATCH_EVENTS_PATH)) return [];
|
|
248
628
|
const cutoff = now - AIMD_WINDOW_MS;
|
|
249
|
-
return readFileSync(BATCH_EVENTS_PATH,
|
|
629
|
+
return readFileSync(BATCH_EVENTS_PATH, "utf8")
|
|
250
630
|
.split(/\r?\n/)
|
|
251
631
|
.filter(Boolean)
|
|
252
632
|
.map((line) => {
|
|
@@ -270,9 +650,9 @@ function calculateAimdBatchSize(events) {
|
|
|
270
650
|
|
|
271
651
|
for (const event of events) {
|
|
272
652
|
const result = event?.result;
|
|
273
|
-
if (result ===
|
|
653
|
+
if (result === "success" || result === "success_with_warnings") {
|
|
274
654
|
batchSize = Math.min(AIMD_MAX_BATCH_SIZE, batchSize + 1);
|
|
275
|
-
} else if (result ===
|
|
655
|
+
} else if (result === "failed" || result === "timeout") {
|
|
276
656
|
batchSize = Math.max(AIMD_MIN_BATCH_SIZE, batchSize * 0.5);
|
|
277
657
|
}
|
|
278
658
|
}
|
|
@@ -294,18 +674,107 @@ function getQosStatsPayload() {
|
|
|
294
674
|
};
|
|
295
675
|
}
|
|
296
676
|
|
|
677
|
+
export function sanitizeBrokerDiagnosticEvent(event = {}) {
|
|
678
|
+
const diagnostic = {
|
|
679
|
+
accountId: event.accountId ?? event.id,
|
|
680
|
+
provider: event.provider,
|
|
681
|
+
direction: event.direction,
|
|
682
|
+
reason: event.reason,
|
|
683
|
+
error: event.error,
|
|
684
|
+
};
|
|
685
|
+
return Object.fromEntries(
|
|
686
|
+
Object.entries(diagnostic).filter(([, value]) => value !== undefined),
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function attachBrokerDiagnostics(currentBroker, logger = hubLog) {
|
|
691
|
+
if (
|
|
692
|
+
!currentBroker ||
|
|
693
|
+
typeof currentBroker.on !== "function" ||
|
|
694
|
+
brokerDiagnosticsAttached.has(currentBroker)
|
|
695
|
+
) {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
currentBroker.on("securityViolation", (event) => {
|
|
700
|
+
logger.warn(
|
|
701
|
+
{ brokerEvent: sanitizeBrokerDiagnosticEvent(event) },
|
|
702
|
+
"broker.security_violation",
|
|
703
|
+
);
|
|
704
|
+
});
|
|
705
|
+
currentBroker.on("authSyncError", (event) => {
|
|
706
|
+
logger.warn(
|
|
707
|
+
{ brokerEvent: sanitizeBrokerDiagnosticEvent(event) },
|
|
708
|
+
"broker.auth_sync_error",
|
|
709
|
+
);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
brokerDiagnosticsAttached.add(currentBroker);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function syncBrokerAuthCache(currentBroker, logger = hubLog) {
|
|
717
|
+
if (
|
|
718
|
+
!currentBroker?.snapshot ||
|
|
719
|
+
typeof currentBroker.syncAuthFromSource !== "function"
|
|
720
|
+
) {
|
|
721
|
+
return [];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const authAccounts = currentBroker
|
|
725
|
+
.snapshot()
|
|
726
|
+
.filter(
|
|
727
|
+
(account) => account.provider === "codex" && account.mode === "auth",
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
return authAccounts.map((account) => {
|
|
731
|
+
try {
|
|
732
|
+
const result = currentBroker.syncAuthFromSource(account.id);
|
|
733
|
+
if (result?.copied) {
|
|
734
|
+
logger.info(
|
|
735
|
+
{
|
|
736
|
+
accountId: account.id,
|
|
737
|
+
reason: result.reason,
|
|
738
|
+
sourcePath: result.sourcePath,
|
|
739
|
+
cachePath: result.cachePath,
|
|
740
|
+
},
|
|
741
|
+
"broker.auth_sync_from_source",
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
return result;
|
|
745
|
+
} catch (error) {
|
|
746
|
+
logger.warn(
|
|
747
|
+
{ accountId: account.id, err: error?.message || String(error) },
|
|
748
|
+
"broker.auth_sync_from_source_failed",
|
|
749
|
+
);
|
|
750
|
+
return {
|
|
751
|
+
ok: false,
|
|
752
|
+
accountId: account.id,
|
|
753
|
+
reason: error?.message || String(error),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function getBrokerPublicSnapshot(currentBroker = brokerInstance) {
|
|
760
|
+
if (!currentBroker || typeof currentBroker.publicSnapshot !== "function") {
|
|
761
|
+
return [];
|
|
762
|
+
}
|
|
763
|
+
return currentBroker.publicSnapshot();
|
|
764
|
+
}
|
|
765
|
+
|
|
297
766
|
function resolvePublicFilePath(path) {
|
|
298
767
|
let relativePath = null;
|
|
299
|
-
if (path ===
|
|
300
|
-
relativePath =
|
|
301
|
-
} else if (path.startsWith(
|
|
302
|
-
relativePath = path.slice(
|
|
768
|
+
if (path === "/dashboard") {
|
|
769
|
+
relativePath = "dashboard.html";
|
|
770
|
+
} else if (path.startsWith("/public/")) {
|
|
771
|
+
relativePath = path.slice("/public/".length);
|
|
303
772
|
}
|
|
304
773
|
|
|
305
774
|
if (!relativePath) return null;
|
|
306
775
|
|
|
307
776
|
try {
|
|
308
|
-
relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/,
|
|
777
|
+
relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/, "");
|
|
309
778
|
} catch {
|
|
310
779
|
return null;
|
|
311
780
|
}
|
|
@@ -324,21 +793,23 @@ function servePublicFile(res, path) {
|
|
|
324
793
|
|
|
325
794
|
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
326
795
|
if (!existsSync(filePath)) {
|
|
327
|
-
hubLog.warn({ filePath },
|
|
796
|
+
hubLog.warn({ filePath }, "static.not_found");
|
|
328
797
|
res.writeHead(404);
|
|
329
|
-
res.end(
|
|
798
|
+
res.end("Not Found (static file missing)");
|
|
330
799
|
return true;
|
|
331
800
|
}
|
|
332
801
|
|
|
333
802
|
try {
|
|
334
803
|
const body = readFileSync(filePath);
|
|
335
804
|
res.writeHead(200, {
|
|
336
|
-
|
|
805
|
+
"Content-Type":
|
|
806
|
+
STATIC_CONTENT_TYPES[extname(filePath).toLowerCase()] ||
|
|
807
|
+
"application/octet-stream",
|
|
337
808
|
});
|
|
338
809
|
res.end(body);
|
|
339
810
|
} catch {
|
|
340
811
|
res.writeHead(404);
|
|
341
|
-
res.end(
|
|
812
|
+
res.end("Not Found");
|
|
342
813
|
}
|
|
343
814
|
return true;
|
|
344
815
|
}
|
|
@@ -355,16 +826,46 @@ function servePublicFile(res, path) {
|
|
|
355
826
|
export async function startHub({
|
|
356
827
|
port: portOpt,
|
|
357
828
|
dbPath,
|
|
358
|
-
host =
|
|
829
|
+
host = "127.0.0.1",
|
|
359
830
|
sessionId = process.pid,
|
|
360
831
|
createDelegatorWorker = createDelegatorMcpWorker,
|
|
361
832
|
} = {}) {
|
|
362
|
-
const
|
|
833
|
+
const resolvedPort = parseHubPort(portOpt);
|
|
834
|
+
const portSpecified = Number.isFinite(resolvedPort) && resolvedPort > 0;
|
|
835
|
+
const livePeer = detectLivePeer();
|
|
836
|
+
const livePidPort = parseHubPort(livePeer?.port);
|
|
837
|
+
const port = portSpecified
|
|
838
|
+
? resolvedPort
|
|
839
|
+
: resolveHubPort(process.env, {
|
|
840
|
+
detectPeer: () => livePeer,
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
if (portSpecified && livePeer.alive && livePidPort && port !== livePidPort) {
|
|
844
|
+
hubLog.warn(
|
|
845
|
+
{
|
|
846
|
+
requestedPort: port,
|
|
847
|
+
livePid: livePeer.pid,
|
|
848
|
+
livePidPort,
|
|
849
|
+
},
|
|
850
|
+
"hub.port_override_pid_mismatch",
|
|
851
|
+
);
|
|
852
|
+
}
|
|
363
853
|
|
|
364
|
-
const existingHub = await tryReuseExistingHub({
|
|
854
|
+
const existingHub = await tryReuseExistingHub({
|
|
855
|
+
port,
|
|
856
|
+
portSpecified,
|
|
857
|
+
host,
|
|
858
|
+
detectPeer: () => livePeer,
|
|
859
|
+
});
|
|
365
860
|
if (existingHub) return existingHub;
|
|
366
861
|
|
|
367
|
-
|
|
862
|
+
attachBrokerDiagnostics(brokerInstance);
|
|
863
|
+
syncBrokerAuthCache(brokerInstance);
|
|
864
|
+
|
|
865
|
+
const hubIdleTimeoutMs = parsePositiveInt(
|
|
866
|
+
process.env.TFX_HUB_IDLE_TIMEOUT_MS,
|
|
867
|
+
HUB_IDLE_TIMEOUT_DEFAULT_MS,
|
|
868
|
+
);
|
|
368
869
|
const hubIdleSweepMs = parsePositiveInt(
|
|
369
870
|
process.env.TFX_HUB_IDLE_SWEEP_MS,
|
|
370
871
|
Math.min(HUB_IDLE_SWEEP_DEFAULT_MS, hubIdleTimeoutMs),
|
|
@@ -378,9 +879,9 @@ export async function startHub({
|
|
|
378
879
|
// DB를 npm 패키지 밖에 저장하여 npm update 시 EBUSY 방지
|
|
379
880
|
// 기존: PROJECT_ROOT/.tfx/state/state.db (패키지 내부 → 락 충돌)
|
|
380
881
|
// 변경: ~/.claude/cache/tfx-hub/state.db (패키지 외부 → 안전)
|
|
381
|
-
const hubCacheDir = join(homedir(),
|
|
882
|
+
const hubCacheDir = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
382
883
|
mkdirSync(hubCacheDir, { recursive: true });
|
|
383
|
-
dbPath = join(hubCacheDir,
|
|
884
|
+
dbPath = join(hubCacheDir, "state.db");
|
|
384
885
|
}
|
|
385
886
|
|
|
386
887
|
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
@@ -396,7 +897,12 @@ export async function startHub({
|
|
|
396
897
|
lockHeld = false;
|
|
397
898
|
};
|
|
398
899
|
|
|
399
|
-
const lockedExistingHub = await tryReuseExistingHub({
|
|
900
|
+
const lockedExistingHub = await tryReuseExistingHub({
|
|
901
|
+
port,
|
|
902
|
+
portSpecified,
|
|
903
|
+
host,
|
|
904
|
+
detectPeer: () => detectLivePeer(),
|
|
905
|
+
});
|
|
400
906
|
if (lockedExistingHub) {
|
|
401
907
|
releaseStartupLock();
|
|
402
908
|
return lockedExistingHub;
|
|
@@ -404,10 +910,12 @@ export async function startHub({
|
|
|
404
910
|
|
|
405
911
|
const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
|
|
406
912
|
if (HUB_TOKEN) {
|
|
407
|
-
mkdirSync(join(homedir(),
|
|
913
|
+
mkdirSync(join(homedir(), ".claude"), { recursive: true });
|
|
408
914
|
writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
|
|
409
915
|
} else {
|
|
410
|
-
try {
|
|
916
|
+
try {
|
|
917
|
+
unlinkSync(TOKEN_FILE);
|
|
918
|
+
} catch {}
|
|
411
919
|
}
|
|
412
920
|
|
|
413
921
|
const store = await createStoreAdapter(dbPath);
|
|
@@ -415,9 +923,51 @@ export async function startHub({
|
|
|
415
923
|
const fingerprintService = createAdaptiveFingerprintService({ store });
|
|
416
924
|
|
|
417
925
|
// Neural Memory adaptive engine 초기화
|
|
418
|
-
const adaptiveEngine = createAdaptiveEngine({
|
|
926
|
+
const adaptiveEngine = createAdaptiveEngine({
|
|
927
|
+
repoRoot: PROJECT_ROOT,
|
|
928
|
+
fingerprintService,
|
|
929
|
+
});
|
|
419
930
|
adaptiveEngine.startSession();
|
|
420
931
|
|
|
932
|
+
// safety-guard → reflexion 패널티 승격 + 적응형 규칙 유지보수
|
|
933
|
+
const projectSlug = PROJECT_ROOT.split(/[\\/]/).pop();
|
|
934
|
+
try {
|
|
935
|
+
const { promotePenalties } = await import("./promote-penalties.mjs");
|
|
936
|
+
const result = promotePenalties(store, { projectSlug });
|
|
937
|
+
if (result.promoted > 0) {
|
|
938
|
+
console.log(
|
|
939
|
+
`[reflexion] ${result.promoted} penalties promoted to adaptive rules`,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
} catch {
|
|
943
|
+
/* promote-penalties 실패는 Hub 시작을 막지 않음 */
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// stale adaptive_rules 정리 (30일 초과 + confidence 0.2 미만)
|
|
947
|
+
try {
|
|
948
|
+
const pruned = store.pruneStaleRules();
|
|
949
|
+
if (pruned > 0)
|
|
950
|
+
console.log(`[reflexion] ${pruned} stale adaptive rules pruned`);
|
|
951
|
+
} catch {
|
|
952
|
+
/* prune 실패 무시 */
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// adaptive rule confidence decay (7일 이상 미관측 규칙 -0.1 감소)
|
|
956
|
+
try {
|
|
957
|
+
const { decayRules } = await import("@triflux/core/hub/reflexion.mjs");
|
|
958
|
+
const decay = decayRules(
|
|
959
|
+
store,
|
|
960
|
+
adaptiveEngine.sessionCount?.() || 1,
|
|
961
|
+
projectSlug,
|
|
962
|
+
);
|
|
963
|
+
if (decay.deleted.length > 0)
|
|
964
|
+
console.log(
|
|
965
|
+
`[reflexion] ${decay.deleted.length} low-confidence rules removed`,
|
|
966
|
+
);
|
|
967
|
+
} catch {
|
|
968
|
+
/* decay 실패 무시 */
|
|
969
|
+
}
|
|
970
|
+
|
|
421
971
|
// Delegator MCP resident service 초기화
|
|
422
972
|
const delegatorWorker = createDelegatorWorker({ cwd: PROJECT_ROOT });
|
|
423
973
|
try {
|
|
@@ -428,518 +978,997 @@ export async function startHub({
|
|
|
428
978
|
}
|
|
429
979
|
const delegatorService = new DelegatorService({ worker: delegatorWorker });
|
|
430
980
|
|
|
431
|
-
|
|
432
|
-
const
|
|
981
|
+
// Synapse Layer 4: session registry + git preflight + swarm locks
|
|
982
|
+
const synapseEmitter = new EventEmitter();
|
|
983
|
+
synapseEmitter.setMaxListeners(50);
|
|
984
|
+
const synapseRegistry = createSynapseRegistry({
|
|
985
|
+
persistPath: join(CACHE_DIR, "tfx-hub", "synapse-sessions.json"),
|
|
986
|
+
emitter: synapseEmitter,
|
|
987
|
+
});
|
|
988
|
+
const swarmLocks = createSwarmLocks({
|
|
989
|
+
repoRoot: PROJECT_ROOT,
|
|
990
|
+
persistPath: join(CACHE_DIR, "tfx-hub", "swarm-locks.json"),
|
|
991
|
+
});
|
|
992
|
+
const gitPreflight = createGitPreflight({
|
|
993
|
+
registry: synapseRegistry,
|
|
994
|
+
locks: swarmLocks,
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// Synapse Layer 5: emitter subscribers — bridge events to hub logging
|
|
998
|
+
synapseEmitter.on("synapse.session.started", ({ sessionId }) => {
|
|
999
|
+
hubLog.info({ sessionId }, "synapse.session.started");
|
|
1000
|
+
});
|
|
1001
|
+
synapseEmitter.on("synapse.session.heartbeat", ({ sessionId }) => {
|
|
1002
|
+
hubLog.debug({ sessionId }, "synapse.session.heartbeat");
|
|
1003
|
+
});
|
|
1004
|
+
synapseEmitter.on("synapse.session.stale", ({ sessionId }) => {
|
|
1005
|
+
hubLog.warn({ sessionId }, "synapse.session.stale");
|
|
1006
|
+
});
|
|
1007
|
+
synapseEmitter.on("synapse.session.removed", ({ sessionId }) => {
|
|
1008
|
+
hubLog.info({ sessionId }, "synapse.session.removed");
|
|
1009
|
+
});
|
|
1010
|
+
|
|
433
1011
|
const hitl = createHitlManager(store, router);
|
|
1012
|
+
const pipe = createPipeServer({
|
|
1013
|
+
router,
|
|
1014
|
+
store,
|
|
1015
|
+
sessionId,
|
|
1016
|
+
delegatorService,
|
|
1017
|
+
hitlManager: hitl,
|
|
1018
|
+
onActivity: markRequestActivity,
|
|
1019
|
+
});
|
|
1020
|
+
const assignCallbacks = createAssignCallbackServer({ store, sessionId });
|
|
434
1021
|
const tools = createTools(store, router, hitl, pipe);
|
|
435
1022
|
const transports = new Map();
|
|
1023
|
+
const workerSpans = new Map();
|
|
1024
|
+
|
|
1025
|
+
async function closeMcpTransportSession(sid, session, reason) {
|
|
1026
|
+
if (!sid) return;
|
|
1027
|
+
if (!session) {
|
|
1028
|
+
transports.delete(sid);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (session.closing) {
|
|
1032
|
+
transports.delete(sid);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
session.closing = true;
|
|
1037
|
+
transports.delete(sid);
|
|
1038
|
+
|
|
1039
|
+
try {
|
|
1040
|
+
await session.mcp.close();
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
hubLog.debug(
|
|
1043
|
+
{ sid, reason, err: String(error?.message || error) },
|
|
1044
|
+
"hub.mcp_session_close_mcp_failed",
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
await session.transport.close();
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
hubLog.debug(
|
|
1051
|
+
{ sid, reason, err: String(error?.message || error) },
|
|
1052
|
+
"hub.mcp_session_close_transport_failed",
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function getTraceClient(clientRef) {
|
|
1058
|
+
return clientRef?.client || "unknown";
|
|
1059
|
+
}
|
|
436
1060
|
|
|
437
|
-
function
|
|
1061
|
+
function finishWorkerTrace(workerId, kind) {
|
|
1062
|
+
if (!workerId || typeof workerId !== "string") return;
|
|
1063
|
+
const span = workerSpans.get(workerId);
|
|
1064
|
+
if (!span) return;
|
|
1065
|
+
workerSpans.delete(workerId);
|
|
1066
|
+
recordWorker(
|
|
1067
|
+
workerId,
|
|
1068
|
+
"exited",
|
|
1069
|
+
span.command || kind,
|
|
1070
|
+
performance.now() - span.startedAt,
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function createMcpForSession(clientRef = { client: "unknown" }) {
|
|
438
1075
|
const mcp = new Server(
|
|
439
|
-
{ name:
|
|
1076
|
+
{ name: "tfx-hub", version: "1.0.0" },
|
|
440
1077
|
{ capabilities: { tools: {} } },
|
|
441
1078
|
);
|
|
442
1079
|
|
|
443
|
-
mcp.setRequestHandler(
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1080
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1081
|
+
const started = performance.now();
|
|
1082
|
+
try {
|
|
1083
|
+
return {
|
|
1084
|
+
tools: tools.map((tool) => ({
|
|
1085
|
+
name: tool.name,
|
|
1086
|
+
description: tool.description,
|
|
1087
|
+
inputSchema: tool.inputSchema,
|
|
1088
|
+
})),
|
|
1089
|
+
};
|
|
1090
|
+
} finally {
|
|
1091
|
+
recordRequest(
|
|
1092
|
+
"tools.list",
|
|
1093
|
+
performance.now() - started,
|
|
1094
|
+
getTraceClient(clientRef),
|
|
1095
|
+
"handler",
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
453
1099
|
|
|
454
|
-
mcp.setRequestHandler(
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
1100
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1101
|
+
const { name, arguments: args } = request.params;
|
|
1102
|
+
const category = `tools.call.${name || "unknown"}`;
|
|
1103
|
+
const started = performance.now();
|
|
1104
|
+
try {
|
|
458
1105
|
const tool = tools.find((candidate) => candidate.name === name);
|
|
459
1106
|
if (!tool) {
|
|
460
1107
|
return {
|
|
461
|
-
content: [
|
|
1108
|
+
content: [
|
|
1109
|
+
{
|
|
1110
|
+
type: "text",
|
|
1111
|
+
text: JSON.stringify({
|
|
1112
|
+
ok: false,
|
|
1113
|
+
error: {
|
|
1114
|
+
code: "UNKNOWN_TOOL",
|
|
1115
|
+
message: `도구 없음: ${name}`,
|
|
1116
|
+
},
|
|
1117
|
+
}),
|
|
1118
|
+
},
|
|
1119
|
+
],
|
|
462
1120
|
isError: true,
|
|
463
1121
|
};
|
|
464
1122
|
}
|
|
465
1123
|
return tool.handler(args || {});
|
|
466
|
-
}
|
|
467
|
-
|
|
1124
|
+
} finally {
|
|
1125
|
+
recordRequest(
|
|
1126
|
+
category,
|
|
1127
|
+
performance.now() - started,
|
|
1128
|
+
getTraceClient(clientRef),
|
|
1129
|
+
"handler",
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
468
1133
|
|
|
469
1134
|
return mcp;
|
|
470
1135
|
}
|
|
471
1136
|
|
|
472
|
-
const httpServer = createHttpServer(
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
1137
|
+
const httpServer = createHttpServer(
|
|
1138
|
+
wrapRequestHandler(async (req, res) => {
|
|
1139
|
+
markRequestActivity();
|
|
1140
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1141
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
1142
|
+
const path = getRequestPath(req.url);
|
|
1143
|
+
const corsAllowed = applyCorsHeaders(req, res);
|
|
1144
|
+
|
|
1145
|
+
if (req.method === "OPTIONS") {
|
|
1146
|
+
const localOnlyMode = !HUB_TOKEN;
|
|
1147
|
+
const isLoopbackRequest = isLoopbackRemoteAddress(
|
|
1148
|
+
req.socket.remoteAddress,
|
|
1149
|
+
);
|
|
1150
|
+
res.writeHead(
|
|
1151
|
+
corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403,
|
|
1152
|
+
);
|
|
1153
|
+
return res.end();
|
|
1154
|
+
}
|
|
485
1155
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
1156
|
+
const clientIp = req.socket.remoteAddress || "unknown";
|
|
1157
|
+
if (!isLoopbackRemoteAddress(clientIp)) {
|
|
1158
|
+
const rateCheck = checkRateLimit(clientIp);
|
|
1159
|
+
if (!rateCheck.allowed) {
|
|
1160
|
+
return writeJson(
|
|
1161
|
+
res,
|
|
1162
|
+
429,
|
|
1163
|
+
{ ok: false, error: "Too Many Requests" },
|
|
1164
|
+
{ "Retry-After": String(rateCheck.retryAfterSec) },
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
|
|
1170
|
+
if (!HUB_TOKEN) {
|
|
1171
|
+
return writeJson(res, 403, {
|
|
1172
|
+
ok: false,
|
|
1173
|
+
error: "Forbidden: localhost only",
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
490
1176
|
return writeJson(
|
|
491
1177
|
res,
|
|
492
|
-
|
|
493
|
-
{ ok: false, error:
|
|
494
|
-
{ '
|
|
1178
|
+
401,
|
|
1179
|
+
{ ok: false, error: "Unauthorized" },
|
|
1180
|
+
{ "WWW-Authenticate": 'Bearer realm="tfx-hub"' },
|
|
495
1181
|
);
|
|
496
1182
|
}
|
|
497
|
-
}
|
|
498
1183
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
1184
|
+
if (path === "/" || path === "/status") {
|
|
1185
|
+
const status = router.getStatus("hub").data;
|
|
1186
|
+
const includeMetrics =
|
|
1187
|
+
new URL(req.url, `http://${host}`).searchParams.get(
|
|
1188
|
+
"include_metrics",
|
|
1189
|
+
) === "1";
|
|
1190
|
+
const body = {
|
|
1191
|
+
...status,
|
|
1192
|
+
sessions: transports.size,
|
|
1193
|
+
pid: process.pid,
|
|
1194
|
+
port,
|
|
1195
|
+
auth_mode: HUB_TOKEN ? "token-required" : "localhost-only",
|
|
1196
|
+
idle_timeout_ms: hubIdleTimeoutMs,
|
|
1197
|
+
last_request_at: new Date(lastRequestAt).toISOString(),
|
|
1198
|
+
pipe_path: pipe.path,
|
|
1199
|
+
pipe: pipe.getStatus(),
|
|
1200
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
1201
|
+
assign_callback_pipe: assignCallbacks.getStatus(),
|
|
1202
|
+
spawn_trace: {
|
|
1203
|
+
max_per_sec: spawnTrace.getMaxSpawnPerSec(),
|
|
1204
|
+
max_total_descendants: spawnTrace.MAX_TOTAL_DESCENDANTS,
|
|
1205
|
+
},
|
|
1206
|
+
version,
|
|
1207
|
+
};
|
|
1208
|
+
if (includeMetrics) {
|
|
1209
|
+
body.metrics = traceSnapshot();
|
|
1210
|
+
}
|
|
1211
|
+
return writeJson(res, 200, body);
|
|
502
1212
|
}
|
|
503
|
-
return writeJson(
|
|
504
|
-
res,
|
|
505
|
-
401,
|
|
506
|
-
{ ok: false, error: 'Unauthorized' },
|
|
507
|
-
{ 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
1213
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
1214
|
+
if (path === "/health" || path === "/healthz") {
|
|
1215
|
+
const status = router.getStatus("hub").data;
|
|
1216
|
+
const healthy = status?.hub?.state === "healthy";
|
|
1217
|
+
return writeJson(res, healthy ? 200 : 503, {
|
|
1218
|
+
ok: healthy,
|
|
1219
|
+
version,
|
|
1220
|
+
platform: process.platform,
|
|
1221
|
+
uptime_s: Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)),
|
|
1222
|
+
node: process.version,
|
|
1223
|
+
sessions: transports.size,
|
|
1224
|
+
store: store.type || "sqlite",
|
|
1225
|
+
idle_timeout_ms: hubIdleTimeoutMs,
|
|
1226
|
+
idle_ms: Math.max(0, Date.now() - lastRequestAt),
|
|
1227
|
+
fingerprint: fingerprintService.getHealth(),
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
527
1230
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
return writeJson(res, healthy ? 200 : 503, {
|
|
532
|
-
ok: healthy,
|
|
533
|
-
version,
|
|
534
|
-
platform: process.platform,
|
|
535
|
-
uptime_s: Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)),
|
|
536
|
-
node: process.version,
|
|
537
|
-
sessions: transports.size,
|
|
538
|
-
store: store.type || 'sqlite',
|
|
539
|
-
idle_timeout_ms: hubIdleTimeoutMs,
|
|
540
|
-
idle_ms: Math.max(0, Date.now() - lastRequestAt),
|
|
541
|
-
fingerprint: fingerprintService.getHealth(),
|
|
542
|
-
});
|
|
543
|
-
}
|
|
1231
|
+
if (path === "/api/qos-stats" && req.method === "GET") {
|
|
1232
|
+
return writeJson(res, 200, getQosStatsPayload());
|
|
1233
|
+
}
|
|
544
1234
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1235
|
+
if (path === "/broker/snapshot" && req.method === "GET") {
|
|
1236
|
+
const snap = getBrokerPublicSnapshot(brokerInstance);
|
|
1237
|
+
return writeJson(res, 200, {
|
|
1238
|
+
ok: true,
|
|
1239
|
+
accounts: snap,
|
|
1240
|
+
ts: Date.now(),
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
548
1243
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
return
|
|
1244
|
+
if (path === "/broker/dashboard" && req.method === "GET") {
|
|
1245
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1246
|
+
res.end(renderBrokerDashboard());
|
|
1247
|
+
return;
|
|
553
1248
|
}
|
|
554
1249
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1250
|
+
if (
|
|
1251
|
+
path === "/broker/quota-refresh" &&
|
|
1252
|
+
(req.method === "POST" || req.method === "GET")
|
|
1253
|
+
) {
|
|
1254
|
+
try {
|
|
1255
|
+
const results = await refreshAllAccountQuotas();
|
|
1256
|
+
logQuotaRefreshFailures(hubLog, results);
|
|
1257
|
+
return writeJson(res, 200, { ok: true, results, ts: Date.now() });
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
hubLog.error(
|
|
1260
|
+
{ err: String(err?.message || err) },
|
|
1261
|
+
"broker.quota_refresh_error",
|
|
1262
|
+
);
|
|
1263
|
+
return writeJson(res, 200, {
|
|
1264
|
+
ok: false,
|
|
1265
|
+
error: String(err?.message || err),
|
|
569
1266
|
});
|
|
570
|
-
return writeJson(res, 200, result);
|
|
571
1267
|
}
|
|
1268
|
+
}
|
|
572
1269
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
|
|
580
|
-
const result = await pipe.executeCommand('register', {
|
|
581
|
-
agent_id,
|
|
582
|
-
cli,
|
|
583
|
-
capabilities,
|
|
584
|
-
topics,
|
|
585
|
-
heartbeat_ttl_ms,
|
|
586
|
-
metadata,
|
|
587
|
-
});
|
|
588
|
-
return writeJson(res, 200, result);
|
|
1270
|
+
if (path === "/broker/reload" && req.method === "POST") {
|
|
1271
|
+
const result = reloadBroker();
|
|
1272
|
+
if (!result.ok) {
|
|
1273
|
+
return writeJson(res, 200, { ok: false, error: result.error });
|
|
589
1274
|
}
|
|
1275
|
+
attachBrokerDiagnostics(result.broker);
|
|
1276
|
+
syncBrokerAuthCache(result.broker);
|
|
1277
|
+
const accounts = getBrokerPublicSnapshot(result.broker).length;
|
|
1278
|
+
return writeJson(res, 200, { ok: true, accounts });
|
|
1279
|
+
}
|
|
590
1280
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1281
|
+
if (path === "/spawn-trace/reload" && req.method === "POST") {
|
|
1282
|
+
return writeJson(res, 200, {
|
|
1283
|
+
ok: true,
|
|
1284
|
+
max_spawn_per_sec: spawnTrace.reload(),
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
596
1287
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
1288
|
+
// ── Synapse Layer 5: session registry + locks + preflight routes ──
|
|
1289
|
+
if (path === "/synapse/sessions" && req.method === "GET") {
|
|
1290
|
+
return writeJson(res, 200, {
|
|
1291
|
+
ok: true,
|
|
1292
|
+
...synapseRegistry.snapshot(),
|
|
1293
|
+
ts: Date.now(),
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
606
1296
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
trace_id,
|
|
615
|
-
correlation_id,
|
|
616
|
-
ttl_ms = 3600000,
|
|
617
|
-
} = body;
|
|
618
|
-
|
|
619
|
-
if (!to_agent || !command) {
|
|
620
|
-
return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
|
|
1297
|
+
if (path === "/synapse/register" && req.method === "POST") {
|
|
1298
|
+
try {
|
|
1299
|
+
const body = await parseBody(req);
|
|
1300
|
+
const { sessionId } = body || {};
|
|
1301
|
+
const result = synapseRegistry.register(sessionId, body);
|
|
1302
|
+
if (!result?.ok) {
|
|
1303
|
+
throw new Error(result?.reason || "register failed");
|
|
621
1304
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
trace_id,
|
|
631
|
-
correlation_id,
|
|
1305
|
+
return writeJson(res, 200, {
|
|
1306
|
+
ok: true,
|
|
1307
|
+
sessionId: result.sessionId || sessionId,
|
|
1308
|
+
});
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
return writeJson(res, 400, {
|
|
1311
|
+
ok: false,
|
|
1312
|
+
error: String(err?.message || err),
|
|
632
1313
|
});
|
|
633
|
-
|
|
634
|
-
return writeJson(res, 200, result);
|
|
635
1314
|
}
|
|
1315
|
+
}
|
|
636
1316
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
} = body;
|
|
645
|
-
|
|
646
|
-
if (agent_id && status) {
|
|
647
|
-
const normalizedAgentId = String(agent_id || '').trim();
|
|
648
|
-
const normalizedStatus = String(status || '').trim().toLowerCase();
|
|
649
|
-
if (!normalizedAgentId || !normalizedStatus) {
|
|
650
|
-
return writeJson(res, 400, { ok: false, error: 'agent_id, status 필수' });
|
|
651
|
-
}
|
|
652
|
-
const statusForStore = new Set(['online', 'stale', 'offline']).has(normalizedStatus)
|
|
653
|
-
? normalizedStatus
|
|
654
|
-
: 'online';
|
|
655
|
-
router.updateAgentStatus(normalizedAgentId, statusForStore);
|
|
656
|
-
const snapshot = await pipe.executeQuery('status', {
|
|
657
|
-
scope: 'agent',
|
|
658
|
-
agent_id: normalizedAgentId,
|
|
659
|
-
include_metrics: false,
|
|
660
|
-
});
|
|
661
|
-
return writeJson(res, 200, {
|
|
662
|
-
ok: true,
|
|
663
|
-
data: {
|
|
664
|
-
agent_id: normalizedAgentId,
|
|
665
|
-
status: statusForStore,
|
|
666
|
-
reported_status: normalizedStatus,
|
|
667
|
-
reported_at_ms: Date.now(),
|
|
668
|
-
snapshot: snapshot?.data?.agent || null,
|
|
669
|
-
},
|
|
670
|
-
});
|
|
1317
|
+
if (path === "/synapse/heartbeat" && req.method === "POST") {
|
|
1318
|
+
try {
|
|
1319
|
+
const body = await parseBody(req);
|
|
1320
|
+
const { sessionId, partial } = body || {};
|
|
1321
|
+
const ok = synapseRegistry.heartbeat(sessionId, partial);
|
|
1322
|
+
if (!ok) {
|
|
1323
|
+
throw new Error("heartbeat failed");
|
|
671
1324
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
trace_id,
|
|
1325
|
+
return writeJson(res, 200, { ok: true });
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
return writeJson(res, 400, {
|
|
1328
|
+
ok: false,
|
|
1329
|
+
error: String(err?.message || err),
|
|
678
1330
|
});
|
|
679
|
-
return writeJson(res, 200, result);
|
|
680
1331
|
}
|
|
1332
|
+
}
|
|
681
1333
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
priority = 5,
|
|
690
|
-
ttl_ms = 600000,
|
|
691
|
-
timeout_ms = 600000,
|
|
692
|
-
max_retries = 0,
|
|
693
|
-
trace_id,
|
|
694
|
-
correlation_id,
|
|
695
|
-
} = body;
|
|
696
|
-
|
|
697
|
-
if (!supervisor_agent || !worker_agent || !task) {
|
|
698
|
-
return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
|
|
1334
|
+
if (path === "/synapse/unregister" && req.method === "POST") {
|
|
1335
|
+
try {
|
|
1336
|
+
const body = await parseBody(req);
|
|
1337
|
+
const { sessionId } = body || {};
|
|
1338
|
+
const ok = synapseRegistry.unregister(sessionId);
|
|
1339
|
+
if (!ok) {
|
|
1340
|
+
throw new Error("unregister failed");
|
|
699
1341
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
topic,
|
|
706
|
-
payload,
|
|
707
|
-
priority,
|
|
708
|
-
ttl_ms,
|
|
709
|
-
timeout_ms,
|
|
710
|
-
max_retries,
|
|
711
|
-
trace_id,
|
|
712
|
-
correlation_id,
|
|
1342
|
+
return writeJson(res, 200, { ok: true });
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
return writeJson(res, 400, {
|
|
1345
|
+
ok: false,
|
|
1346
|
+
error: String(err?.message || err),
|
|
713
1347
|
});
|
|
714
|
-
return writeJson(res, result.ok ? 200 : 400, result);
|
|
715
1348
|
}
|
|
1349
|
+
}
|
|
716
1350
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
error: assignError,
|
|
725
|
-
payload = {},
|
|
726
|
-
metadata = {},
|
|
727
|
-
} = body;
|
|
728
|
-
|
|
729
|
-
if (!job_id || !status) {
|
|
730
|
-
return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
|
|
731
|
-
}
|
|
1351
|
+
if (path === "/synapse/locks" && req.method === "GET") {
|
|
1352
|
+
return writeJson(res, 200, {
|
|
1353
|
+
ok: true,
|
|
1354
|
+
locks: swarmLocks.snapshot(),
|
|
1355
|
+
ts: Date.now(),
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
732
1358
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1359
|
+
if (path === "/synapse/preflight" && req.method === "POST") {
|
|
1360
|
+
try {
|
|
1361
|
+
const body = await parseBody(req);
|
|
1362
|
+
const { op, args = {}, sessionContext = {} } = body;
|
|
1363
|
+
if (!op || typeof op !== "string") {
|
|
1364
|
+
return writeJson(res, 400, { ok: false, error: "op 필수" });
|
|
1365
|
+
}
|
|
1366
|
+
if (!SYNAPSE_VALID_OPS.has(op)) {
|
|
1367
|
+
return writeJson(res, 400, {
|
|
1368
|
+
ok: false,
|
|
1369
|
+
error: `invalid op: ${op}`,
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
const result = gitPreflight.check(op, args, sessionContext);
|
|
1373
|
+
return writeJson(res, 200, { ok: true, ...result });
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
return writeJson(res, 400, {
|
|
1376
|
+
ok: false,
|
|
1377
|
+
error: String(err?.message || err),
|
|
742
1378
|
});
|
|
743
|
-
return writeJson(res, result.ok ? 200 : 409, result);
|
|
744
1379
|
}
|
|
1380
|
+
}
|
|
745
1381
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1382
|
+
if (path.startsWith("/bridge")) {
|
|
1383
|
+
const isBridgeStatusGet =
|
|
1384
|
+
path === "/bridge/status" && req.method === "GET";
|
|
1385
|
+
const isBridgeHitlPendingGet =
|
|
1386
|
+
path === "/bridge/hitl/pending" && req.method === "GET";
|
|
1387
|
+
if (
|
|
1388
|
+
req.method !== "POST" &&
|
|
1389
|
+
req.method !== "DELETE" &&
|
|
1390
|
+
!isBridgeStatusGet &&
|
|
1391
|
+
!isBridgeHitlPendingGet
|
|
1392
|
+
) {
|
|
1393
|
+
return writeJson(res, 405, {
|
|
1394
|
+
ok: false,
|
|
1395
|
+
error: "Method Not Allowed",
|
|
1396
|
+
});
|
|
750
1397
|
}
|
|
751
1398
|
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
1399
|
+
try {
|
|
1400
|
+
const body = req.method === "POST" ? await parseBody(req) : {};
|
|
1401
|
+
const requestUrl = new URL(req.url || path, "http://127.0.0.1");
|
|
1402
|
+
|
|
1403
|
+
if (path === "/bridge/status" && req.method === "GET") {
|
|
1404
|
+
const scope = requestUrl.searchParams.get("scope") || "hub";
|
|
1405
|
+
const include_metrics =
|
|
1406
|
+
requestUrl.searchParams.get("include_metrics") !== "0";
|
|
1407
|
+
const agent_id =
|
|
1408
|
+
requestUrl.searchParams.get("agent_id") || undefined;
|
|
1409
|
+
const trace_id =
|
|
1410
|
+
requestUrl.searchParams.get("trace_id") || undefined;
|
|
1411
|
+
const result = await pipe.executeQuery("status", {
|
|
1412
|
+
scope,
|
|
1413
|
+
include_metrics,
|
|
1414
|
+
agent_id,
|
|
1415
|
+
trace_id,
|
|
1416
|
+
});
|
|
1417
|
+
return writeJson(res, 200, result);
|
|
756
1418
|
}
|
|
757
1419
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1420
|
+
if (path === "/bridge/hitl/pending" && req.method === "GET") {
|
|
1421
|
+
const result = { ok: true, data: hitl.getPendingRequests() };
|
|
1422
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (path === "/bridge/register" && req.method === "POST") {
|
|
1426
|
+
const {
|
|
1427
|
+
agent_id,
|
|
1428
|
+
cli,
|
|
1429
|
+
timeout_sec = 600,
|
|
1430
|
+
topics = [],
|
|
1431
|
+
capabilities = [],
|
|
1432
|
+
metadata = {},
|
|
1433
|
+
} = body;
|
|
1434
|
+
if (!agent_id || !cli) {
|
|
1435
|
+
return writeJson(res, 400, {
|
|
1436
|
+
ok: false,
|
|
1437
|
+
error: "agent_id, cli 필수",
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
769
1440
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1441
|
+
const command =
|
|
1442
|
+
body.command ||
|
|
1443
|
+
metadata?.command ||
|
|
1444
|
+
metadata?.cmd ||
|
|
1445
|
+
metadata?.task ||
|
|
1446
|
+
cli;
|
|
1447
|
+
const workerStartedAt = performance.now();
|
|
1448
|
+
const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
|
|
1449
|
+
const result = await pipe.executeCommand("register", {
|
|
1450
|
+
agent_id,
|
|
1451
|
+
cli,
|
|
1452
|
+
capabilities,
|
|
1453
|
+
topics,
|
|
1454
|
+
heartbeat_ttl_ms,
|
|
1455
|
+
metadata,
|
|
1456
|
+
});
|
|
1457
|
+
if (result.ok) {
|
|
1458
|
+
workerSpans.set(agent_id, {
|
|
1459
|
+
command,
|
|
1460
|
+
startedAt: workerStartedAt,
|
|
1461
|
+
});
|
|
1462
|
+
recordWorker(agent_id, "spawned", command, 0);
|
|
1463
|
+
}
|
|
1464
|
+
return writeJson(res, 200, result);
|
|
780
1465
|
}
|
|
781
1466
|
|
|
782
|
-
if (
|
|
783
|
-
|
|
1467
|
+
if (path === "/bridge/result" && req.method === "POST") {
|
|
1468
|
+
const {
|
|
1469
|
+
agent_id,
|
|
1470
|
+
topic = "task.result",
|
|
1471
|
+
payload = {},
|
|
1472
|
+
trace_id,
|
|
1473
|
+
correlation_id,
|
|
1474
|
+
} = body;
|
|
1475
|
+
if (!agent_id) {
|
|
1476
|
+
return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const result = await pipe.executeCommand("result", {
|
|
1480
|
+
agent_id,
|
|
1481
|
+
topic,
|
|
1482
|
+
payload,
|
|
1483
|
+
trace_id,
|
|
1484
|
+
correlation_id,
|
|
1485
|
+
});
|
|
1486
|
+
return writeJson(res, 200, result);
|
|
784
1487
|
}
|
|
785
1488
|
|
|
786
|
-
if (path
|
|
787
|
-
|
|
1489
|
+
if (path === "/bridge/control" && req.method === "POST") {
|
|
1490
|
+
const {
|
|
1491
|
+
from_agent = "lead",
|
|
1492
|
+
to_agent,
|
|
1493
|
+
command,
|
|
1494
|
+
reason = "",
|
|
1495
|
+
payload = {},
|
|
1496
|
+
trace_id,
|
|
1497
|
+
correlation_id,
|
|
1498
|
+
ttl_ms = 3600000,
|
|
1499
|
+
} = body;
|
|
1500
|
+
|
|
1501
|
+
if (!to_agent || !command) {
|
|
1502
|
+
return writeJson(res, 400, {
|
|
1503
|
+
ok: false,
|
|
1504
|
+
error: "to_agent, command 필수",
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const result = await pipe.executeCommand("control", {
|
|
1509
|
+
from_agent,
|
|
1510
|
+
to_agent,
|
|
1511
|
+
command,
|
|
1512
|
+
reason,
|
|
1513
|
+
payload,
|
|
1514
|
+
ttl_ms,
|
|
1515
|
+
trace_id,
|
|
1516
|
+
correlation_id,
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
return writeJson(res, 200, result);
|
|
788
1520
|
}
|
|
789
1521
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1522
|
+
if (path === "/bridge/handoff" && req.method === "POST") {
|
|
1523
|
+
const result = router.handleHandoff(normalizeHandoffBody(body));
|
|
1524
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
794
1525
|
}
|
|
795
1526
|
|
|
796
|
-
if (path ===
|
|
797
|
-
const
|
|
798
|
-
|
|
1527
|
+
if (path === "/bridge/publish" && req.method === "POST") {
|
|
1528
|
+
const normalized = normalizePublishBody(body);
|
|
1529
|
+
const result = router.handlePublish(normalized);
|
|
1530
|
+
if (result.ok) {
|
|
1531
|
+
finishWorkerTrace(
|
|
1532
|
+
normalized.from ||
|
|
1533
|
+
normalized.agent_id ||
|
|
1534
|
+
normalized.worker_agent ||
|
|
1535
|
+
normalized.from_agent,
|
|
1536
|
+
"publish",
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
799
1540
|
}
|
|
800
1541
|
|
|
801
|
-
if (path ===
|
|
802
|
-
const
|
|
803
|
-
|
|
1542
|
+
if (path === "/bridge/send-input" && req.method === "POST") {
|
|
1543
|
+
const { session_id, text } = body;
|
|
1544
|
+
if (!session_id || typeof text !== "string" || text.length === 0) {
|
|
1545
|
+
return writeJson(res, 400, {
|
|
1546
|
+
ok: false,
|
|
1547
|
+
error: "session_id, text 필수",
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const result = await pipe.executeCommand("send_input", {
|
|
1552
|
+
session_id,
|
|
1553
|
+
text,
|
|
1554
|
+
});
|
|
1555
|
+
return writeJson(res, resolveSendInputStatusCode(result), result);
|
|
804
1556
|
}
|
|
805
1557
|
|
|
806
|
-
if (path ===
|
|
807
|
-
const
|
|
808
|
-
|
|
1558
|
+
if (path === "/bridge/status" && req.method === "POST") {
|
|
1559
|
+
const {
|
|
1560
|
+
scope = "hub",
|
|
1561
|
+
agent_id,
|
|
1562
|
+
status,
|
|
1563
|
+
include_metrics = true,
|
|
1564
|
+
trace_id,
|
|
1565
|
+
} = body;
|
|
1566
|
+
|
|
1567
|
+
if (agent_id && status) {
|
|
1568
|
+
const normalizedAgentId = String(agent_id || "").trim();
|
|
1569
|
+
const normalizedStatus = String(status || "")
|
|
1570
|
+
.trim()
|
|
1571
|
+
.toLowerCase();
|
|
1572
|
+
if (!normalizedAgentId || !normalizedStatus) {
|
|
1573
|
+
return writeJson(res, 400, {
|
|
1574
|
+
ok: false,
|
|
1575
|
+
error: "agent_id, status 필수",
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
const statusForStore = new Set([
|
|
1579
|
+
"online",
|
|
1580
|
+
"stale",
|
|
1581
|
+
"offline",
|
|
1582
|
+
]).has(normalizedStatus)
|
|
1583
|
+
? normalizedStatus
|
|
1584
|
+
: "online";
|
|
1585
|
+
router.updateAgentStatus(normalizedAgentId, statusForStore);
|
|
1586
|
+
const snapshot = await pipe.executeQuery("status", {
|
|
1587
|
+
scope: "agent",
|
|
1588
|
+
agent_id: normalizedAgentId,
|
|
1589
|
+
include_metrics: false,
|
|
1590
|
+
});
|
|
1591
|
+
return writeJson(res, 200, {
|
|
1592
|
+
ok: true,
|
|
1593
|
+
data: {
|
|
1594
|
+
agent_id: normalizedAgentId,
|
|
1595
|
+
status: statusForStore,
|
|
1596
|
+
reported_status: normalizedStatus,
|
|
1597
|
+
reported_at_ms: Date.now(),
|
|
1598
|
+
snapshot: snapshot?.data?.agent || null,
|
|
1599
|
+
},
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const result = await pipe.executeQuery("status", {
|
|
1604
|
+
scope,
|
|
1605
|
+
agent_id,
|
|
1606
|
+
include_metrics,
|
|
1607
|
+
trace_id,
|
|
1608
|
+
});
|
|
1609
|
+
return writeJson(res, 200, result);
|
|
809
1610
|
}
|
|
810
1611
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1612
|
+
if (path === "/bridge/assign/async" && req.method === "POST") {
|
|
1613
|
+
const {
|
|
1614
|
+
supervisor_agent,
|
|
1615
|
+
worker_agent,
|
|
1616
|
+
task,
|
|
1617
|
+
topic = "assign.job",
|
|
1618
|
+
payload = {},
|
|
1619
|
+
priority = 5,
|
|
1620
|
+
ttl_ms = 600000,
|
|
1621
|
+
timeout_ms = 600000,
|
|
1622
|
+
max_retries = 0,
|
|
1623
|
+
trace_id,
|
|
1624
|
+
correlation_id,
|
|
1625
|
+
} = body;
|
|
1626
|
+
|
|
1627
|
+
if (!supervisor_agent || !worker_agent || !task) {
|
|
1628
|
+
return writeJson(res, 400, {
|
|
1629
|
+
ok: false,
|
|
1630
|
+
error: "supervisor_agent, worker_agent, task 필수",
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const result = await pipe.executeCommand("assign", {
|
|
1635
|
+
supervisor_agent,
|
|
1636
|
+
worker_agent,
|
|
1637
|
+
task,
|
|
1638
|
+
topic,
|
|
1639
|
+
payload,
|
|
1640
|
+
priority,
|
|
1641
|
+
ttl_ms,
|
|
1642
|
+
timeout_ms,
|
|
1643
|
+
max_retries,
|
|
1644
|
+
trace_id,
|
|
1645
|
+
correlation_id,
|
|
1646
|
+
});
|
|
814
1647
|
return writeJson(res, result.ok ? 200 : 400, result);
|
|
815
1648
|
}
|
|
816
1649
|
|
|
817
|
-
if (path ===
|
|
818
|
-
const
|
|
819
|
-
|
|
1650
|
+
if (path === "/bridge/assign/result" && req.method === "POST") {
|
|
1651
|
+
const {
|
|
1652
|
+
job_id,
|
|
1653
|
+
worker_agent,
|
|
1654
|
+
status,
|
|
1655
|
+
attempt,
|
|
1656
|
+
result: assignResult,
|
|
1657
|
+
error: assignError,
|
|
1658
|
+
payload = {},
|
|
1659
|
+
metadata = {},
|
|
1660
|
+
} = body;
|
|
1661
|
+
|
|
1662
|
+
if (!job_id || !status) {
|
|
1663
|
+
return writeJson(res, 400, {
|
|
1664
|
+
ok: false,
|
|
1665
|
+
error: "job_id, status 필수",
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const result = await pipe.executeCommand("assign_result", {
|
|
1670
|
+
job_id,
|
|
1671
|
+
worker_agent,
|
|
1672
|
+
status,
|
|
1673
|
+
attempt,
|
|
1674
|
+
result: assignResult,
|
|
1675
|
+
error: assignError,
|
|
1676
|
+
payload,
|
|
1677
|
+
metadata,
|
|
1678
|
+
});
|
|
1679
|
+
return writeJson(res, result.ok ? 200 : 409, result);
|
|
820
1680
|
}
|
|
821
1681
|
|
|
822
|
-
if (path ===
|
|
823
|
-
const result = await pipe.executeQuery(
|
|
824
|
-
|
|
1682
|
+
if (path === "/bridge/assign/status" && req.method === "POST") {
|
|
1683
|
+
const result = await pipe.executeQuery("assign_status", body);
|
|
1684
|
+
const statusCode = result.ok
|
|
1685
|
+
? 200
|
|
1686
|
+
: result.error?.code === "ASSIGN_NOT_FOUND"
|
|
1687
|
+
? 404
|
|
1688
|
+
: 400;
|
|
1689
|
+
return writeJson(res, statusCode, result);
|
|
825
1690
|
}
|
|
826
|
-
}
|
|
827
1691
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1692
|
+
if (path === "/bridge/assign/retry" && req.method === "POST") {
|
|
1693
|
+
const { job_id, reason, requested_by } = body;
|
|
1694
|
+
if (!job_id) {
|
|
1695
|
+
return writeJson(res, 400, { ok: false, error: "job_id 필수" });
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const result = await pipe.executeCommand("assign_retry", {
|
|
1699
|
+
job_id,
|
|
1700
|
+
reason,
|
|
1701
|
+
requested_by,
|
|
1702
|
+
});
|
|
1703
|
+
const statusCode = result.ok
|
|
1704
|
+
? 200
|
|
1705
|
+
: result.error?.code === "ASSIGN_NOT_FOUND"
|
|
1706
|
+
? 404
|
|
1707
|
+
: result.error?.code === "ASSIGN_RETRY_EXHAUSTED"
|
|
1708
|
+
? 409
|
|
1709
|
+
: 400;
|
|
1710
|
+
return writeJson(res, statusCode, result);
|
|
832
1711
|
}
|
|
833
1712
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1713
|
+
if (req.method === "POST") {
|
|
1714
|
+
let teamResult = null;
|
|
1715
|
+
if (path === "/bridge/team/info" || path === "/bridge/team-info") {
|
|
1716
|
+
teamResult = await pipe.executeQuery("team_info", body);
|
|
1717
|
+
} else if (
|
|
1718
|
+
path === "/bridge/team/task-list" ||
|
|
1719
|
+
path === "/bridge/team-task-list"
|
|
1720
|
+
) {
|
|
1721
|
+
teamResult = await pipe.executeQuery("team_task_list", body);
|
|
1722
|
+
} else if (
|
|
1723
|
+
path === "/bridge/team/task-update" ||
|
|
1724
|
+
path === "/bridge/team-task-update"
|
|
1725
|
+
) {
|
|
1726
|
+
teamResult = await pipe.executeCommand("team_task_update", body);
|
|
1727
|
+
} else if (
|
|
1728
|
+
path === "/bridge/team/send-message" ||
|
|
1729
|
+
path === "/bridge/team-send-message"
|
|
1730
|
+
) {
|
|
1731
|
+
teamResult = await pipe.executeCommand("team_send_message", body);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
if (teamResult) {
|
|
1735
|
+
return writeJson(
|
|
1736
|
+
res,
|
|
1737
|
+
resolveTeamStatusCode(teamResult),
|
|
1738
|
+
teamResult,
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
842
1741
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1742
|
+
if (path.startsWith("/bridge/team")) {
|
|
1743
|
+
return writeJson(res, 404, {
|
|
1744
|
+
ok: false,
|
|
1745
|
+
error: `Unknown team endpoint: ${path}`,
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// ── 파이프라인 엔드포인트 ──
|
|
1750
|
+
if (path === "/bridge/pipeline/state" && req.method === "POST") {
|
|
1751
|
+
const result = await pipe.executeQuery("pipeline_state", body);
|
|
1752
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (path === "/bridge/pipeline/advance" && req.method === "POST") {
|
|
1756
|
+
const result = await pipe.executeCommand(
|
|
1757
|
+
"pipeline_advance",
|
|
1758
|
+
body,
|
|
1759
|
+
);
|
|
1760
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
if (path === "/bridge/pipeline/init" && req.method === "POST") {
|
|
1764
|
+
const result = await pipe.executeCommand("pipeline_init", body);
|
|
1765
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
if (path === "/bridge/pipeline/list" && req.method === "POST") {
|
|
1769
|
+
const result = await pipe.executeQuery("pipeline_list", body);
|
|
1770
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// ── Delegator 엔드포인트 ──
|
|
1774
|
+
if (
|
|
1775
|
+
path === "/bridge/delegator/delegate" &&
|
|
1776
|
+
req.method === "POST"
|
|
1777
|
+
) {
|
|
1778
|
+
const result = await pipe.executeCommand(
|
|
1779
|
+
"delegator_delegate",
|
|
1780
|
+
body,
|
|
1781
|
+
);
|
|
1782
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
if (path === "/bridge/delegator/reply" && req.method === "POST") {
|
|
1786
|
+
const result = await pipe.executeCommand("delegator_reply", body);
|
|
1787
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
if (path === "/bridge/delegator/status" && req.method === "POST") {
|
|
1791
|
+
const result = await pipe.executeQuery("delegator_status", body);
|
|
1792
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
if (path === "/bridge/hitl/request" && req.method === "POST") {
|
|
1796
|
+
const result = hitl.requestHumanInput(body);
|
|
1797
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
if (path === "/bridge/hitl/submit" && req.method === "POST") {
|
|
1801
|
+
const result = hitl.submitHumanInput(body);
|
|
1802
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1803
|
+
}
|
|
847
1804
|
}
|
|
848
|
-
const result = await pipe.executeCommand('deregister', { agent_id });
|
|
849
|
-
return writeJson(res, 200, result);
|
|
850
|
-
}
|
|
851
1805
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1806
|
+
if (path === "/bridge/context" && req.method === "POST") {
|
|
1807
|
+
const {
|
|
1808
|
+
agent_id,
|
|
1809
|
+
topics,
|
|
1810
|
+
max_messages = 10,
|
|
1811
|
+
auto_ack = true,
|
|
1812
|
+
} = body;
|
|
1813
|
+
if (!agent_id) {
|
|
1814
|
+
return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const result = await pipe.executeQuery("drain", {
|
|
1818
|
+
agent_id,
|
|
1819
|
+
topics,
|
|
1820
|
+
max_messages,
|
|
1821
|
+
auto_ack,
|
|
1822
|
+
});
|
|
1823
|
+
return writeJson(res, 200, result);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
if (path === "/bridge/deregister" && req.method === "POST") {
|
|
1827
|
+
const { agent_id } = body;
|
|
1828
|
+
if (!agent_id) {
|
|
1829
|
+
return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
|
|
1830
|
+
}
|
|
1831
|
+
const result = await pipe.executeCommand("deregister", {
|
|
1832
|
+
agent_id,
|
|
1833
|
+
});
|
|
1834
|
+
if (result.ok) {
|
|
1835
|
+
finishWorkerTrace(agent_id, "deregister");
|
|
1836
|
+
}
|
|
1837
|
+
return writeJson(res, 200, result);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
return writeJson(res, 404, {
|
|
1841
|
+
ok: false,
|
|
1842
|
+
error: "Unknown bridge endpoint",
|
|
1843
|
+
});
|
|
1844
|
+
} catch (error) {
|
|
1845
|
+
if (!res.headersSent) {
|
|
1846
|
+
console.error("[tfx-hub] bridge error:", error);
|
|
1847
|
+
writeJson(res, 500, { ok: false, error: "Internal server error" });
|
|
1848
|
+
}
|
|
1849
|
+
return;
|
|
857
1850
|
}
|
|
858
|
-
return;
|
|
859
1851
|
}
|
|
860
|
-
}
|
|
861
1852
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1853
|
+
if (req.method === "GET" && servePublicFile(res, path)) {
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
865
1856
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1857
|
+
if (path !== "/mcp") {
|
|
1858
|
+
res.writeHead(404);
|
|
1859
|
+
return res.end("Not Found");
|
|
1860
|
+
}
|
|
870
1861
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1862
|
+
try {
|
|
1863
|
+
const sessionIdHeader = req.headers["mcp-session-id"];
|
|
1864
|
+
|
|
1865
|
+
if (req.method === "POST") {
|
|
1866
|
+
const body = await parseBody(req);
|
|
1867
|
+
|
|
1868
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
1869
|
+
const session = transports.get(sessionIdHeader);
|
|
1870
|
+
session.transport._lastActivity = Date.now();
|
|
1871
|
+
await session.transport.handleRequest(req, res, body);
|
|
1872
|
+
} else if (!sessionIdHeader && isInitializeRequest(body)) {
|
|
1873
|
+
const client = parseTfxClientHeader(req.headers["x-tfx-client"]);
|
|
1874
|
+
const clientRef = { client };
|
|
1875
|
+
const initializeStarted = performance.now();
|
|
1876
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1877
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1878
|
+
onsessioninitialized: (sid) => {
|
|
1879
|
+
transport._lastActivity = Date.now();
|
|
1880
|
+
transports.set(sid, {
|
|
1881
|
+
transport,
|
|
1882
|
+
mcp,
|
|
1883
|
+
closing: false,
|
|
1884
|
+
client,
|
|
1885
|
+
createdAt: Date.now(),
|
|
1886
|
+
});
|
|
1887
|
+
},
|
|
1888
|
+
});
|
|
1889
|
+
transport.onclose = () => {
|
|
1890
|
+
const sid = transport.sessionId;
|
|
1891
|
+
if (sid)
|
|
1892
|
+
void closeMcpTransportSession(
|
|
1893
|
+
sid,
|
|
1894
|
+
transports.get(sid),
|
|
1895
|
+
"transport.onclose",
|
|
1896
|
+
);
|
|
1897
|
+
};
|
|
1898
|
+
const mcp = createMcpForSession(clientRef);
|
|
1899
|
+
await mcp.connect(transport);
|
|
1900
|
+
try {
|
|
1901
|
+
await transport.handleRequest(req, res, body);
|
|
1902
|
+
} finally {
|
|
1903
|
+
recordRequest(
|
|
1904
|
+
"initialize",
|
|
1905
|
+
performance.now() - initializeStarted,
|
|
1906
|
+
client,
|
|
1907
|
+
"ingress",
|
|
1908
|
+
);
|
|
896
1909
|
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
if (
|
|
911
|
-
|
|
1910
|
+
} else {
|
|
1911
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1912
|
+
res.end(
|
|
1913
|
+
JSON.stringify({
|
|
1914
|
+
jsonrpc: "2.0",
|
|
1915
|
+
error: {
|
|
1916
|
+
code: -32000,
|
|
1917
|
+
message: "Bad Request: No valid session ID",
|
|
1918
|
+
},
|
|
1919
|
+
id: null,
|
|
1920
|
+
}),
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
} else if (req.method === "GET") {
|
|
1924
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
1925
|
+
await transports
|
|
1926
|
+
.get(sessionIdHeader)
|
|
1927
|
+
.transport.handleRequest(req, res);
|
|
1928
|
+
} else {
|
|
1929
|
+
res.writeHead(400);
|
|
1930
|
+
res.end("Invalid or missing session ID");
|
|
1931
|
+
}
|
|
1932
|
+
} else if (req.method === "DELETE") {
|
|
1933
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
1934
|
+
await transports
|
|
1935
|
+
.get(sessionIdHeader)
|
|
1936
|
+
.transport.handleRequest(req, res);
|
|
1937
|
+
} else {
|
|
1938
|
+
res.writeHead(400);
|
|
1939
|
+
res.end("Invalid or missing session ID");
|
|
1940
|
+
}
|
|
912
1941
|
} else {
|
|
913
|
-
res.writeHead(
|
|
914
|
-
res.end(
|
|
1942
|
+
res.writeHead(405);
|
|
1943
|
+
res.end("Method Not Allowed");
|
|
915
1944
|
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1945
|
+
} catch (error) {
|
|
1946
|
+
hubLog.error({ err: error }, "http.error");
|
|
1947
|
+
if (!res.headersSent) {
|
|
1948
|
+
const code =
|
|
1949
|
+
error.statusCode === 413
|
|
1950
|
+
? 413
|
|
1951
|
+
: error instanceof SyntaxError
|
|
1952
|
+
? 400
|
|
1953
|
+
: 500;
|
|
1954
|
+
const message =
|
|
1955
|
+
code === 413
|
|
1956
|
+
? "Body too large"
|
|
1957
|
+
: code === 400
|
|
1958
|
+
? "Invalid JSON"
|
|
1959
|
+
: "Internal server error";
|
|
1960
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
1961
|
+
res.end(
|
|
1962
|
+
JSON.stringify({
|
|
1963
|
+
jsonrpc: "2.0",
|
|
1964
|
+
error: { code: code === 500 ? -32603 : -32700, message },
|
|
1965
|
+
id: null,
|
|
1966
|
+
}),
|
|
1967
|
+
);
|
|
922
1968
|
}
|
|
923
|
-
} else {
|
|
924
|
-
res.writeHead(405);
|
|
925
|
-
res.end('Method Not Allowed');
|
|
926
|
-
}
|
|
927
|
-
} catch (error) {
|
|
928
|
-
hubLog.error({ err: error }, 'http.error');
|
|
929
|
-
if (!res.headersSent) {
|
|
930
|
-
const code = error.statusCode === 413 ? 413
|
|
931
|
-
: error instanceof SyntaxError ? 400 : 500;
|
|
932
|
-
const message = code === 413 ? 'Body too large'
|
|
933
|
-
: code === 400 ? 'Invalid JSON' : 'Internal server error';
|
|
934
|
-
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
935
|
-
res.end(JSON.stringify({
|
|
936
|
-
jsonrpc: '2.0',
|
|
937
|
-
error: { code: code === 500 ? -32603 : -32700, message },
|
|
938
|
-
id: null,
|
|
939
|
-
}));
|
|
940
1969
|
}
|
|
941
|
-
}
|
|
942
|
-
|
|
1970
|
+
}),
|
|
1971
|
+
);
|
|
943
1972
|
|
|
944
1973
|
httpServer.requestTimeout = 30000;
|
|
945
1974
|
httpServer.headersTimeout = 10000;
|
|
@@ -947,41 +1976,74 @@ export async function startHub({
|
|
|
947
1976
|
router.startSweeper();
|
|
948
1977
|
|
|
949
1978
|
const hitlTimer = setInterval(() => {
|
|
950
|
-
try {
|
|
1979
|
+
try {
|
|
1980
|
+
hitl.checkTimeouts();
|
|
1981
|
+
} catch (err) {
|
|
1982
|
+
hubLog.warn({ err }, "hitl.timeout_check_failed");
|
|
1983
|
+
}
|
|
951
1984
|
}, 10000);
|
|
952
1985
|
hitlTimer.unref();
|
|
953
1986
|
|
|
954
1987
|
// MCP session TTL: sessions idle for SESSION_TTL_MS are closed automatically.
|
|
955
1988
|
// Configurable via SESSION_TTL_MS (default 30 minutes). The sweep runs every 60 s.
|
|
956
|
-
const SESSION_TTL_MS =
|
|
1989
|
+
const SESSION_TTL_MS =
|
|
1990
|
+
parseInt(process.env.TFX_SESSION_TTL_MS || "", 10) || 30 * 60 * 1000;
|
|
957
1991
|
const sessionTimer = setInterval(() => {
|
|
958
1992
|
const now = Date.now();
|
|
959
1993
|
for (const [sid, session] of transports) {
|
|
960
|
-
if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS)
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
transports.delete(sid);
|
|
1994
|
+
if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS)
|
|
1995
|
+
continue;
|
|
1996
|
+
void closeMcpTransportSession(sid, session, "session_ttl");
|
|
964
1997
|
}
|
|
965
1998
|
}, 60000);
|
|
966
1999
|
sessionTimer.unref();
|
|
967
2000
|
|
|
968
2001
|
// 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
if (
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
2002
|
+
// TFX_DISABLE_ORPHAN_CLEANUP=1 로 비활성 (active SSH/swarm 세션 보호 회피용 hotfix gate)
|
|
2003
|
+
const orphanCleanupTimer = setInterval(
|
|
2004
|
+
() => {
|
|
2005
|
+
if (process.env.TFX_DISABLE_ORPHAN_CLEANUP === "1") return;
|
|
2006
|
+
try {
|
|
2007
|
+
const { killed, killedProcesses = [] } = cleanupOrphanNodeProcesses();
|
|
2008
|
+
const {
|
|
2009
|
+
killed: runtimeKilled,
|
|
2010
|
+
killedProcesses: runtimeKilledProcesses = [],
|
|
2011
|
+
} = cleanupOrphanRuntimeProcesses();
|
|
2012
|
+
const totalKilled = killed + runtimeKilled;
|
|
2013
|
+
if (totalKilled > 0) {
|
|
2014
|
+
hubLog.info(
|
|
2015
|
+
{
|
|
2016
|
+
killed: totalKilled,
|
|
2017
|
+
processes: [...killedProcesses, ...runtimeKilledProcesses],
|
|
2018
|
+
caller: "timer",
|
|
2019
|
+
},
|
|
2020
|
+
"hub.orphan_cleanup",
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
976
2023
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
2024
|
+
const { killed: fsmonitorKilled, stale } = cleanupStaleFsmonitorDaemons(
|
|
2025
|
+
{
|
|
2026
|
+
minAgeMs: 24 * 60 * 60 * 1000,
|
|
2027
|
+
},
|
|
2028
|
+
);
|
|
2029
|
+
if (fsmonitorKilled > 0) {
|
|
2030
|
+
hubLog.info(
|
|
2031
|
+
{ killed: fsmonitorKilled, stale: stale.length },
|
|
2032
|
+
"hub.fsmonitor_cleanup",
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
} catch {}
|
|
2036
|
+
|
|
2037
|
+
// stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
|
|
2038
|
+
try {
|
|
2039
|
+
const staleKilled = cleanupStaleSpawnSessions(hubLog);
|
|
2040
|
+
if (staleKilled > 0) {
|
|
2041
|
+
hubLog.info({ killed: staleKilled }, "hub.stale_spawn_cleanup");
|
|
2042
|
+
}
|
|
2043
|
+
} catch {}
|
|
2044
|
+
},
|
|
2045
|
+
5 * 60 * 1000,
|
|
2046
|
+
);
|
|
985
2047
|
orphanCleanupTimer.unref();
|
|
986
2048
|
|
|
987
2049
|
// Evict stale rate-limit buckets once per minute to bound memory usage.
|
|
@@ -1000,38 +2062,27 @@ export async function startHub({
|
|
|
1000
2062
|
|
|
1001
2063
|
mkdirSync(PID_DIR, { recursive: true });
|
|
1002
2064
|
|
|
1003
|
-
|
|
1004
|
-
if (existsSync(PID_FILE)) {
|
|
2065
|
+
const cleanupStartupFailure = async ({ preserveTokenFile = false } = {}) => {
|
|
1005
2066
|
try {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
try { unlinkSync(PID_FILE); } catch {}
|
|
2067
|
+
router.stopSweeper();
|
|
2068
|
+
} catch {}
|
|
2069
|
+
try {
|
|
2070
|
+
await pipe.stop();
|
|
2071
|
+
} catch {}
|
|
2072
|
+
try {
|
|
2073
|
+
await assignCallbacks.stop();
|
|
2074
|
+
} catch {}
|
|
2075
|
+
try {
|
|
2076
|
+
await delegatorWorker.stop();
|
|
2077
|
+
} catch {}
|
|
2078
|
+
try {
|
|
2079
|
+
store.close();
|
|
2080
|
+
} catch {}
|
|
2081
|
+
if (!preserveTokenFile) {
|
|
2082
|
+
try {
|
|
2083
|
+
unlinkSync(TOKEN_FILE);
|
|
2084
|
+
} catch {}
|
|
1025
2085
|
}
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
const cleanupStartupFailure = async () => {
|
|
1029
|
-
try { router.stopSweeper(); } catch {}
|
|
1030
|
-
try { await pipe.stop(); } catch {}
|
|
1031
|
-
try { await assignCallbacks.stop(); } catch {}
|
|
1032
|
-
try { await delegatorWorker.stop(); } catch {}
|
|
1033
|
-
try { store.close(); } catch {}
|
|
1034
|
-
try { unlinkSync(TOKEN_FILE); } catch {}
|
|
1035
2086
|
releaseStartupLock();
|
|
1036
2087
|
};
|
|
1037
2088
|
|
|
@@ -1044,120 +2095,231 @@ export async function startHub({
|
|
|
1044
2095
|
}
|
|
1045
2096
|
|
|
1046
2097
|
return await new Promise((resolveHub, reject) => {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
2098
|
+
let bindAttempts = 0;
|
|
2099
|
+
|
|
2100
|
+
const listenOnce = () => {
|
|
2101
|
+
const onError = (err) => {
|
|
2102
|
+
void (async () => {
|
|
2103
|
+
if (err.code !== "EADDRINUSE") {
|
|
2104
|
+
await cleanupStartupFailure();
|
|
2105
|
+
reject(err);
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
1051
2108
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
idleTimeoutMs: hubIdleTimeoutMs,
|
|
1067
|
-
};
|
|
2109
|
+
const conflict = await resolvePortInUse({
|
|
2110
|
+
port,
|
|
2111
|
+
host,
|
|
2112
|
+
version,
|
|
2113
|
+
});
|
|
2114
|
+
if (conflict.action === "retry" && bindAttempts < 1) {
|
|
2115
|
+
bindAttempts += 1;
|
|
2116
|
+
hubLog.warn(
|
|
2117
|
+
{ port, reason: conflict.cleaned?.reason },
|
|
2118
|
+
"hub.port_in_use_retry_after_stale_pid_cleanup",
|
|
2119
|
+
);
|
|
2120
|
+
listenOnce();
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
1068
2123
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
2124
|
+
await cleanupStartupFailure({ preserveTokenFile: true });
|
|
2125
|
+
if (conflict.action === "reuse") {
|
|
2126
|
+
hubLog.info(
|
|
2127
|
+
{
|
|
2128
|
+
port,
|
|
2129
|
+
pid: conflict.peer?.pid,
|
|
2130
|
+
version: conflict.peer?.version,
|
|
2131
|
+
url: conflict.url,
|
|
2132
|
+
},
|
|
2133
|
+
"hub.already_running",
|
|
2134
|
+
);
|
|
2135
|
+
resolveHub({
|
|
2136
|
+
reused: true,
|
|
2137
|
+
external: true,
|
|
2138
|
+
port,
|
|
2139
|
+
pid: conflict.peer?.pid,
|
|
2140
|
+
url: conflict.url,
|
|
2141
|
+
stop: async () => false,
|
|
2142
|
+
});
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
hubLog.error({ port, host }, "hub.port_in_use");
|
|
2147
|
+
const wrapped = new Error(conflict.message);
|
|
2148
|
+
wrapped.code = "EADDRINUSE";
|
|
2149
|
+
reject(wrapped);
|
|
2150
|
+
})();
|
|
2151
|
+
};
|
|
2152
|
+
|
|
2153
|
+
httpServer.once("error", onError);
|
|
2154
|
+
httpServer.listen(port, host, () => {
|
|
2155
|
+
httpServer.off("error", onError);
|
|
2156
|
+
try {
|
|
2157
|
+
let idleTimer = null;
|
|
2158
|
+
let stopPromise = null;
|
|
2159
|
+
|
|
2160
|
+
const info = {
|
|
2161
|
+
port,
|
|
2162
|
+
host,
|
|
2163
|
+
dbPath,
|
|
2164
|
+
pid: process.pid,
|
|
2165
|
+
hubToken: HUB_TOKEN,
|
|
2166
|
+
authMode: HUB_TOKEN ? "token-required" : "localhost-only",
|
|
2167
|
+
url: buildHubUrl(host, port),
|
|
2168
|
+
pipe_path: pipe.path,
|
|
2169
|
+
pipePath: pipe.path,
|
|
2170
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
2171
|
+
assignCallbackPipePath: assignCallbacks.path,
|
|
2172
|
+
version,
|
|
2173
|
+
storeType: store.type || "sqlite",
|
|
2174
|
+
idleTimeoutMs: hubIdleTimeoutMs,
|
|
2175
|
+
};
|
|
2176
|
+
|
|
2177
|
+
writeState({
|
|
2178
|
+
pid: process.pid,
|
|
2179
|
+
port,
|
|
2180
|
+
host,
|
|
2181
|
+
auth_mode: HUB_TOKEN ? "token-required" : "localhost-only",
|
|
2182
|
+
url: info.url,
|
|
2183
|
+
pipe_path: pipe.path,
|
|
2184
|
+
pipePath: pipe.path,
|
|
2185
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
2186
|
+
assignCallbackPipePath: assignCallbacks.path,
|
|
2187
|
+
authMode: HUB_TOKEN ? "token-required" : "localhost-only",
|
|
2188
|
+
startedAt,
|
|
2189
|
+
started: startedAtMs,
|
|
2190
|
+
version,
|
|
2191
|
+
sessionId,
|
|
2192
|
+
session_id: sessionId,
|
|
1119
2193
|
});
|
|
2194
|
+
releaseStartupLock();
|
|
2195
|
+
void syncHubMcpSettingsIfAvailable({ hubUrl: info.url });
|
|
2196
|
+
|
|
2197
|
+
hubLog.info(
|
|
2198
|
+
{
|
|
2199
|
+
url: info.url,
|
|
2200
|
+
pipePath: pipe.path,
|
|
2201
|
+
assignCallbackPath: assignCallbacks.path,
|
|
2202
|
+
pid: process.pid,
|
|
2203
|
+
storeType: info.storeType,
|
|
2204
|
+
version,
|
|
2205
|
+
},
|
|
2206
|
+
"hub.started",
|
|
2207
|
+
);
|
|
2208
|
+
hubLog.debug(
|
|
2209
|
+
{
|
|
2210
|
+
publicDir: PUBLIC_DIR,
|
|
2211
|
+
exists: existsSync(PUBLIC_DIR),
|
|
2212
|
+
hasDashboard: existsSync(resolve(PUBLIC_DIR, "dashboard.html")),
|
|
2213
|
+
},
|
|
2214
|
+
"hub.public_dir",
|
|
2215
|
+
);
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* Hub 서버 정지 함수.
|
|
2219
|
+
*
|
|
2220
|
+
* Trade-off (F01 — 영구 poisoning 허용):
|
|
2221
|
+
* 첫 정지 호출에서 cleanup 파이프라인이 실패하면 stopPromise는 실패 상태로
|
|
2222
|
+
* 고정되고, 이후 모든 호출은 동일한 실패 promise를 반환합니다. stopPromise를
|
|
2223
|
+
* null로 리셋하면 재시도가 가능하지만, router sweeper / transports / pipe 등이
|
|
2224
|
+
* 이미 부분 해제된 상태에서 두 번째 close가 실행되면 use-after-close 및
|
|
2225
|
+
* race condition을 유발합니다. 실패한 stopFn은 프로세스 전체 재시작으로 복구해야
|
|
2226
|
+
* 하며, 이 동작은 의도된 설계입니다.
|
|
2227
|
+
*/
|
|
2228
|
+
const stopFn = async () => {
|
|
2229
|
+
if (stopPromise) return stopPromise;
|
|
2230
|
+
|
|
2231
|
+
stopPromise = (async () => {
|
|
2232
|
+
router.stopSweeper();
|
|
2233
|
+
clearInterval(hitlTimer);
|
|
2234
|
+
clearInterval(sessionTimer);
|
|
2235
|
+
clearInterval(rateLimitTimer);
|
|
2236
|
+
clearInterval(orphanCleanupTimer);
|
|
2237
|
+
if (idleTimer) {
|
|
2238
|
+
clearInterval(idleTimer);
|
|
2239
|
+
}
|
|
2240
|
+
for (const [sid, session] of Array.from(transports)) {
|
|
2241
|
+
await closeMcpTransportSession(sid, session, "hub.stop");
|
|
2242
|
+
}
|
|
2243
|
+
transports.clear();
|
|
2244
|
+
await pipe.stop();
|
|
2245
|
+
await assignCallbacks.stop();
|
|
2246
|
+
await delegatorWorker.stop().catch(() => {});
|
|
2247
|
+
try {
|
|
2248
|
+
synapseRegistry.destroy();
|
|
2249
|
+
} catch {}
|
|
2250
|
+
store.close();
|
|
2251
|
+
try {
|
|
2252
|
+
unlinkSync(PID_FILE);
|
|
2253
|
+
} catch {}
|
|
2254
|
+
try {
|
|
2255
|
+
unlinkSync(TOKEN_FILE);
|
|
2256
|
+
} catch {}
|
|
2257
|
+
httpServer.closeAllConnections();
|
|
2258
|
+
await new Promise((resolveClose) =>
|
|
2259
|
+
httpServer.close(resolveClose),
|
|
2260
|
+
);
|
|
2261
|
+
})().catch((error) => {
|
|
2262
|
+
hubLog.error(
|
|
2263
|
+
{ err: String(error?.message || error) },
|
|
2264
|
+
"hub.stop_error",
|
|
2265
|
+
);
|
|
2266
|
+
// stopPromise를 null로 리셋하지 않음 — double-close 방지
|
|
2267
|
+
});
|
|
1120
2268
|
|
|
1121
|
-
|
|
1122
|
-
|
|
2269
|
+
return stopPromise;
|
|
2270
|
+
};
|
|
2271
|
+
|
|
2272
|
+
if (hubIdleTimeoutMs > 0) {
|
|
2273
|
+
idleTimer = setInterval(() => {
|
|
2274
|
+
const idleMs = Date.now() - lastRequestAt;
|
|
2275
|
+
if (idleMs < hubIdleTimeoutMs) return;
|
|
2276
|
+
hubLog.warn(
|
|
2277
|
+
{ idleMs, idleTimeoutMs: hubIdleTimeoutMs, port },
|
|
2278
|
+
"hub.idle_timeout_shutdown",
|
|
2279
|
+
);
|
|
2280
|
+
void stopFn().catch((error) => {
|
|
2281
|
+
hubLog.error(
|
|
2282
|
+
{
|
|
2283
|
+
err: error,
|
|
2284
|
+
idleMs,
|
|
2285
|
+
idleTimeoutMs: hubIdleTimeoutMs,
|
|
2286
|
+
port,
|
|
2287
|
+
},
|
|
2288
|
+
"hub.idle_timeout_shutdown_failed",
|
|
2289
|
+
);
|
|
2290
|
+
});
|
|
2291
|
+
}, hubIdleSweepMs);
|
|
2292
|
+
idleTimer.unref();
|
|
2293
|
+
}
|
|
1123
2294
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
2295
|
+
resolveHub({
|
|
2296
|
+
reused: false,
|
|
2297
|
+
external: false,
|
|
2298
|
+
...info,
|
|
2299
|
+
httpServer,
|
|
2300
|
+
store,
|
|
2301
|
+
router,
|
|
2302
|
+
hitl,
|
|
2303
|
+
pipe,
|
|
2304
|
+
assignCallbacks,
|
|
2305
|
+
delegatorService,
|
|
2306
|
+
delegatorWorker,
|
|
2307
|
+
getMcpSessions: () =>
|
|
2308
|
+
Array.from(transports.entries()).map(([id, session]) => ({
|
|
2309
|
+
id,
|
|
2310
|
+
client: session.client || "unknown",
|
|
2311
|
+
createdAt: session.createdAt ?? null,
|
|
2312
|
+
closing: Boolean(session.closing),
|
|
2313
|
+
})),
|
|
2314
|
+
stop: stopFn,
|
|
1130
2315
|
});
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
httpServer,
|
|
1139
|
-
store,
|
|
1140
|
-
router,
|
|
1141
|
-
hitl,
|
|
1142
|
-
pipe,
|
|
1143
|
-
assignCallbacks,
|
|
1144
|
-
delegatorService,
|
|
1145
|
-
delegatorWorker,
|
|
1146
|
-
stop: stopFn,
|
|
1147
|
-
});
|
|
1148
|
-
} catch (error) {
|
|
1149
|
-
void cleanupStartupFailure().finally(() => reject(error));
|
|
1150
|
-
}
|
|
1151
|
-
});
|
|
1152
|
-
httpServer.on('error', (err) => {
|
|
1153
|
-
void cleanupStartupFailure();
|
|
1154
|
-
if (err.code === 'EADDRINUSE') {
|
|
1155
|
-
hubLog.error({ port, host }, 'hub.port_in_use: port already occupied — check for existing hub or other service');
|
|
1156
|
-
reject(new Error(`Hub 포트 ${port}이(가) 이미 사용 중입니다. 기존 Hub 프로세스를 확인하세요. (PID file: ${PID_FILE})`));
|
|
1157
|
-
} else {
|
|
1158
|
-
reject(err);
|
|
1159
|
-
}
|
|
1160
|
-
});
|
|
2316
|
+
} catch (error) {
|
|
2317
|
+
void cleanupStartupFailure().finally(() => reject(error));
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
};
|
|
2321
|
+
|
|
2322
|
+
listenOnce();
|
|
1161
2323
|
});
|
|
1162
2324
|
}
|
|
1163
2325
|
|
|
@@ -1198,8 +2360,14 @@ export async function getOrCreateServer(opts = {}) {
|
|
|
1198
2360
|
*/
|
|
1199
2361
|
function cleanupStaleSpawnSessions(log) {
|
|
1200
2362
|
const MAX_AGE_MS = 30 * 60 * 1000;
|
|
1201
|
-
const IDLE_PROMPT_RE =
|
|
1202
|
-
|
|
2363
|
+
const IDLE_PROMPT_RE =
|
|
2364
|
+
/^(PS\s|[$%>#]\s*$|\w+@[\w.-]+[:\s]|╰─|╭─|[fb]wd-i-search:|client_loop:\s|Connection\s+(reset|closed))/;
|
|
2365
|
+
const execOpts = {
|
|
2366
|
+
encoding: "utf8",
|
|
2367
|
+
timeout: 5000,
|
|
2368
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2369
|
+
windowsHide: true,
|
|
2370
|
+
};
|
|
1203
2371
|
|
|
1204
2372
|
let killed = 0;
|
|
1205
2373
|
let raw;
|
|
@@ -1211,7 +2379,9 @@ function cleanupStaleSpawnSessions(log) {
|
|
|
1211
2379
|
|
|
1212
2380
|
const now = Date.now();
|
|
1213
2381
|
for (const line of raw.split(/\r?\n/)) {
|
|
1214
|
-
const match = line.match(
|
|
2382
|
+
const match = line.match(
|
|
2383
|
+
/^(tfx-spawn-[^:]+):\s+\d+\s+windows?\s+\(created\s+(.+)\)/,
|
|
2384
|
+
);
|
|
1215
2385
|
if (!match) continue;
|
|
1216
2386
|
|
|
1217
2387
|
const [, sessionName, createdStr] = match;
|
|
@@ -1221,8 +2391,14 @@ function cleanupStaleSpawnSessions(log) {
|
|
|
1221
2391
|
|
|
1222
2392
|
// pane 내용 확인 — 마지막 3줄 중 idle 쉘 프롬프트가 있는지
|
|
1223
2393
|
try {
|
|
1224
|
-
const pane = execSyncHub(
|
|
1225
|
-
|
|
2394
|
+
const pane = execSyncHub(
|
|
2395
|
+
`psmux capture-pane -t "${sessionName}:0.0" -p`,
|
|
2396
|
+
execOpts,
|
|
2397
|
+
);
|
|
2398
|
+
const tailLines = pane
|
|
2399
|
+
.split(/\r?\n/)
|
|
2400
|
+
.filter((l) => l.trim())
|
|
2401
|
+
.slice(-3);
|
|
1226
2402
|
const hasIdleLine = tailLines.some((l) => IDLE_PROMPT_RE.test(l.trim()));
|
|
1227
2403
|
if (!hasIdleLine) continue; // 아직 활성 — 건드리지 않음
|
|
1228
2404
|
} catch {
|
|
@@ -1233,32 +2409,336 @@ function cleanupStaleSpawnSessions(log) {
|
|
|
1233
2409
|
try {
|
|
1234
2410
|
execSyncHub(`psmux kill-session -t "${sessionName}"`, execOpts);
|
|
1235
2411
|
killed++;
|
|
1236
|
-
if (log)
|
|
2412
|
+
if (log)
|
|
2413
|
+
log.info(
|
|
2414
|
+
{ session: sessionName, ageMin: Math.round((now - created) / 60000) },
|
|
2415
|
+
"hub.stale_spawn_killed",
|
|
2416
|
+
);
|
|
1237
2417
|
} catch {}
|
|
1238
2418
|
}
|
|
1239
2419
|
|
|
1240
2420
|
return killed;
|
|
1241
2421
|
}
|
|
1242
2422
|
|
|
1243
|
-
|
|
2423
|
+
// ── Quota check for all accounts ───────────────────────────────
|
|
2424
|
+
|
|
2425
|
+
const QUOTA_CACHE_PATH = join(CACHE_DIR, "broker-quota-cache.json");
|
|
2426
|
+
|
|
2427
|
+
async function checkSingleAccountQuota(acct) {
|
|
2428
|
+
try {
|
|
2429
|
+
const authPath = join(PID_DIR, acct.authFile);
|
|
2430
|
+
if (!authPath.startsWith(PID_DIR + sep))
|
|
2431
|
+
return { id: acct.id, status: "path_blocked" };
|
|
2432
|
+
if (!existsSync(authPath)) return { id: acct.id, status: "no_auth" };
|
|
2433
|
+
const auth = JSON.parse(readFileSync(authPath, "utf8"));
|
|
2434
|
+
if (acct.provider === "codex") {
|
|
2435
|
+
const token = auth.tokens?.access_token || auth.OPENAI_API_KEY || "";
|
|
2436
|
+
if (!token) return { id: acct.id, status: "no_token" };
|
|
2437
|
+
const r = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
2438
|
+
method: "POST",
|
|
2439
|
+
signal: AbortSignal.timeout(8000),
|
|
2440
|
+
headers: {
|
|
2441
|
+
Authorization: "Bearer " + token,
|
|
2442
|
+
"Content-Type": "application/json",
|
|
2443
|
+
},
|
|
2444
|
+
body: JSON.stringify({
|
|
2445
|
+
model: "gpt-4.1-nano",
|
|
2446
|
+
messages: [{ role: "user", content: "hi" }],
|
|
2447
|
+
max_tokens: 1,
|
|
2448
|
+
}),
|
|
2449
|
+
});
|
|
2450
|
+
const hdrs = Object.fromEntries(
|
|
2451
|
+
[...r.headers.entries()].filter(([k]) => /ratelimit/i.test(k)),
|
|
2452
|
+
);
|
|
2453
|
+
if (r.status === 429)
|
|
2454
|
+
return { id: acct.id, status: "quota_hit", http: 429, headers: hdrs };
|
|
2455
|
+
if (r.status === 401) {
|
|
2456
|
+
// 401이어도 ratelimit 헤더가 있으면 쿼터 정보 추출 가능
|
|
2457
|
+
return { id: acct.id, status: "auth_error", http: 401, headers: hdrs };
|
|
2458
|
+
}
|
|
2459
|
+
return {
|
|
2460
|
+
id: acct.id,
|
|
2461
|
+
status: r.ok ? "ok" : "error",
|
|
2462
|
+
http: r.status,
|
|
2463
|
+
headers: hdrs,
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
// gemini — OAuth token refresh needed for accurate check
|
|
2467
|
+
return { id: acct.id, status: "oauth_check_needed" };
|
|
2468
|
+
} catch (e) {
|
|
2469
|
+
return {
|
|
2470
|
+
id: acct.id,
|
|
2471
|
+
status: "error",
|
|
2472
|
+
message: e.message?.substring(0, 60),
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
async function refreshAllAccountQuotas() {
|
|
2478
|
+
const snap = brokerInstance?.snapshot() || [];
|
|
2479
|
+
const checks = snap
|
|
2480
|
+
.filter((a) => a.authFile)
|
|
2481
|
+
.map((a) => checkSingleAccountQuota(a));
|
|
2482
|
+
const settled = await Promise.allSettled(checks);
|
|
2483
|
+
const results = settled.map((s, i) =>
|
|
2484
|
+
s.status === "fulfilled"
|
|
2485
|
+
? s.value
|
|
2486
|
+
: {
|
|
2487
|
+
id: snap.filter((a) => a.authFile)[i]?.id ?? "unknown",
|
|
2488
|
+
status: "error",
|
|
2489
|
+
message: String(s.reason?.message || s.reason).substring(0, 60),
|
|
2490
|
+
},
|
|
2491
|
+
);
|
|
2492
|
+
// 캐시 저장
|
|
2493
|
+
try {
|
|
2494
|
+
writeFileSync(
|
|
2495
|
+
QUOTA_CACHE_PATH,
|
|
2496
|
+
JSON.stringify({ ts: Date.now(), results }),
|
|
2497
|
+
);
|
|
2498
|
+
} catch {
|
|
2499
|
+
/* best-effort */
|
|
2500
|
+
}
|
|
2501
|
+
return results;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function _loadQuotaCache() {
|
|
2505
|
+
try {
|
|
2506
|
+
if (!existsSync(QUOTA_CACHE_PATH)) return null;
|
|
2507
|
+
return JSON.parse(readFileSync(QUOTA_CACHE_PATH, "utf8"));
|
|
2508
|
+
} catch {
|
|
2509
|
+
return null;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
// ── Broker Dashboard HTML ──────────────────────────────────────
|
|
2514
|
+
|
|
2515
|
+
function renderBrokerDashboard() {
|
|
2516
|
+
return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">
|
|
2517
|
+
<title>Account Broker Dashboard</title>
|
|
2518
|
+
<style>
|
|
2519
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
2520
|
+
body{background:#0a0a0b;color:#e8e8ec;font:14px/1.6 'SF Mono',Consolas,monospace;padding:24px}
|
|
2521
|
+
h1{font-size:18px;color:#a8b1ff;margin-bottom:8px}
|
|
2522
|
+
.toolbar{margin-bottom:16px;display:flex;gap:8px;align-items:center}
|
|
2523
|
+
.toolbar button{background:#1a1a2e;color:#a8b1ff;border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:4px 12px;cursor:pointer;font:12px inherit}
|
|
2524
|
+
.toolbar button:hover{background:#252547}
|
|
2525
|
+
.toolbar .status-text{font-size:11px;color:#555}
|
|
2526
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:12px}
|
|
2527
|
+
.card{background:#141416;border:1px solid rgba(255,255,255,0.06);border-radius:8px;padding:16px}
|
|
2528
|
+
.card.available{border-left:3px solid #34d399}
|
|
2529
|
+
.card.busy{border-left:3px solid #fbbf24}
|
|
2530
|
+
.card.cooldown{border-left:3px solid #f87171}
|
|
2531
|
+
.card.circuit-open{border-left:3px solid #ef4444;opacity:0.7}
|
|
2532
|
+
.provider{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#888}
|
|
2533
|
+
.id{font-size:13px;color:#c8c8d0;margin:4px 0}
|
|
2534
|
+
.status{font-size:12px;font-weight:600;padding:2px 8px;border-radius:4px;display:inline-block}
|
|
2535
|
+
.status.available{background:#064e3b;color:#34d399}
|
|
2536
|
+
.status.busy{background:#451a03;color:#fbbf24}
|
|
2537
|
+
.status.cooldown{background:#450a0a;color:#f87171}
|
|
2538
|
+
.status.circuit-open{background:#450a0a;color:#ef4444}
|
|
2539
|
+
.gauge{margin-top:8px}
|
|
2540
|
+
.gauge-row{display:flex;align-items:center;gap:8px;margin:3px 0}
|
|
2541
|
+
.gauge-label{font-size:10px;color:#888;width:28px;text-align:right}
|
|
2542
|
+
.gauge-bar{flex:1;height:8px;background:#1a1a1e;border-radius:4px;overflow:hidden}
|
|
2543
|
+
.gauge-fill{height:100%;border-radius:4px;transition:width .3s}
|
|
2544
|
+
.gauge-fill.green{background:linear-gradient(90deg,#34d399,#059669)}
|
|
2545
|
+
.gauge-fill.yellow{background:linear-gradient(90deg,#fbbf24,#d97706)}
|
|
2546
|
+
.gauge-fill.red{background:linear-gradient(90deg,#f87171,#dc2626)}
|
|
2547
|
+
.gauge-pct{font-size:10px;color:#999;width:32px}
|
|
2548
|
+
.gauge-reset{font-size:9px;color:#555}
|
|
2549
|
+
.meta{margin-top:6px;font-size:11px;color:#666;line-height:1.8}
|
|
2550
|
+
.meta b{color:#999}
|
|
2551
|
+
.timer{color:#f87171;font-weight:600}
|
|
2552
|
+
.refresh-bar{color:#555;font-size:11px;margin-top:16px}
|
|
2553
|
+
</style></head><body>
|
|
2554
|
+
<h1>Account Broker Dashboard</h1>
|
|
2555
|
+
<div class="toolbar">
|
|
2556
|
+
<button onclick="checkQuotas()">Check All Quotas</button>
|
|
2557
|
+
<button onclick="reloadBroker()">Reload Broker</button>
|
|
2558
|
+
<span id="toolbar-status" class="status-text"></span>
|
|
2559
|
+
</div>
|
|
2560
|
+
<div id="grid" class="grid"></div>
|
|
2561
|
+
<p class="refresh-bar">auto-refresh: 10s | <a href="/broker/snapshot" style="color:#666">JSON</a> | <a href="/broker/quota-refresh" style="color:#666">Quota API</a></p>
|
|
2562
|
+
<script>
|
|
2563
|
+
let quotaData={};
|
|
2564
|
+
function fmt(ms){if(ms<=0)return'-';const s=Math.floor(ms/1000),m=Math.floor(s/60),h=Math.floor(m/60),d=Math.floor(h/24);if(d>0)return d+'d '+h%24+'h';if(h>0)return h+'h '+m%60+'m';if(m>0)return m+'m '+s%60+'s';return s+'s'}
|
|
2565
|
+
|
|
2566
|
+
function gaugeColor(pct){return pct<60?'green':pct<85?'yellow':'red'}
|
|
2567
|
+
|
|
2568
|
+
function renderGauge(label,pct,resetTs){
|
|
2569
|
+
if(pct==null)return '';
|
|
2570
|
+
const now=Date.now()/1000;
|
|
2571
|
+
const resetIn=resetTs>now?fmt((resetTs-now)*1000):'';
|
|
2572
|
+
return '<div class="gauge-row"><span class="gauge-label">'+label+'</span>'+
|
|
2573
|
+
'<div class="gauge-bar"><div class="gauge-fill '+gaugeColor(pct)+'" style="width:'+pct+'%"></div></div>'+
|
|
2574
|
+
'<span class="gauge-pct">'+pct+'%</span>'+
|
|
2575
|
+
(resetIn?'<span class="gauge-reset">reset '+resetIn+'</span>':'')+
|
|
2576
|
+
'</div>';
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
function renderQuotaGauges(acctId){
|
|
2580
|
+
const q=quotaData[acctId];
|
|
2581
|
+
if(!q)return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#444">quota: click Check All Quotas</span></div></div>';
|
|
2582
|
+
if(q.status==='quota_hit')return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#f87171">QUOTA EXHAUSTED</span></div></div>';
|
|
2583
|
+
if(q.status==='auth_error'||q.status==='no_token'||q.status==='oauth_check_needed'){
|
|
2584
|
+
// 401이어도 ratelimit 헤더가 있으면 표시
|
|
2585
|
+
const h=q.headers||{};
|
|
2586
|
+
const limReq=h['x-ratelimit-limit-requests'],remReq=h['x-ratelimit-remaining-requests'];
|
|
2587
|
+
const limTok=h['x-ratelimit-limit-tokens'],remTok=h['x-ratelimit-remaining-tokens'];
|
|
2588
|
+
if(limReq){
|
|
2589
|
+
const pctReq=Math.round((1-remReq/limReq)*100);
|
|
2590
|
+
const pctTok=limTok?Math.round((1-remTok/limTok)*100):null;
|
|
2591
|
+
return '<div class="gauge">'+renderGauge('req',pctReq,0)+(pctTok!=null?renderGauge('tok',pctTok,0):'')+'</div>';
|
|
2592
|
+
}
|
|
2593
|
+
return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#fbbf24">'+(q.status==='oauth_check_needed'?'OAuth refresh needed':'auth: token refresh needed')+'</span></div></div>';
|
|
2594
|
+
}
|
|
2595
|
+
if(q.status==='ok'||q.status==='error'){
|
|
2596
|
+
const h=q.headers||{};
|
|
2597
|
+
const limReq=h['x-ratelimit-limit-requests'],remReq=h['x-ratelimit-remaining-requests'];
|
|
2598
|
+
const limTok=h['x-ratelimit-limit-tokens'],remTok=h['x-ratelimit-remaining-tokens'];
|
|
2599
|
+
const resetReq=h['x-ratelimit-reset-requests'],resetTok=h['x-ratelimit-reset-tokens'];
|
|
2600
|
+
if(!limReq)return '';
|
|
2601
|
+
const pctReq=Math.round((1-remReq/limReq)*100);
|
|
2602
|
+
const pctTok=limTok?Math.round((1-remTok/limTok)*100):null;
|
|
2603
|
+
return '<div class="gauge">'+renderGauge('req',pctReq,0)+(pctTok!=null?renderGauge('tok',pctTok,0):'')+'</div>';
|
|
2604
|
+
}
|
|
2605
|
+
return '';
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
async function refresh(){
|
|
2609
|
+
try{
|
|
2610
|
+
const r=await fetch('/broker/snapshot');
|
|
2611
|
+
const d=await r.json();
|
|
2612
|
+
if(!d.ok)return;
|
|
2613
|
+
const now=d.ts;
|
|
2614
|
+
const grid=document.getElementById('grid');
|
|
2615
|
+
grid.innerHTML=d.accounts.map(a=>{
|
|
2616
|
+
const cd=a.cooldownUntil>now?a.cooldownUntil-now:0;
|
|
2617
|
+
const rm=a.remainingMs||0;
|
|
2618
|
+
let st='available',sl='Available';
|
|
2619
|
+
if(a.circuitState==='open'){st='circuit-open';sl='Circuit Open'}
|
|
2620
|
+
else if(cd>0){st='cooldown';sl='Cooldown'}
|
|
2621
|
+
else if(a.busy){st='busy';sl='Busy ('+fmt(rm)+')'}
|
|
2622
|
+
return '<div class="card '+st+'">'+
|
|
2623
|
+
'<div class="provider">'+a.provider+' / '+(a.tier||'unknown')+'</div>'+
|
|
2624
|
+
'<div class="id">'+a.id+'</div>'+
|
|
2625
|
+
'<span class="status '+st+'">'+sl+'</span>'+
|
|
2626
|
+
(cd>0?'<span class="timer" style="margin-left:8px">'+fmt(cd)+' remaining</span>':'')+
|
|
2627
|
+
renderQuotaGauges(a.id)+
|
|
2628
|
+
'<div class="meta">'+
|
|
2629
|
+
'<b>Sessions:</b> '+a.totalSessions+
|
|
2630
|
+
(a.lastUsedAt?' | <b>Last:</b> '+new Date(a.lastUsedAt).toLocaleTimeString():'')+
|
|
2631
|
+
(a.failureTimestamps?.length?' | <b>Failures:</b> '+a.failureTimestamps.length:'')+
|
|
2632
|
+
(a.circuitState!=='closed'?' | <b>Circuit:</b> '+a.circuitState:'')+
|
|
2633
|
+
'</div></div>'
|
|
2634
|
+
}).join('');
|
|
2635
|
+
}catch(e){console.error('refresh failed',e)}
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
async function checkQuotas(){
|
|
2639
|
+
const el=document.getElementById('toolbar-status');
|
|
2640
|
+
el.textContent='checking quotas...';
|
|
2641
|
+
try{
|
|
2642
|
+
const r=await fetch('/broker/quota-refresh',{method:'POST'});
|
|
2643
|
+
const d=await r.json();
|
|
2644
|
+
if(d.ok){
|
|
2645
|
+
d.results.forEach(q=>{quotaData[q.id]=q});
|
|
2646
|
+
el.textContent='updated '+d.results.length+' accounts ('+new Date().toLocaleTimeString()+')';
|
|
2647
|
+
refresh();
|
|
2648
|
+
}else{el.textContent='error';}
|
|
2649
|
+
}catch(e){el.textContent='failed: '+e.message;}
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
async function reloadBroker(){
|
|
2653
|
+
await fetch('/broker/reload',{method:'POST'});
|
|
2654
|
+
document.getElementById('toolbar-status').textContent='broker reloaded';
|
|
2655
|
+
refresh();
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// 캐시에서 초기 로드
|
|
2659
|
+
fetch('/broker/quota-refresh').catch(()=>{});
|
|
2660
|
+
refresh();setInterval(refresh,10000);
|
|
2661
|
+
</script></body></html>`;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
const selfRun = process.argv[1]?.replace(/\\/g, "/").endsWith("hub/server.mjs");
|
|
1244
2665
|
if (selfRun) {
|
|
1245
|
-
const port =
|
|
2666
|
+
const port = resolveHubPort(process.env);
|
|
1246
2667
|
const dbPath = process.env.TFX_HUB_DB || undefined;
|
|
1247
2668
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
};
|
|
1256
|
-
|
|
1257
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1258
|
-
}).catch((error) => {
|
|
1259
|
-
hubLog.fatal({ err: error }, 'hub.start_failed');
|
|
2669
|
+
const cleanupPidFile = () => {
|
|
2670
|
+
try {
|
|
2671
|
+
unlinkSync(PID_FILE);
|
|
2672
|
+
} catch {}
|
|
2673
|
+
};
|
|
2674
|
+
|
|
2675
|
+
process.on("unhandledRejection", (err) => {
|
|
2676
|
+
hubLog.fatal({ err }, "hub.unhandledRejection");
|
|
2677
|
+
cleanupPidFile();
|
|
1260
2678
|
process.exit(1);
|
|
1261
2679
|
});
|
|
2680
|
+
process.on("uncaughtException", (err) => {
|
|
2681
|
+
hubLog.fatal({ err }, "hub.uncaughtException");
|
|
2682
|
+
cleanupPidFile();
|
|
2683
|
+
process.exit(1);
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
startHub({ port, dbPath })
|
|
2687
|
+
.then((info) => {
|
|
2688
|
+
if (info?.reused && info?.external) {
|
|
2689
|
+
hubLog.info(
|
|
2690
|
+
{ pid: info.pid, port: info.port, url: info.url },
|
|
2691
|
+
"hub.already_running_exit",
|
|
2692
|
+
);
|
|
2693
|
+
process.exit(0);
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
const shutdown = async (signal) => {
|
|
2697
|
+
hubLog.info({ signal }, "hub.stopping");
|
|
2698
|
+
try {
|
|
2699
|
+
if (process.env.TFX_DISABLE_ORPHAN_CLEANUP !== "1") {
|
|
2700
|
+
const { killed: orphanKilled, killedProcesses = [] } =
|
|
2701
|
+
cleanupOrphanNodeProcesses();
|
|
2702
|
+
const {
|
|
2703
|
+
killed: runtimeKilled,
|
|
2704
|
+
killedProcesses: runtimeKilledProcesses = [],
|
|
2705
|
+
} = cleanupOrphanRuntimeProcesses();
|
|
2706
|
+
const totalKilled = orphanKilled + runtimeKilled;
|
|
2707
|
+
if (totalKilled > 0) {
|
|
2708
|
+
hubLog.info(
|
|
2709
|
+
{
|
|
2710
|
+
killed: totalKilled,
|
|
2711
|
+
processes: [...killedProcesses, ...runtimeKilledProcesses],
|
|
2712
|
+
caller: "shutdown",
|
|
2713
|
+
},
|
|
2714
|
+
"hub.orphan_cleanup",
|
|
2715
|
+
);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
const { killed, stale } = cleanupStaleFsmonitorDaemons({
|
|
2719
|
+
minAgeMs: 24 * 60 * 60 * 1000,
|
|
2720
|
+
});
|
|
2721
|
+
if (killed > 0) {
|
|
2722
|
+
hubLog.info(
|
|
2723
|
+
{ killed, stale: stale.length },
|
|
2724
|
+
"hub.fsmonitor_cleanup",
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
} catch {}
|
|
2728
|
+
try {
|
|
2729
|
+
cleanupStaleSpawnSessions(hubLog);
|
|
2730
|
+
} catch {}
|
|
2731
|
+
await info.stop();
|
|
2732
|
+
process.exit(0);
|
|
2733
|
+
};
|
|
2734
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
2735
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
2736
|
+
})
|
|
2737
|
+
.catch((error) => {
|
|
2738
|
+
hubLog.fatal({ err: error }, "hub.start_failed");
|
|
2739
|
+
cleanupPidFile();
|
|
2740
|
+
process.exit(1);
|
|
2741
|
+
});
|
|
1262
2742
|
}
|
|
1263
2743
|
|
|
1264
2744
|
export { startHub as createServer };
|