triflux 3.3.0-dev.3 → 3.3.0-dev.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub/assign-callbacks.mjs +136 -0
- package/hub/bridge.mjs +289 -98
- package/hub/pipe.mjs +81 -0
- package/hub/server.mjs +159 -133
- package/hub/store.mjs +36 -2
- package/hub/team/cli-team-status.mjs +17 -3
- package/hub/team/native-supervisor.mjs +62 -22
- package/hub/team/native.mjs +266 -200
- package/hub/team/nativeProxy.mjs +173 -72
- package/hub/tools.mjs +6 -6
- package/hub/workers/delegator-mcp.mjs +285 -140
- package/package.json +60 -60
- package/scripts/completions/tfx.bash +47 -0
- package/scripts/completions/tfx.fish +44 -0
- package/scripts/completions/tfx.zsh +83 -0
- package/scripts/lib/mcp-filter.mjs +642 -0
- package/scripts/lib/mcp-server-catalog.mjs +118 -0
- package/scripts/mcp-check.mjs +126 -88
- package/scripts/test-tfx-route-no-claude-native.mjs +10 -2
- package/scripts/tfx-route.sh +504 -180
- package/skills/tfx-auto/SKILL.md +6 -1
package/hub/pipe.mjs
CHANGED
|
@@ -5,6 +5,19 @@ import net from 'node:net';
|
|
|
5
5
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import {
|
|
9
|
+
teamInfo,
|
|
10
|
+
teamTaskList,
|
|
11
|
+
teamTaskUpdate,
|
|
12
|
+
teamSendMessage,
|
|
13
|
+
} from './team/nativeProxy.mjs';
|
|
14
|
+
import { createPipeline } from './pipeline/index.mjs';
|
|
15
|
+
import {
|
|
16
|
+
ensurePipelineTable,
|
|
17
|
+
initPipelineState,
|
|
18
|
+
listPipelineStates,
|
|
19
|
+
readPipelineState,
|
|
20
|
+
} from './pipeline/state.mjs';
|
|
8
21
|
|
|
9
22
|
const DEFAULT_HEARTBEAT_TTL_MS = 60000;
|
|
10
23
|
|
|
@@ -236,6 +249,41 @@ export function createPipeServer({
|
|
|
236
249
|
};
|
|
237
250
|
}
|
|
238
251
|
|
|
252
|
+
case 'team_task_update': {
|
|
253
|
+
const result = await teamTaskUpdate(payload);
|
|
254
|
+
if (client) touchClient(client);
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'team_send_message': {
|
|
259
|
+
const result = await teamSendMessage(payload);
|
|
260
|
+
if (client) touchClient(client);
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case 'pipeline_advance': {
|
|
265
|
+
if (client) touchClient(client);
|
|
266
|
+
if (!store?.db) {
|
|
267
|
+
return { ok: false, error: 'hub_db_not_found' };
|
|
268
|
+
}
|
|
269
|
+
ensurePipelineTable(store.db);
|
|
270
|
+
const pipeline = createPipeline(store.db, payload.team_name);
|
|
271
|
+
return pipeline.advance(payload.phase);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case 'pipeline_init': {
|
|
275
|
+
if (client) touchClient(client);
|
|
276
|
+
if (!store?.db) {
|
|
277
|
+
return { ok: false, error: 'hub_db_not_found' };
|
|
278
|
+
}
|
|
279
|
+
ensurePipelineTable(store.db);
|
|
280
|
+
const state = initPipelineState(store.db, payload.team_name, {
|
|
281
|
+
fix_max: payload.fix_max,
|
|
282
|
+
ralph_max: payload.ralph_max,
|
|
283
|
+
});
|
|
284
|
+
return { ok: true, data: state };
|
|
285
|
+
}
|
|
286
|
+
|
|
239
287
|
default:
|
|
240
288
|
return {
|
|
241
289
|
ok: false,
|
|
@@ -305,6 +353,39 @@ export function createPipeServer({
|
|
|
305
353
|
return router.getAssignStatus(payload);
|
|
306
354
|
}
|
|
307
355
|
|
|
356
|
+
case 'team_info': {
|
|
357
|
+
const result = await teamInfo(payload);
|
|
358
|
+
if (client) touchClient(client);
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
case 'team_task_list': {
|
|
363
|
+
const result = await teamTaskList(payload);
|
|
364
|
+
if (client) touchClient(client);
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case 'pipeline_state': {
|
|
369
|
+
if (client) touchClient(client);
|
|
370
|
+
if (!store?.db) {
|
|
371
|
+
return { ok: false, error: 'hub_db_not_found' };
|
|
372
|
+
}
|
|
373
|
+
ensurePipelineTable(store.db);
|
|
374
|
+
const state = readPipelineState(store.db, payload.team_name);
|
|
375
|
+
return state
|
|
376
|
+
? { ok: true, data: state }
|
|
377
|
+
: { ok: false, error: 'pipeline_not_found' };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
case 'pipeline_list': {
|
|
381
|
+
if (client) touchClient(client);
|
|
382
|
+
if (!store?.db) {
|
|
383
|
+
return { ok: false, error: 'hub_db_not_found' };
|
|
384
|
+
}
|
|
385
|
+
ensurePipelineTable(store.db);
|
|
386
|
+
return { ok: true, data: listPipelineStates(store.db) };
|
|
387
|
+
}
|
|
388
|
+
|
|
308
389
|
default:
|
|
309
390
|
return {
|
|
310
391
|
ok: false,
|
package/hub/server.mjs
CHANGED
|
@@ -13,22 +13,13 @@ import { createStore } from './store.mjs';
|
|
|
13
13
|
import { createRouter } from './router.mjs';
|
|
14
14
|
import { createHitlManager } from './hitl.mjs';
|
|
15
15
|
import { createPipeServer } from './pipe.mjs';
|
|
16
|
+
import { createAssignCallbackServer } from './assign-callbacks.mjs';
|
|
16
17
|
import { createTools } from './tools.mjs';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
readPipelineState,
|
|
23
|
-
initPipelineState,
|
|
24
|
-
listPipelineStates,
|
|
25
|
-
} from './pipeline/state.mjs';
|
|
26
|
-
import {
|
|
27
|
-
teamInfo,
|
|
28
|
-
teamTaskList,
|
|
29
|
-
teamTaskUpdate,
|
|
30
|
-
teamSendMessage,
|
|
31
|
-
} from './team/nativeProxy.mjs';
|
|
18
|
+
|
|
19
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
20
|
+
const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
|
|
21
|
+
const LOOPBACK_REMOTE_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
22
|
+
const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
32
23
|
|
|
33
24
|
function isInitializeRequest(body) {
|
|
34
25
|
if (body?.method === 'initialize') return true;
|
|
@@ -36,7 +27,6 @@ function isInitializeRequest(body) {
|
|
|
36
27
|
return false;
|
|
37
28
|
}
|
|
38
29
|
|
|
39
|
-
const MAX_BODY_SIZE = 1024 * 1024;
|
|
40
30
|
async function parseBody(req) {
|
|
41
31
|
const chunks = [];
|
|
42
32
|
let size = 0;
|
|
@@ -54,13 +44,72 @@ const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
|
|
|
54
44
|
const PID_FILE = join(PID_DIR, 'hub.pid');
|
|
55
45
|
const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
|
|
56
46
|
|
|
57
|
-
// localhost 계열 Origin만 허용
|
|
58
|
-
const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
59
|
-
|
|
60
47
|
function isAllowedOrigin(origin) {
|
|
61
48
|
return origin && ALLOWED_ORIGIN_RE.test(origin);
|
|
62
49
|
}
|
|
63
50
|
|
|
51
|
+
function getRequestPath(url = '/') {
|
|
52
|
+
try {
|
|
53
|
+
return new URL(url, 'http://127.0.0.1').pathname;
|
|
54
|
+
} catch {
|
|
55
|
+
return String(url).replace(/\?.*/, '') || '/';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLoopbackRemoteAddress(remoteAddress) {
|
|
60
|
+
return typeof remoteAddress === 'string' && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractBearerToken(req) {
|
|
64
|
+
const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
|
|
65
|
+
return authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeJson(res, statusCode, body, headers = {}) {
|
|
69
|
+
res.writeHead(statusCode, {
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
...headers,
|
|
72
|
+
});
|
|
73
|
+
res.end(JSON.stringify(body));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyCorsHeaders(req, res) {
|
|
77
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
|
|
78
|
+
if (origin) {
|
|
79
|
+
res.setHeader('Vary', 'Origin');
|
|
80
|
+
}
|
|
81
|
+
if (!isAllowedOrigin(origin)) return false;
|
|
82
|
+
|
|
83
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
84
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
85
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isAuthorizedRequest(req, path, hubToken) {
|
|
90
|
+
if (!hubToken) {
|
|
91
|
+
return isLoopbackRemoteAddress(req.socket.remoteAddress);
|
|
92
|
+
}
|
|
93
|
+
if (PUBLIC_PATHS.has(path)) return true;
|
|
94
|
+
return extractBearerToken(req) === hubToken;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveTeamStatusCode(result) {
|
|
98
|
+
if (result?.ok) return 200;
|
|
99
|
+
const code = result?.error?.code;
|
|
100
|
+
if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
|
|
101
|
+
if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
|
|
102
|
+
if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
|
|
103
|
+
return 500;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolvePipelineStatusCode(result) {
|
|
107
|
+
if (result?.ok) return 200;
|
|
108
|
+
if (result?.error === 'pipeline_not_found') return 404;
|
|
109
|
+
if (result?.error === 'hub_db_not_found') return 503;
|
|
110
|
+
return 400;
|
|
111
|
+
}
|
|
112
|
+
|
|
64
113
|
/**
|
|
65
114
|
* tfx-hub 시작
|
|
66
115
|
* @param {object} opts
|
|
@@ -74,14 +123,18 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
74
123
|
dbPath = join(PID_DIR, 'state.db');
|
|
75
124
|
}
|
|
76
125
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
126
|
+
const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
|
|
127
|
+
if (HUB_TOKEN) {
|
|
128
|
+
mkdirSync(join(homedir(), '.claude'), { recursive: true });
|
|
129
|
+
writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
|
|
130
|
+
} else {
|
|
131
|
+
try { unlinkSync(TOKEN_FILE); } catch {}
|
|
132
|
+
}
|
|
81
133
|
|
|
82
134
|
const store = createStore(dbPath);
|
|
83
135
|
const router = createRouter(store);
|
|
84
136
|
const pipe = createPipeServer({ router, store, sessionId });
|
|
137
|
+
const assignCallbacks = createAssignCallbackServer({ store, sessionId });
|
|
85
138
|
const hitl = createHitlManager(store, router);
|
|
86
139
|
const tools = createTools(store, router, hitl, pipe);
|
|
87
140
|
const transports = new Map();
|
|
@@ -122,64 +175,61 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
122
175
|
}
|
|
123
176
|
|
|
124
177
|
const httpServer = createHttpServer(async (req, res) => {
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
if (isAllowedOrigin(origin)) {
|
|
128
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
129
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
130
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
|
|
131
|
-
}
|
|
178
|
+
const path = getRequestPath(req.url);
|
|
179
|
+
const corsAllowed = applyCorsHeaders(req, res);
|
|
132
180
|
|
|
133
181
|
if (req.method === 'OPTIONS') {
|
|
134
|
-
|
|
182
|
+
const localOnlyMode = !HUB_TOKEN;
|
|
183
|
+
const isLoopbackRequest = isLoopbackRemoteAddress(req.socket.remoteAddress);
|
|
184
|
+
res.writeHead(corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403);
|
|
135
185
|
return res.end();
|
|
136
186
|
}
|
|
137
187
|
|
|
138
|
-
if (req
|
|
188
|
+
if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
|
|
189
|
+
if (!HUB_TOKEN) {
|
|
190
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden: localhost only' });
|
|
191
|
+
}
|
|
192
|
+
return writeJson(
|
|
193
|
+
res,
|
|
194
|
+
401,
|
|
195
|
+
{ ok: false, error: 'Unauthorized' },
|
|
196
|
+
{ 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (path === '/' || path === '/status') {
|
|
139
201
|
const status = router.getStatus('hub').data;
|
|
140
|
-
res
|
|
141
|
-
return res.end(JSON.stringify({
|
|
202
|
+
return writeJson(res, 200, {
|
|
142
203
|
...status,
|
|
143
204
|
sessions: transports.size,
|
|
144
205
|
pid: process.pid,
|
|
145
206
|
port,
|
|
207
|
+
auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
146
208
|
pipe_path: pipe.path,
|
|
147
209
|
pipe: pipe.getStatus(),
|
|
148
|
-
|
|
210
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
211
|
+
assign_callback_pipe: assignCallbacks.getStatus(),
|
|
212
|
+
});
|
|
149
213
|
}
|
|
150
214
|
|
|
151
|
-
if (
|
|
215
|
+
if (path === '/health' || path === '/healthz') {
|
|
152
216
|
const status = router.getStatus('hub').data;
|
|
153
217
|
const healthy = status?.hub?.state === 'healthy';
|
|
154
|
-
res
|
|
155
|
-
return res.end(JSON.stringify({ ok: healthy }));
|
|
218
|
+
return writeJson(res, healthy ? 200 : 503, { ok: healthy });
|
|
156
219
|
}
|
|
157
220
|
|
|
158
|
-
if (
|
|
159
|
-
res.setHeader('Content-Type', 'application/json');
|
|
160
|
-
|
|
161
|
-
// Bearer 토큰 인증
|
|
162
|
-
const authHeader = req.headers['authorization'] || '';
|
|
163
|
-
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
164
|
-
if (bearerToken !== HUB_TOKEN) {
|
|
165
|
-
res.writeHead(401);
|
|
166
|
-
return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
|
|
167
|
-
}
|
|
168
|
-
|
|
221
|
+
if (path.startsWith('/bridge')) {
|
|
169
222
|
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
170
|
-
res
|
|
171
|
-
return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
|
|
223
|
+
return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
|
|
172
224
|
}
|
|
173
225
|
|
|
174
226
|
try {
|
|
175
227
|
const body = req.method === 'POST' ? await parseBody(req) : {};
|
|
176
|
-
const path = req.url.replace(/\?.*/, '');
|
|
177
228
|
|
|
178
229
|
if (path === '/bridge/register' && req.method === 'POST') {
|
|
179
230
|
const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
|
|
180
231
|
if (!agent_id || !cli) {
|
|
181
|
-
res
|
|
182
|
-
return res.end(JSON.stringify({ ok: false, error: 'agent_id, cli 필수' }));
|
|
232
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id, cli 필수' });
|
|
183
233
|
}
|
|
184
234
|
|
|
185
235
|
const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
|
|
@@ -191,15 +241,13 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
191
241
|
heartbeat_ttl_ms,
|
|
192
242
|
metadata,
|
|
193
243
|
});
|
|
194
|
-
res
|
|
195
|
-
return res.end(JSON.stringify(result));
|
|
244
|
+
return writeJson(res, 200, result);
|
|
196
245
|
}
|
|
197
246
|
|
|
198
247
|
if (path === '/bridge/result' && req.method === 'POST') {
|
|
199
248
|
const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
|
|
200
249
|
if (!agent_id) {
|
|
201
|
-
res
|
|
202
|
-
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
250
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
203
251
|
}
|
|
204
252
|
|
|
205
253
|
const result = await pipe.executeCommand('result', {
|
|
@@ -209,8 +257,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
209
257
|
trace_id,
|
|
210
258
|
correlation_id,
|
|
211
259
|
});
|
|
212
|
-
res
|
|
213
|
-
return res.end(JSON.stringify(result));
|
|
260
|
+
return writeJson(res, 200, result);
|
|
214
261
|
}
|
|
215
262
|
|
|
216
263
|
if (path === '/bridge/control' && req.method === 'POST') {
|
|
@@ -226,8 +273,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
226
273
|
} = body;
|
|
227
274
|
|
|
228
275
|
if (!to_agent || !command) {
|
|
229
|
-
res
|
|
230
|
-
return res.end(JSON.stringify({ ok: false, error: 'to_agent, command 필수' }));
|
|
276
|
+
return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
|
|
231
277
|
}
|
|
232
278
|
|
|
233
279
|
const result = await pipe.executeCommand('control', {
|
|
@@ -241,8 +287,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
241
287
|
correlation_id,
|
|
242
288
|
});
|
|
243
289
|
|
|
244
|
-
res
|
|
245
|
-
return res.end(JSON.stringify(result));
|
|
290
|
+
return writeJson(res, 200, result);
|
|
246
291
|
}
|
|
247
292
|
|
|
248
293
|
if (path === '/bridge/assign/async' && req.method === 'POST') {
|
|
@@ -261,8 +306,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
261
306
|
} = body;
|
|
262
307
|
|
|
263
308
|
if (!supervisor_agent || !worker_agent || !task) {
|
|
264
|
-
res
|
|
265
|
-
return res.end(JSON.stringify({ ok: false, error: 'supervisor_agent, worker_agent, task 필수' }));
|
|
309
|
+
return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
|
|
266
310
|
}
|
|
267
311
|
|
|
268
312
|
const result = await pipe.executeCommand('assign', {
|
|
@@ -278,8 +322,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
278
322
|
trace_id,
|
|
279
323
|
correlation_id,
|
|
280
324
|
});
|
|
281
|
-
res
|
|
282
|
-
return res.end(JSON.stringify(result));
|
|
325
|
+
return writeJson(res, result.ok ? 200 : 400, result);
|
|
283
326
|
}
|
|
284
327
|
|
|
285
328
|
if (path === '/bridge/assign/result' && req.method === 'POST') {
|
|
@@ -295,8 +338,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
295
338
|
} = body;
|
|
296
339
|
|
|
297
340
|
if (!job_id || !status) {
|
|
298
|
-
res
|
|
299
|
-
return res.end(JSON.stringify({ ok: false, error: 'job_id, status 필수' }));
|
|
341
|
+
return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
|
|
300
342
|
}
|
|
301
343
|
|
|
302
344
|
const result = await pipe.executeCommand('assign_result', {
|
|
@@ -309,22 +351,19 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
309
351
|
payload,
|
|
310
352
|
metadata,
|
|
311
353
|
});
|
|
312
|
-
res
|
|
313
|
-
return res.end(JSON.stringify(result));
|
|
354
|
+
return writeJson(res, result.ok ? 200 : 409, result);
|
|
314
355
|
}
|
|
315
356
|
|
|
316
357
|
if (path === '/bridge/assign/status' && req.method === 'POST') {
|
|
317
358
|
const result = await pipe.executeQuery('assign_status', body);
|
|
318
359
|
const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
|
|
319
|
-
res
|
|
320
|
-
return res.end(JSON.stringify(result));
|
|
360
|
+
return writeJson(res, statusCode, result);
|
|
321
361
|
}
|
|
322
362
|
|
|
323
363
|
if (path === '/bridge/assign/retry' && req.method === 'POST') {
|
|
324
364
|
const { job_id, reason, requested_by } = body;
|
|
325
365
|
if (!job_id) {
|
|
326
|
-
res
|
|
327
|
-
return res.end(JSON.stringify({ ok: false, error: 'job_id 필수' }));
|
|
366
|
+
return writeJson(res, 400, { ok: false, error: 'job_id 필수' });
|
|
328
367
|
}
|
|
329
368
|
|
|
330
369
|
const result = await pipe.executeCommand('assign_retry', {
|
|
@@ -336,115 +375,85 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
336
375
|
: result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
|
|
337
376
|
: result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
|
|
338
377
|
: 400;
|
|
339
|
-
res
|
|
340
|
-
return res.end(JSON.stringify(result));
|
|
378
|
+
return writeJson(res, statusCode, result);
|
|
341
379
|
}
|
|
342
380
|
|
|
343
381
|
if (req.method === 'POST') {
|
|
344
382
|
let teamResult = null;
|
|
345
383
|
if (path === '/bridge/team/info' || path === '/bridge/team-info') {
|
|
346
|
-
teamResult = await
|
|
384
|
+
teamResult = await pipe.executeQuery('team_info', body);
|
|
347
385
|
} else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
|
|
348
|
-
teamResult = await
|
|
386
|
+
teamResult = await pipe.executeQuery('team_task_list', body);
|
|
349
387
|
} else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
|
|
350
|
-
teamResult = await
|
|
388
|
+
teamResult = await pipe.executeCommand('team_task_update', body);
|
|
351
389
|
} else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
|
|
352
|
-
teamResult = await
|
|
390
|
+
teamResult = await pipe.executeCommand('team_send_message', body);
|
|
353
391
|
}
|
|
354
392
|
|
|
355
393
|
if (teamResult) {
|
|
356
|
-
|
|
357
|
-
const code = teamResult?.error?.code;
|
|
358
|
-
if (!teamResult.ok) {
|
|
359
|
-
if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') status = 404;
|
|
360
|
-
else if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') status = 409;
|
|
361
|
-
else if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') status = 400;
|
|
362
|
-
else status = 500;
|
|
363
|
-
}
|
|
364
|
-
res.writeHead(status);
|
|
365
|
-
return res.end(JSON.stringify(teamResult));
|
|
394
|
+
return writeJson(res, resolveTeamStatusCode(teamResult), teamResult);
|
|
366
395
|
}
|
|
367
396
|
|
|
368
397
|
if (path.startsWith('/bridge/team')) {
|
|
369
|
-
res
|
|
370
|
-
return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
|
|
398
|
+
return writeJson(res, 404, { ok: false, error: `Unknown team endpoint: ${path}` });
|
|
371
399
|
}
|
|
372
400
|
|
|
373
401
|
// ── 파이프라인 엔드포인트 ──
|
|
374
402
|
if (path === '/bridge/pipeline/state' && req.method === 'POST') {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const state = readPipelineState(store.db, team_name);
|
|
378
|
-
res.writeHead(state ? 200 : 404);
|
|
379
|
-
return res.end(JSON.stringify(state
|
|
380
|
-
? { ok: true, data: state }
|
|
381
|
-
: { ok: false, error: 'pipeline_not_found' }));
|
|
403
|
+
const result = await pipe.executeQuery('pipeline_state', body);
|
|
404
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
382
405
|
}
|
|
383
406
|
|
|
384
407
|
if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const pipeline = createPipeline(store.db, team_name);
|
|
388
|
-
const result = pipeline.advance(phase);
|
|
389
|
-
res.writeHead(result.ok ? 200 : 400);
|
|
390
|
-
return res.end(JSON.stringify(result));
|
|
408
|
+
const result = await pipe.executeCommand('pipeline_advance', body);
|
|
409
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
391
410
|
}
|
|
392
411
|
|
|
393
412
|
if (path === '/bridge/pipeline/init' && req.method === 'POST') {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
|
|
397
|
-
res.writeHead(200);
|
|
398
|
-
return res.end(JSON.stringify({ ok: true, data: state }));
|
|
413
|
+
const result = await pipe.executeCommand('pipeline_init', body);
|
|
414
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
399
415
|
}
|
|
400
416
|
|
|
401
417
|
if (path === '/bridge/pipeline/list' && req.method === 'POST') {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
res.writeHead(200);
|
|
405
|
-
return res.end(JSON.stringify({ ok: true, data: states }));
|
|
418
|
+
const result = await pipe.executeQuery('pipeline_list', body);
|
|
419
|
+
return writeJson(res, resolvePipelineStatusCode(result), result);
|
|
406
420
|
}
|
|
407
421
|
}
|
|
408
422
|
|
|
409
423
|
if (path === '/bridge/context' && req.method === 'POST') {
|
|
410
|
-
const { agent_id, topics, max_messages = 10 } = body;
|
|
424
|
+
const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
|
|
411
425
|
if (!agent_id) {
|
|
412
|
-
res
|
|
413
|
-
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
426
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
414
427
|
}
|
|
415
428
|
|
|
416
|
-
const result = await pipe.executeQuery('
|
|
429
|
+
const result = await pipe.executeQuery('drain', {
|
|
417
430
|
agent_id,
|
|
418
431
|
topics,
|
|
419
432
|
max_messages,
|
|
433
|
+
auto_ack,
|
|
420
434
|
});
|
|
421
|
-
res
|
|
422
|
-
return res.end(JSON.stringify(result));
|
|
435
|
+
return writeJson(res, 200, result);
|
|
423
436
|
}
|
|
424
437
|
|
|
425
438
|
if (path === '/bridge/deregister' && req.method === 'POST') {
|
|
426
439
|
const { agent_id } = body;
|
|
427
440
|
if (!agent_id) {
|
|
428
|
-
res
|
|
429
|
-
return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
|
|
441
|
+
return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
|
|
430
442
|
}
|
|
431
443
|
const result = await pipe.executeCommand('deregister', { agent_id });
|
|
432
|
-
res
|
|
433
|
-
return res.end(JSON.stringify(result));
|
|
444
|
+
return writeJson(res, 200, result);
|
|
434
445
|
}
|
|
435
446
|
|
|
436
|
-
res
|
|
437
|
-
return res.end(JSON.stringify({ ok: false, error: 'Unknown bridge endpoint' }));
|
|
447
|
+
return writeJson(res, 404, { ok: false, error: 'Unknown bridge endpoint' });
|
|
438
448
|
} catch (error) {
|
|
439
449
|
if (!res.headersSent) {
|
|
440
|
-
res.
|
|
441
|
-
res.end(JSON.stringify({ ok: false, error: error.message }));
|
|
450
|
+
writeJson(res, 500, { ok: false, error: error.message });
|
|
442
451
|
}
|
|
443
452
|
return;
|
|
444
453
|
}
|
|
445
454
|
}
|
|
446
455
|
|
|
447
|
-
if (
|
|
456
|
+
if (path !== '/mcp') {
|
|
448
457
|
res.writeHead(404);
|
|
449
458
|
return res.end('Not Found');
|
|
450
459
|
}
|
|
@@ -536,6 +545,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
536
545
|
|
|
537
546
|
mkdirSync(PID_DIR, { recursive: true });
|
|
538
547
|
await pipe.start();
|
|
548
|
+
await assignCallbacks.start();
|
|
539
549
|
|
|
540
550
|
return new Promise((resolve, reject) => {
|
|
541
551
|
httpServer.listen(port, host, () => {
|
|
@@ -544,22 +554,28 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
544
554
|
host,
|
|
545
555
|
dbPath,
|
|
546
556
|
pid: process.pid,
|
|
557
|
+
hubToken: HUB_TOKEN,
|
|
558
|
+
authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
547
559
|
url: `http://${host}:${port}/mcp`,
|
|
548
560
|
pipe_path: pipe.path,
|
|
549
561
|
pipePath: pipe.path,
|
|
562
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
563
|
+
assignCallbackPipePath: assignCallbacks.path,
|
|
550
564
|
};
|
|
551
565
|
|
|
552
566
|
writeFileSync(PID_FILE, JSON.stringify({
|
|
553
567
|
pid: process.pid,
|
|
554
568
|
port,
|
|
555
569
|
host,
|
|
570
|
+
auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
|
|
556
571
|
url: info.url,
|
|
557
572
|
pipe_path: pipe.path,
|
|
558
573
|
pipePath: pipe.path,
|
|
574
|
+
assign_callback_pipe_path: assignCallbacks.path,
|
|
559
575
|
started: Date.now(),
|
|
560
576
|
}));
|
|
561
577
|
|
|
562
|
-
console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} (PID ${process.pid})`);
|
|
578
|
+
console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} / assign-callback ${assignCallbacks.path} (PID ${process.pid})`);
|
|
563
579
|
|
|
564
580
|
const stopFn = async () => {
|
|
565
581
|
router.stopSweeper();
|
|
@@ -570,13 +586,23 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
|
|
|
570
586
|
}
|
|
571
587
|
transports.clear();
|
|
572
588
|
await pipe.stop();
|
|
589
|
+
await assignCallbacks.stop();
|
|
573
590
|
store.close();
|
|
574
591
|
try { unlinkSync(PID_FILE); } catch {}
|
|
575
592
|
try { unlinkSync(TOKEN_FILE); } catch {}
|
|
576
593
|
await new Promise((resolveClose) => httpServer.close(resolveClose));
|
|
577
594
|
};
|
|
578
595
|
|
|
579
|
-
resolve({
|
|
596
|
+
resolve({
|
|
597
|
+
...info,
|
|
598
|
+
httpServer,
|
|
599
|
+
store,
|
|
600
|
+
router,
|
|
601
|
+
hitl,
|
|
602
|
+
pipe,
|
|
603
|
+
assignCallbacks,
|
|
604
|
+
stop: stopFn,
|
|
605
|
+
});
|
|
580
606
|
});
|
|
581
607
|
httpServer.on('error', reject);
|
|
582
608
|
});
|