triflux 3.2.0-dev.9 → 3.3.0-dev.3
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/bin/triflux.mjs +1516 -1386
- package/hooks/hooks.json +22 -0
- package/hooks/keyword-rules.json +4 -4
- package/hooks/pipeline-stop.mjs +54 -0
- package/hub/bridge.mjs +120 -13
- package/hub/pipe.mjs +23 -0
- package/hub/pipeline/index.mjs +121 -0
- package/hub/pipeline/state.mjs +164 -0
- package/hub/pipeline/transitions.mjs +114 -0
- package/hub/router.mjs +322 -1
- package/hub/schema.sql +49 -2
- package/hub/server.mjs +173 -8
- package/hub/store.mjs +259 -1
- package/hub/team/cli-team-control.mjs +381 -381
- package/hub/team/cli-team-start.mjs +474 -470
- package/hub/team/cli-team-status.mjs +238 -238
- package/hub/team/cli.mjs +86 -86
- package/hub/team/native.mjs +144 -6
- package/hub/team/nativeProxy.mjs +51 -38
- package/hub/team/orchestrator.mjs +15 -20
- package/hub/team/pane.mjs +101 -90
- package/hub/team/psmux.mjs +721 -72
- package/hub/team/session.mjs +450 -450
- package/hub/tools.mjs +223 -63
- package/hub/workers/delegator-mcp.mjs +900 -0
- package/hub/workers/factory.mjs +3 -0
- package/hub/workers/interface.mjs +2 -2
- package/hud/hud-qos-status.mjs +89 -144
- package/package.json +1 -1
- package/scripts/__tests__/keyword-detector.test.mjs +11 -11
- package/scripts/__tests__/smoke.test.mjs +34 -0
- package/scripts/hub-ensure.mjs +21 -3
- package/scripts/preflight-cache.mjs +72 -0
- package/scripts/setup.mjs +23 -11
- package/scripts/tfx-route.sh +74 -15
- package/skills/{tfx-team → tfx-multi}/SKILL.md +115 -32
package/hooks/hooks.json
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
"type": "command",
|
|
10
10
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/setup.mjs\"",
|
|
11
11
|
"timeout": 10
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "command",
|
|
15
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hub-ensure.mjs\"",
|
|
16
|
+
"timeout": 8
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/preflight-cache.mjs\"",
|
|
21
|
+
"timeout": 5
|
|
12
22
|
}
|
|
13
23
|
]
|
|
14
24
|
}
|
|
@@ -24,6 +34,18 @@
|
|
|
24
34
|
}
|
|
25
35
|
]
|
|
26
36
|
}
|
|
37
|
+
],
|
|
38
|
+
"Stop": [
|
|
39
|
+
{
|
|
40
|
+
"matcher": "*",
|
|
41
|
+
"hooks": [
|
|
42
|
+
{
|
|
43
|
+
"type": "command",
|
|
44
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pipeline-stop.mjs\"",
|
|
45
|
+
"timeout": 5
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
27
49
|
]
|
|
28
50
|
}
|
|
29
51
|
}
|
package/hooks/keyword-rules.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"skill": "tfx-cancel",
|
|
12
12
|
"priority": 0,
|
|
13
13
|
"supersedes": [
|
|
14
|
-
"tfx-
|
|
14
|
+
"tfx-multi",
|
|
15
15
|
"tfx-auto",
|
|
16
16
|
"tfx-auto-codex",
|
|
17
17
|
"tfx-codex",
|
|
@@ -22,14 +22,14 @@
|
|
|
22
22
|
"mcp_route": null
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
|
-
"id": "tfx-
|
|
25
|
+
"id": "tfx-multi",
|
|
26
26
|
"patterns": [
|
|
27
27
|
{
|
|
28
|
-
"source": "(?<!\\b(?:my|the|our|omc|oh-my-claudecode)\\s)\\btfx[\\s-]?
|
|
28
|
+
"source": "(?<!\\b(?:my|the|our|omc|oh-my-claudecode)\\s)\\btfx[\\s-]?multi\\b",
|
|
29
29
|
"flags": "i"
|
|
30
30
|
}
|
|
31
31
|
],
|
|
32
|
-
"skill": "tfx-
|
|
32
|
+
"skill": "tfx-multi",
|
|
33
33
|
"priority": 1,
|
|
34
34
|
"supersedes": [],
|
|
35
35
|
"exclusive": false,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/pipeline-stop.mjs — 파이프라인 진행 중 세션 중단 시 지속 프롬프트 주입
|
|
3
|
+
//
|
|
4
|
+
// Claude Code의 Stop 이벤트에서 실행.
|
|
5
|
+
// 비터미널 단계의 파이프라인이 있으면 "작업 계속" 프롬프트를 반환한다.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
const HUB_DB_PATH = join(homedir(), '.claude', 'cache', 'tfx-hub', 'state.db');
|
|
12
|
+
const TERMINAL = new Set(['complete', 'failed']);
|
|
13
|
+
|
|
14
|
+
async function getPipelineStopPrompt() {
|
|
15
|
+
if (!existsSync(HUB_DB_PATH)) return null;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const { default: Database } = await import('better-sqlite3');
|
|
19
|
+
const { ensurePipelineTable, listPipelineStates } = await import(
|
|
20
|
+
join(process.env.CLAUDE_PLUGIN_ROOT || '.', 'hub', 'pipeline', 'state.mjs')
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const db = new Database(HUB_DB_PATH, { readonly: true });
|
|
24
|
+
ensurePipelineTable(db);
|
|
25
|
+
const states = listPipelineStates(db);
|
|
26
|
+
db.close();
|
|
27
|
+
|
|
28
|
+
// 비터미널 단계의 활성 파이프라인 찾기
|
|
29
|
+
const active = states.filter((s) => !TERMINAL.has(s.phase));
|
|
30
|
+
if (active.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
const lines = active.map((s) =>
|
|
33
|
+
`- 팀 ${s.team_name}: ${s.phase} 단계 (fix: ${s.fix_attempt}/${s.fix_max}, ralph: ${s.ralph_iteration}/${s.ralph_max})`
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return `[tfx-multi 파이프라인 진행 중]
|
|
37
|
+
활성 파이프라인이 있습니다:
|
|
38
|
+
${lines.join('\n')}
|
|
39
|
+
|
|
40
|
+
파이프라인을 이어서 진행하려면 /tfx-multi status 로 상태를 확인하세요.`;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const prompt = await getPipelineStopPrompt();
|
|
48
|
+
if (prompt) {
|
|
49
|
+
// hook 출력으로 지속 프롬프트 전달
|
|
50
|
+
console.log(prompt);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// stop 훅 실패는 무시
|
|
54
|
+
}
|
package/hub/bridge.mjs
CHANGED
|
@@ -12,6 +12,16 @@ import { parseArgs as nodeParseArgs } from 'node:util';
|
|
|
12
12
|
import { randomUUID } from 'node:crypto';
|
|
13
13
|
|
|
14
14
|
const HUB_PID_FILE = join(homedir(), '.claude', 'cache', 'tfx-hub', 'hub.pid');
|
|
15
|
+
const HUB_TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
|
|
16
|
+
|
|
17
|
+
// Hub 인증 토큰 읽기 (파일 없으면 null → 하위 호환)
|
|
18
|
+
function readHubToken() {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(HUB_TOKEN_FILE, 'utf8').trim();
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
|
|
16
26
|
export function getHubUrl() {
|
|
17
27
|
if (process.env.TFX_HUB_URL) return process.env.TFX_HUB_URL.replace(/\/mcp$/, '');
|
|
@@ -46,9 +56,15 @@ export async function post(path, body, timeoutMs = 5000) {
|
|
|
46
56
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
47
57
|
|
|
48
58
|
try {
|
|
59
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
60
|
+
const token = readHubToken();
|
|
61
|
+
if (token) {
|
|
62
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
const res = await fetch(`${getHubUrl()}${path}`, {
|
|
50
66
|
method: 'POST',
|
|
51
|
-
headers
|
|
67
|
+
headers,
|
|
52
68
|
body: JSON.stringify(body),
|
|
53
69
|
signal: controller.signal,
|
|
54
70
|
});
|
|
@@ -304,27 +320,41 @@ async function cmdDeregister(args) {
|
|
|
304
320
|
}
|
|
305
321
|
|
|
306
322
|
async function cmdTeamInfo(args) {
|
|
307
|
-
const
|
|
323
|
+
const body = {
|
|
308
324
|
team_name: args.team,
|
|
309
325
|
include_members: true,
|
|
310
326
|
include_paths: true,
|
|
311
|
-
}
|
|
312
|
-
|
|
327
|
+
};
|
|
328
|
+
const result = await post('/bridge/team/info', body);
|
|
329
|
+
if (result) {
|
|
330
|
+
console.log(JSON.stringify(result));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Hub 미실행 fallback — nativeProxy 직접 호출
|
|
334
|
+
const { teamInfo } = await import('./team/nativeProxy.mjs');
|
|
335
|
+
console.log(JSON.stringify(await teamInfo(body)));
|
|
313
336
|
}
|
|
314
337
|
|
|
315
338
|
async function cmdTeamTaskList(args) {
|
|
316
|
-
const
|
|
339
|
+
const body = {
|
|
317
340
|
team_name: args.team,
|
|
318
341
|
owner: args.owner,
|
|
319
342
|
statuses: args.statuses ? args.statuses.split(',').map((status) => status.trim()).filter(Boolean) : [],
|
|
320
343
|
include_internal: !!args['include-internal'],
|
|
321
344
|
limit: parseInt(args.limit || '200', 10),
|
|
322
|
-
}
|
|
323
|
-
|
|
345
|
+
};
|
|
346
|
+
const result = await post('/bridge/team/task-list', body);
|
|
347
|
+
if (result) {
|
|
348
|
+
console.log(JSON.stringify(result));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Hub 미실행 fallback — nativeProxy 직접 호출
|
|
352
|
+
const { teamTaskList } = await import('./team/nativeProxy.mjs');
|
|
353
|
+
console.log(JSON.stringify(await teamTaskList(body)));
|
|
324
354
|
}
|
|
325
355
|
|
|
326
356
|
async function cmdTeamTaskUpdate(args) {
|
|
327
|
-
const
|
|
357
|
+
const body = {
|
|
328
358
|
team_name: args.team,
|
|
329
359
|
task_id: args['task-id'],
|
|
330
360
|
claim: !!args.claim,
|
|
@@ -338,20 +368,95 @@ async function cmdTeamTaskUpdate(args) {
|
|
|
338
368
|
metadata_patch: args['metadata-patch'] ? parseJsonSafe(args['metadata-patch'], null) : undefined,
|
|
339
369
|
if_match_mtime_ms: args['if-match-mtime-ms'] != null ? Number(args['if-match-mtime-ms']) : undefined,
|
|
340
370
|
actor: args.actor,
|
|
341
|
-
}
|
|
342
|
-
|
|
371
|
+
};
|
|
372
|
+
const result = await post('/bridge/team/task-update', body);
|
|
373
|
+
if (result) {
|
|
374
|
+
console.log(JSON.stringify(result));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Hub 미실행 fallback — nativeProxy 직접 호출
|
|
378
|
+
const { teamTaskUpdate } = await import('./team/nativeProxy.mjs');
|
|
379
|
+
console.log(JSON.stringify(await teamTaskUpdate(body)));
|
|
343
380
|
}
|
|
344
381
|
|
|
345
382
|
async function cmdTeamSendMessage(args) {
|
|
346
|
-
const
|
|
383
|
+
const body = {
|
|
347
384
|
team_name: args.team,
|
|
348
385
|
from: args.from,
|
|
349
386
|
to: args.to || 'team-lead',
|
|
350
387
|
text: args.text,
|
|
351
388
|
summary: args.summary,
|
|
352
389
|
color: args.color || 'blue',
|
|
390
|
+
};
|
|
391
|
+
const result = await post('/bridge/team/send-message', body);
|
|
392
|
+
if (result) {
|
|
393
|
+
console.log(JSON.stringify(result));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Hub 미실행 fallback — nativeProxy 직접 호출
|
|
397
|
+
const { teamSendMessage } = await import('./team/nativeProxy.mjs');
|
|
398
|
+
console.log(JSON.stringify(await teamSendMessage(body)));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function getHubDbPath() {
|
|
402
|
+
return join(homedir(), '.claude', 'cache', 'tfx-hub', 'state.db');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function cmdPipelineState(args) {
|
|
406
|
+
// HTTP 우선
|
|
407
|
+
const result = await post('/bridge/pipeline/state', { team_name: args.team });
|
|
408
|
+
if (result) {
|
|
409
|
+
console.log(JSON.stringify(result));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// Hub 미실행 fallback — 직접 SQLite 접근
|
|
413
|
+
try {
|
|
414
|
+
const { default: Database } = await import('better-sqlite3');
|
|
415
|
+
const { ensurePipelineTable, readPipelineState } = await import('./pipeline/state.mjs');
|
|
416
|
+
const dbPath = getHubDbPath();
|
|
417
|
+
if (!existsSync(dbPath)) {
|
|
418
|
+
console.log(JSON.stringify({ ok: false, error: 'hub_db_not_found' }));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const db = new Database(dbPath, { readonly: true });
|
|
422
|
+
ensurePipelineTable(db);
|
|
423
|
+
const state = readPipelineState(db, args.team);
|
|
424
|
+
db.close();
|
|
425
|
+
console.log(JSON.stringify(state
|
|
426
|
+
? { ok: true, data: state }
|
|
427
|
+
: { ok: false, error: 'pipeline_not_found' }));
|
|
428
|
+
} catch (e) {
|
|
429
|
+
console.log(JSON.stringify({ ok: false, error: e.message }));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function cmdPipelineAdvance(args) {
|
|
434
|
+
// HTTP 우선
|
|
435
|
+
const result = await post('/bridge/pipeline/advance', {
|
|
436
|
+
team_name: args.team,
|
|
437
|
+
phase: args.status, // --status를 phase로 재활용
|
|
353
438
|
});
|
|
354
|
-
|
|
439
|
+
if (result) {
|
|
440
|
+
console.log(JSON.stringify(result));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Hub 미실행 fallback — 직접 SQLite 접근
|
|
444
|
+
try {
|
|
445
|
+
const { default: Database } = await import('better-sqlite3');
|
|
446
|
+
const { createPipeline } = await import('./pipeline/index.mjs');
|
|
447
|
+
const dbPath = getHubDbPath();
|
|
448
|
+
if (!existsSync(dbPath)) {
|
|
449
|
+
console.log(JSON.stringify({ ok: false, error: 'hub_db_not_found' }));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const db = new Database(dbPath);
|
|
453
|
+
const pipeline = createPipeline(db, args.team);
|
|
454
|
+
const advanceResult = pipeline.advance(args.status);
|
|
455
|
+
db.close();
|
|
456
|
+
console.log(JSON.stringify(advanceResult));
|
|
457
|
+
} catch (e) {
|
|
458
|
+
console.log(JSON.stringify({ ok: false, error: e.message }));
|
|
459
|
+
}
|
|
355
460
|
}
|
|
356
461
|
|
|
357
462
|
async function cmdPing() {
|
|
@@ -397,9 +502,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
397
502
|
case 'team-task-list': await cmdTeamTaskList(args); break;
|
|
398
503
|
case 'team-task-update': await cmdTeamTaskUpdate(args); break;
|
|
399
504
|
case 'team-send-message': await cmdTeamSendMessage(args); break;
|
|
505
|
+
case 'pipeline-state': await cmdPipelineState(args); break;
|
|
506
|
+
case 'pipeline-advance': await cmdPipelineAdvance(args); break;
|
|
400
507
|
case 'ping': await cmdPing(args); break;
|
|
401
508
|
default:
|
|
402
|
-
console.error('사용법: bridge.mjs <register|result|context|deregister|team-info|team-task-list|team-task-update|team-send-message|ping> [--옵션]');
|
|
509
|
+
console.error('사용법: bridge.mjs <register|result|context|deregister|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|ping> [--옵션]');
|
|
403
510
|
process.exit(1);
|
|
404
511
|
}
|
|
405
512
|
}
|
package/hub/pipe.mjs
CHANGED
|
@@ -173,6 +173,24 @@ export function createPipeServer({
|
|
|
173
173
|
return result;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
case 'assign': {
|
|
177
|
+
const result = router.assignAsync(payload);
|
|
178
|
+
if (client) touchClient(client);
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case 'assign_result': {
|
|
183
|
+
const result = router.reportAssignResult(payload);
|
|
184
|
+
if (client) touchClient(client);
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'assign_retry': {
|
|
189
|
+
const result = router.retryAssign(payload.job_id, payload);
|
|
190
|
+
if (client) touchClient(client);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
176
194
|
case 'result': {
|
|
177
195
|
const result = router.handlePublish({
|
|
178
196
|
from: payload.agent_id,
|
|
@@ -282,6 +300,11 @@ export function createPipeServer({
|
|
|
282
300
|
return router.getStatus(scope, payload);
|
|
283
301
|
}
|
|
284
302
|
|
|
303
|
+
case 'assign_status': {
|
|
304
|
+
if (client) touchClient(client);
|
|
305
|
+
return router.getAssignStatus(payload);
|
|
306
|
+
}
|
|
307
|
+
|
|
285
308
|
default:
|
|
286
309
|
return {
|
|
287
310
|
ok: false,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// hub/pipeline/index.mjs — 파이프라인 매니저
|
|
2
|
+
//
|
|
3
|
+
// 상태(state.mjs) + 전이(transitions.mjs) 통합 인터페이스
|
|
4
|
+
|
|
5
|
+
import { canTransition, transitionPhase, ralphRestart, TERMINAL } from './transitions.mjs';
|
|
6
|
+
import {
|
|
7
|
+
ensurePipelineTable,
|
|
8
|
+
initPipelineState,
|
|
9
|
+
readPipelineState,
|
|
10
|
+
updatePipelineState,
|
|
11
|
+
removePipelineState,
|
|
12
|
+
} from './state.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 파이프라인 매니저 생성
|
|
16
|
+
* @param {object} db - better-sqlite3 인스턴스 (store.db)
|
|
17
|
+
* @param {string} teamName
|
|
18
|
+
* @param {object} opts - { fix_max?, ralph_max? }
|
|
19
|
+
* @returns {object} 파이프라인 API
|
|
20
|
+
*/
|
|
21
|
+
export function createPipeline(db, teamName, opts = {}) {
|
|
22
|
+
ensurePipelineTable(db);
|
|
23
|
+
|
|
24
|
+
// 기존 상태가 있으면 로드, 없으면 초기화
|
|
25
|
+
let state = readPipelineState(db, teamName);
|
|
26
|
+
if (!state) {
|
|
27
|
+
state = initPipelineState(db, teamName, opts);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
/**
|
|
32
|
+
* 현재 상태 조회
|
|
33
|
+
*/
|
|
34
|
+
getState() {
|
|
35
|
+
state = readPipelineState(db, teamName) || state;
|
|
36
|
+
return { ...state };
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 다음 단계로 전이 가능 여부
|
|
41
|
+
* @param {string} phase
|
|
42
|
+
*/
|
|
43
|
+
canAdvance(phase) {
|
|
44
|
+
const current = readPipelineState(db, teamName);
|
|
45
|
+
return current ? canTransition(current.phase, phase) : false;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 다음 단계로 전이
|
|
50
|
+
* @param {string} nextPhase
|
|
51
|
+
* @returns {{ ok: boolean, state?: object, error?: string }}
|
|
52
|
+
*/
|
|
53
|
+
advance(nextPhase) {
|
|
54
|
+
const current = readPipelineState(db, teamName);
|
|
55
|
+
if (!current) {
|
|
56
|
+
return { ok: false, error: `파이프라인 없음: ${teamName}` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = transitionPhase(current, nextPhase);
|
|
60
|
+
if (!result.ok) return result;
|
|
61
|
+
|
|
62
|
+
state = updatePipelineState(db, teamName, result.state);
|
|
63
|
+
return { ok: true, state: { ...state } };
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ralph loop 재시작 (plan부터 다시)
|
|
68
|
+
* @returns {{ ok: boolean, state?: object, error?: string }}
|
|
69
|
+
*/
|
|
70
|
+
restart() {
|
|
71
|
+
const current = readPipelineState(db, teamName);
|
|
72
|
+
if (!current) {
|
|
73
|
+
return { ok: false, error: `파이프라인 없음: ${teamName}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = ralphRestart(current);
|
|
77
|
+
if (!result.ok) return result;
|
|
78
|
+
|
|
79
|
+
state = updatePipelineState(db, teamName, result.state);
|
|
80
|
+
return { ok: true, state: { ...state } };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* artifact 저장 (plan_path, prd_path, verify_report 등)
|
|
85
|
+
* @param {string} key
|
|
86
|
+
* @param {*} value
|
|
87
|
+
*/
|
|
88
|
+
setArtifact(key, value) {
|
|
89
|
+
const current = readPipelineState(db, teamName);
|
|
90
|
+
if (!current) return;
|
|
91
|
+
const artifacts = { ...(current.artifacts || {}), [key]: value };
|
|
92
|
+
state = updatePipelineState(db, teamName, { artifacts });
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 터미널 상태 여부
|
|
97
|
+
*/
|
|
98
|
+
isTerminal() {
|
|
99
|
+
const current = readPipelineState(db, teamName);
|
|
100
|
+
return current ? TERMINAL.has(current.phase) : true;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 파이프라인 초기화 (리셋)
|
|
105
|
+
*/
|
|
106
|
+
reset() {
|
|
107
|
+
state = initPipelineState(db, teamName, opts);
|
|
108
|
+
return { ...state };
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 파이프라인 삭제
|
|
113
|
+
*/
|
|
114
|
+
remove() {
|
|
115
|
+
return removePipelineState(db, teamName);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { ensurePipelineTable } from './state.mjs';
|
|
121
|
+
export { PHASES, TERMINAL, ALLOWED, canTransition } from './transitions.mjs';
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// hub/pipeline/state.mjs — Hub SQLite 파이프라인 상태 저장/로드
|
|
2
|
+
//
|
|
3
|
+
// store.mjs의 기존 SQLite 연결(db)을 활용한다.
|
|
4
|
+
// pipeline_state 테이블은 schema.sql에 정의.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* pipeline_state 테이블 초기화 (store.db에 없으면 생성)
|
|
8
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
9
|
+
*/
|
|
10
|
+
export function ensurePipelineTable(db) {
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS pipeline_state (
|
|
13
|
+
team_name TEXT PRIMARY KEY,
|
|
14
|
+
phase TEXT NOT NULL DEFAULT 'plan',
|
|
15
|
+
fix_attempt INTEGER DEFAULT 0,
|
|
16
|
+
fix_max INTEGER DEFAULT 3,
|
|
17
|
+
ralph_iteration INTEGER DEFAULT 0,
|
|
18
|
+
ralph_max INTEGER DEFAULT 10,
|
|
19
|
+
artifacts TEXT DEFAULT '{}',
|
|
20
|
+
phase_history TEXT DEFAULT '[]',
|
|
21
|
+
created_at INTEGER,
|
|
22
|
+
updated_at INTEGER
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STATEMENTS = new WeakMap();
|
|
28
|
+
|
|
29
|
+
function getStatements(db) {
|
|
30
|
+
let s = STATEMENTS.get(db);
|
|
31
|
+
if (s) return s;
|
|
32
|
+
|
|
33
|
+
s = {
|
|
34
|
+
get: db.prepare('SELECT * FROM pipeline_state WHERE team_name = ?'),
|
|
35
|
+
insert: db.prepare(`
|
|
36
|
+
INSERT INTO pipeline_state (team_name, phase, fix_attempt, fix_max, ralph_iteration, ralph_max, artifacts, phase_history, created_at, updated_at)
|
|
37
|
+
VALUES (@team_name, @phase, @fix_attempt, @fix_max, @ralph_iteration, @ralph_max, @artifacts, @phase_history, @created_at, @updated_at)
|
|
38
|
+
`),
|
|
39
|
+
update: db.prepare(`
|
|
40
|
+
UPDATE pipeline_state SET
|
|
41
|
+
phase = @phase,
|
|
42
|
+
fix_attempt = @fix_attempt,
|
|
43
|
+
fix_max = @fix_max,
|
|
44
|
+
ralph_iteration = @ralph_iteration,
|
|
45
|
+
ralph_max = @ralph_max,
|
|
46
|
+
artifacts = @artifacts,
|
|
47
|
+
phase_history = @phase_history,
|
|
48
|
+
updated_at = @updated_at
|
|
49
|
+
WHERE team_name = @team_name
|
|
50
|
+
`),
|
|
51
|
+
remove: db.prepare('DELETE FROM pipeline_state WHERE team_name = ?'),
|
|
52
|
+
list: db.prepare('SELECT * FROM pipeline_state ORDER BY updated_at DESC'),
|
|
53
|
+
};
|
|
54
|
+
STATEMENTS.set(db, s);
|
|
55
|
+
return s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseRow(row) {
|
|
59
|
+
if (!row) return null;
|
|
60
|
+
return {
|
|
61
|
+
...row,
|
|
62
|
+
artifacts: JSON.parse(row.artifacts || '{}'),
|
|
63
|
+
phase_history: JSON.parse(row.phase_history || '[]'),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function serializeState(state) {
|
|
68
|
+
return {
|
|
69
|
+
team_name: state.team_name,
|
|
70
|
+
phase: state.phase || 'plan',
|
|
71
|
+
fix_attempt: state.fix_attempt ?? 0,
|
|
72
|
+
fix_max: state.fix_max ?? 3,
|
|
73
|
+
ralph_iteration: state.ralph_iteration ?? 0,
|
|
74
|
+
ralph_max: state.ralph_max ?? 10,
|
|
75
|
+
artifacts: JSON.stringify(state.artifacts || {}),
|
|
76
|
+
phase_history: JSON.stringify(state.phase_history || []),
|
|
77
|
+
created_at: state.created_at ?? Date.now(),
|
|
78
|
+
updated_at: state.updated_at ?? Date.now(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 파이프라인 상태 초기화 (새 파이프라인)
|
|
84
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
85
|
+
* @param {string} teamName
|
|
86
|
+
* @param {object} opts - { fix_max?, ralph_max? }
|
|
87
|
+
* @returns {object} 초기 상태
|
|
88
|
+
*/
|
|
89
|
+
export function initPipelineState(db, teamName, opts = {}) {
|
|
90
|
+
const S = getStatements(db);
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const state = {
|
|
93
|
+
team_name: teamName,
|
|
94
|
+
phase: 'plan',
|
|
95
|
+
fix_attempt: 0,
|
|
96
|
+
fix_max: opts.fix_max ?? 3,
|
|
97
|
+
ralph_iteration: 0,
|
|
98
|
+
ralph_max: opts.ralph_max ?? 10,
|
|
99
|
+
artifacts: {},
|
|
100
|
+
phase_history: [],
|
|
101
|
+
created_at: now,
|
|
102
|
+
updated_at: now,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// 기존 상태가 있으면 삭제 후 재생성
|
|
106
|
+
S.remove.run(teamName);
|
|
107
|
+
S.insert.run(serializeState(state));
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 파이프라인 상태 조회
|
|
113
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
114
|
+
* @param {string} teamName
|
|
115
|
+
* @returns {object|null}
|
|
116
|
+
*/
|
|
117
|
+
export function readPipelineState(db, teamName) {
|
|
118
|
+
const S = getStatements(db);
|
|
119
|
+
return parseRow(S.get.get(teamName));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 파이프라인 상태 업데이트 (부분 패치)
|
|
124
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
125
|
+
* @param {string} teamName
|
|
126
|
+
* @param {object} patch - 업데이트할 필드
|
|
127
|
+
* @returns {object|null} 업데이트된 상태
|
|
128
|
+
*/
|
|
129
|
+
export function updatePipelineState(db, teamName, patch) {
|
|
130
|
+
const S = getStatements(db);
|
|
131
|
+
const current = parseRow(S.get.get(teamName));
|
|
132
|
+
if (!current) return null;
|
|
133
|
+
|
|
134
|
+
const merged = {
|
|
135
|
+
...current,
|
|
136
|
+
...patch,
|
|
137
|
+
team_name: teamName, // team_name 변경 불가
|
|
138
|
+
updated_at: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
S.update.run(serializeState(merged));
|
|
142
|
+
return merged;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 파이프라인 상태 삭제
|
|
147
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
148
|
+
* @param {string} teamName
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
export function removePipelineState(db, teamName) {
|
|
152
|
+
const S = getStatements(db);
|
|
153
|
+
return S.remove.run(teamName).changes > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 활성 파이프라인 목록
|
|
158
|
+
* @param {object} db - better-sqlite3 인스턴스
|
|
159
|
+
* @returns {object[]}
|
|
160
|
+
*/
|
|
161
|
+
export function listPipelineStates(db) {
|
|
162
|
+
const S = getStatements(db);
|
|
163
|
+
return S.list.all().map(parseRow);
|
|
164
|
+
}
|