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