triflux 3.2.0-dev.11 → 3.2.0-dev.13
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/hooks/hooks.json +12 -0
- package/hooks/pipeline-stop.mjs +54 -0
- package/hub/bridge.mjs +120 -13
- package/hub/pipeline/index.mjs +121 -0
- package/hub/pipeline/state.mjs +164 -0
- package/hub/pipeline/transitions.mjs +114 -0
- package/hub/schema.sql +14 -0
- package/hub/server.mjs +78 -8
- package/hub/team/native.mjs +5 -1
- package/hub/team/nativeProxy.mjs +51 -38
- package/hub/tools.mjs +126 -41
- package/package.json +1 -1
- package/scripts/tfx-route.sh +3 -2
- package/skills/tfx-multi/SKILL.md +72 -9
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
package/hub/team/native.mjs
CHANGED
|
@@ -33,12 +33,16 @@ export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
|
33
33
|
agentName = "",
|
|
34
34
|
leadName = "team-lead",
|
|
35
35
|
mcp_profile = "auto",
|
|
36
|
+
pipelinePhase = "",
|
|
36
37
|
} = opts;
|
|
37
38
|
|
|
38
39
|
// 셸 이스케이프
|
|
39
40
|
const escaped = subtask.replace(/'/g, "'\\''");
|
|
41
|
+
const pipelineHint = pipelinePhase
|
|
42
|
+
? `\n파이프라인 단계: ${pipelinePhase}`
|
|
43
|
+
: '';
|
|
40
44
|
|
|
41
|
-
return `Bash 1회 실행 후 반드시 종료하라. 어떤 경우에도 hang하지
|
|
45
|
+
return `Bash 1회 실행 후 반드시 종료하라. 어떤 경우에도 hang하지 마라.${pipelineHint}
|
|
42
46
|
gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
|
|
43
47
|
프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
|
|
44
48
|
|
package/hub/team/nativeProxy.mjs
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
import {
|
|
5
5
|
existsSync,
|
|
6
6
|
mkdirSync,
|
|
7
|
-
readdirSync,
|
|
8
|
-
readFileSync,
|
|
9
7
|
renameSync,
|
|
10
8
|
statSync,
|
|
11
9
|
unlinkSync,
|
|
@@ -13,6 +11,7 @@ import {
|
|
|
13
11
|
openSync,
|
|
14
12
|
closeSync,
|
|
15
13
|
} from 'node:fs';
|
|
14
|
+
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
16
15
|
import { basename, dirname, join } from 'node:path';
|
|
17
16
|
import { homedir } from 'node:os';
|
|
18
17
|
import { randomUUID } from 'node:crypto';
|
|
@@ -25,6 +24,7 @@ const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
|
|
|
25
24
|
// ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
|
|
26
25
|
const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
|
|
27
26
|
const _taskIdIndex = new Map(); // taskId → filePath
|
|
27
|
+
const _taskContentCache = new Map(); // filePath → { mtimeMs, data }
|
|
28
28
|
|
|
29
29
|
function _invalidateCache(tasksDir) {
|
|
30
30
|
_dirCache.delete(tasksDir);
|
|
@@ -40,9 +40,9 @@ function validateTeamName(teamName) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function readJsonSafe(path) {
|
|
43
|
+
async function readJsonSafe(path) {
|
|
44
44
|
try {
|
|
45
|
-
return JSON.parse(
|
|
45
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
46
46
|
} catch {
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
@@ -66,12 +66,11 @@ function atomicWriteJson(path, value) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function sleepMs(ms) {
|
|
70
|
-
|
|
71
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
69
|
+
async function sleepMs(ms) {
|
|
70
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
|
|
73
|
+
async function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
|
|
75
74
|
let fd = null;
|
|
76
75
|
for (let i = 0; i < retries; i += 1) {
|
|
77
76
|
try {
|
|
@@ -79,12 +78,12 @@ function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
|
|
|
79
78
|
break;
|
|
80
79
|
} catch (e) {
|
|
81
80
|
if (e?.code !== 'EEXIST' || i === retries - 1) throw e;
|
|
82
|
-
sleepMs(delayMs);
|
|
81
|
+
await sleepMs(delayMs);
|
|
83
82
|
}
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
try {
|
|
87
|
-
return fn();
|
|
86
|
+
return await fn();
|
|
88
87
|
} finally {
|
|
89
88
|
try { if (fd != null) closeSync(fd); } catch {}
|
|
90
89
|
try { unlinkSync(lockPath); } catch {}
|
|
@@ -99,13 +98,13 @@ function getLeadSessionId(config) {
|
|
|
99
98
|
|| null;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
export function resolveTeamPaths(teamName) {
|
|
101
|
+
export async function resolveTeamPaths(teamName) {
|
|
103
102
|
validateTeamName(teamName);
|
|
104
103
|
|
|
105
104
|
const teamDir = join(TEAMS_ROOT, teamName);
|
|
106
105
|
const configPath = join(teamDir, 'config.json');
|
|
107
106
|
const inboxesDir = join(teamDir, 'inboxes');
|
|
108
|
-
const config = readJsonSafe(configPath);
|
|
107
|
+
const config = await readJsonSafe(configPath);
|
|
109
108
|
const leadSessionId = getLeadSessionId(config);
|
|
110
109
|
|
|
111
110
|
const byTeam = join(TASKS_ROOT, teamName);
|
|
@@ -131,19 +130,20 @@ export function resolveTeamPaths(teamName) {
|
|
|
131
130
|
};
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
function collectTaskFiles(tasksDir) {
|
|
133
|
+
async function collectTaskFiles(tasksDir) {
|
|
135
134
|
if (!existsSync(tasksDir)) return [];
|
|
136
135
|
|
|
137
136
|
// 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
|
|
138
137
|
let dirMtime;
|
|
139
|
-
try { dirMtime =
|
|
138
|
+
try { dirMtime = (await stat(tasksDir)).mtimeMs; } catch { return []; }
|
|
140
139
|
|
|
141
140
|
const cached = _dirCache.get(tasksDir);
|
|
142
141
|
if (cached && cached.mtimeMs === dirMtime) {
|
|
143
142
|
return cached.files;
|
|
144
143
|
}
|
|
145
144
|
|
|
146
|
-
const
|
|
145
|
+
const entries = await readdir(tasksDir);
|
|
146
|
+
const files = entries
|
|
147
147
|
.filter((name) => name.endsWith('.json'))
|
|
148
148
|
.filter((name) => !name.endsWith('.lock'))
|
|
149
149
|
.filter((name) => name !== '.highwatermark')
|
|
@@ -153,7 +153,7 @@ function collectTaskFiles(tasksDir) {
|
|
|
153
153
|
return files;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
function locateTaskFile(tasksDir, taskId) {
|
|
156
|
+
async function locateTaskFile(tasksDir, taskId) {
|
|
157
157
|
const direct = join(tasksDir, `${taskId}.json`);
|
|
158
158
|
if (existsSync(direct)) return direct;
|
|
159
159
|
|
|
@@ -162,13 +162,13 @@ function locateTaskFile(tasksDir, taskId) {
|
|
|
162
162
|
if (indexed && existsSync(indexed)) return indexed;
|
|
163
163
|
|
|
164
164
|
// 캐시된 collectTaskFiles로 풀 스캔
|
|
165
|
-
const files = collectTaskFiles(tasksDir);
|
|
165
|
+
const files = await collectTaskFiles(tasksDir);
|
|
166
166
|
for (const file of files) {
|
|
167
167
|
if (basename(file, '.json') === taskId) {
|
|
168
168
|
_taskIdIndex.set(taskId, file);
|
|
169
169
|
return file;
|
|
170
170
|
}
|
|
171
|
-
const json = readJsonSafe(file);
|
|
171
|
+
const json = await readJsonSafe(file);
|
|
172
172
|
if (json && String(json.id || '') === taskId) {
|
|
173
173
|
_taskIdIndex.set(taskId, file);
|
|
174
174
|
return file;
|
|
@@ -181,7 +181,7 @@ function isObject(v) {
|
|
|
181
181
|
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
export function teamInfo(args = {}) {
|
|
184
|
+
export async function teamInfo(args = {}) {
|
|
185
185
|
const { team_name, include_members = true, include_paths = true } = args;
|
|
186
186
|
try {
|
|
187
187
|
validateTeamName(team_name);
|
|
@@ -189,7 +189,7 @@ export function teamInfo(args = {}) {
|
|
|
189
189
|
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
const paths = resolveTeamPaths(team_name);
|
|
192
|
+
const paths = await resolveTeamPaths(team_name);
|
|
193
193
|
if (!existsSync(paths.team_dir)) {
|
|
194
194
|
return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
|
|
195
195
|
}
|
|
@@ -224,7 +224,7 @@ export function teamInfo(args = {}) {
|
|
|
224
224
|
};
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
export function teamTaskList(args = {}) {
|
|
227
|
+
export async function teamTaskList(args = {}) {
|
|
228
228
|
const {
|
|
229
229
|
team_name,
|
|
230
230
|
owner,
|
|
@@ -239,7 +239,7 @@ export function teamTaskList(args = {}) {
|
|
|
239
239
|
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
const paths = resolveTeamPaths(team_name);
|
|
242
|
+
const paths = await resolveTeamPaths(team_name);
|
|
243
243
|
if (paths.tasks_dir_resolution === 'not_found') {
|
|
244
244
|
return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
|
|
245
245
|
}
|
|
@@ -249,8 +249,22 @@ export function teamTaskList(args = {}) {
|
|
|
249
249
|
let parseWarnings = 0;
|
|
250
250
|
|
|
251
251
|
const tasks = [];
|
|
252
|
-
for (const file of collectTaskFiles(paths.tasks_dir)) {
|
|
253
|
-
|
|
252
|
+
for (const file of await collectTaskFiles(paths.tasks_dir)) {
|
|
253
|
+
// mtime 기반 task 콘텐츠 캐시 — 변경 없으면 파일 읽기 생략
|
|
254
|
+
let fileMtime = Date.now();
|
|
255
|
+
try { fileMtime = (await stat(file)).mtimeMs; } catch {}
|
|
256
|
+
|
|
257
|
+
const contentCached = _taskContentCache.get(file);
|
|
258
|
+
let json;
|
|
259
|
+
if (contentCached && contentCached.mtimeMs === fileMtime) {
|
|
260
|
+
json = contentCached.data;
|
|
261
|
+
} else {
|
|
262
|
+
json = await readJsonSafe(file);
|
|
263
|
+
if (json && isObject(json)) {
|
|
264
|
+
_taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
254
268
|
if (!json || !isObject(json)) {
|
|
255
269
|
parseWarnings += 1;
|
|
256
270
|
continue;
|
|
@@ -260,13 +274,10 @@ export function teamTaskList(args = {}) {
|
|
|
260
274
|
if (owner && String(json.owner || '') !== String(owner)) continue;
|
|
261
275
|
if (statusSet.size > 0 && !statusSet.has(String(json.status || ''))) continue;
|
|
262
276
|
|
|
263
|
-
let mtime = Date.now();
|
|
264
|
-
try { mtime = statSync(file).mtimeMs; } catch {}
|
|
265
|
-
|
|
266
277
|
tasks.push({
|
|
267
278
|
...json,
|
|
268
279
|
task_file: file,
|
|
269
|
-
mtime_ms:
|
|
280
|
+
mtime_ms: fileMtime,
|
|
270
281
|
});
|
|
271
282
|
}
|
|
272
283
|
|
|
@@ -288,7 +299,7 @@ export function teamTaskList(args = {}) {
|
|
|
288
299
|
// status 화이트리스트 (Claude Code API 호환)
|
|
289
300
|
const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
|
290
301
|
|
|
291
|
-
export function teamTaskUpdate(args = {}) {
|
|
302
|
+
export async function teamTaskUpdate(args = {}) {
|
|
292
303
|
// "failed" → "completed" + metadata.result 자동 매핑
|
|
293
304
|
if (String(args.status || '') === 'failed') {
|
|
294
305
|
args = {
|
|
@@ -326,12 +337,12 @@ export function teamTaskUpdate(args = {}) {
|
|
|
326
337
|
return err('INVALID_TASK_ID', 'task_id가 필요합니다');
|
|
327
338
|
}
|
|
328
339
|
|
|
329
|
-
const paths = resolveTeamPaths(team_name);
|
|
340
|
+
const paths = await resolveTeamPaths(team_name);
|
|
330
341
|
if (paths.tasks_dir_resolution === 'not_found') {
|
|
331
342
|
return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
|
|
332
343
|
}
|
|
333
344
|
|
|
334
|
-
const taskFile = locateTaskFile(paths.tasks_dir, String(task_id));
|
|
345
|
+
const taskFile = await locateTaskFile(paths.tasks_dir, String(task_id));
|
|
335
346
|
if (!taskFile) {
|
|
336
347
|
return err('TASK_NOT_FOUND', `task를 찾지 못했습니다: ${task_id}`);
|
|
337
348
|
}
|
|
@@ -339,14 +350,14 @@ export function teamTaskUpdate(args = {}) {
|
|
|
339
350
|
const lockFile = `${taskFile}.lock`;
|
|
340
351
|
|
|
341
352
|
try {
|
|
342
|
-
return withFileLock(lockFile, () => {
|
|
343
|
-
const before = readJsonSafe(taskFile);
|
|
353
|
+
return await withFileLock(lockFile, async () => {
|
|
354
|
+
const before = await readJsonSafe(taskFile);
|
|
344
355
|
if (!before || !isObject(before)) {
|
|
345
356
|
return err('INVALID_TASK_FILE', `task 파일 파싱 실패: ${taskFile}`);
|
|
346
357
|
}
|
|
347
358
|
|
|
348
359
|
let beforeMtime = Date.now();
|
|
349
|
-
try { beforeMtime =
|
|
360
|
+
try { beforeMtime = (await stat(taskFile)).mtimeMs; } catch {}
|
|
350
361
|
|
|
351
362
|
if (if_match_mtime_ms != null && Number(if_match_mtime_ms) !== Number(beforeMtime)) {
|
|
352
363
|
return err('MTIME_CONFLICT', 'if_match_mtime_ms가 일치하지 않습니다', {
|
|
@@ -427,6 +438,8 @@ export function teamTaskUpdate(args = {}) {
|
|
|
427
438
|
if (updated) {
|
|
428
439
|
atomicWriteJson(taskFile, after);
|
|
429
440
|
_invalidateCache(dirname(taskFile));
|
|
441
|
+
// 콘텐츠 캐시 무효화
|
|
442
|
+
_taskContentCache.delete(taskFile);
|
|
430
443
|
}
|
|
431
444
|
|
|
432
445
|
let afterMtime = beforeMtime;
|
|
@@ -453,7 +466,7 @@ function sanitizeRecipientName(v) {
|
|
|
453
466
|
return String(v || 'team-lead').replace(/[\\/:*?"<>|]/g, '_');
|
|
454
467
|
}
|
|
455
468
|
|
|
456
|
-
export function teamSendMessage(args = {}) {
|
|
469
|
+
export async function teamSendMessage(args = {}) {
|
|
457
470
|
const {
|
|
458
471
|
team_name,
|
|
459
472
|
from,
|
|
@@ -472,7 +485,7 @@ export function teamSendMessage(args = {}) {
|
|
|
472
485
|
if (!String(from || '').trim()) return err('INVALID_FROM', 'from이 필요합니다');
|
|
473
486
|
if (!String(text || '').trim()) return err('INVALID_TEXT', 'text가 필요합니다');
|
|
474
487
|
|
|
475
|
-
const paths = resolveTeamPaths(team_name);
|
|
488
|
+
const paths = await resolveTeamPaths(team_name);
|
|
476
489
|
if (!existsSync(paths.team_dir)) {
|
|
477
490
|
return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
|
|
478
491
|
}
|
|
@@ -483,8 +496,8 @@ export function teamSendMessage(args = {}) {
|
|
|
483
496
|
let message;
|
|
484
497
|
|
|
485
498
|
try {
|
|
486
|
-
const unreadCount = withFileLock(lockFile, () => {
|
|
487
|
-
const queue = readJsonSafe(inboxFile);
|
|
499
|
+
const unreadCount = await withFileLock(lockFile, async () => {
|
|
500
|
+
const queue = await readJsonSafe(inboxFile);
|
|
488
501
|
const list = Array.isArray(queue) ? queue : [];
|
|
489
502
|
|
|
490
503
|
message = {
|
package/hub/tools.mjs
CHANGED
|
@@ -8,16 +8,25 @@ import {
|
|
|
8
8
|
teamTaskUpdate,
|
|
9
9
|
teamSendMessage,
|
|
10
10
|
} from './team/nativeProxy.mjs';
|
|
11
|
+
import {
|
|
12
|
+
ensurePipelineTable,
|
|
13
|
+
createPipeline,
|
|
14
|
+
} from './pipeline/index.mjs';
|
|
15
|
+
import {
|
|
16
|
+
readPipelineState,
|
|
17
|
+
initPipelineState,
|
|
18
|
+
listPipelineStates,
|
|
19
|
+
} from './pipeline/state.mjs';
|
|
11
20
|
|
|
12
21
|
/**
|
|
13
22
|
* MCP 도구 목록 생성
|
|
14
23
|
* @param {object} store — createStore() 반환
|
|
15
24
|
* @param {object} router — createRouter() 반환
|
|
16
|
-
* @param {object} hitl — createHitlManager() 반환
|
|
17
|
-
* @param {object} pipe — createPipeServer() 반환
|
|
18
|
-
* @returns {Array<{name, description, inputSchema, handler}>}
|
|
19
|
-
*/
|
|
20
|
-
export function createTools(store, router, hitl, pipe = null) {
|
|
25
|
+
* @param {object} hitl — createHitlManager() 반환
|
|
26
|
+
* @param {object} pipe — createPipeServer() 반환
|
|
27
|
+
* @returns {Array<{name, description, inputSchema, handler}>}
|
|
28
|
+
*/
|
|
29
|
+
export function createTools(store, router, hitl, pipe = null) {
|
|
21
30
|
/** 도구 핸들러 래퍼 — 에러 처리 + MCP content 형식 변환 */
|
|
22
31
|
function wrap(code, fn) {
|
|
23
32
|
return async (args) => {
|
|
@@ -49,10 +58,10 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
49
58
|
heartbeat_ttl_ms: { type: 'integer', minimum: 10000, maximum: 7200000 },
|
|
50
59
|
},
|
|
51
60
|
},
|
|
52
|
-
handler: wrap('REGISTER_FAILED', (args) => {
|
|
53
|
-
const data = router.registerAgent(args);
|
|
54
|
-
return { ok: true, data };
|
|
55
|
-
}),
|
|
61
|
+
handler: wrap('REGISTER_FAILED', (args) => {
|
|
62
|
+
const data = router.registerAgent(args);
|
|
63
|
+
return { ok: true, data };
|
|
64
|
+
}),
|
|
56
65
|
},
|
|
57
66
|
|
|
58
67
|
// ── 2. status ──
|
|
@@ -122,10 +131,10 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
122
131
|
}),
|
|
123
132
|
},
|
|
124
133
|
|
|
125
|
-
// ── 5. poll_messages ──
|
|
126
|
-
{
|
|
127
|
-
name: 'poll_messages',
|
|
128
|
-
description: 'Deprecated. poll_messages 대신 Named Pipe subscribe/publish 채널을 사용합니다',
|
|
134
|
+
// ── 5. poll_messages ──
|
|
135
|
+
{
|
|
136
|
+
name: 'poll_messages',
|
|
137
|
+
description: 'Deprecated. poll_messages 대신 Named Pipe subscribe/publish 채널을 사용합니다',
|
|
129
138
|
inputSchema: {
|
|
130
139
|
type: 'object',
|
|
131
140
|
required: ['agent_id'],
|
|
@@ -137,34 +146,34 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
137
146
|
ack_ids: { type: 'array', items: { type: 'string' }, maxItems: 100 },
|
|
138
147
|
auto_ack: { type: 'boolean', default: false },
|
|
139
148
|
},
|
|
140
|
-
},
|
|
141
|
-
handler: wrap('POLL_DEPRECATED', async (args) => {
|
|
142
|
-
const replay = router.drainAgent(args.agent_id, {
|
|
143
|
-
max_messages: args.max_messages,
|
|
144
|
-
include_topics: args.include_topics,
|
|
145
|
-
auto_ack: args.auto_ack,
|
|
146
|
-
});
|
|
147
|
-
if (args.ack_ids?.length) {
|
|
148
|
-
router.ackMessages(args.ack_ids, args.agent_id);
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
ok: false,
|
|
152
|
-
error: {
|
|
153
|
-
code: 'POLL_DEPRECATED',
|
|
154
|
-
message: 'poll_messages는 deprecated 되었습니다. pipe subscribe/publish 채널을 사용하세요.',
|
|
155
|
-
},
|
|
156
|
-
data: {
|
|
157
|
-
pipe_path: pipe?.path || null,
|
|
158
|
-
delivery_mode: 'pipe_push',
|
|
159
|
-
protocol: 'ndjson',
|
|
160
|
-
replay: {
|
|
161
|
-
messages: replay,
|
|
162
|
-
count: replay.length,
|
|
163
|
-
},
|
|
164
|
-
server_time_ms: Date.now(),
|
|
165
|
-
},
|
|
166
|
-
};
|
|
167
|
-
}),
|
|
149
|
+
},
|
|
150
|
+
handler: wrap('POLL_DEPRECATED', async (args) => {
|
|
151
|
+
const replay = router.drainAgent(args.agent_id, {
|
|
152
|
+
max_messages: args.max_messages,
|
|
153
|
+
include_topics: args.include_topics,
|
|
154
|
+
auto_ack: args.auto_ack,
|
|
155
|
+
});
|
|
156
|
+
if (args.ack_ids?.length) {
|
|
157
|
+
router.ackMessages(args.ack_ids, args.agent_id);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
error: {
|
|
162
|
+
code: 'POLL_DEPRECATED',
|
|
163
|
+
message: 'poll_messages는 deprecated 되었습니다. pipe subscribe/publish 채널을 사용하세요.',
|
|
164
|
+
},
|
|
165
|
+
data: {
|
|
166
|
+
pipe_path: pipe?.path || null,
|
|
167
|
+
delivery_mode: 'pipe_push',
|
|
168
|
+
protocol: 'ndjson',
|
|
169
|
+
replay: {
|
|
170
|
+
messages: replay,
|
|
171
|
+
count: replay.length,
|
|
172
|
+
},
|
|
173
|
+
server_time_ms: Date.now(),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}),
|
|
168
177
|
},
|
|
169
178
|
|
|
170
179
|
// ── 6. handoff ──
|
|
@@ -325,5 +334,81 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
325
334
|
return teamSendMessage(args);
|
|
326
335
|
}),
|
|
327
336
|
},
|
|
337
|
+
|
|
338
|
+
// ── 13. pipeline_state ──
|
|
339
|
+
{
|
|
340
|
+
name: 'pipeline_state',
|
|
341
|
+
description: '파이프라인 상태를 조회합니다 (--thorough 모드)',
|
|
342
|
+
inputSchema: {
|
|
343
|
+
type: 'object',
|
|
344
|
+
required: ['team_name'],
|
|
345
|
+
properties: {
|
|
346
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
handler: wrap('PIPELINE_STATE_FAILED', (args) => {
|
|
350
|
+
ensurePipelineTable(store.db);
|
|
351
|
+
const state = readPipelineState(store.db, args.team_name);
|
|
352
|
+
return state
|
|
353
|
+
? { ok: true, data: state }
|
|
354
|
+
: { ok: false, error: { code: 'PIPELINE_NOT_FOUND', message: `파이프라인 없음: ${args.team_name}` } };
|
|
355
|
+
}),
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// ── 14. pipeline_advance ──
|
|
359
|
+
{
|
|
360
|
+
name: 'pipeline_advance',
|
|
361
|
+
description: '파이프라인을 다음 단계로 전이합니다 (전이 규칙 + fix loop 바운딩 적용)',
|
|
362
|
+
inputSchema: {
|
|
363
|
+
type: 'object',
|
|
364
|
+
required: ['team_name', 'phase'],
|
|
365
|
+
properties: {
|
|
366
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
367
|
+
phase: { type: 'string', enum: ['plan', 'prd', 'exec', 'verify', 'fix', 'complete', 'failed'] },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
handler: wrap('PIPELINE_ADVANCE_FAILED', (args) => {
|
|
371
|
+
ensurePipelineTable(store.db);
|
|
372
|
+
const pipeline = createPipeline(store.db, args.team_name);
|
|
373
|
+
return pipeline.advance(args.phase);
|
|
374
|
+
}),
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
// ── 15. pipeline_init ──
|
|
378
|
+
{
|
|
379
|
+
name: 'pipeline_init',
|
|
380
|
+
description: '새 파이프라인을 초기화합니다 (기존 상태 덮어쓰기)',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
required: ['team_name'],
|
|
384
|
+
properties: {
|
|
385
|
+
team_name: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$' },
|
|
386
|
+
fix_max: { type: 'integer', minimum: 1, maximum: 20, default: 3 },
|
|
387
|
+
ralph_max: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
handler: wrap('PIPELINE_INIT_FAILED', (args) => {
|
|
391
|
+
ensurePipelineTable(store.db);
|
|
392
|
+
const state = initPipelineState(store.db, args.team_name, {
|
|
393
|
+
fix_max: args.fix_max,
|
|
394
|
+
ralph_max: args.ralph_max,
|
|
395
|
+
});
|
|
396
|
+
return { ok: true, data: state };
|
|
397
|
+
}),
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
// ── 16. pipeline_list ──
|
|
401
|
+
{
|
|
402
|
+
name: 'pipeline_list',
|
|
403
|
+
description: '활성 파이프라인 목록을 조회합니다',
|
|
404
|
+
inputSchema: {
|
|
405
|
+
type: 'object',
|
|
406
|
+
properties: {},
|
|
407
|
+
},
|
|
408
|
+
handler: wrap('PIPELINE_LIST_FAILED', () => {
|
|
409
|
+
ensurePipelineTable(store.db);
|
|
410
|
+
return { ok: true, data: listPipelineStates(store.db) };
|
|
411
|
+
}),
|
|
412
|
+
},
|
|
328
413
|
];
|
|
329
414
|
}
|
package/package.json
CHANGED
package/scripts/tfx-route.sh
CHANGED
|
@@ -226,8 +226,9 @@ route_agent() {
|
|
|
226
226
|
CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
|
|
227
227
|
CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=300; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
228
228
|
verifier)
|
|
229
|
-
CLI_TYPE="
|
|
230
|
-
|
|
229
|
+
CLI_TYPE="codex"; CLI_CMD="codex"
|
|
230
|
+
CLI_ARGS="exec --profile thorough ${codex_base} review"
|
|
231
|
+
CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
231
232
|
test-engineer)
|
|
232
233
|
CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
|
|
233
234
|
CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=300; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|