triflux 3.3.0-dev.3 → 3.3.0-dev.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hub/pipe.mjs 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,8 @@ 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
- import {
18
- ensurePipelineTable,
19
- createPipeline,
20
- } from './pipeline/index.mjs';
21
- import {
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';
32
18
 
33
19
  function isInitializeRequest(body) {
34
20
  if (body?.method === 'initialize') return true;
@@ -61,6 +47,22 @@ function isAllowedOrigin(origin) {
61
47
  return origin && ALLOWED_ORIGIN_RE.test(origin);
62
48
  }
63
49
 
50
+ function resolveTeamStatusCode(result) {
51
+ if (result?.ok) return 200;
52
+ const code = result?.error?.code;
53
+ if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
54
+ if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
55
+ if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
56
+ return 500;
57
+ }
58
+
59
+ function resolvePipelineStatusCode(result) {
60
+ if (result?.ok) return 200;
61
+ if (result?.error === 'pipeline_not_found') return 404;
62
+ if (result?.error === 'hub_db_not_found') return 503;
63
+ return 400;
64
+ }
65
+
64
66
  /**
65
67
  * tfx-hub 시작
66
68
  * @param {object} opts
@@ -82,6 +84,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
82
84
  const store = createStore(dbPath);
83
85
  const router = createRouter(store);
84
86
  const pipe = createPipeServer({ router, store, sessionId });
87
+ const assignCallbacks = createAssignCallbackServer({ store, sessionId });
85
88
  const hitl = createHitlManager(store, router);
86
89
  const tools = createTools(store, router, hitl, pipe);
87
90
  const transports = new Map();
@@ -145,6 +148,8 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
145
148
  port,
146
149
  pipe_path: pipe.path,
147
150
  pipe: pipe.getStatus(),
151
+ assign_callback_pipe_path: assignCallbacks.path,
152
+ assign_callback_pipe: assignCallbacks.getStatus(),
148
153
  }));
149
154
  }
150
155
 
@@ -343,25 +348,17 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
343
348
  if (req.method === 'POST') {
344
349
  let teamResult = null;
345
350
  if (path === '/bridge/team/info' || path === '/bridge/team-info') {
346
- teamResult = await teamInfo(body);
351
+ teamResult = await pipe.executeQuery('team_info', body);
347
352
  } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
348
- teamResult = await teamTaskList(body);
353
+ teamResult = await pipe.executeQuery('team_task_list', body);
349
354
  } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
350
- teamResult = await teamTaskUpdate(body);
355
+ teamResult = await pipe.executeCommand('team_task_update', body);
351
356
  } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
352
- teamResult = await teamSendMessage(body);
357
+ teamResult = await pipe.executeCommand('team_send_message', body);
353
358
  }
354
359
 
355
360
  if (teamResult) {
356
- let status = 200;
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);
361
+ res.writeHead(resolveTeamStatusCode(teamResult));
365
362
  return res.end(JSON.stringify(teamResult));
366
363
  }
367
364
 
@@ -372,51 +369,42 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
372
369
 
373
370
  // ── 파이프라인 엔드포인트 ──
374
371
  if (path === '/bridge/pipeline/state' && req.method === 'POST') {
375
- ensurePipelineTable(store.db);
376
- const { team_name } = body;
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' }));
372
+ const result = await pipe.executeQuery('pipeline_state', body);
373
+ res.writeHead(resolvePipelineStatusCode(result));
374
+ return res.end(JSON.stringify(result));
382
375
  }
383
376
 
384
377
  if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
385
- ensurePipelineTable(store.db);
386
- const { team_name, phase } = body;
387
- const pipeline = createPipeline(store.db, team_name);
388
- const result = pipeline.advance(phase);
389
- res.writeHead(result.ok ? 200 : 400);
378
+ const result = await pipe.executeCommand('pipeline_advance', body);
379
+ res.writeHead(resolvePipelineStatusCode(result));
390
380
  return res.end(JSON.stringify(result));
391
381
  }
392
382
 
393
383
  if (path === '/bridge/pipeline/init' && req.method === 'POST') {
394
- ensurePipelineTable(store.db);
395
- const { team_name, fix_max, ralph_max } = body;
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 }));
384
+ const result = await pipe.executeCommand('pipeline_init', body);
385
+ res.writeHead(resolvePipelineStatusCode(result));
386
+ return res.end(JSON.stringify(result));
399
387
  }
400
388
 
401
389
  if (path === '/bridge/pipeline/list' && req.method === 'POST') {
402
- ensurePipelineTable(store.db);
403
- const states = listPipelineStates(store.db);
404
- res.writeHead(200);
405
- return res.end(JSON.stringify({ ok: true, data: states }));
390
+ const result = await pipe.executeQuery('pipeline_list', body);
391
+ res.writeHead(resolvePipelineStatusCode(result));
392
+ return res.end(JSON.stringify(result));
406
393
  }
407
394
  }
408
395
 
409
396
  if (path === '/bridge/context' && req.method === 'POST') {
410
- const { agent_id, topics, max_messages = 10 } = body;
397
+ const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
411
398
  if (!agent_id) {
412
399
  res.writeHead(400);
413
400
  return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
414
401
  }
415
402
 
416
- const result = await pipe.executeQuery('context', {
403
+ const result = await pipe.executeQuery('drain', {
417
404
  agent_id,
418
405
  topics,
419
406
  max_messages,
407
+ auto_ack,
420
408
  });
421
409
  res.writeHead(200);
422
410
  return res.end(JSON.stringify(result));
@@ -536,6 +524,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
536
524
 
537
525
  mkdirSync(PID_DIR, { recursive: true });
538
526
  await pipe.start();
527
+ await assignCallbacks.start();
539
528
 
540
529
  return new Promise((resolve, reject) => {
541
530
  httpServer.listen(port, host, () => {
@@ -544,9 +533,12 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
544
533
  host,
545
534
  dbPath,
546
535
  pid: process.pid,
536
+ hubToken: HUB_TOKEN,
547
537
  url: `http://${host}:${port}/mcp`,
548
538
  pipe_path: pipe.path,
549
539
  pipePath: pipe.path,
540
+ assign_callback_pipe_path: assignCallbacks.path,
541
+ assignCallbackPipePath: assignCallbacks.path,
550
542
  };
551
543
 
552
544
  writeFileSync(PID_FILE, JSON.stringify({
@@ -556,10 +548,11 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
556
548
  url: info.url,
557
549
  pipe_path: pipe.path,
558
550
  pipePath: pipe.path,
551
+ assign_callback_pipe_path: assignCallbacks.path,
559
552
  started: Date.now(),
560
553
  }));
561
554
 
562
- console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} (PID ${process.pid})`);
555
+ console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} / assign-callback ${assignCallbacks.path} (PID ${process.pid})`);
563
556
 
564
557
  const stopFn = async () => {
565
558
  router.stopSweeper();
@@ -570,13 +563,23 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
570
563
  }
571
564
  transports.clear();
572
565
  await pipe.stop();
566
+ await assignCallbacks.stop();
573
567
  store.close();
574
568
  try { unlinkSync(PID_FILE); } catch {}
575
569
  try { unlinkSync(TOKEN_FILE); } catch {}
576
570
  await new Promise((resolveClose) => httpServer.close(resolveClose));
577
571
  };
578
572
 
579
- resolve({ ...info, httpServer, store, router, hitl, pipe, stop: stopFn });
573
+ resolve({
574
+ ...info,
575
+ httpServer,
576
+ store,
577
+ router,
578
+ hitl,
579
+ pipe,
580
+ assignCallbacks,
581
+ stop: stopFn,
582
+ });
580
583
  });
581
584
  httpServer.on('error', reject);
582
585
  });
package/hub/store.mjs CHANGED
@@ -223,6 +223,24 @@ export function createStore(dbPath) {
223
223
  activeAssignCount: db.prepare("SELECT COUNT(*) as cnt FROM assign_jobs WHERE status IN ('queued','running')"),
224
224
  };
225
225
 
226
+ const assignStatusListeners = new Set();
227
+
228
+ function buildAssignCallbackEvent(row) {
229
+ return {
230
+ job_id: row.job_id,
231
+ status: row.status,
232
+ result: row.result ?? row.error ?? null,
233
+ timestamp: new Date(row.updated_at_ms || Date.now()).toISOString(),
234
+ };
235
+ }
236
+
237
+ function notifyAssignStatusListeners(row) {
238
+ const event = buildAssignCallbackEvent(row);
239
+ for (const listener of Array.from(assignStatusListeners)) {
240
+ try { listener(event, row); } catch {}
241
+ }
242
+ }
243
+
226
244
  function clampMaxMessages(value, fallback = 20) {
227
245
  const num = Number(value);
228
246
  if (!Number.isFinite(num)) return fallback;
@@ -481,7 +499,9 @@ export function createStore(dbPath) {
481
499
  last_retry_at_ms: retry_count > 0 ? now : null,
482
500
  };
483
501
  S.insertAssign.run(row);
484
- return store.getAssign(row.job_id);
502
+ const inserted = store.getAssign(row.job_id);
503
+ notifyAssignStatusListeners(inserted);
504
+ return inserted;
485
505
  },
486
506
 
487
507
  getAssign(jobId) {
@@ -541,7 +561,11 @@ export function createStore(dbPath) {
541
561
  : current.last_retry_at_ms,
542
562
  };
543
563
  S.updateAssign.run(nextRow);
544
- return store.getAssign(jobId);
564
+ const updated = store.getAssign(jobId);
565
+ if (updated && current.status !== updated.status) {
566
+ notifyAssignStatusListeners(updated);
567
+ }
568
+ return updated;
545
569
  },
546
570
 
547
571
  listAssigns({
@@ -639,6 +663,16 @@ export function createStore(dbPath) {
639
663
  };
640
664
  },
641
665
 
666
+ onAssignStatusChange(listener) {
667
+ if (typeof listener !== 'function') {
668
+ return () => {};
669
+ }
670
+ assignStatusListeners.add(listener);
671
+ return () => {
672
+ assignStatusListeners.delete(listener);
673
+ };
674
+ },
675
+
642
676
  getDeliveryStats() {
643
677
  return {
644
678
  total_deliveries: S.ackedRecent.get(Date.now()).cnt,
@@ -54,6 +54,21 @@ function renderTasks(tasks = []) {
54
54
  console.log("");
55
55
  }
56
56
 
57
+ function formatCompletionSuffix(member) {
58
+ if (!member?.completionStatus) return "";
59
+ if (member.completionStatus === "abnormal") {
60
+ const reason = member.completionReason || "unknown";
61
+ return ` ${RED}[abnormal:${reason}]${RESET}`;
62
+ }
63
+ if (member.completionStatus === "normal") {
64
+ return ` ${GREEN}[route-ok]${RESET}`;
65
+ }
66
+ if (member.completionStatus === "unchecked") {
67
+ return ` ${GRAY}[route-unchecked]${RESET}`;
68
+ }
69
+ return "";
70
+ }
71
+
57
72
  export async function teamStatus() {
58
73
  const state = loadTeamState();
59
74
  if (!state) {
@@ -91,7 +106,7 @@ export async function teamStatus() {
91
106
  if (nativeMembers.length) {
92
107
  console.log("");
93
108
  for (const m of nativeMembers) {
94
- console.log(` • ${m.name}: ${m.status}${m.lastPreview ? ` ${DIM}${m.lastPreview}${RESET}` : ""}`);
109
+ console.log(` • ${m.name}: ${m.status}${formatCompletionSuffix(m)}${m.lastPreview ? ` ${DIM}${m.lastPreview}${RESET}` : ""}`);
95
110
  }
96
111
  }
97
112
  }
@@ -216,7 +231,7 @@ export async function teamDebug() {
216
231
  console.log(` ${DIM}(no data)${RESET}`);
217
232
  } else {
218
233
  for (const m of members) {
219
- console.log(` - ${m.name}: ${m.status}${m.lastPreview ? ` ${DIM}${m.lastPreview}${RESET}` : ""}`);
234
+ console.log(` - ${m.name}: ${m.status}${formatCompletionSuffix(m)}${m.lastPreview ? ` ${DIM}${m.lastPreview}${RESET}` : ""}`);
220
235
  }
221
236
  }
222
237
  console.log("");
@@ -266,4 +281,3 @@ export function teamList() {
266
281
  }
267
282
  console.log("");
268
283
  }
269
-
@@ -1,8 +1,11 @@
1
1
  // hub/team/native-supervisor.mjs — tmux 없이 멀티 CLI를 직접 띄우는 네이티브 팀 런타임
2
- import { createServer } from "node:http";
3
- import { spawn } from "node:child_process";
4
- import { mkdirSync, readFileSync, writeFileSync, createWriteStream } from "node:fs";
5
- import { dirname, join } from "node:path";
2
+ import { createServer } from "node:http";
3
+ import { spawn } from "node:child_process";
4
+ import { mkdirSync, readFileSync, writeFileSync, createWriteStream } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { verifySlimWrapperRouteExecution } from "./native.mjs";
7
+
8
+ const ROUTE_LOG_TAIL_BYTES = 65536;
6
9
 
7
10
  function parseArgs(argv) {
8
11
  const out = {};
@@ -19,10 +22,43 @@ async function readJson(path) {
19
22
  return JSON.parse(readFileSync(path, "utf8"));
20
23
  }
21
24
 
22
- function safeText(v, fallback = "") {
23
- if (v == null) return fallback;
24
- return String(v);
25
- }
25
+ function safeText(v, fallback = "") {
26
+ if (v == null) return fallback;
27
+ return String(v);
28
+ }
29
+
30
+ function readTailText(path, maxBytes = ROUTE_LOG_TAIL_BYTES) {
31
+ try {
32
+ const raw = readFileSync(path, "utf8");
33
+ if (raw.length <= maxBytes) return raw;
34
+ return raw.slice(-maxBytes);
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ function finalizeRouteVerification(state) {
41
+ if (state?.member?.role !== "worker") return;
42
+
43
+ const verification = verifySlimWrapperRouteExecution({
44
+ promptText: safeText(state.member?.prompt),
45
+ stdoutText: readTailText(state.logFile),
46
+ stderrText: readTailText(state.errFile),
47
+ });
48
+
49
+ state.routeVerification = verification;
50
+ if (!verification.expectedRouteInvocation) {
51
+ state.completionStatus = "unchecked";
52
+ state.completionReason = null;
53
+ return;
54
+ }
55
+
56
+ state.completionStatus = verification.abnormal ? "abnormal" : "normal";
57
+ state.completionReason = verification.reason;
58
+ if (verification.abnormal) {
59
+ state.lastPreview = "[abnormal] tfx-route.sh evidence missing";
60
+ }
61
+ }
26
62
 
27
63
  function nowMs() {
28
64
  return Date.now();
@@ -60,13 +96,16 @@ function memberStateSnapshot() {
60
96
  agentId: m.agentId,
61
97
  command: m.command,
62
98
  pid: state?.child?.pid || null,
63
- status: state?.status || "unknown",
64
- exitCode: state?.exitCode ?? null,
65
- lastPreview: state?.lastPreview || "",
66
- logFile: state?.logFile || null,
67
- errFile: state?.errFile || null,
68
- });
69
- }
99
+ status: state?.status || "unknown",
100
+ exitCode: state?.exitCode ?? null,
101
+ lastPreview: state?.lastPreview || "",
102
+ completionStatus: state?.completionStatus || null,
103
+ completionReason: state?.completionReason || null,
104
+ routeVerification: state?.routeVerification || null,
105
+ logFile: state?.logFile || null,
106
+ errFile: state?.errFile || null,
107
+ });
108
+ }
70
109
  return states;
71
110
  }
72
111
 
@@ -128,13 +167,14 @@ function spawnMember(member) {
128
167
  }
129
168
  });
130
169
 
131
- child.on("exit", (code) => {
132
- state.status = "exited";
133
- state.exitCode = code;
134
- try { outWs.end(); } catch {}
135
- try { errWs.end(); } catch {}
136
- maybeAutoShutdown();
137
- });
170
+ child.on("exit", (code) => {
171
+ state.status = "exited";
172
+ state.exitCode = code;
173
+ finalizeRouteVerification(state);
174
+ try { outWs.end(); } catch {}
175
+ try { errWs.end(); } catch {}
176
+ maybeAutoShutdown();
177
+ });
138
178
 
139
179
  processMap.set(member.name, state);
140
180
  }