triflux 10.2.0 → 10.3.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/README.md +236 -156
- package/hub/bridge.mjs +638 -290
- package/hub/codex-compat.mjs +1 -1
- package/hub/fullcycle.mjs +1 -1
- package/hub/intent.mjs +1 -0
- package/hub/lib/mcp-response-cache.mjs +205 -0
- package/hub/pipe.mjs +228 -119
- package/hub/reflexion.mjs +87 -13
- package/hub/research.mjs +1 -0
- package/hub/server.mjs +997 -611
- package/hub/team/ansi.mjs +1 -1
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +256 -125
- package/hub/team/execution-mode.mjs +105 -0
- package/hub/team/headless.mjs +686 -252
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/swarm-hypervisor.mjs +208 -86
- package/hub/team/tui-lite.mjs +18 -2
- package/hub/token-mode.mjs +1 -0
- package/hub/tools.mjs +474 -252
- package/package.json +5 -5
- package/scripts/codex-gateway-preflight.mjs +133 -0
- package/scripts/codex-mcp-gateway-sync.mjs +199 -0
- package/skills/star-prompt/SKILL.md +169 -69
- package/skills/tfx-setup/SKILL.md +124 -0
- package/skills/tfx-swarm/SKILL.md +124 -72
package/hub/server.mjs
CHANGED
|
@@ -1,51 +1,75 @@
|
|
|
1
1
|
// hub/server.mjs — HTTP MCP + REST bridge + Named Pipe 서버 진입점
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import { createModuleLogger } from
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
2
|
+
|
|
3
|
+
import { execSync as execSyncHub } from "node:child_process";
|
|
4
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { createServer as createHttpServer } from "node:http";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
19
|
+
import {
|
|
20
|
+
CallToolRequestSchema,
|
|
21
|
+
ListToolsRequestSchema,
|
|
22
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
import { createModuleLogger } from "../scripts/lib/logger.mjs";
|
|
24
|
+
import { createAdaptiveEngine } from "./adaptive.mjs";
|
|
25
|
+
import { createAssignCallbackServer } from "./assign-callbacks.mjs";
|
|
26
|
+
import { DelegatorService } from "./delegator/index.mjs";
|
|
27
|
+
import { createHitlManager } from "./hitl.mjs";
|
|
28
|
+
import { cleanupOrphanNodeProcesses } from "./lib/process-utils.mjs";
|
|
29
|
+
import { wrapRequestHandler } from "./middleware/request-logger.mjs";
|
|
30
|
+
import { createPipeServer } from "./pipe.mjs";
|
|
31
|
+
import { createRouter } from "./router.mjs";
|
|
32
|
+
import { createAdaptiveFingerprintService } from "./session-fingerprint.mjs";
|
|
33
|
+
import {
|
|
34
|
+
acquireLock,
|
|
35
|
+
getVersionHash,
|
|
36
|
+
isServerHealthy,
|
|
37
|
+
readState,
|
|
38
|
+
releaseLock,
|
|
39
|
+
writeState,
|
|
40
|
+
} from "./state.mjs";
|
|
41
|
+
import { createStoreAdapter } from "./store-adapter.mjs";
|
|
42
|
+
import { nativeProxy } from "./team/nativeProxy.mjs";
|
|
43
|
+
import { registerTeamBridge } from "./team-bridge.mjs";
|
|
44
|
+
import { createTools } from "./tools.mjs";
|
|
45
|
+
import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
|
|
30
46
|
|
|
31
47
|
registerTeamBridge(nativeProxy);
|
|
32
48
|
|
|
33
|
-
const hubLog = createModuleLogger(
|
|
49
|
+
const hubLog = createModuleLogger("hub");
|
|
34
50
|
|
|
35
51
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
36
|
-
const PUBLIC_PATHS = new Set([
|
|
37
|
-
const RATE_LIMIT_MAX = 100;
|
|
52
|
+
const PUBLIC_PATHS = new Set(["/", "/status", "/health", "/healthz"]);
|
|
53
|
+
const RATE_LIMIT_MAX = 100; // requests per window
|
|
38
54
|
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
|
|
55
|
+
const LOOPBACK_REMOTE_ADDRESSES = new Set([
|
|
56
|
+
"127.0.0.1",
|
|
57
|
+
"::1",
|
|
58
|
+
"::ffff:127.0.0.1",
|
|
59
|
+
]);
|
|
60
|
+
const ALLOWED_ORIGIN_RE =
|
|
61
|
+
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
62
|
+
const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
63
|
+
const PUBLIC_DIR = resolve(join(PROJECT_ROOT, "hub", "public"));
|
|
64
|
+
const CACHE_DIR = join(homedir(), ".claude", "cache");
|
|
65
|
+
const BATCH_EVENTS_PATH = join(CACHE_DIR, "batch-events.jsonl");
|
|
66
|
+
const SV_ACCUMULATOR_PATH = join(CACHE_DIR, "sv-accumulator.json");
|
|
67
|
+
const CODEX_RATE_LIMITS_CACHE_PATH = join(
|
|
68
|
+
CACHE_DIR,
|
|
69
|
+
"codex-rate-limits-cache.json",
|
|
70
|
+
);
|
|
71
|
+
const GEMINI_QUOTA_CACHE_PATH = join(CACHE_DIR, "gemini-quota-cache.json");
|
|
72
|
+
const CLAUDE_USAGE_CACHE_PATH = join(CACHE_DIR, "claude-usage-cache.json");
|
|
49
73
|
const AIMD_WINDOW_MS = 30 * 60 * 1000;
|
|
50
74
|
const AIMD_INITIAL_BATCH_SIZE = 3;
|
|
51
75
|
const AIMD_MIN_BATCH_SIZE = 1;
|
|
@@ -53,22 +77,22 @@ const AIMD_MAX_BATCH_SIZE = 10;
|
|
|
53
77
|
const HUB_IDLE_TIMEOUT_DEFAULT_MS = 10 * 60 * 1000;
|
|
54
78
|
const HUB_IDLE_SWEEP_DEFAULT_MS = 60 * 1000;
|
|
55
79
|
const STATIC_CONTENT_TYPES = Object.freeze({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
".html": "text/html",
|
|
81
|
+
".css": "text/css",
|
|
82
|
+
".js": "application/javascript",
|
|
83
|
+
".png": "image/png",
|
|
60
84
|
});
|
|
61
85
|
|
|
62
86
|
// IP-based sliding window rate limiter (in-memory, no external deps)
|
|
63
87
|
// Each entry is an array of request timestamps within the current window.
|
|
64
88
|
const rateLimitMap = new Map();
|
|
65
89
|
|
|
66
|
-
function formatHostForUrl(host =
|
|
67
|
-
return String(host).includes(
|
|
90
|
+
function formatHostForUrl(host = "127.0.0.1") {
|
|
91
|
+
return String(host).includes(":") ? `[${host}]` : host;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
function buildHubUrl(host, port) {
|
|
71
|
-
return `http://${formatHostForUrl(host ||
|
|
95
|
+
return `http://${formatHostForUrl(host || "127.0.0.1")}:${port}/mcp`;
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
function isPidAlive(pid, killFn = process.kill) {
|
|
@@ -84,7 +108,7 @@ function isPidAlive(pid, killFn = process.kill) {
|
|
|
84
108
|
|
|
85
109
|
async function tryReuseExistingHub({
|
|
86
110
|
port,
|
|
87
|
-
host =
|
|
111
|
+
host = "127.0.0.1",
|
|
88
112
|
readCurrentState = readState,
|
|
89
113
|
readInfo = getHubInfo,
|
|
90
114
|
checkHealth = isServerHealthy,
|
|
@@ -92,14 +116,19 @@ async function tryReuseExistingHub({
|
|
|
92
116
|
} = {}) {
|
|
93
117
|
const existing = readCurrentState();
|
|
94
118
|
const existingPort = Number(existing?.port);
|
|
95
|
-
if (
|
|
119
|
+
if (
|
|
120
|
+
!isPidAlive(existing?.pid, killFn) ||
|
|
121
|
+
!Number.isFinite(existingPort) ||
|
|
122
|
+
existingPort <= 0
|
|
123
|
+
) {
|
|
96
124
|
return null;
|
|
97
125
|
}
|
|
98
|
-
if (Number.isFinite(Number(port)) && existingPort !== Number(port))
|
|
126
|
+
if (Number.isFinite(Number(port)) && existingPort !== Number(port))
|
|
127
|
+
return null;
|
|
99
128
|
if (!(await checkHealth(existingPort))) return null;
|
|
100
129
|
|
|
101
130
|
const info = readInfo() ?? existing;
|
|
102
|
-
const infoHost = typeof info?.host ===
|
|
131
|
+
const infoHost = typeof info?.host === "string" ? info.host : host;
|
|
103
132
|
return {
|
|
104
133
|
reused: true,
|
|
105
134
|
external: true,
|
|
@@ -125,8 +154,9 @@ function checkRateLimit(ip) {
|
|
|
125
154
|
}
|
|
126
155
|
|
|
127
156
|
function isInitializeRequest(body) {
|
|
128
|
-
if (body?.method ===
|
|
129
|
-
if (Array.isArray(body))
|
|
157
|
+
if (body?.method === "initialize") return true;
|
|
158
|
+
if (Array.isArray(body))
|
|
159
|
+
return body.some((message) => message.method === "initialize");
|
|
130
160
|
return false;
|
|
131
161
|
}
|
|
132
162
|
|
|
@@ -136,69 +166,81 @@ async function parseBody(req) {
|
|
|
136
166
|
for await (const chunk of req) {
|
|
137
167
|
size += chunk.length;
|
|
138
168
|
if (size > MAX_BODY_SIZE) {
|
|
139
|
-
throw Object.assign(new Error(
|
|
169
|
+
throw Object.assign(new Error("Body too large"), { statusCode: 413 });
|
|
140
170
|
}
|
|
141
171
|
chunks.push(chunk);
|
|
142
172
|
}
|
|
143
173
|
return JSON.parse(Buffer.concat(chunks).toString());
|
|
144
174
|
}
|
|
145
175
|
|
|
146
|
-
const PID_DIR = join(homedir(),
|
|
147
|
-
const PID_FILE = join(PID_DIR,
|
|
148
|
-
const TOKEN_FILE = join(homedir(),
|
|
176
|
+
const PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
177
|
+
const PID_FILE = join(PID_DIR, "hub.pid");
|
|
178
|
+
const TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
|
|
149
179
|
|
|
150
180
|
function isPublicPath(path) {
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
181
|
+
return (
|
|
182
|
+
PUBLIC_PATHS.has(path) ||
|
|
183
|
+
path === "/dashboard" ||
|
|
184
|
+
path === "/api/qos-stats" ||
|
|
185
|
+
path.startsWith("/public/")
|
|
186
|
+
);
|
|
155
187
|
}
|
|
156
188
|
|
|
157
189
|
function isAllowedOrigin(origin) {
|
|
158
190
|
return origin && ALLOWED_ORIGIN_RE.test(origin);
|
|
159
191
|
}
|
|
160
192
|
|
|
161
|
-
function getRequestPath(url =
|
|
193
|
+
function getRequestPath(url = "/") {
|
|
162
194
|
try {
|
|
163
|
-
return new URL(url,
|
|
195
|
+
return new URL(url, "http://127.0.0.1").pathname;
|
|
164
196
|
} catch {
|
|
165
|
-
return String(url).replace(/\?.*/,
|
|
197
|
+
return String(url).replace(/\?.*/, "") || "/";
|
|
166
198
|
}
|
|
167
199
|
}
|
|
168
200
|
|
|
169
201
|
function isLoopbackRemoteAddress(remoteAddress) {
|
|
170
|
-
return
|
|
202
|
+
return (
|
|
203
|
+
typeof remoteAddress === "string" &&
|
|
204
|
+
LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress)
|
|
205
|
+
);
|
|
171
206
|
}
|
|
172
207
|
|
|
173
208
|
function extractBearerToken(req) {
|
|
174
|
-
const authHeader =
|
|
175
|
-
|
|
209
|
+
const authHeader =
|
|
210
|
+
typeof req.headers.authorization === "string"
|
|
211
|
+
? req.headers.authorization
|
|
212
|
+
: "";
|
|
213
|
+
return authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
|
176
214
|
}
|
|
177
215
|
|
|
178
216
|
function writeJson(res, statusCode, body, headers = {}) {
|
|
179
217
|
res.writeHead(statusCode, {
|
|
180
|
-
|
|
218
|
+
"Content-Type": "application/json",
|
|
181
219
|
...headers,
|
|
182
220
|
});
|
|
183
221
|
res.end(JSON.stringify(body));
|
|
184
222
|
}
|
|
185
223
|
|
|
186
224
|
function applyCorsHeaders(req, res) {
|
|
187
|
-
const origin =
|
|
225
|
+
const origin =
|
|
226
|
+
typeof req.headers.origin === "string" ? req.headers.origin : "";
|
|
188
227
|
if (origin) {
|
|
189
|
-
res.setHeader(
|
|
228
|
+
res.setHeader("Vary", "Origin");
|
|
190
229
|
}
|
|
191
230
|
if (!isAllowedOrigin(origin)) return false;
|
|
192
231
|
|
|
193
|
-
res.setHeader(
|
|
194
|
-
res.setHeader(
|
|
195
|
-
res.setHeader(
|
|
232
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
233
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
234
|
+
res.setHeader(
|
|
235
|
+
"Access-Control-Allow-Headers",
|
|
236
|
+
"Content-Type, Authorization, mcp-session-id, Last-Event-ID",
|
|
237
|
+
);
|
|
196
238
|
return true;
|
|
197
239
|
}
|
|
198
240
|
|
|
199
241
|
function safeTokenCompare(a, b) {
|
|
200
|
-
const ha = createHash(
|
|
201
|
-
const hb = createHash(
|
|
242
|
+
const ha = createHash("sha256").update(a).digest();
|
|
243
|
+
const hb = createHash("sha256").update(b).digest();
|
|
202
244
|
return timingSafeEqual(ha, hb);
|
|
203
245
|
}
|
|
204
246
|
|
|
@@ -215,30 +257,83 @@ function isAuthorizedRequest(req, path, hubToken) {
|
|
|
215
257
|
function resolveTeamStatusCode(result) {
|
|
216
258
|
if (result?.ok) return 200;
|
|
217
259
|
const code = result?.error?.code;
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
260
|
+
if (
|
|
261
|
+
code === "TEAM_NOT_FOUND" ||
|
|
262
|
+
code === "TASK_NOT_FOUND" ||
|
|
263
|
+
code === "TASKS_DIR_NOT_FOUND"
|
|
264
|
+
)
|
|
265
|
+
return 404;
|
|
266
|
+
if (code === "CLAIM_CONFLICT" || code === "MTIME_CONFLICT") return 409;
|
|
267
|
+
if (
|
|
268
|
+
code === "INVALID_TEAM_NAME" ||
|
|
269
|
+
code === "INVALID_TASK_ID" ||
|
|
270
|
+
code === "INVALID_TEXT" ||
|
|
271
|
+
code === "INVALID_FROM" ||
|
|
272
|
+
code === "INVALID_STATUS"
|
|
273
|
+
)
|
|
274
|
+
return 400;
|
|
221
275
|
return 500;
|
|
222
276
|
}
|
|
223
277
|
|
|
224
278
|
function resolvePipelineStatusCode(result) {
|
|
225
279
|
if (result?.ok) return 200;
|
|
226
|
-
if (result?.error ===
|
|
227
|
-
if (result?.error ===
|
|
280
|
+
if (result?.error === "pipeline_not_found") return 404;
|
|
281
|
+
if (result?.error === "hub_db_not_found") return 503;
|
|
228
282
|
return 400;
|
|
229
283
|
}
|
|
230
284
|
|
|
285
|
+
function resolveSendInputStatusCode(result) {
|
|
286
|
+
if (result?.ok) return 200;
|
|
287
|
+
const code = result?.error?.code;
|
|
288
|
+
if (code === "CONDUCTOR_REGISTRY_NOT_AVAILABLE") return 503;
|
|
289
|
+
if (code === "CONDUCTOR_SESSION_NOT_FOUND") return 404;
|
|
290
|
+
if (code === "SEND_INPUT_FAILED") return 409;
|
|
291
|
+
return 400;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function normalizeBridgePayload(payload) {
|
|
295
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
296
|
+
return {};
|
|
297
|
+
}
|
|
298
|
+
return payload;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeHandoffBody(body) {
|
|
302
|
+
const payload = normalizeBridgePayload(body?.payload);
|
|
303
|
+
return {
|
|
304
|
+
...payload,
|
|
305
|
+
...body,
|
|
306
|
+
from: body?.from,
|
|
307
|
+
to: body?.to,
|
|
308
|
+
payload,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function normalizePublishBody(body) {
|
|
313
|
+
const payload = normalizeBridgePayload(body?.payload);
|
|
314
|
+
const type = body?.type || body?.message_type || "event";
|
|
315
|
+
return {
|
|
316
|
+
...payload,
|
|
317
|
+
...body,
|
|
318
|
+
from: body?.from,
|
|
319
|
+
to: body?.to,
|
|
320
|
+
type,
|
|
321
|
+
message_type: type,
|
|
322
|
+
payload,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
231
326
|
function safeReadJsonFile(filePath) {
|
|
232
327
|
try {
|
|
233
328
|
if (!existsSync(filePath)) return null;
|
|
234
|
-
return JSON.parse(readFileSync(filePath,
|
|
329
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
235
330
|
} catch {
|
|
236
331
|
return null;
|
|
237
332
|
}
|
|
238
333
|
}
|
|
239
334
|
|
|
240
335
|
function parsePositiveInt(value, fallback) {
|
|
241
|
-
const parsed = Number.parseInt(String(value ??
|
|
336
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
242
337
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
243
338
|
}
|
|
244
339
|
|
|
@@ -246,7 +341,7 @@ function readRecentAimdEvents(now = Date.now()) {
|
|
|
246
341
|
try {
|
|
247
342
|
if (!existsSync(BATCH_EVENTS_PATH)) return [];
|
|
248
343
|
const cutoff = now - AIMD_WINDOW_MS;
|
|
249
|
-
return readFileSync(BATCH_EVENTS_PATH,
|
|
344
|
+
return readFileSync(BATCH_EVENTS_PATH, "utf8")
|
|
250
345
|
.split(/\r?\n/)
|
|
251
346
|
.filter(Boolean)
|
|
252
347
|
.map((line) => {
|
|
@@ -270,9 +365,9 @@ function calculateAimdBatchSize(events) {
|
|
|
270
365
|
|
|
271
366
|
for (const event of events) {
|
|
272
367
|
const result = event?.result;
|
|
273
|
-
if (result ===
|
|
368
|
+
if (result === "success" || result === "success_with_warnings") {
|
|
274
369
|
batchSize = Math.min(AIMD_MAX_BATCH_SIZE, batchSize + 1);
|
|
275
|
-
} else if (result ===
|
|
370
|
+
} else if (result === "failed" || result === "timeout") {
|
|
276
371
|
batchSize = Math.max(AIMD_MIN_BATCH_SIZE, batchSize * 0.5);
|
|
277
372
|
}
|
|
278
373
|
}
|
|
@@ -296,16 +391,16 @@ function getQosStatsPayload() {
|
|
|
296
391
|
|
|
297
392
|
function resolvePublicFilePath(path) {
|
|
298
393
|
let relativePath = null;
|
|
299
|
-
if (path ===
|
|
300
|
-
relativePath =
|
|
301
|
-
} else if (path.startsWith(
|
|
302
|
-
relativePath = path.slice(
|
|
394
|
+
if (path === "/dashboard") {
|
|
395
|
+
relativePath = "dashboard.html";
|
|
396
|
+
} else if (path.startsWith("/public/")) {
|
|
397
|
+
relativePath = path.slice("/public/".length);
|
|
303
398
|
}
|
|
304
399
|
|
|
305
400
|
if (!relativePath) return null;
|
|
306
401
|
|
|
307
402
|
try {
|
|
308
|
-
relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/,
|
|
403
|
+
relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/, "");
|
|
309
404
|
} catch {
|
|
310
405
|
return null;
|
|
311
406
|
}
|
|
@@ -324,21 +419,23 @@ function servePublicFile(res, path) {
|
|
|
324
419
|
|
|
325
420
|
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
326
421
|
if (!existsSync(filePath)) {
|
|
327
|
-
hubLog.warn({ filePath },
|
|
422
|
+
hubLog.warn({ filePath }, "static.not_found");
|
|
328
423
|
res.writeHead(404);
|
|
329
|
-
res.end(
|
|
424
|
+
res.end("Not Found (static file missing)");
|
|
330
425
|
return true;
|
|
331
426
|
}
|
|
332
427
|
|
|
333
428
|
try {
|
|
334
429
|
const body = readFileSync(filePath);
|
|
335
430
|
res.writeHead(200, {
|
|
336
|
-
|
|
431
|
+
"Content-Type":
|
|
432
|
+
STATIC_CONTENT_TYPES[extname(filePath).toLowerCase()] ||
|
|
433
|
+
"application/octet-stream",
|
|
337
434
|
});
|
|
338
435
|
res.end(body);
|
|
339
436
|
} catch {
|
|
340
437
|
res.writeHead(404);
|
|
341
|
-
res.end(
|
|
438
|
+
res.end("Not Found");
|
|
342
439
|
}
|
|
343
440
|
return true;
|
|
344
441
|
}
|
|
@@ -355,16 +452,19 @@ function servePublicFile(res, path) {
|
|
|
355
452
|
export async function startHub({
|
|
356
453
|
port: portOpt,
|
|
357
454
|
dbPath,
|
|
358
|
-
host =
|
|
455
|
+
host = "127.0.0.1",
|
|
359
456
|
sessionId = process.pid,
|
|
360
457
|
createDelegatorWorker = createDelegatorMcpWorker,
|
|
361
458
|
} = {}) {
|
|
362
|
-
const port = portOpt ?? parseInt(process.env.TFX_HUB_PORT ||
|
|
459
|
+
const port = portOpt ?? parseInt(process.env.TFX_HUB_PORT || "27888", 10);
|
|
363
460
|
|
|
364
461
|
const existingHub = await tryReuseExistingHub({ port, host });
|
|
365
462
|
if (existingHub) return existingHub;
|
|
366
463
|
|
|
367
|
-
const hubIdleTimeoutMs = parsePositiveInt(
|
|
464
|
+
const hubIdleTimeoutMs = parsePositiveInt(
|
|
465
|
+
process.env.TFX_HUB_IDLE_TIMEOUT_MS,
|
|
466
|
+
HUB_IDLE_TIMEOUT_DEFAULT_MS,
|
|
467
|
+
);
|
|
368
468
|
const hubIdleSweepMs = parsePositiveInt(
|
|
369
469
|
process.env.TFX_HUB_IDLE_SWEEP_MS,
|
|
370
470
|
Math.min(HUB_IDLE_SWEEP_DEFAULT_MS, hubIdleTimeoutMs),
|
|
@@ -378,9 +478,9 @@ export async function startHub({
|
|
|
378
478
|
// DB를 npm 패키지 밖에 저장하여 npm update 시 EBUSY 방지
|
|
379
479
|
// 기존: PROJECT_ROOT/.tfx/state/state.db (패키지 내부 → 락 충돌)
|
|
380
480
|
// 변경: ~/.claude/cache/tfx-hub/state.db (패키지 외부 → 안전)
|
|
381
|
-
const hubCacheDir = join(homedir(),
|
|
481
|
+
const hubCacheDir = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
382
482
|
mkdirSync(hubCacheDir, { recursive: true });
|
|
383
|
-
dbPath = join(hubCacheDir,
|
|
483
|
+
dbPath = join(hubCacheDir, "state.db");
|
|
384
484
|
}
|
|
385
485
|
|
|
386
486
|
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
@@ -404,10 +504,12 @@ export async function startHub({
|
|
|
404
504
|
|
|
405
505
|
const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
|
|
406
506
|
if (HUB_TOKEN) {
|
|
407
|
-
mkdirSync(join(homedir(),
|
|
507
|
+
mkdirSync(join(homedir(), ".claude"), { recursive: true });
|
|
408
508
|
writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
|
|
409
509
|
} else {
|
|
410
|
-
try {
|
|
510
|
+
try {
|
|
511
|
+
unlinkSync(TOKEN_FILE);
|
|
512
|
+
} catch {}
|
|
411
513
|
}
|
|
412
514
|
|
|
413
515
|
const store = await createStoreAdapter(dbPath);
|
|
@@ -415,7 +517,10 @@ export async function startHub({
|
|
|
415
517
|
const fingerprintService = createAdaptiveFingerprintService({ store });
|
|
416
518
|
|
|
417
519
|
// Neural Memory adaptive engine 초기화
|
|
418
|
-
const adaptiveEngine = createAdaptiveEngine({
|
|
520
|
+
const adaptiveEngine = createAdaptiveEngine({
|
|
521
|
+
repoRoot: PROJECT_ROOT,
|
|
522
|
+
fingerprintService,
|
|
523
|
+
});
|
|
419
524
|
adaptiveEngine.startSession();
|
|
420
525
|
|
|
421
526
|
// safety-guard → reflexion 패널티 승격 + 적응형 규칙 유지보수
|
|
@@ -424,22 +529,38 @@ export async function startHub({
|
|
|
424
529
|
const { promotePenalties } = await import("./promote-penalties.mjs");
|
|
425
530
|
const result = promotePenalties(store, { projectSlug });
|
|
426
531
|
if (result.promoted > 0) {
|
|
427
|
-
console.log(
|
|
532
|
+
console.log(
|
|
533
|
+
`[reflexion] ${result.promoted} penalties promoted to adaptive rules`,
|
|
534
|
+
);
|
|
428
535
|
}
|
|
429
|
-
} catch {
|
|
536
|
+
} catch {
|
|
537
|
+
/* promote-penalties 실패는 Hub 시작을 막지 않음 */
|
|
538
|
+
}
|
|
430
539
|
|
|
431
540
|
// stale adaptive_rules 정리 (30일 초과 + confidence 0.2 미만)
|
|
432
541
|
try {
|
|
433
542
|
const pruned = store.pruneStaleRules();
|
|
434
|
-
if (pruned > 0)
|
|
435
|
-
|
|
543
|
+
if (pruned > 0)
|
|
544
|
+
console.log(`[reflexion] ${pruned} stale adaptive rules pruned`);
|
|
545
|
+
} catch {
|
|
546
|
+
/* prune 실패 무시 */
|
|
547
|
+
}
|
|
436
548
|
|
|
437
549
|
// adaptive rule confidence decay (7일 이상 미관측 규칙 -0.1 감소)
|
|
438
550
|
try {
|
|
439
551
|
const { decayRules } = await import("./reflexion.mjs");
|
|
440
|
-
const decay = decayRules(
|
|
441
|
-
|
|
442
|
-
|
|
552
|
+
const decay = decayRules(
|
|
553
|
+
store,
|
|
554
|
+
adaptiveEngine.sessionCount?.() || 1,
|
|
555
|
+
projectSlug,
|
|
556
|
+
);
|
|
557
|
+
if (decay.deleted.length > 0)
|
|
558
|
+
console.log(
|
|
559
|
+
`[reflexion] ${decay.deleted.length} low-confidence rules removed`,
|
|
560
|
+
);
|
|
561
|
+
} catch {
|
|
562
|
+
/* decay 실패 무시 */
|
|
563
|
+
}
|
|
443
564
|
|
|
444
565
|
// Delegator MCP resident service 초기화
|
|
445
566
|
const delegatorWorker = createDelegatorWorker({ cwd: PROJECT_ROOT });
|
|
@@ -451,518 +572,687 @@ export async function startHub({
|
|
|
451
572
|
}
|
|
452
573
|
const delegatorService = new DelegatorService({ worker: delegatorWorker });
|
|
453
574
|
|
|
454
|
-
const pipe = createPipeServer({ router, store, sessionId, delegatorService });
|
|
455
|
-
const assignCallbacks = createAssignCallbackServer({ store, sessionId });
|
|
456
575
|
const hitl = createHitlManager(store, router);
|
|
576
|
+
const pipe = createPipeServer({
|
|
577
|
+
router,
|
|
578
|
+
store,
|
|
579
|
+
sessionId,
|
|
580
|
+
delegatorService,
|
|
581
|
+
hitlManager: hitl,
|
|
582
|
+
});
|
|
583
|
+
const assignCallbacks = createAssignCallbackServer({ store, sessionId });
|
|
457
584
|
const tools = createTools(store, router, hitl, pipe);
|
|
458
585
|
const transports = new Map();
|
|
459
586
|
|
|
460
587
|
function createMcpForSession() {
|
|
461
588
|
const mcp = new Server(
|
|
462
|
-
{ name:
|
|
589
|
+
{ name: "tfx-hub", version: "1.0.0" },
|
|
463
590
|
{ capabilities: { tools: {} } },
|
|
464
591
|
);
|
|
465
592
|
|
|
466
|
-
mcp.setRequestHandler(
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
593
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
594
|
+
tools: tools.map((tool) => ({
|
|
595
|
+
name: tool.name,
|
|
596
|
+
description: tool.description,
|
|
597
|
+
inputSchema: tool.inputSchema,
|
|
598
|
+
})),
|
|
599
|
+
}));
|
|
600
|
+
|
|
601
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
602
|
+
const { name, arguments: args } = request.params;
|
|
603
|
+
const tool = tools.find((candidate) => candidate.name === name);
|
|
604
|
+
if (!tool) {
|
|
605
|
+
return {
|
|
606
|
+
content: [
|
|
607
|
+
{
|
|
608
|
+
type: "text",
|
|
609
|
+
text: JSON.stringify({
|
|
610
|
+
ok: false,
|
|
611
|
+
error: { code: "UNKNOWN_TOOL", message: `도구 없음: ${name}` },
|
|
612
|
+
}),
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
isError: true,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
return tool.handler(args || {});
|
|
619
|
+
});
|
|
491
620
|
|
|
492
621
|
return mcp;
|
|
493
622
|
}
|
|
494
623
|
|
|
495
|
-
const httpServer = createHttpServer(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
624
|
+
const httpServer = createHttpServer(
|
|
625
|
+
wrapRequestHandler(async (req, res) => {
|
|
626
|
+
markRequestActivity();
|
|
627
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
628
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
629
|
+
const path = getRequestPath(req.url);
|
|
630
|
+
const corsAllowed = applyCorsHeaders(req, res);
|
|
631
|
+
|
|
632
|
+
if (req.method === "OPTIONS") {
|
|
633
|
+
const localOnlyMode = !HUB_TOKEN;
|
|
634
|
+
const isLoopbackRequest = isLoopbackRemoteAddress(
|
|
635
|
+
req.socket.remoteAddress,
|
|
636
|
+
);
|
|
637
|
+
res.writeHead(
|
|
638
|
+
corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403,
|
|
639
|
+
);
|
|
640
|
+
return res.end();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const clientIp = req.socket.remoteAddress || "unknown";
|
|
644
|
+
if (!isLoopbackRemoteAddress(clientIp)) {
|
|
645
|
+
const rateCheck = checkRateLimit(clientIp);
|
|
646
|
+
if (!rateCheck.allowed) {
|
|
647
|
+
return writeJson(
|
|
648
|
+
res,
|
|
649
|
+
429,
|
|
650
|
+
{ ok: false, error: "Too Many Requests" },
|
|
651
|
+
{ "Retry-After": String(rateCheck.retryAfterSec) },
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
508
655
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
656
|
+
if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
|
|
657
|
+
if (!HUB_TOKEN) {
|
|
658
|
+
return writeJson(res, 403, {
|
|
659
|
+
ok: false,
|
|
660
|
+
error: "Forbidden: localhost only",
|
|
661
|
+
});
|
|
662
|
+
}
|
|
513
663
|
return writeJson(
|
|
514
664
|
res,
|
|
515
|
-
|
|
516
|
-
{ ok: false, error:
|
|
517
|
-
{ '
|
|
665
|
+
401,
|
|
666
|
+
{ ok: false, error: "Unauthorized" },
|
|
667
|
+
{ "WWW-Authenticate": 'Bearer realm="tfx-hub"' },
|
|
518
668
|
);
|
|
519
669
|
}
|
|
520
|
-
}
|
|
521
670
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
return writeJson(res,
|
|
671
|
+
if (path === "/" || path === "/status") {
|
|
672
|
+
const status = router.getStatus("hub").data;
|
|
673
|
+
return writeJson(res, 200, {
|
|
674
|
+
...status,
|
|
675
|
+
sessions: transports.size,
|
|
676
|
+
pid: process.pid,
|
|
677
|
+
port,
|
|
678
|
+
auth_mode: HUB_TOKEN ? "token-required" : "localhost-only",
|
|
679
|
+
idle_timeout_ms: hubIdleTimeoutMs,
|
|
680
|
+
last_request_at: new Date(lastRequestAt).toISOString(),
|
|
681
|
+
pipe_path: pipe.path,
|
|
682
|
+
pipe: pipe.getStatus(),
|
|
683
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
684
|
+
assign_callback_pipe: assignCallbacks.getStatus(),
|
|
685
|
+
});
|
|
525
686
|
}
|
|
526
|
-
return writeJson(
|
|
527
|
-
res,
|
|
528
|
-
401,
|
|
529
|
-
{ ok: false, error: 'Unauthorized' },
|
|
530
|
-
{ 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
|
|
531
|
-
);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
if (path === '/' || path === '/status') {
|
|
535
|
-
const status = router.getStatus('hub').data;
|
|
536
|
-
return writeJson(res, 200, {
|
|
537
|
-
...status,
|
|
538
|
-
sessions: transports.size,
|
|
539
|
-
pid: process.pid,
|
|
540
|
-
port,
|
|
541
|
-
auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
542
|
-
idle_timeout_ms: hubIdleTimeoutMs,
|
|
543
|
-
last_request_at: new Date(lastRequestAt).toISOString(),
|
|
544
|
-
pipe_path: pipe.path,
|
|
545
|
-
pipe: pipe.getStatus(),
|
|
546
|
-
assign_callback_pipe_path: assignCallbacks.path,
|
|
547
|
-
assign_callback_pipe: assignCallbacks.getStatus(),
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
687
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (path === '/api/qos-stats' && req.method === 'GET') {
|
|
569
|
-
return writeJson(res, 200, getQosStatsPayload());
|
|
570
|
-
}
|
|
688
|
+
if (path === "/health" || path === "/healthz") {
|
|
689
|
+
const status = router.getStatus("hub").data;
|
|
690
|
+
const healthy = status?.hub?.state === "healthy";
|
|
691
|
+
return writeJson(res, healthy ? 200 : 503, {
|
|
692
|
+
ok: healthy,
|
|
693
|
+
version,
|
|
694
|
+
platform: process.platform,
|
|
695
|
+
uptime_s: Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)),
|
|
696
|
+
node: process.version,
|
|
697
|
+
sessions: transports.size,
|
|
698
|
+
store: store.type || "sqlite",
|
|
699
|
+
idle_timeout_ms: hubIdleTimeoutMs,
|
|
700
|
+
idle_ms: Math.max(0, Date.now() - lastRequestAt),
|
|
701
|
+
fingerprint: fingerprintService.getHealth(),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
571
704
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (req.method !== 'POST' && req.method !== 'DELETE' && !isBridgeStatusGet) {
|
|
575
|
-
return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
|
|
705
|
+
if (path === "/api/qos-stats" && req.method === "GET") {
|
|
706
|
+
return writeJson(res, 200, getQosStatsPayload());
|
|
576
707
|
}
|
|
577
708
|
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
709
|
+
if (path.startsWith("/bridge")) {
|
|
710
|
+
const isBridgeStatusGet =
|
|
711
|
+
path === "/bridge/status" && req.method === "GET";
|
|
712
|
+
const isBridgeHitlPendingGet =
|
|
713
|
+
path === "/bridge/hitl/pending" && req.method === "GET";
|
|
714
|
+
if (
|
|
715
|
+
req.method !== "POST" &&
|
|
716
|
+
req.method !== "DELETE" &&
|
|
717
|
+
!isBridgeStatusGet &&
|
|
718
|
+
!isBridgeHitlPendingGet
|
|
719
|
+
) {
|
|
720
|
+
return writeJson(res, 405, {
|
|
721
|
+
ok: false,
|
|
722
|
+
error: "Method Not Allowed",
|
|
592
723
|
});
|
|
593
|
-
return writeJson(res, 200, result);
|
|
594
724
|
}
|
|
595
725
|
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
726
|
+
try {
|
|
727
|
+
const body = req.method === "POST" ? await parseBody(req) : {};
|
|
728
|
+
const requestUrl = new URL(req.url || path, "http://127.0.0.1");
|
|
729
|
+
|
|
730
|
+
if (path === "/bridge/status" && req.method === "GET") {
|
|
731
|
+
const scope = requestUrl.searchParams.get("scope") || "hub";
|
|
732
|
+
const include_metrics =
|
|
733
|
+
requestUrl.searchParams.get("include_metrics") !== "0";
|
|
734
|
+
const agent_id =
|
|
735
|
+
requestUrl.searchParams.get("agent_id") || undefined;
|
|
736
|
+
const trace_id =
|
|
737
|
+
requestUrl.searchParams.get("trace_id") || undefined;
|
|
738
|
+
const result = await pipe.executeQuery("status", {
|
|
739
|
+
scope,
|
|
740
|
+
include_metrics,
|
|
741
|
+
agent_id,
|
|
742
|
+
trace_id,
|
|
743
|
+
});
|
|
744
|
+
return writeJson(res, 200, result);
|
|
600
745
|
}
|
|
601
746
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
cli,
|
|
606
|
-
capabilities,
|
|
607
|
-
topics,
|
|
608
|
-
heartbeat_ttl_ms,
|
|
609
|
-
metadata,
|
|
610
|
-
});
|
|
611
|
-
return writeJson(res, 200, result);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (path === '/bridge/result' && req.method === 'POST') {
|
|
615
|
-
const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
|
|
616
|
-
if (!agent_id) {
|
|
617
|
-
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
747
|
+
if (path === "/bridge/hitl/pending" && req.method === "GET") {
|
|
748
|
+
const result = { ok: true, data: hitl.getPendingRequests() };
|
|
749
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
618
750
|
}
|
|
619
751
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
752
|
+
if (path === "/bridge/register" && req.method === "POST") {
|
|
753
|
+
const {
|
|
754
|
+
agent_id,
|
|
755
|
+
cli,
|
|
756
|
+
timeout_sec = 600,
|
|
757
|
+
topics = [],
|
|
758
|
+
capabilities = [],
|
|
759
|
+
metadata = {},
|
|
760
|
+
} = body;
|
|
761
|
+
if (!agent_id || !cli) {
|
|
762
|
+
return writeJson(res, 400, {
|
|
763
|
+
ok: false,
|
|
764
|
+
error: "agent_id, cli 필수",
|
|
765
|
+
});
|
|
766
|
+
}
|
|
629
767
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
} = body;
|
|
641
|
-
|
|
642
|
-
if (!to_agent || !command) {
|
|
643
|
-
return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
|
|
768
|
+
const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
|
|
769
|
+
const result = await pipe.executeCommand("register", {
|
|
770
|
+
agent_id,
|
|
771
|
+
cli,
|
|
772
|
+
capabilities,
|
|
773
|
+
topics,
|
|
774
|
+
heartbeat_ttl_ms,
|
|
775
|
+
metadata,
|
|
776
|
+
});
|
|
777
|
+
return writeJson(res, 200, result);
|
|
644
778
|
}
|
|
645
779
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
780
|
+
if (path === "/bridge/result" && req.method === "POST") {
|
|
781
|
+
const {
|
|
782
|
+
agent_id,
|
|
783
|
+
topic = "task.result",
|
|
784
|
+
payload = {},
|
|
785
|
+
trace_id,
|
|
786
|
+
correlation_id,
|
|
787
|
+
} = body;
|
|
788
|
+
if (!agent_id) {
|
|
789
|
+
return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
|
|
790
|
+
}
|
|
656
791
|
|
|
657
|
-
|
|
658
|
-
|
|
792
|
+
const result = await pipe.executeCommand("result", {
|
|
793
|
+
agent_id,
|
|
794
|
+
topic,
|
|
795
|
+
payload,
|
|
796
|
+
trace_id,
|
|
797
|
+
correlation_id,
|
|
798
|
+
});
|
|
799
|
+
return writeJson(res, 200, result);
|
|
800
|
+
}
|
|
659
801
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (!
|
|
673
|
-
return writeJson(res, 400, {
|
|
802
|
+
if (path === "/bridge/control" && req.method === "POST") {
|
|
803
|
+
const {
|
|
804
|
+
from_agent = "lead",
|
|
805
|
+
to_agent,
|
|
806
|
+
command,
|
|
807
|
+
reason = "",
|
|
808
|
+
payload = {},
|
|
809
|
+
trace_id,
|
|
810
|
+
correlation_id,
|
|
811
|
+
ttl_ms = 3600000,
|
|
812
|
+
} = body;
|
|
813
|
+
|
|
814
|
+
if (!to_agent || !command) {
|
|
815
|
+
return writeJson(res, 400, {
|
|
816
|
+
ok: false,
|
|
817
|
+
error: "to_agent, command 필수",
|
|
818
|
+
});
|
|
674
819
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
},
|
|
820
|
+
|
|
821
|
+
const result = await pipe.executeCommand("control", {
|
|
822
|
+
from_agent,
|
|
823
|
+
to_agent,
|
|
824
|
+
command,
|
|
825
|
+
reason,
|
|
826
|
+
payload,
|
|
827
|
+
ttl_ms,
|
|
828
|
+
trace_id,
|
|
829
|
+
correlation_id,
|
|
693
830
|
});
|
|
831
|
+
|
|
832
|
+
return writeJson(res, 200, result);
|
|
694
833
|
}
|
|
695
834
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
trace_id,
|
|
701
|
-
});
|
|
702
|
-
return writeJson(res, 200, result);
|
|
703
|
-
}
|
|
835
|
+
if (path === "/bridge/handoff" && req.method === "POST") {
|
|
836
|
+
const result = router.handleHandoff(normalizeHandoffBody(body));
|
|
837
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
838
|
+
}
|
|
704
839
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
worker_agent,
|
|
709
|
-
task,
|
|
710
|
-
topic = 'assign.job',
|
|
711
|
-
payload = {},
|
|
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 필수' });
|
|
840
|
+
if (path === "/bridge/publish" && req.method === "POST") {
|
|
841
|
+
const result = router.handlePublish(normalizePublishBody(body));
|
|
842
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
722
843
|
}
|
|
723
844
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
timeout_ms,
|
|
733
|
-
max_retries,
|
|
734
|
-
trace_id,
|
|
735
|
-
correlation_id,
|
|
736
|
-
});
|
|
737
|
-
return writeJson(res, result.ok ? 200 : 400, result);
|
|
738
|
-
}
|
|
845
|
+
if (path === "/bridge/send-input" && req.method === "POST") {
|
|
846
|
+
const { session_id, text } = body;
|
|
847
|
+
if (!session_id || typeof text !== "string" || text.length === 0) {
|
|
848
|
+
return writeJson(res, 400, {
|
|
849
|
+
ok: false,
|
|
850
|
+
error: "session_id, text 필수",
|
|
851
|
+
});
|
|
852
|
+
}
|
|
739
853
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
attempt,
|
|
746
|
-
result: assignResult,
|
|
747
|
-
error: assignError,
|
|
748
|
-
payload = {},
|
|
749
|
-
metadata = {},
|
|
750
|
-
} = body;
|
|
751
|
-
|
|
752
|
-
if (!job_id || !status) {
|
|
753
|
-
return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
|
|
854
|
+
const result = await pipe.executeCommand("send_input", {
|
|
855
|
+
session_id,
|
|
856
|
+
text,
|
|
857
|
+
});
|
|
858
|
+
return writeJson(res, resolveSendInputStatusCode(result), result);
|
|
754
859
|
}
|
|
755
860
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
861
|
+
if (path === "/bridge/status" && req.method === "POST") {
|
|
862
|
+
const {
|
|
863
|
+
scope = "hub",
|
|
864
|
+
agent_id,
|
|
865
|
+
status,
|
|
866
|
+
include_metrics = true,
|
|
867
|
+
trace_id,
|
|
868
|
+
} = body;
|
|
869
|
+
|
|
870
|
+
if (agent_id && status) {
|
|
871
|
+
const normalizedAgentId = String(agent_id || "").trim();
|
|
872
|
+
const normalizedStatus = String(status || "")
|
|
873
|
+
.trim()
|
|
874
|
+
.toLowerCase();
|
|
875
|
+
if (!normalizedAgentId || !normalizedStatus) {
|
|
876
|
+
return writeJson(res, 400, {
|
|
877
|
+
ok: false,
|
|
878
|
+
error: "agent_id, status 필수",
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
const statusForStore = new Set([
|
|
882
|
+
"online",
|
|
883
|
+
"stale",
|
|
884
|
+
"offline",
|
|
885
|
+
]).has(normalizedStatus)
|
|
886
|
+
? normalizedStatus
|
|
887
|
+
: "online";
|
|
888
|
+
router.updateAgentStatus(normalizedAgentId, statusForStore);
|
|
889
|
+
const snapshot = await pipe.executeQuery("status", {
|
|
890
|
+
scope: "agent",
|
|
891
|
+
agent_id: normalizedAgentId,
|
|
892
|
+
include_metrics: false,
|
|
893
|
+
});
|
|
894
|
+
return writeJson(res, 200, {
|
|
895
|
+
ok: true,
|
|
896
|
+
data: {
|
|
897
|
+
agent_id: normalizedAgentId,
|
|
898
|
+
status: statusForStore,
|
|
899
|
+
reported_status: normalizedStatus,
|
|
900
|
+
reported_at_ms: Date.now(),
|
|
901
|
+
snapshot: snapshot?.data?.agent || null,
|
|
902
|
+
},
|
|
903
|
+
});
|
|
904
|
+
}
|
|
774
905
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
906
|
+
const result = await pipe.executeQuery("status", {
|
|
907
|
+
scope,
|
|
908
|
+
agent_id,
|
|
909
|
+
include_metrics,
|
|
910
|
+
trace_id,
|
|
911
|
+
});
|
|
912
|
+
return writeJson(res, 200, result);
|
|
779
913
|
}
|
|
780
914
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
915
|
+
if (path === "/bridge/assign/async" && req.method === "POST") {
|
|
916
|
+
const {
|
|
917
|
+
supervisor_agent,
|
|
918
|
+
worker_agent,
|
|
919
|
+
task,
|
|
920
|
+
topic = "assign.job",
|
|
921
|
+
payload = {},
|
|
922
|
+
priority = 5,
|
|
923
|
+
ttl_ms = 600000,
|
|
924
|
+
timeout_ms = 600000,
|
|
925
|
+
max_retries = 0,
|
|
926
|
+
trace_id,
|
|
927
|
+
correlation_id,
|
|
928
|
+
} = body;
|
|
929
|
+
|
|
930
|
+
if (!supervisor_agent || !worker_agent || !task) {
|
|
931
|
+
return writeJson(res, 400, {
|
|
932
|
+
ok: false,
|
|
933
|
+
error: "supervisor_agent, worker_agent, task 필수",
|
|
934
|
+
});
|
|
935
|
+
}
|
|
792
936
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
937
|
+
const result = await pipe.executeCommand("assign", {
|
|
938
|
+
supervisor_agent,
|
|
939
|
+
worker_agent,
|
|
940
|
+
task,
|
|
941
|
+
topic,
|
|
942
|
+
payload,
|
|
943
|
+
priority,
|
|
944
|
+
ttl_ms,
|
|
945
|
+
timeout_ms,
|
|
946
|
+
max_retries,
|
|
947
|
+
trace_id,
|
|
948
|
+
correlation_id,
|
|
949
|
+
});
|
|
950
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
803
951
|
}
|
|
804
952
|
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
953
|
+
if (path === "/bridge/assign/result" && req.method === "POST") {
|
|
954
|
+
const {
|
|
955
|
+
job_id,
|
|
956
|
+
worker_agent,
|
|
957
|
+
status,
|
|
958
|
+
attempt,
|
|
959
|
+
result: assignResult,
|
|
960
|
+
error: assignError,
|
|
961
|
+
payload = {},
|
|
962
|
+
metadata = {},
|
|
963
|
+
} = body;
|
|
964
|
+
|
|
965
|
+
if (!job_id || !status) {
|
|
966
|
+
return writeJson(res, 400, {
|
|
967
|
+
ok: false,
|
|
968
|
+
error: "job_id, status 필수",
|
|
969
|
+
});
|
|
970
|
+
}
|
|
808
971
|
|
|
809
|
-
|
|
810
|
-
|
|
972
|
+
const result = await pipe.executeCommand("assign_result", {
|
|
973
|
+
job_id,
|
|
974
|
+
worker_agent,
|
|
975
|
+
status,
|
|
976
|
+
attempt,
|
|
977
|
+
result: assignResult,
|
|
978
|
+
error: assignError,
|
|
979
|
+
payload,
|
|
980
|
+
metadata,
|
|
981
|
+
});
|
|
982
|
+
return writeJson(res, result.ok ? 200 : 409, result);
|
|
811
983
|
}
|
|
812
984
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
985
|
+
if (path === "/bridge/assign/status" && req.method === "POST") {
|
|
986
|
+
const result = await pipe.executeQuery("assign_status", body);
|
|
987
|
+
const statusCode = result.ok
|
|
988
|
+
? 200
|
|
989
|
+
: result.error?.code === "ASSIGN_NOT_FOUND"
|
|
990
|
+
? 404
|
|
991
|
+
: 400;
|
|
992
|
+
return writeJson(res, statusCode, result);
|
|
817
993
|
}
|
|
818
994
|
|
|
819
|
-
if (path ===
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
995
|
+
if (path === "/bridge/assign/retry" && req.method === "POST") {
|
|
996
|
+
const { job_id, reason, requested_by } = body;
|
|
997
|
+
if (!job_id) {
|
|
998
|
+
return writeJson(res, 400, { ok: false, error: "job_id 필수" });
|
|
999
|
+
}
|
|
823
1000
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1001
|
+
const result = await pipe.executeCommand("assign_retry", {
|
|
1002
|
+
job_id,
|
|
1003
|
+
reason,
|
|
1004
|
+
requested_by,
|
|
1005
|
+
});
|
|
1006
|
+
const statusCode = result.ok
|
|
1007
|
+
? 200
|
|
1008
|
+
: result.error?.code === "ASSIGN_NOT_FOUND"
|
|
1009
|
+
? 404
|
|
1010
|
+
: result.error?.code === "ASSIGN_RETRY_EXHAUSTED"
|
|
1011
|
+
? 409
|
|
1012
|
+
: 400;
|
|
1013
|
+
return writeJson(res, statusCode, result);
|
|
827
1014
|
}
|
|
828
1015
|
|
|
829
|
-
if (
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1016
|
+
if (req.method === "POST") {
|
|
1017
|
+
let teamResult = null;
|
|
1018
|
+
if (path === "/bridge/team/info" || path === "/bridge/team-info") {
|
|
1019
|
+
teamResult = await pipe.executeQuery("team_info", body);
|
|
1020
|
+
} else if (
|
|
1021
|
+
path === "/bridge/team/task-list" ||
|
|
1022
|
+
path === "/bridge/team-task-list"
|
|
1023
|
+
) {
|
|
1024
|
+
teamResult = await pipe.executeQuery("team_task_list", body);
|
|
1025
|
+
} else if (
|
|
1026
|
+
path === "/bridge/team/task-update" ||
|
|
1027
|
+
path === "/bridge/team-task-update"
|
|
1028
|
+
) {
|
|
1029
|
+
teamResult = await pipe.executeCommand("team_task_update", body);
|
|
1030
|
+
} else if (
|
|
1031
|
+
path === "/bridge/team/send-message" ||
|
|
1032
|
+
path === "/bridge/team-send-message"
|
|
1033
|
+
) {
|
|
1034
|
+
teamResult = await pipe.executeCommand("team_send_message", body);
|
|
1035
|
+
}
|
|
833
1036
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1037
|
+
if (teamResult) {
|
|
1038
|
+
return writeJson(
|
|
1039
|
+
res,
|
|
1040
|
+
resolveTeamStatusCode(teamResult),
|
|
1041
|
+
teamResult,
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
839
1044
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1045
|
+
if (path.startsWith("/bridge/team")) {
|
|
1046
|
+
return writeJson(res, 404, {
|
|
1047
|
+
ok: false,
|
|
1048
|
+
error: `Unknown team endpoint: ${path}`,
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ── 파이프라인 엔드포인트 ──
|
|
1053
|
+
if (path === "/bridge/pipeline/state" && req.method === "POST") {
|
|
1054
|
+
const result = await pipe.executeQuery("pipeline_state", body);
|
|
1055
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (path === "/bridge/pipeline/advance" && req.method === "POST") {
|
|
1059
|
+
const result = await pipe.executeCommand(
|
|
1060
|
+
"pipeline_advance",
|
|
1061
|
+
body,
|
|
1062
|
+
);
|
|
1063
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (path === "/bridge/pipeline/init" && req.method === "POST") {
|
|
1067
|
+
const result = await pipe.executeCommand("pipeline_init", body);
|
|
1068
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (path === "/bridge/pipeline/list" && req.method === "POST") {
|
|
1072
|
+
const result = await pipe.executeQuery("pipeline_list", body);
|
|
1073
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// ── Delegator 엔드포인트 ──
|
|
1077
|
+
if (
|
|
1078
|
+
path === "/bridge/delegator/delegate" &&
|
|
1079
|
+
req.method === "POST"
|
|
1080
|
+
) {
|
|
1081
|
+
const result = await pipe.executeCommand(
|
|
1082
|
+
"delegator_delegate",
|
|
1083
|
+
body,
|
|
1084
|
+
);
|
|
1085
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (path === "/bridge/delegator/reply" && req.method === "POST") {
|
|
1089
|
+
const result = await pipe.executeCommand("delegator_reply", body);
|
|
1090
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (path === "/bridge/delegator/status" && req.method === "POST") {
|
|
1094
|
+
const result = await pipe.executeQuery("delegator_status", body);
|
|
1095
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (path === "/bridge/hitl/request" && req.method === "POST") {
|
|
1099
|
+
const result = hitl.requestHumanInput(body);
|
|
1100
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (path === "/bridge/hitl/submit" && req.method === "POST") {
|
|
1104
|
+
const result = hitl.submitHumanInput(body);
|
|
1105
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1106
|
+
}
|
|
843
1107
|
}
|
|
844
1108
|
|
|
845
|
-
if (path ===
|
|
846
|
-
const
|
|
847
|
-
|
|
1109
|
+
if (path === "/bridge/context" && req.method === "POST") {
|
|
1110
|
+
const {
|
|
1111
|
+
agent_id,
|
|
1112
|
+
topics,
|
|
1113
|
+
max_messages = 10,
|
|
1114
|
+
auto_ack = true,
|
|
1115
|
+
} = body;
|
|
1116
|
+
if (!agent_id) {
|
|
1117
|
+
return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const result = await pipe.executeQuery("drain", {
|
|
1121
|
+
agent_id,
|
|
1122
|
+
topics,
|
|
1123
|
+
max_messages,
|
|
1124
|
+
auto_ack,
|
|
1125
|
+
});
|
|
1126
|
+
return writeJson(res, 200, result);
|
|
848
1127
|
}
|
|
849
|
-
}
|
|
850
1128
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1129
|
+
if (path === "/bridge/deregister" && req.method === "POST") {
|
|
1130
|
+
const { agent_id } = body;
|
|
1131
|
+
if (!agent_id) {
|
|
1132
|
+
return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
|
|
1133
|
+
}
|
|
1134
|
+
const result = await pipe.executeCommand("deregister", {
|
|
1135
|
+
agent_id,
|
|
1136
|
+
});
|
|
1137
|
+
return writeJson(res, 200, result);
|
|
855
1138
|
}
|
|
856
1139
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
max_messages,
|
|
861
|
-
auto_ack,
|
|
1140
|
+
return writeJson(res, 404, {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
error: "Unknown bridge endpoint",
|
|
862
1143
|
});
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const { agent_id } = body;
|
|
868
|
-
if (!agent_id) {
|
|
869
|
-
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
if (!res.headersSent) {
|
|
1146
|
+
console.error("[tfx-hub] bridge error:", error);
|
|
1147
|
+
writeJson(res, 500, { ok: false, error: "Internal server error" });
|
|
870
1148
|
}
|
|
871
|
-
|
|
872
|
-
return writeJson(res, 200, result);
|
|
1149
|
+
return;
|
|
873
1150
|
}
|
|
1151
|
+
}
|
|
874
1152
|
|
|
875
|
-
|
|
876
|
-
} catch (error) {
|
|
877
|
-
if (!res.headersSent) {
|
|
878
|
-
console.error('[tfx-hub] bridge error:', error);
|
|
879
|
-
writeJson(res, 500, { ok: false, error: 'Internal server error' });
|
|
880
|
-
}
|
|
1153
|
+
if (req.method === "GET" && servePublicFile(res, path)) {
|
|
881
1154
|
return;
|
|
882
1155
|
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
if (req.method === 'GET' && servePublicFile(res, path)) {
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
1156
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1157
|
+
if (path !== "/mcp") {
|
|
1158
|
+
res.writeHead(404);
|
|
1159
|
+
return res.end("Not Found");
|
|
1160
|
+
}
|
|
893
1161
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1162
|
+
try {
|
|
1163
|
+
const sessionIdHeader = req.headers["mcp-session-id"];
|
|
1164
|
+
|
|
1165
|
+
if (req.method === "POST") {
|
|
1166
|
+
const body = await parseBody(req);
|
|
1167
|
+
|
|
1168
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
1169
|
+
const session = transports.get(sessionIdHeader);
|
|
1170
|
+
session.transport._lastActivity = Date.now();
|
|
1171
|
+
await session.transport.handleRequest(req, res, body);
|
|
1172
|
+
} else if (!sessionIdHeader && isInitializeRequest(body)) {
|
|
1173
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1174
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1175
|
+
onsessioninitialized: (sid) => {
|
|
1176
|
+
transport._lastActivity = Date.now();
|
|
1177
|
+
transports.set(sid, { transport, mcp });
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
transport.onclose = () => {
|
|
1181
|
+
if (transport.sessionId) {
|
|
1182
|
+
const session = transports.get(transport.sessionId);
|
|
1183
|
+
if (session) {
|
|
1184
|
+
try {
|
|
1185
|
+
session.mcp.close();
|
|
1186
|
+
} catch {}
|
|
1187
|
+
}
|
|
1188
|
+
transports.delete(transport.sessionId);
|
|
917
1189
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1190
|
+
};
|
|
1191
|
+
const mcp = createMcpForSession();
|
|
1192
|
+
await mcp.connect(transport);
|
|
1193
|
+
await transport.handleRequest(req, res, body);
|
|
1194
|
+
} else {
|
|
1195
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1196
|
+
res.end(
|
|
1197
|
+
JSON.stringify({
|
|
1198
|
+
jsonrpc: "2.0",
|
|
1199
|
+
error: {
|
|
1200
|
+
code: -32000,
|
|
1201
|
+
message: "Bad Request: No valid session ID",
|
|
1202
|
+
},
|
|
1203
|
+
id: null,
|
|
1204
|
+
}),
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
} else if (req.method === "GET") {
|
|
1208
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
1209
|
+
await transports
|
|
1210
|
+
.get(sessionIdHeader)
|
|
1211
|
+
.transport.handleRequest(req, res);
|
|
1212
|
+
} else {
|
|
1213
|
+
res.writeHead(400);
|
|
1214
|
+
res.end("Invalid or missing session ID");
|
|
1215
|
+
}
|
|
1216
|
+
} else if (req.method === "DELETE") {
|
|
1217
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
1218
|
+
await transports
|
|
1219
|
+
.get(sessionIdHeader)
|
|
1220
|
+
.transport.handleRequest(req, res);
|
|
1221
|
+
} else {
|
|
1222
|
+
res.writeHead(400);
|
|
1223
|
+
res.end("Invalid or missing session ID");
|
|
1224
|
+
}
|
|
935
1225
|
} else {
|
|
936
|
-
res.writeHead(
|
|
937
|
-
res.end(
|
|
1226
|
+
res.writeHead(405);
|
|
1227
|
+
res.end("Method Not Allowed");
|
|
938
1228
|
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
hubLog.error({ err: error }, "http.error");
|
|
1231
|
+
if (!res.headersSent) {
|
|
1232
|
+
const code =
|
|
1233
|
+
error.statusCode === 413
|
|
1234
|
+
? 413
|
|
1235
|
+
: error instanceof SyntaxError
|
|
1236
|
+
? 400
|
|
1237
|
+
: 500;
|
|
1238
|
+
const message =
|
|
1239
|
+
code === 413
|
|
1240
|
+
? "Body too large"
|
|
1241
|
+
: code === 400
|
|
1242
|
+
? "Invalid JSON"
|
|
1243
|
+
: "Internal server error";
|
|
1244
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
1245
|
+
res.end(
|
|
1246
|
+
JSON.stringify({
|
|
1247
|
+
jsonrpc: "2.0",
|
|
1248
|
+
error: { code: code === 500 ? -32603 : -32700, message },
|
|
1249
|
+
id: null,
|
|
1250
|
+
}),
|
|
1251
|
+
);
|
|
945
1252
|
}
|
|
946
|
-
} else {
|
|
947
|
-
res.writeHead(405);
|
|
948
|
-
res.end('Method Not Allowed');
|
|
949
1253
|
}
|
|
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
|
-
}));
|
|
1254
|
+
}),
|
|
1255
|
+
);
|
|
966
1256
|
|
|
967
1257
|
httpServer.requestTimeout = 30000;
|
|
968
1258
|
httpServer.headersTimeout = 10000;
|
|
@@ -970,41 +1260,54 @@ export async function startHub({
|
|
|
970
1260
|
router.startSweeper();
|
|
971
1261
|
|
|
972
1262
|
const hitlTimer = setInterval(() => {
|
|
973
|
-
try {
|
|
1263
|
+
try {
|
|
1264
|
+
hitl.checkTimeouts();
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
hubLog.warn({ err }, "hitl.timeout_check_failed");
|
|
1267
|
+
}
|
|
974
1268
|
}, 10000);
|
|
975
1269
|
hitlTimer.unref();
|
|
976
1270
|
|
|
977
1271
|
// MCP session TTL: sessions idle for SESSION_TTL_MS are closed automatically.
|
|
978
1272
|
// Configurable via SESSION_TTL_MS (default 30 minutes). The sweep runs every 60 s.
|
|
979
|
-
const SESSION_TTL_MS =
|
|
1273
|
+
const SESSION_TTL_MS =
|
|
1274
|
+
parseInt(process.env.TFX_SESSION_TTL_MS || "", 10) || 30 * 60 * 1000;
|
|
980
1275
|
const sessionTimer = setInterval(() => {
|
|
981
1276
|
const now = Date.now();
|
|
982
1277
|
for (const [sid, session] of transports) {
|
|
983
|
-
if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS)
|
|
984
|
-
|
|
985
|
-
try {
|
|
1278
|
+
if (now - (session.transport._lastActivity || 0) <= SESSION_TTL_MS)
|
|
1279
|
+
continue;
|
|
1280
|
+
try {
|
|
1281
|
+
session.mcp.close();
|
|
1282
|
+
} catch {}
|
|
1283
|
+
try {
|
|
1284
|
+
session.transport.close();
|
|
1285
|
+
} catch {}
|
|
986
1286
|
transports.delete(sid);
|
|
987
1287
|
}
|
|
988
1288
|
}, 60000);
|
|
989
1289
|
sessionTimer.unref();
|
|
990
1290
|
|
|
991
1291
|
// 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
|
|
992
|
-
const orphanCleanupTimer = setInterval(
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1292
|
+
const orphanCleanupTimer = setInterval(
|
|
1293
|
+
() => {
|
|
1294
|
+
try {
|
|
1295
|
+
const { killed } = cleanupOrphanNodeProcesses();
|
|
1296
|
+
if (killed > 0) {
|
|
1297
|
+
hubLog.info({ killed }, "hub.orphan_cleanup");
|
|
1298
|
+
}
|
|
1299
|
+
} catch {}
|
|
999
1300
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1301
|
+
// stale tfx-spawn-* psmux 세션 정리 (30분 이상 idle)
|
|
1302
|
+
try {
|
|
1303
|
+
const staleKilled = cleanupStaleSpawnSessions(hubLog);
|
|
1304
|
+
if (staleKilled > 0) {
|
|
1305
|
+
hubLog.info({ killed: staleKilled }, "hub.stale_spawn_cleanup");
|
|
1306
|
+
}
|
|
1307
|
+
} catch {}
|
|
1308
|
+
},
|
|
1309
|
+
5 * 60 * 1000,
|
|
1310
|
+
);
|
|
1008
1311
|
orphanCleanupTimer.unref();
|
|
1009
1312
|
|
|
1010
1313
|
// Evict stale rate-limit buckets once per minute to bound memory usage.
|
|
@@ -1026,35 +1329,56 @@ export async function startHub({
|
|
|
1026
1329
|
// Stale PID 파일 정리 — 이전 Hub 프로세스가 비정상 종료된 경우
|
|
1027
1330
|
if (existsSync(PID_FILE)) {
|
|
1028
1331
|
try {
|
|
1029
|
-
const prevInfo = JSON.parse(readFileSync(PID_FILE,
|
|
1332
|
+
const prevInfo = JSON.parse(readFileSync(PID_FILE, "utf8"));
|
|
1030
1333
|
const prevPid = Number(prevInfo?.pid);
|
|
1031
1334
|
if (Number.isFinite(prevPid) && prevPid > 0) {
|
|
1032
1335
|
try {
|
|
1033
1336
|
process.kill(prevPid, 0); // alive 체크만
|
|
1034
1337
|
// 프로세스가 살아있으면 포트 충돌 가능성 — 기존 Hub 재사용 안내
|
|
1035
1338
|
if (Number(prevInfo.port) === Number(port)) {
|
|
1036
|
-
hubLog.warn(
|
|
1339
|
+
hubLog.warn(
|
|
1340
|
+
{ prevPid, port },
|
|
1341
|
+
"hub.stale_pid: previous hub still alive on same port",
|
|
1342
|
+
);
|
|
1037
1343
|
}
|
|
1038
1344
|
} catch {
|
|
1039
1345
|
// 프로세스 죽음 → stale PID 파일 삭제
|
|
1040
|
-
try {
|
|
1041
|
-
|
|
1346
|
+
try {
|
|
1347
|
+
unlinkSync(PID_FILE);
|
|
1348
|
+
} catch {}
|
|
1349
|
+
hubLog.info({ prevPid }, "hub.stale_pid_cleaned");
|
|
1042
1350
|
}
|
|
1043
1351
|
} else {
|
|
1044
|
-
try {
|
|
1352
|
+
try {
|
|
1353
|
+
unlinkSync(PID_FILE);
|
|
1354
|
+
} catch {}
|
|
1045
1355
|
}
|
|
1046
1356
|
} catch {
|
|
1047
|
-
try {
|
|
1357
|
+
try {
|
|
1358
|
+
unlinkSync(PID_FILE);
|
|
1359
|
+
} catch {}
|
|
1048
1360
|
}
|
|
1049
1361
|
}
|
|
1050
1362
|
|
|
1051
1363
|
const cleanupStartupFailure = async () => {
|
|
1052
|
-
try {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
try {
|
|
1056
|
-
|
|
1057
|
-
|
|
1364
|
+
try {
|
|
1365
|
+
router.stopSweeper();
|
|
1366
|
+
} catch {}
|
|
1367
|
+
try {
|
|
1368
|
+
await pipe.stop();
|
|
1369
|
+
} catch {}
|
|
1370
|
+
try {
|
|
1371
|
+
await assignCallbacks.stop();
|
|
1372
|
+
} catch {}
|
|
1373
|
+
try {
|
|
1374
|
+
await delegatorWorker.stop();
|
|
1375
|
+
} catch {}
|
|
1376
|
+
try {
|
|
1377
|
+
store.close();
|
|
1378
|
+
} catch {}
|
|
1379
|
+
try {
|
|
1380
|
+
unlinkSync(TOKEN_FILE);
|
|
1381
|
+
} catch {}
|
|
1058
1382
|
releaseStartupLock();
|
|
1059
1383
|
};
|
|
1060
1384
|
|
|
@@ -1078,14 +1402,14 @@ export async function startHub({
|
|
|
1078
1402
|
dbPath,
|
|
1079
1403
|
pid: process.pid,
|
|
1080
1404
|
hubToken: HUB_TOKEN,
|
|
1081
|
-
authMode: HUB_TOKEN ?
|
|
1405
|
+
authMode: HUB_TOKEN ? "token-required" : "localhost-only",
|
|
1082
1406
|
url: buildHubUrl(host, port),
|
|
1083
1407
|
pipe_path: pipe.path,
|
|
1084
1408
|
pipePath: pipe.path,
|
|
1085
1409
|
assign_callback_pipe_path: assignCallbacks.path,
|
|
1086
1410
|
assignCallbackPipePath: assignCallbacks.path,
|
|
1087
1411
|
version,
|
|
1088
|
-
storeType: store.type ||
|
|
1412
|
+
storeType: store.type || "sqlite",
|
|
1089
1413
|
idleTimeoutMs: hubIdleTimeoutMs,
|
|
1090
1414
|
};
|
|
1091
1415
|
|
|
@@ -1093,13 +1417,13 @@ export async function startHub({
|
|
|
1093
1417
|
pid: process.pid,
|
|
1094
1418
|
port,
|
|
1095
1419
|
host,
|
|
1096
|
-
auth_mode: HUB_TOKEN ?
|
|
1420
|
+
auth_mode: HUB_TOKEN ? "token-required" : "localhost-only",
|
|
1097
1421
|
url: info.url,
|
|
1098
1422
|
pipe_path: pipe.path,
|
|
1099
1423
|
pipePath: pipe.path,
|
|
1100
1424
|
assign_callback_pipe_path: assignCallbacks.path,
|
|
1101
1425
|
assignCallbackPipePath: assignCallbacks.path,
|
|
1102
|
-
authMode: HUB_TOKEN ?
|
|
1426
|
+
authMode: HUB_TOKEN ? "token-required" : "localhost-only",
|
|
1103
1427
|
startedAt,
|
|
1104
1428
|
started: startedAtMs,
|
|
1105
1429
|
version,
|
|
@@ -1108,8 +1432,25 @@ export async function startHub({
|
|
|
1108
1432
|
});
|
|
1109
1433
|
releaseStartupLock();
|
|
1110
1434
|
|
|
1111
|
-
hubLog.info(
|
|
1112
|
-
|
|
1435
|
+
hubLog.info(
|
|
1436
|
+
{
|
|
1437
|
+
url: info.url,
|
|
1438
|
+
pipePath: pipe.path,
|
|
1439
|
+
assignCallbackPath: assignCallbacks.path,
|
|
1440
|
+
pid: process.pid,
|
|
1441
|
+
storeType: info.storeType,
|
|
1442
|
+
version,
|
|
1443
|
+
},
|
|
1444
|
+
"hub.started",
|
|
1445
|
+
);
|
|
1446
|
+
hubLog.debug(
|
|
1447
|
+
{
|
|
1448
|
+
publicDir: PUBLIC_DIR,
|
|
1449
|
+
exists: existsSync(PUBLIC_DIR),
|
|
1450
|
+
hasDashboard: existsSync(resolve(PUBLIC_DIR, "dashboard.html")),
|
|
1451
|
+
},
|
|
1452
|
+
"hub.public_dir",
|
|
1453
|
+
);
|
|
1113
1454
|
|
|
1114
1455
|
const stopFn = async () => {
|
|
1115
1456
|
if (stopPromise) return stopPromise;
|
|
@@ -1124,16 +1465,24 @@ export async function startHub({
|
|
|
1124
1465
|
clearInterval(idleTimer);
|
|
1125
1466
|
}
|
|
1126
1467
|
for (const [, session] of transports) {
|
|
1127
|
-
try {
|
|
1128
|
-
|
|
1468
|
+
try {
|
|
1469
|
+
await session.mcp.close();
|
|
1470
|
+
} catch {}
|
|
1471
|
+
try {
|
|
1472
|
+
await session.transport.close();
|
|
1473
|
+
} catch {}
|
|
1129
1474
|
}
|
|
1130
1475
|
transports.clear();
|
|
1131
1476
|
await pipe.stop();
|
|
1132
1477
|
await assignCallbacks.stop();
|
|
1133
1478
|
await delegatorWorker.stop().catch(() => {});
|
|
1134
1479
|
store.close();
|
|
1135
|
-
try {
|
|
1136
|
-
|
|
1480
|
+
try {
|
|
1481
|
+
unlinkSync(PID_FILE);
|
|
1482
|
+
} catch {}
|
|
1483
|
+
try {
|
|
1484
|
+
unlinkSync(TOKEN_FILE);
|
|
1485
|
+
} catch {}
|
|
1137
1486
|
httpServer.closeAllConnections();
|
|
1138
1487
|
await new Promise((resolveClose) => httpServer.close(resolveClose));
|
|
1139
1488
|
})().catch((error) => {
|
|
@@ -1147,9 +1496,15 @@ export async function startHub({
|
|
|
1147
1496
|
idleTimer = setInterval(() => {
|
|
1148
1497
|
const idleMs = Date.now() - lastRequestAt;
|
|
1149
1498
|
if (idleMs < hubIdleTimeoutMs) return;
|
|
1150
|
-
hubLog.warn(
|
|
1499
|
+
hubLog.warn(
|
|
1500
|
+
{ idleMs, idleTimeoutMs: hubIdleTimeoutMs, port },
|
|
1501
|
+
"hub.idle_timeout_shutdown",
|
|
1502
|
+
);
|
|
1151
1503
|
void stopFn().catch((error) => {
|
|
1152
|
-
hubLog.error(
|
|
1504
|
+
hubLog.error(
|
|
1505
|
+
{ err: error, idleMs, idleTimeoutMs: hubIdleTimeoutMs, port },
|
|
1506
|
+
"hub.idle_timeout_shutdown_failed",
|
|
1507
|
+
);
|
|
1153
1508
|
});
|
|
1154
1509
|
}, hubIdleSweepMs);
|
|
1155
1510
|
idleTimer.unref();
|
|
@@ -1172,11 +1527,18 @@ export async function startHub({
|
|
|
1172
1527
|
void cleanupStartupFailure().finally(() => reject(error));
|
|
1173
1528
|
}
|
|
1174
1529
|
});
|
|
1175
|
-
httpServer.on(
|
|
1530
|
+
httpServer.on("error", (err) => {
|
|
1176
1531
|
void cleanupStartupFailure();
|
|
1177
|
-
if (err.code ===
|
|
1178
|
-
hubLog.error(
|
|
1179
|
-
|
|
1532
|
+
if (err.code === "EADDRINUSE") {
|
|
1533
|
+
hubLog.error(
|
|
1534
|
+
{ port, host },
|
|
1535
|
+
"hub.port_in_use: port already occupied — check for existing hub or other service",
|
|
1536
|
+
);
|
|
1537
|
+
reject(
|
|
1538
|
+
new Error(
|
|
1539
|
+
`Hub 포트 ${port}이(가) 이미 사용 중입니다. 기존 Hub 프로세스를 확인하세요. (PID file: ${PID_FILE})`,
|
|
1540
|
+
),
|
|
1541
|
+
);
|
|
1180
1542
|
} else {
|
|
1181
1543
|
reject(err);
|
|
1182
1544
|
}
|
|
@@ -1221,8 +1583,14 @@ export async function getOrCreateServer(opts = {}) {
|
|
|
1221
1583
|
*/
|
|
1222
1584
|
function cleanupStaleSpawnSessions(log) {
|
|
1223
1585
|
const MAX_AGE_MS = 30 * 60 * 1000;
|
|
1224
|
-
const IDLE_PROMPT_RE =
|
|
1225
|
-
|
|
1586
|
+
const IDLE_PROMPT_RE =
|
|
1587
|
+
/^(PS\s|[$%>#]\s*$|\w+@[\w.-]+[:\s]|╰─|╭─|[fb]wd-i-search:|client_loop:\s|Connection\s+(reset|closed))/;
|
|
1588
|
+
const execOpts = {
|
|
1589
|
+
encoding: "utf8",
|
|
1590
|
+
timeout: 5000,
|
|
1591
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1592
|
+
windowsHide: true,
|
|
1593
|
+
};
|
|
1226
1594
|
|
|
1227
1595
|
let killed = 0;
|
|
1228
1596
|
let raw;
|
|
@@ -1234,7 +1602,9 @@ function cleanupStaleSpawnSessions(log) {
|
|
|
1234
1602
|
|
|
1235
1603
|
const now = Date.now();
|
|
1236
1604
|
for (const line of raw.split(/\r?\n/)) {
|
|
1237
|
-
const match = line.match(
|
|
1605
|
+
const match = line.match(
|
|
1606
|
+
/^(tfx-spawn-[^:]+):\s+\d+\s+windows?\s+\(created\s+(.+)\)/,
|
|
1607
|
+
);
|
|
1238
1608
|
if (!match) continue;
|
|
1239
1609
|
|
|
1240
1610
|
const [, sessionName, createdStr] = match;
|
|
@@ -1244,8 +1614,14 @@ function cleanupStaleSpawnSessions(log) {
|
|
|
1244
1614
|
|
|
1245
1615
|
// pane 내용 확인 — 마지막 3줄 중 idle 쉘 프롬프트가 있는지
|
|
1246
1616
|
try {
|
|
1247
|
-
const pane = execSyncHub(
|
|
1248
|
-
|
|
1617
|
+
const pane = execSyncHub(
|
|
1618
|
+
`psmux capture-pane -t "${sessionName}:0.0" -p`,
|
|
1619
|
+
execOpts,
|
|
1620
|
+
);
|
|
1621
|
+
const tailLines = pane
|
|
1622
|
+
.split(/\r?\n/)
|
|
1623
|
+
.filter((l) => l.trim())
|
|
1624
|
+
.slice(-3);
|
|
1249
1625
|
const hasIdleLine = tailLines.some((l) => IDLE_PROMPT_RE.test(l.trim()));
|
|
1250
1626
|
if (!hasIdleLine) continue; // 아직 활성 — 건드리지 않음
|
|
1251
1627
|
} catch {
|
|
@@ -1256,32 +1632,42 @@ function cleanupStaleSpawnSessions(log) {
|
|
|
1256
1632
|
try {
|
|
1257
1633
|
execSyncHub(`psmux kill-session -t "${sessionName}"`, execOpts);
|
|
1258
1634
|
killed++;
|
|
1259
|
-
if (log)
|
|
1635
|
+
if (log)
|
|
1636
|
+
log.info(
|
|
1637
|
+
{ session: sessionName, ageMin: Math.round((now - created) / 60000) },
|
|
1638
|
+
"hub.stale_spawn_killed",
|
|
1639
|
+
);
|
|
1260
1640
|
} catch {}
|
|
1261
1641
|
}
|
|
1262
1642
|
|
|
1263
1643
|
return killed;
|
|
1264
1644
|
}
|
|
1265
1645
|
|
|
1266
|
-
const selfRun = process.argv[1]?.replace(/\\/g,
|
|
1646
|
+
const selfRun = process.argv[1]?.replace(/\\/g, "/").endsWith("hub/server.mjs");
|
|
1267
1647
|
if (selfRun) {
|
|
1268
|
-
const port = parseInt(process.env.TFX_HUB_PORT ||
|
|
1648
|
+
const port = parseInt(process.env.TFX_HUB_PORT || "27888", 10);
|
|
1269
1649
|
const dbPath = process.env.TFX_HUB_DB || undefined;
|
|
1270
1650
|
|
|
1271
|
-
startHub({ port, dbPath })
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1651
|
+
startHub({ port, dbPath })
|
|
1652
|
+
.then((info) => {
|
|
1653
|
+
const shutdown = async (signal) => {
|
|
1654
|
+
hubLog.info({ signal }, "hub.stopping");
|
|
1655
|
+
try {
|
|
1656
|
+
cleanupOrphanNodeProcesses();
|
|
1657
|
+
} catch {}
|
|
1658
|
+
try {
|
|
1659
|
+
cleanupStaleSpawnSessions(hubLog);
|
|
1660
|
+
} catch {}
|
|
1661
|
+
await info.stop();
|
|
1662
|
+
process.exit(0);
|
|
1663
|
+
};
|
|
1664
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1665
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1666
|
+
})
|
|
1667
|
+
.catch((error) => {
|
|
1668
|
+
hubLog.fatal({ err: error }, "hub.start_failed");
|
|
1669
|
+
process.exit(1);
|
|
1670
|
+
});
|
|
1285
1671
|
}
|
|
1286
1672
|
|
|
1287
1673
|
export { startHub as createServer };
|