triflux 3.2.0-dev.9 → 3.3.0-dev.1

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.
@@ -0,0 +1,114 @@
1
+ // hub/pipeline/transitions.mjs — 파이프라인 단계 전이 규칙
2
+ //
3
+ // plan → prd → exec → verify → complete/fix
4
+ // fix → exec/verify/complete/failed
5
+ // complete, failed = 터미널 상태
6
+
7
+ export const PHASES = ['plan', 'prd', 'exec', 'verify', 'fix', 'complete', 'failed'];
8
+
9
+ export const TERMINAL = new Set(['complete', 'failed']);
10
+
11
+ export const ALLOWED = {
12
+ 'plan': ['prd'],
13
+ 'prd': ['exec'],
14
+ 'exec': ['verify'],
15
+ 'verify': ['fix', 'complete', 'failed'],
16
+ 'fix': ['exec', 'verify', 'complete', 'failed'],
17
+ 'complete': [],
18
+ 'failed': [],
19
+ };
20
+
21
+ /**
22
+ * 전이 가능 여부 확인
23
+ * @param {string} from - 현재 단계
24
+ * @param {string} to - 다음 단계
25
+ * @returns {boolean}
26
+ */
27
+ export function canTransition(from, to) {
28
+ const targets = ALLOWED[from];
29
+ if (!targets) return false;
30
+ return targets.includes(to);
31
+ }
32
+
33
+ /**
34
+ * 상태 전이 실행 — fix loop 바운딩 포함
35
+ * @param {object} state - 파이프라인 상태 객체
36
+ * @param {string} nextPhase - 다음 단계
37
+ * @returns {{ ok: boolean, state?: object, error?: string }}
38
+ */
39
+ export function transitionPhase(state, nextPhase) {
40
+ const current = state.phase;
41
+
42
+ if (!canTransition(current, nextPhase)) {
43
+ return {
44
+ ok: false,
45
+ error: `전이 불가: ${current} → ${nextPhase}. 허용: [${(ALLOWED[current] || []).join(', ')}]`,
46
+ };
47
+ }
48
+
49
+ const next = { ...state, phase: nextPhase, updated_at: Date.now() };
50
+
51
+ // fix 단계 진입 시 attempt 증가 + 바운딩
52
+ if (nextPhase === 'fix') {
53
+ next.fix_attempt = (state.fix_attempt || 0) + 1;
54
+ if (next.fix_attempt > (state.fix_max || 3)) {
55
+ return {
56
+ ok: false,
57
+ error: `fix loop 초과: ${next.fix_attempt}/${state.fix_max || 3}회. ralph loop로 승격 필요.`,
58
+ };
59
+ }
60
+ }
61
+
62
+ // fix → exec 재진입 시 (fix 후 재실행)
63
+ if (current === 'fix' && nextPhase === 'exec') {
64
+ // fix_attempt 유지 (이미 fix 진입 시 증가됨)
65
+ }
66
+
67
+ // verify → fix → ... → verify 반복 후 fix_max 초과 시 ralph loop
68
+ if (nextPhase === 'failed' && current === 'fix') {
69
+ // ralph loop 반복 증가
70
+ next.ralph_iteration = (state.ralph_iteration || 0) + 1;
71
+ if (next.ralph_iteration > (state.ralph_max || 10)) {
72
+ // 최종 실패 — ralph loop도 초과
73
+ next.phase = 'failed';
74
+ }
75
+ }
76
+
77
+ // phase_history 기록
78
+ const history = Array.isArray(state.phase_history) ? [...state.phase_history] : [];
79
+ history.push({ from: current, to: nextPhase, at: Date.now() });
80
+ next.phase_history = history;
81
+
82
+ return { ok: true, state: next };
83
+ }
84
+
85
+ /**
86
+ * ralph loop 재시작 전이
87
+ * fix_max 초과 시 plan으로 돌아가며 ralph_iteration 증가
88
+ * @param {object} state - 현재 상태
89
+ * @returns {{ ok: boolean, state?: object, error?: string }}
90
+ */
91
+ export function ralphRestart(state) {
92
+ const iteration = (state.ralph_iteration || 0) + 1;
93
+ if (iteration > (state.ralph_max || 10)) {
94
+ return {
95
+ ok: false,
96
+ error: `ralph loop 초과: ${iteration}/${state.ralph_max || 10}회. 최종 실패.`,
97
+ };
98
+ }
99
+
100
+ const history = Array.isArray(state.phase_history) ? [...state.phase_history] : [];
101
+ history.push({ from: state.phase, to: 'plan', at: Date.now(), ralph_restart: true });
102
+
103
+ return {
104
+ ok: true,
105
+ state: {
106
+ ...state,
107
+ phase: 'plan',
108
+ fix_attempt: 0,
109
+ ralph_iteration: iteration,
110
+ phase_history: history,
111
+ updated_at: Date.now(),
112
+ },
113
+ };
114
+ }
package/hub/schema.sql CHANGED
@@ -78,3 +78,17 @@ CREATE INDEX IF NOT EXISTS idx_inbox_message ON message_inbox(message_id);
78
78
  CREATE INDEX IF NOT EXISTS idx_human_requests_state ON human_requests(state);
79
79
  CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
80
80
  CREATE INDEX IF NOT EXISTS idx_agents_lease ON agents(lease_expires_ms);
