triflux 3.3.0-dev.7 → 4.0.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.ko.md +108 -199
- package/README.md +108 -199
- package/bin/triflux.mjs +2415 -1762
- package/hooks/keyword-rules.json +361 -354
- package/hooks/pipeline-stop.mjs +5 -2
- package/hub/assign-callbacks.mjs +136 -136
- package/hub/bridge.mjs +734 -708
- package/hub/delegator/contracts.mjs +38 -0
- package/hub/delegator/index.mjs +14 -0
- package/hub/delegator/schema/delegator-tools.schema.json +250 -0
- package/hub/delegator/service.mjs +302 -0
- package/hub/delegator/tool-definitions.mjs +35 -0
- package/hub/hitl.mjs +67 -67
- package/hub/paths.mjs +28 -0
- package/hub/pipe.mjs +589 -561
- package/hub/pipeline/state.mjs +23 -0
- package/hub/public/dashboard.html +349 -0
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/router.mjs +782 -782
- package/hub/schema.sql +40 -40
- package/hub/server.mjs +810 -637
- package/hub/store.mjs +706 -706
- package/hub/team/cli/commands/attach.mjs +37 -0
- package/hub/team/cli/commands/control.mjs +43 -0
- package/hub/team/cli/commands/debug.mjs +74 -0
- package/hub/team/cli/commands/focus.mjs +53 -0
- package/hub/team/cli/commands/interrupt.mjs +36 -0
- package/hub/team/cli/commands/kill.mjs +37 -0
- package/hub/team/cli/commands/list.mjs +24 -0
- package/hub/team/cli/commands/send.mjs +37 -0
- package/hub/team/cli/commands/start/index.mjs +87 -0
- package/hub/team/cli/commands/start/parse-args.mjs +32 -0
- package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
- package/hub/team/cli/commands/start/start-mux.mjs +73 -0
- package/hub/team/cli/commands/start/start-wt.mjs +69 -0
- package/hub/team/cli/commands/status.mjs +87 -0
- package/hub/team/cli/commands/stop.mjs +31 -0
- package/hub/team/cli/commands/task.mjs +30 -0
- package/hub/team/cli/commands/tasks.mjs +13 -0
- package/hub/team/{cli.mjs → cli/help.mjs} +38 -99
- package/hub/team/cli/index.mjs +39 -0
- package/hub/team/cli/manifest.mjs +28 -0
- package/hub/team/cli/render.mjs +30 -0
- package/hub/team/cli/services/attach-fallback.mjs +54 -0
- package/hub/team/cli/services/hub-client.mjs +171 -0
- package/hub/team/cli/services/member-selector.mjs +30 -0
- package/hub/team/cli/services/native-control.mjs +115 -0
- package/hub/team/cli/services/runtime-mode.mjs +60 -0
- package/hub/team/cli/services/state-store.mjs +34 -0
- package/hub/team/cli/services/task-model.mjs +30 -0
- package/hub/team/native-supervisor.mjs +69 -63
- package/hub/team/native.mjs +367 -266
- package/hub/team/nativeProxy.mjs +217 -173
- package/hub/team/pane.mjs +149 -149
- package/hub/team/psmux.mjs +946 -946
- package/hub/team/session.mjs +608 -608
- package/hub/team/staleState.mjs +369 -299
- package/hub/tools.mjs +107 -107
- package/hub/tray.mjs +332 -0
- package/hub/workers/claude-worker.mjs +446 -446
- package/hub/workers/codex-mcp.mjs +414 -414
- package/hub/workers/delegator-mcp.mjs +1045 -1045
- package/hub/workers/factory.mjs +21 -21
- package/hub/workers/gemini-worker.mjs +349 -349
- package/hub/workers/interface.mjs +41 -41
- package/package.json +61 -60
- package/scripts/__tests__/keyword-detector.test.mjs +234 -234
- package/scripts/hub-ensure.mjs +102 -101
- package/scripts/keyword-detector.mjs +272 -272
- package/scripts/keyword-rules-expander.mjs +521 -521
- package/scripts/lib/keyword-rules.mjs +168 -168
- package/scripts/lib/mcp-filter.mjs +642 -642
- package/scripts/lib/mcp-server-catalog.mjs +118 -118
- package/scripts/mcp-check.mjs +126 -126
- package/scripts/preflight-cache.mjs +19 -0
- package/scripts/run.cjs +62 -62
- package/scripts/setup.mjs +68 -31
- package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
- package/scripts/tfx-route-worker.mjs +161 -161
- package/scripts/tfx-route.sh +1360 -1326
- package/skills/tfx-auto/SKILL.md +196 -196
- package/skills/tfx-auto-codex/SKILL.md +77 -77
- package/skills/tfx-multi/SKILL.md +378 -378
- package/hub/team/cli-team-common.mjs +0 -348
- package/hub/team/cli-team-control.mjs +0 -393
- package/hub/team/cli-team-start.mjs +0 -516
- package/hub/team/cli-team-status.mjs +0 -283
- package/skills/auto-verify/SKILL.md +0 -145
- package/skills/manage-skills/SKILL.md +0 -192
- package/skills/verify-implementation/SKILL.md +0 -138
package/hub/server.mjs
CHANGED
|
@@ -1,637 +1,810 @@
|
|
|
1
|
-
// hub/server.mjs — HTTP MCP + REST bridge + Named Pipe 서버 진입점
|
|
2
|
-
import { createServer as createHttpServer } from 'node:http';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { homedir } from 'node:os';
|
|
6
|
-
import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!
|
|
426
|
-
return writeJson(res, 400, { ok: false, error: '
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const result = await pipe.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
res.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1
|
+
// hub/server.mjs — HTTP MCP + REST bridge + Named Pipe 서버 진입점
|
|
2
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
|
|
13
|
+
import { createStore } from './store.mjs';
|
|
14
|
+
import { createRouter } from './router.mjs';
|
|
15
|
+
import { createHitlManager } from './hitl.mjs';
|
|
16
|
+
import { createPipeServer } from './pipe.mjs';
|
|
17
|
+
import { createAssignCallbackServer } from './assign-callbacks.mjs';
|
|
18
|
+
import { createTools } from './tools.mjs';
|
|
19
|
+
import { ensurePipelineStateDbPath } from './pipeline/state.mjs';
|
|
20
|
+
import { DelegatorService } from './delegator/index.mjs';
|
|
21
|
+
import { createDelegatorMcpWorker } from './workers/delegator-mcp.mjs';
|
|
22
|
+
|
|
23
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
24
|
+
const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
|
|
25
|
+
const LOOPBACK_REMOTE_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
26
|
+
const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
27
|
+
const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
28
|
+
const PUBLIC_DIR = resolve(join(PROJECT_ROOT, 'hub', 'public'));
|
|
29
|
+
const CACHE_DIR = join(homedir(), '.claude', 'cache');
|
|
30
|
+
const BATCH_EVENTS_PATH = join(CACHE_DIR, 'batch-events.jsonl');
|
|
31
|
+
const SV_ACCUMULATOR_PATH = join(CACHE_DIR, 'sv-accumulator.json');
|
|
32
|
+
const CODEX_RATE_LIMITS_CACHE_PATH = join(CACHE_DIR, 'codex-rate-limits-cache.json');
|
|
33
|
+
const GEMINI_QUOTA_CACHE_PATH = join(CACHE_DIR, 'gemini-quota-cache.json');
|
|
34
|
+
const CLAUDE_USAGE_CACHE_PATH = join(CACHE_DIR, 'claude-usage-cache.json');
|
|
35
|
+
const AIMD_WINDOW_MS = 30 * 60 * 1000;
|
|
36
|
+
const AIMD_INITIAL_BATCH_SIZE = 3;
|
|
37
|
+
const AIMD_MIN_BATCH_SIZE = 1;
|
|
38
|
+
const AIMD_MAX_BATCH_SIZE = 10;
|
|
39
|
+
const STATIC_CONTENT_TYPES = Object.freeze({
|
|
40
|
+
'.html': 'text/html',
|
|
41
|
+
'.css': 'text/css',
|
|
42
|
+
'.js': 'application/javascript',
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function isInitializeRequest(body) {
|
|
47
|
+
if (body?.method === 'initialize') return true;
|
|
48
|
+
if (Array.isArray(body)) return body.some((message) => message.method === 'initialize');
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function parseBody(req) {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
let size = 0;
|
|
55
|
+
for await (const chunk of req) {
|
|
56
|
+
size += chunk.length;
|
|
57
|
+
if (size > MAX_BODY_SIZE) {
|
|
58
|
+
throw Object.assign(new Error('Body too large'), { statusCode: 413 });
|
|
59
|
+
}
|
|
60
|
+
chunks.push(chunk);
|
|
61
|
+
}
|
|
62
|
+
return JSON.parse(Buffer.concat(chunks).toString());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
|
|
66
|
+
const PID_FILE = join(PID_DIR, 'hub.pid');
|
|
67
|
+
const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
|
|
68
|
+
|
|
69
|
+
function isPublicPath(path) {
|
|
70
|
+
return PUBLIC_PATHS.has(path)
|
|
71
|
+
|| path === '/dashboard'
|
|
72
|
+
|| path === '/api/qos-stats'
|
|
73
|
+
|| path.startsWith('/public/');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAllowedOrigin(origin) {
|
|
77
|
+
return origin && ALLOWED_ORIGIN_RE.test(origin);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getRequestPath(url = '/') {
|
|
81
|
+
try {
|
|
82
|
+
return new URL(url, 'http://127.0.0.1').pathname;
|
|
83
|
+
} catch {
|
|
84
|
+
return String(url).replace(/\?.*/, '') || '/';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isLoopbackRemoteAddress(remoteAddress) {
|
|
89
|
+
return typeof remoteAddress === 'string' && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractBearerToken(req) {
|
|
93
|
+
const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
|
|
94
|
+
return authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeJson(res, statusCode, body, headers = {}) {
|
|
98
|
+
res.writeHead(statusCode, {
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
...headers,
|
|
101
|
+
});
|
|
102
|
+
res.end(JSON.stringify(body));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function applyCorsHeaders(req, res) {
|
|
106
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
|
|
107
|
+
if (origin) {
|
|
108
|
+
res.setHeader('Vary', 'Origin');
|
|
109
|
+
}
|
|
110
|
+
if (!isAllowedOrigin(origin)) return false;
|
|
111
|
+
|
|
112
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
113
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
114
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isAuthorizedRequest(req, path, hubToken) {
|
|
119
|
+
if (!hubToken) {
|
|
120
|
+
return isLoopbackRemoteAddress(req.socket.remoteAddress);
|
|
121
|
+
}
|
|
122
|
+
if (isPublicPath(path)) return true;
|
|
123
|
+
return extractBearerToken(req) === hubToken;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveTeamStatusCode(result) {
|
|
127
|
+
if (result?.ok) return 200;
|
|
128
|
+
const code = result?.error?.code;
|
|
129
|
+
if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
|
|
130
|
+
if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
|
|
131
|
+
if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
|
|
132
|
+
return 500;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolvePipelineStatusCode(result) {
|
|
136
|
+
if (result?.ok) return 200;
|
|
137
|
+
if (result?.error === 'pipeline_not_found') return 404;
|
|
138
|
+
if (result?.error === 'hub_db_not_found') return 503;
|
|
139
|
+
return 400;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function safeReadJsonFile(filePath) {
|
|
143
|
+
try {
|
|
144
|
+
if (!existsSync(filePath)) return null;
|
|
145
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readRecentAimdEvents(now = Date.now()) {
|
|
152
|
+
try {
|
|
153
|
+
if (!existsSync(BATCH_EVENTS_PATH)) return [];
|
|
154
|
+
const cutoff = now - AIMD_WINDOW_MS;
|
|
155
|
+
return readFileSync(BATCH_EVENTS_PATH, 'utf8')
|
|
156
|
+
.split(/\r?\n/)
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.map((line) => {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(line);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
.filter((event) => {
|
|
166
|
+
const timestamp = Number(event?.ts ?? event?.timestamp ?? 0);
|
|
167
|
+
return event && Number.isFinite(timestamp) && timestamp >= cutoff;
|
|
168
|
+
});
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function calculateAimdBatchSize(events) {
|
|
175
|
+
let batchSize = AIMD_INITIAL_BATCH_SIZE;
|
|
176
|
+
|
|
177
|
+
for (const event of events) {
|
|
178
|
+
const result = event?.result;
|
|
179
|
+
if (result === 'success' || result === 'success_with_warnings') {
|
|
180
|
+
batchSize = Math.min(AIMD_MAX_BATCH_SIZE, batchSize + 1);
|
|
181
|
+
} else if (result === 'failed' || result === 'timeout') {
|
|
182
|
+
batchSize = Math.max(AIMD_MIN_BATCH_SIZE, batchSize * 0.5);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return batchSize;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getQosStatsPayload() {
|
|
190
|
+
const events = readRecentAimdEvents();
|
|
191
|
+
return {
|
|
192
|
+
aimd: {
|
|
193
|
+
batchSize: calculateAimdBatchSize(events),
|
|
194
|
+
events,
|
|
195
|
+
},
|
|
196
|
+
accumulator: safeReadJsonFile(SV_ACCUMULATOR_PATH),
|
|
197
|
+
codex: safeReadJsonFile(CODEX_RATE_LIMITS_CACHE_PATH),
|
|
198
|
+
gemini: safeReadJsonFile(GEMINI_QUOTA_CACHE_PATH),
|
|
199
|
+
claude: safeReadJsonFile(CLAUDE_USAGE_CACHE_PATH),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function resolvePublicFilePath(path) {
|
|
204
|
+
let relativePath = null;
|
|
205
|
+
if (path === '/dashboard') {
|
|
206
|
+
relativePath = 'dashboard.html';
|
|
207
|
+
} else if (path.startsWith('/public/')) {
|
|
208
|
+
relativePath = path.slice('/public/'.length);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!relativePath) return null;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
relativePath = decodeURIComponent(relativePath).replace(/^[/\\]+/, '');
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const filePath = resolve(PUBLIC_DIR, relativePath);
|
|
220
|
+
const publicPrefix = `${PUBLIC_DIR}${sep}`;
|
|
221
|
+
if (filePath !== PUBLIC_DIR && !filePath.startsWith(publicPrefix)) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return filePath;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function servePublicFile(res, path) {
|
|
228
|
+
const filePath = resolvePublicFilePath(path);
|
|
229
|
+
if (!filePath) return false;
|
|
230
|
+
|
|
231
|
+
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
232
|
+
if (!existsSync(filePath)) {
|
|
233
|
+
res.writeHead(404);
|
|
234
|
+
res.end('Not Found');
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const body = readFileSync(filePath);
|
|
240
|
+
res.writeHead(200, {
|
|
241
|
+
'Content-Type': STATIC_CONTENT_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream',
|
|
242
|
+
});
|
|
243
|
+
res.end(body);
|
|
244
|
+
} catch {
|
|
245
|
+
res.writeHead(404);
|
|
246
|
+
res.end('Not Found');
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* tfx-hub 시작
|
|
253
|
+
* @param {object} opts
|
|
254
|
+
* @param {number} [opts.port]
|
|
255
|
+
* @param {string} [opts.dbPath]
|
|
256
|
+
* @param {string} [opts.host]
|
|
257
|
+
* @param {string|number} [opts.sessionId]
|
|
258
|
+
*/
|
|
259
|
+
export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessionId = process.pid } = {}) {
|
|
260
|
+
if (!dbPath) {
|
|
261
|
+
dbPath = ensurePipelineStateDbPath(PROJECT_ROOT);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
265
|
+
|
|
266
|
+
const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
|
|
267
|
+
if (HUB_TOKEN) {
|
|
268
|
+
mkdirSync(join(homedir(), '.claude'), { recursive: true });
|
|
269
|
+
writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
|
|
270
|
+
} else {
|
|
271
|
+
try { unlinkSync(TOKEN_FILE); } catch {}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const store = createStore(dbPath);
|
|
275
|
+
const router = createRouter(store);
|
|
276
|
+
|
|
277
|
+
// Delegator MCP resident service 초기화
|
|
278
|
+
const delegatorWorker = createDelegatorMcpWorker({ cwd: PROJECT_ROOT });
|
|
279
|
+
await delegatorWorker.start();
|
|
280
|
+
const delegatorService = new DelegatorService({ worker: delegatorWorker });
|
|
281
|
+
|
|
282
|
+
const pipe = createPipeServer({ router, store, sessionId, delegatorService });
|
|
283
|
+
const assignCallbacks = createAssignCallbackServer({ store, sessionId });
|
|
284
|
+
const hitl = createHitlManager(store, router);
|
|
285
|
+
const tools = createTools(store, router, hitl, pipe);
|
|
286
|
+
const transports = new Map();
|
|
287
|
+
|
|
288
|
+
function createMcpForSession() {
|
|
289
|
+
const mcp = new Server(
|
|
290
|
+
{ name: 'tfx-hub', version: '1.0.0' },
|
|
291
|
+
{ capabilities: { tools: {} } },
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
mcp.setRequestHandler(
|
|
295
|
+
ListToolsRequestSchema,
|
|
296
|
+
async () => ({
|
|
297
|
+
tools: tools.map((tool) => ({
|
|
298
|
+
name: tool.name,
|
|
299
|
+
description: tool.description,
|
|
300
|
+
inputSchema: tool.inputSchema,
|
|
301
|
+
})),
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
mcp.setRequestHandler(
|
|
306
|
+
CallToolRequestSchema,
|
|
307
|
+
async (request) => {
|
|
308
|
+
const { name, arguments: args } = request.params;
|
|
309
|
+
const tool = tools.find((candidate) => candidate.name === name);
|
|
310
|
+
if (!tool) {
|
|
311
|
+
return {
|
|
312
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'UNKNOWN_TOOL', message: `도구 없음: ${name}` } }) }],
|
|
313
|
+
isError: true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return tool.handler(args || {});
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
return mcp;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
324
|
+
const path = getRequestPath(req.url);
|
|
325
|
+
const corsAllowed = applyCorsHeaders(req, res);
|
|
326
|
+
|
|
327
|
+
if (req.method === 'OPTIONS') {
|
|
328
|
+
const localOnlyMode = !HUB_TOKEN;
|
|
329
|
+
const isLoopbackRequest = isLoopbackRemoteAddress(req.socket.remoteAddress);
|
|
330
|
+
res.writeHead(corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403);
|
|
331
|
+
return res.end();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
|
|
335
|
+
if (!HUB_TOKEN) {
|
|
336
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden: localhost only' });
|
|
337
|
+
}
|
|
338
|
+
return writeJson(
|
|
339
|
+
res,
|
|
340
|
+
401,
|
|
341
|
+
{ ok: false, error: 'Unauthorized' },
|
|
342
|
+
{ 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (path === '/' || path === '/status') {
|
|
347
|
+
const status = router.getStatus('hub').data;
|
|
348
|
+
return writeJson(res, 200, {
|
|
349
|
+
...status,
|
|
350
|
+
sessions: transports.size,
|
|
351
|
+
pid: process.pid,
|
|
352
|
+
port,
|
|
353
|
+
auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
354
|
+
pipe_path: pipe.path,
|
|
355
|
+
pipe: pipe.getStatus(),
|
|
356
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
357
|
+
assign_callback_pipe: assignCallbacks.getStatus(),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (path === '/health' || path === '/healthz') {
|
|
362
|
+
const status = router.getStatus('hub').data;
|
|
363
|
+
const healthy = status?.hub?.state === 'healthy';
|
|
364
|
+
return writeJson(res, healthy ? 200 : 503, { ok: healthy });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (path === '/api/qos-stats' && req.method === 'GET') {
|
|
368
|
+
return writeJson(res, 200, getQosStatsPayload());
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (path.startsWith('/bridge')) {
|
|
372
|
+
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
373
|
+
return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const body = req.method === 'POST' ? await parseBody(req) : {};
|
|
378
|
+
|
|
379
|
+
if (path === '/bridge/register' && req.method === 'POST') {
|
|
380
|
+
const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
|
|
381
|
+
if (!agent_id || !cli) {
|
|
382
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id, cli 필수' });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
|
|
386
|
+
const result = await pipe.executeCommand('register', {
|
|
387
|
+
agent_id,
|
|
388
|
+
cli,
|
|
389
|
+
capabilities,
|
|
390
|
+
topics,
|
|
391
|
+
heartbeat_ttl_ms,
|
|
392
|
+
metadata,
|
|
393
|
+
});
|
|
394
|
+
return writeJson(res, 200, result);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (path === '/bridge/result' && req.method === 'POST') {
|
|
398
|
+
const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
|
|
399
|
+
if (!agent_id) {
|
|
400
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const result = await pipe.executeCommand('result', {
|
|
404
|
+
agent_id,
|
|
405
|
+
topic,
|
|
406
|
+
payload,
|
|
407
|
+
trace_id,
|
|
408
|
+
correlation_id,
|
|
409
|
+
});
|
|
410
|
+
return writeJson(res, 200, result);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (path === '/bridge/control' && req.method === 'POST') {
|
|
414
|
+
const {
|
|
415
|
+
from_agent = 'lead',
|
|
416
|
+
to_agent,
|
|
417
|
+
command,
|
|
418
|
+
reason = '',
|
|
419
|
+
payload = {},
|
|
420
|
+
trace_id,
|
|
421
|
+
correlation_id,
|
|
422
|
+
ttl_ms = 3600000,
|
|
423
|
+
} = body;
|
|
424
|
+
|
|
425
|
+
if (!to_agent || !command) {
|
|
426
|
+
return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const result = await pipe.executeCommand('control', {
|
|
430
|
+
from_agent,
|
|
431
|
+
to_agent,
|
|
432
|
+
command,
|
|
433
|
+
reason,
|
|
434
|
+
payload,
|
|
435
|
+
ttl_ms,
|
|
436
|
+
trace_id,
|
|
437
|
+
correlation_id,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
return writeJson(res, 200, result);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (path === '/bridge/assign/async' && req.method === 'POST') {
|
|
444
|
+
const {
|
|
445
|
+
supervisor_agent,
|
|
446
|
+
worker_agent,
|
|
447
|
+
task,
|
|
448
|
+
topic = 'assign.job',
|
|
449
|
+
payload = {},
|
|
450
|
+
priority = 5,
|
|
451
|
+
ttl_ms = 600000,
|
|
452
|
+
timeout_ms = 600000,
|
|
453
|
+
max_retries = 0,
|
|
454
|
+
trace_id,
|
|
455
|
+
correlation_id,
|
|
456
|
+
} = body;
|
|
457
|
+
|
|
458
|
+
if (!supervisor_agent || !worker_agent || !task) {
|
|
459
|
+
return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const result = await pipe.executeCommand('assign', {
|
|
463
|
+
supervisor_agent,
|
|
464
|
+
worker_agent,
|
|
465
|
+
task,
|
|
466
|
+
topic,
|
|
467
|
+
payload,
|
|
468
|
+
priority,
|
|
469
|
+
ttl_ms,
|
|
470
|
+
timeout_ms,
|
|
471
|
+
max_retries,
|
|
472
|
+
trace_id,
|
|
473
|
+
correlation_id,
|
|
474
|
+
});
|
|
475
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (path === '/bridge/assign/result' && req.method === 'POST') {
|
|
479
|
+
const {
|
|
480
|
+
job_id,
|
|
481
|
+
worker_agent,
|
|
482
|
+
status,
|
|
483
|
+
attempt,
|
|
484
|
+
result: assignResult,
|
|
485
|
+
error: assignError,
|
|
486
|
+
payload = {},
|
|
487
|
+
metadata = {},
|
|
488
|
+
} = body;
|
|
489
|
+
|
|
490
|
+
if (!job_id || !status) {
|
|
491
|
+
return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const result = await pipe.executeCommand('assign_result', {
|
|
495
|
+
job_id,
|
|
496
|
+
worker_agent,
|
|
497
|
+
status,
|
|
498
|
+
attempt,
|
|
499
|
+
result: assignResult,
|
|
500
|
+
error: assignError,
|
|
501
|
+
payload,
|
|
502
|
+
metadata,
|
|
503
|
+
});
|
|
504
|
+
return writeJson(res, result.ok ? 200 : 409, result);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (path === '/bridge/assign/status' && req.method === 'POST') {
|
|
508
|
+
const result = await pipe.executeQuery('assign_status', body);
|
|
509
|
+
const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
|
|
510
|
+
return writeJson(res, statusCode, result);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (path === '/bridge/assign/retry' && req.method === 'POST') {
|
|
514
|
+
const { job_id, reason, requested_by } = body;
|
|
515
|
+
if (!job_id) {
|
|
516
|
+
return writeJson(res, 400, { ok: false, error: 'job_id 필수' });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const result = await pipe.executeCommand('assign_retry', {
|
|
520
|
+
job_id,
|
|
521
|
+
reason,
|
|
522
|
+
requested_by,
|
|
523
|
+
});
|
|
524
|
+
const statusCode = result.ok ? 200
|
|
525
|
+
: result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
|
|
526
|
+
: result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
|
|
527
|
+
: 400;
|
|
528
|
+
return writeJson(res, statusCode, result);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (req.method === 'POST') {
|
|
532
|
+
let teamResult = null;
|
|
533
|
+
if (path === '/bridge/team/info' || path === '/bridge/team-info') {
|
|
534
|
+
teamResult = await pipe.executeQuery('team_info', body);
|
|
535
|
+
} else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
|
|
536
|
+
teamResult = await pipe.executeQuery('team_task_list', body);
|
|
537
|
+
} else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
|
|
538
|
+
teamResult = await pipe.executeCommand('team_task_update', body);
|
|
539
|
+
} else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
|
|
540
|
+
teamResult = await pipe.executeCommand('team_send_message', body);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (teamResult) {
|
|
544
|
+
return writeJson(res, resolveTeamStatusCode(teamResult), teamResult);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (path.startsWith('/bridge/team')) {
|
|
548
|
+
return writeJson(res, 404, { ok: false, error: `Unknown team endpoint: ${path}` });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── 파이프라인 엔드포인트 ──
|
|
552
|
+
if (path === '/bridge/pipeline/state' && req.method === 'POST') {
|
|
553
|
+
const result = await pipe.executeQuery('pipeline_state', body);
|
|
554
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
|
|
558
|
+
const result = await pipe.executeCommand('pipeline_advance', body);
|
|
559
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (path === '/bridge/pipeline/init' && req.method === 'POST') {
|
|
563
|
+
const result = await pipe.executeCommand('pipeline_init', body);
|
|
564
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (path === '/bridge/pipeline/list' && req.method === 'POST') {
|
|
568
|
+
const result = await pipe.executeQuery('pipeline_list', body);
|
|
569
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── Delegator 엔드포인트 ──
|
|
573
|
+
if (path === '/bridge/delegator/delegate' && req.method === 'POST') {
|
|
574
|
+
const result = await pipe.executeCommand('delegator_delegate', body);
|
|
575
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (path === '/bridge/delegator/reply' && req.method === 'POST') {
|
|
579
|
+
const result = await pipe.executeCommand('delegator_reply', body);
|
|
580
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (path === '/bridge/delegator/status' && req.method === 'POST') {
|
|
584
|
+
const result = await pipe.executeQuery('delegator_status', body);
|
|
585
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (path === '/bridge/context' && req.method === 'POST') {
|
|
590
|
+
const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
|
|
591
|
+
if (!agent_id) {
|
|
592
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const result = await pipe.executeQuery('drain', {
|
|
596
|
+
agent_id,
|
|
597
|
+
topics,
|
|
598
|
+
max_messages,
|
|
599
|
+
auto_ack,
|
|
600
|
+
});
|
|
601
|
+
return writeJson(res, 200, result);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (path === '/bridge/deregister' && req.method === 'POST') {
|
|
605
|
+
const { agent_id } = body;
|
|
606
|
+
if (!agent_id) {
|
|
607
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
608
|
+
}
|
|
609
|
+
const result = await pipe.executeCommand('deregister', { agent_id });
|
|
610
|
+
return writeJson(res, 200, result);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return writeJson(res, 404, { ok: false, error: 'Unknown bridge endpoint' });
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (!res.headersSent) {
|
|
616
|
+
writeJson(res, 500, { ok: false, error: error.message });
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (req.method === 'GET' && servePublicFile(res, path)) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (path !== '/mcp') {
|
|
627
|
+
res.writeHead(404);
|
|
628
|
+
return res.end('Not Found');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
const sessionIdHeader = req.headers['mcp-session-id'];
|
|
633
|
+
|
|
634
|
+
if (req.method === 'POST') {
|
|
635
|
+
const body = await parseBody(req);
|
|
636
|
+
|
|
637
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
638
|
+
const transport = transports.get(sessionIdHeader);
|
|
639
|
+
transport._lastActivity = Date.now();
|
|
640
|
+
await transport.handleRequest(req, res, body);
|
|
641
|
+
} else if (!sessionIdHeader && isInitializeRequest(body)) {
|
|
642
|
+
const transport = new StreamableHTTPServerTransport({
|
|
643
|
+
sessionIdGenerator: () => randomUUID(),
|
|
644
|
+
onsessioninitialized: (sid) => {
|
|
645
|
+
transport._lastActivity = Date.now();
|
|
646
|
+
transports.set(sid, transport);
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
transport.onclose = () => {
|
|
650
|
+
if (transport.sessionId) transports.delete(transport.sessionId);
|
|
651
|
+
};
|
|
652
|
+
const mcp = createMcpForSession();
|
|
653
|
+
await mcp.connect(transport);
|
|
654
|
+
await transport.handleRequest(req, res, body);
|
|
655
|
+
} else {
|
|
656
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
657
|
+
res.end(JSON.stringify({
|
|
658
|
+
jsonrpc: '2.0',
|
|
659
|
+
error: { code: -32000, message: 'Bad Request: No valid session ID' },
|
|
660
|
+
id: null,
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
} else if (req.method === 'GET') {
|
|
664
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
665
|
+
await transports.get(sessionIdHeader).handleRequest(req, res);
|
|
666
|
+
} else {
|
|
667
|
+
res.writeHead(400);
|
|
668
|
+
res.end('Invalid or missing session ID');
|
|
669
|
+
}
|
|
670
|
+
} else if (req.method === 'DELETE') {
|
|
671
|
+
if (sessionIdHeader && transports.has(sessionIdHeader)) {
|
|
672
|
+
await transports.get(sessionIdHeader).handleRequest(req, res);
|
|
673
|
+
} else {
|
|
674
|
+
res.writeHead(400);
|
|
675
|
+
res.end('Invalid or missing session ID');
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
res.writeHead(405);
|
|
679
|
+
res.end('Method Not Allowed');
|
|
680
|
+
}
|
|
681
|
+
} catch (error) {
|
|
682
|
+
console.error('[tfx-hub] 요청 처리 에러:', error.message);
|
|
683
|
+
if (!res.headersSent) {
|
|
684
|
+
const code = error.statusCode === 413 ? 413
|
|
685
|
+
: error instanceof SyntaxError ? 400 : 500;
|
|
686
|
+
const message = code === 413 ? 'Body too large'
|
|
687
|
+
: code === 400 ? 'Invalid JSON' : 'Internal server error';
|
|
688
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
689
|
+
res.end(JSON.stringify({
|
|
690
|
+
jsonrpc: '2.0',
|
|
691
|
+
error: { code: code === 500 ? -32603 : -32700, message },
|
|
692
|
+
id: null,
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
router.startSweeper();
|
|
699
|
+
|
|
700
|
+
const hitlTimer = setInterval(() => {
|
|
701
|
+
try { hitl.checkTimeouts(); } catch {}
|
|
702
|
+
}, 10000);
|
|
703
|
+
hitlTimer.unref();
|
|
704
|
+
|
|
705
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
706
|
+
const sessionTimer = setInterval(() => {
|
|
707
|
+
const now = Date.now();
|
|
708
|
+
for (const [sid, transport] of transports) {
|
|
709
|
+
if (now - (transport._lastActivity || 0) <= SESSION_TTL_MS) continue;
|
|
710
|
+
try { transport.close(); } catch {}
|
|
711
|
+
transports.delete(sid);
|
|
712
|
+
}
|
|
713
|
+
}, 60000);
|
|
714
|
+
sessionTimer.unref();
|
|
715
|
+
|
|
716
|
+
mkdirSync(PID_DIR, { recursive: true });
|
|
717
|
+
await pipe.start();
|
|
718
|
+
await assignCallbacks.start();
|
|
719
|
+
|
|
720
|
+
return new Promise((resolve, reject) => {
|
|
721
|
+
httpServer.listen(port, host, () => {
|
|
722
|
+
const info = {
|
|
723
|
+
port,
|
|
724
|
+
host,
|
|
725
|
+
dbPath,
|
|
726
|
+
pid: process.pid,
|
|
727
|
+
hubToken: HUB_TOKEN,
|
|
728
|
+
authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
729
|
+
url: `http://${host}:${port}/mcp`,
|
|
730
|
+
pipe_path: pipe.path,
|
|
731
|
+
pipePath: pipe.path,
|
|
732
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
733
|
+
assignCallbackPipePath: assignCallbacks.path,
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
writeFileSync(PID_FILE, JSON.stringify({
|
|
737
|
+
pid: process.pid,
|
|
738
|
+
port,
|
|
739
|
+
host,
|
|
740
|
+
auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
741
|
+
url: info.url,
|
|
742
|
+
pipe_path: pipe.path,
|
|
743
|
+
pipePath: pipe.path,
|
|
744
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
745
|
+
started: Date.now(),
|
|
746
|
+
}));
|
|
747
|
+
|
|
748
|
+
console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} / assign-callback ${assignCallbacks.path} (PID ${process.pid})`);
|
|
749
|
+
|
|
750
|
+
const stopFn = async () => {
|
|
751
|
+
router.stopSweeper();
|
|
752
|
+
clearInterval(hitlTimer);
|
|
753
|
+
clearInterval(sessionTimer);
|
|
754
|
+
for (const [, transport] of transports) {
|
|
755
|
+
try { await transport.close(); } catch {}
|
|
756
|
+
}
|
|
757
|
+
transports.clear();
|
|
758
|
+
await pipe.stop();
|
|
759
|
+
await assignCallbacks.stop();
|
|
760
|
+
await delegatorWorker.stop().catch(() => {});
|
|
761
|
+
store.close();
|
|
762
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
763
|
+
try { unlinkSync(TOKEN_FILE); } catch {}
|
|
764
|
+
await new Promise((resolveClose) => httpServer.close(resolveClose));
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
resolve({
|
|
768
|
+
...info,
|
|
769
|
+
httpServer,
|
|
770
|
+
store,
|
|
771
|
+
router,
|
|
772
|
+
hitl,
|
|
773
|
+
pipe,
|
|
774
|
+
assignCallbacks,
|
|
775
|
+
delegatorService,
|
|
776
|
+
delegatorWorker,
|
|
777
|
+
stop: stopFn,
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
httpServer.on('error', reject);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function getHubInfo() {
|
|
785
|
+
if (!existsSync(PID_FILE)) return null;
|
|
786
|
+
try {
|
|
787
|
+
return JSON.parse(readFileSync(PID_FILE, 'utf8'));
|
|
788
|
+
} catch {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const selfRun = process.argv[1]?.replace(/\\/g, '/').endsWith('hub/server.mjs');
|
|
794
|
+
if (selfRun) {
|
|
795
|
+
const port = parseInt(process.env.TFX_HUB_PORT || '27888', 10);
|
|
796
|
+
const dbPath = process.env.TFX_HUB_DB || undefined;
|
|
797
|
+
|
|
798
|
+
startHub({ port, dbPath }).then((info) => {
|
|
799
|
+
const shutdown = async (signal) => {
|
|
800
|
+
console.log(`\n[tfx-hub] ${signal} 수신, 종료 중...`);
|
|
801
|
+
await info.stop();
|
|
802
|
+
process.exit(0);
|
|
803
|
+
};
|
|
804
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
805
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
806
|
+
}).catch((error) => {
|
|
807
|
+
console.error('[tfx-hub] 시작 실패:', error.message);
|
|
808
|
+
process.exit(1);
|
|
809
|
+
});
|
|
810
|
+
}
|