81
+
82
+ -- 파이프라인 상태 테이블 (Phase 2)
83
+ CREATE TABLE IF NOT EXISTS pipeline_state (
84
+ team_name TEXT PRIMARY KEY,
85
+ phase TEXT NOT NULL DEFAULT 'plan',
86
+ fix_attempt INTEGER DEFAULT 0,
87
+ fix_max INTEGER DEFAULT 3,
88
+ ralph_iteration INTEGER DEFAULT 0,
89
+ ralph_max INTEGER DEFAULT 10,
90
+ artifacts TEXT DEFAULT '{}',
91
+ phase_history TEXT DEFAULT '[]',
92
+ created_at INTEGER,
93
+ updated_at INTEGER
94
+ );
package/hub/server.mjs CHANGED
@@ -14,6 +14,15 @@ import { createRouter } from './router.mjs';
14
14
  import { createHitlManager } from './hitl.mjs';
15
15
  import { createPipeServer } from './pipe.mjs';
16
16
  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';
17
26
  import {
18
27
  teamInfo,
19
28
  teamTaskList,
@@ -43,6 +52,14 @@ async function parseBody(req) {
43
52
 
44
53
  const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
45
54
  const PID_FILE = join(PID_DIR, 'hub.pid');
55
+ const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
56
+
57
+ // localhost 계열 Origin만 허용
58
+ const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
59
+
60
+ function isAllowedOrigin(origin) {
61
+ return origin && ALLOWED_ORIGIN_RE.test(origin);
62
+ }
46
63
 
47
64
  /**
48
65
  * tfx-hub 시작
@@ -57,6 +74,11 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
57
74
  dbPath = join(PID_DIR, 'state.db');
58
75
  }
59
76
 
77
+ // 인증 토큰 생성 (환경변수 우선, 없으면 자동 생성)
78
+ const HUB_TOKEN = process.env.TFX_HUB_TOKEN || randomUUID();
79
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
80
+ writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
81
+
60
82
  const store = createStore(dbPath);
61
83
  const router = createRouter(store);
62
84
  const pipe = createPipeServer({ router, store, sessionId });
@@ -100,12 +122,16 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
100
122
  }
101
123
 
102
124
  const httpServer = createHttpServer(async (req, res) => {
103
- res.setHeader('Access-Control-Allow-Origin', '*');
104
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
105
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Last-Event-ID');
125
+ // CORS: localhost 계열 Origin 허용
126
+ const origin = req.headers['origin'];
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
+ }
106
132
 
107
133
  if (req.method === 'OPTIONS') {
108
- res.writeHead(204);
134
+ res.writeHead(isAllowedOrigin(origin) ? 204 : 403);
109
135
  return res.end();
110
136
  }
111
137
 
@@ -132,6 +158,14 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
132
158
  if (req.url.startsWith('/bridge')) {
133
159
  res.setHeader('Content-Type', 'application/json');
134
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
+
135
169
  if (req.method !== 'POST' && req.method !== 'DELETE') {
136
170
  res.writeHead(405);
137
171
  return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
@@ -214,13 +248,13 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
214
248
  if (req.method === 'POST') {
215
249
  let teamResult = null;
216
250
  if (path === '/bridge/team/info' || path === '/bridge/team-info') {
217
- teamResult = teamInfo(body);
251
+ teamResult = await teamInfo(body);
218
252
  } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
219
- teamResult = teamTaskList(body);
253
+ teamResult = await teamTaskList(body);
220
254
  } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
221
- teamResult = teamTaskUpdate(body);
255
+ teamResult = await teamTaskUpdate(body);
222
256
  } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
223
- teamResult = teamSendMessage(body);
257
+ teamResult = await teamSendMessage(body);
224
258
  }
225
259
 
226
260
  if (teamResult) {
@@ -240,6 +274,41 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
240
274
  res.writeHead(404);
241
275
  return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
242
276
  }
277
+
278
+ // ── 파이프라인 엔드포인트 ──
279
+ if (path === '/bridge/pipeline/state' && req.method === 'POST') {
280
+ ensurePipelineTable(store.db);
281
+ const { team_name } = body;
282
+ const state = readPipelineState(store.db, team_name);
283
+ res.writeHead(state ? 200 : 404);
284
+ return res.end(JSON.stringify(state
285
+ ? { ok: true, data: state }
286
+ : { ok: false, error: 'pipeline_not_found' }));
287
+ }
288
+
289
+ if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
290
+ ensurePipelineTable(store.db);
291
+ const { team_name, phase } = body;
292
+ const pipeline = createPipeline(store.db, team_name);
293
+ const result = pipeline.advance(phase);
294
+ res.writeHead(result.ok ? 200 : 400);
295
+ return res.end(JSON.stringify(result));
296
+ }
297
+
298
+ if (path === '/bridge/pipeline/init' && req.method === 'POST') {
299
+ ensurePipelineTable(store.db);
300
+ const { team_name, fix_max, ralph_max } = body;
301
+ const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
302
+ res.writeHead(200);
303
+ return res.end(JSON.stringify({ ok: true, data: state }));
304
+ }
305
+
306
+ if (path === '/bridge/pipeline/list' && req.method === 'POST') {
307
+ ensurePipelineTable(store.db);
308
+ const states = listPipelineStates(store.db);
309
+ res.writeHead(200);
310
+ return res.end(JSON.stringify({ ok: true, data: states }));
311
+ }
243
312
  }
244
313
 
245
314
  if (path === '/bridge/context' && req.method === 'POST') {
@@ -408,6 +477,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
408
477
  await pipe.stop();
409
478
  store.close();
410
479
  try { unlinkSync(PID_FILE); } catch {}
480
+ try { unlinkSync(TOKEN_FILE); } catch {}
411
481
  await new Promise((resolveClose) => httpServer.close(resolveClose));
412
482
  };
413
483