e2e-agent-runner 1.0.0 → 1.1.0
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/README.md +15 -1
- package/claude.mjs +7 -1
- package/discover.mjs +190 -0
- package/git.mjs +19 -2
- package/package.json +3 -2
- package/runner.mjs +79 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# e2e-runner — suseona 작업 에이전트 로컬 러너
|
|
2
2
|
|
|
3
|
-
내 컴퓨터의 **Claude Code** 로 `구현 → 테스트 → 리뷰 → 배포`
|
|
3
|
+
내 컴퓨터의 **Claude Code** 로 `구현 → 테스트 → 리뷰 → 배포` 파이프라인과 웹사이트 기능 지도 추출을 자동 수행하는
|
|
4
4
|
상주 데몬입니다. 코드는 내 폴더에, 실행은 내 Claude 구독으로 — 서버(e2e)는 오케스트레이션만
|
|
5
5
|
담당합니다. 아웃바운드 폴링만 쓰므로 인바운드 포트·포트포워딩이 필요 없습니다.
|
|
6
6
|
|
|
@@ -89,6 +89,20 @@ WantedBy=default.target
|
|
|
89
89
|
| `CLAUDE_BIN` | | `claude` | claude CLI 경로(비표준 설치 위치) |
|
|
90
90
|
| `POLL_S` / `HEARTBEAT_S` | | 3 / 30 | 폴링·하트비트 주기 |
|
|
91
91
|
| `RUNNER_STEP_TIMEOUT_S` | | 2700 | 단계 타임아웃(서버 watchdog 3600s 보다 작게) |
|
|
92
|
+
| `DISCOVER_TIMEOUT_S` / `MAX_TURNS_DISCOVER` | | 5400 / 500 | 기능 지도 추출 시간·턴 상한 |
|
|
93
|
+
| `E2E_BROWSER_MCP` | | (Playwright) | `off`=브라우저 없이 코드만 / MCP 설정 JSON 경로=사용자 지정 브라우저 MCP |
|
|
94
|
+
| `E2E_PLAYWRIGHT_MCP` | | `@playwright/mcp@0.0.84` | 기능 지도용 Playwright MCP 패키지 스펙 |
|
|
95
|
+
| `E2E_BROWSER_HEADED` | | — | `1`이면 브라우저 창을 띄워 탐색(디버깅) |
|
|
96
|
+
|
|
97
|
+
## 기능 지도 추출(웹사이트 프로젝트)
|
|
98
|
+
|
|
99
|
+
웹에서 프로젝트 유형을 **웹사이트**로 지정하면 "기능 지도 추출"을 실행할 수 있습니다. 러너는
|
|
100
|
+
Claude Code 에 **Playwright MCP**(`npx @playwright/mcp`, 격리 프로필·헤드리스)를 붙여 실행하고,
|
|
101
|
+
Claude 는 소스의 라우트 정의를 읽은 뒤 **라이브 사이트에 직접 접속해 화면을 보며** 라우트별 기능과
|
|
102
|
+
상세 정의(흐름·입력 검증·기대 결과·상태·예외·QA 체크포인트)를 임시 폴더에 기록합니다. 러너가 이를
|
|
103
|
+
수집해 서버에 보고합니다. 이 단계는 **읽기 전용**(브랜치·커밋 없음)이며 라이브 사이트에서 삭제·결제·
|
|
104
|
+
발송 같은 파괴적 동작을 하지 않도록 지시됩니다. 첫 실행 시 브라우저 다운로드가 필요할 수 있습니다
|
|
105
|
+
(Claude 가 `browser_install` 로 자동 설치).
|
|
92
106
|
|
|
93
107
|
## 보안 노트
|
|
94
108
|
|
package/claude.mjs
CHANGED
|
@@ -44,11 +44,13 @@ const BRIEF_MAX = 240; // tool_use 요약 상한
|
|
|
44
44
|
* @param {(ev:{kind:string,data:object})=>void} opts.onEvent message/tool 이벤트 콜백
|
|
45
45
|
* @param {()=>boolean} opts.shouldCancel 취소 플래그 폴링(라인 수신·1s 인터벌)
|
|
46
46
|
* @param {(sessionId:string)=>void} opts.onSessionId init 이벤트 수신(session 저장·task_meta 전송)
|
|
47
|
+
* @param {string[]} [opts.extraArgs] claude 추가 인자(discover 의 MCP·추가 디렉터리)
|
|
47
48
|
* @returns {Promise<{ok:boolean, cancelled?:boolean, timedOut?:boolean, finalText?:string,
|
|
48
49
|
* sessionId?:string|null, usage?:object|null, error?:string|null}>}
|
|
49
50
|
*/
|
|
50
51
|
export function runClaude(opts) {
|
|
51
52
|
const { cwd, prompt, resumeSessionId, maxTurns, timeoutS, onEvent, shouldCancel, onSessionId } = opts;
|
|
53
|
+
const extraArgs = opts.extraArgs ?? [];
|
|
52
54
|
return new Promise((resolve) => {
|
|
53
55
|
const args = [
|
|
54
56
|
'-p', prompt,
|
|
@@ -57,11 +59,15 @@ export function runClaude(opts) {
|
|
|
57
59
|
'--permission-mode', 'bypassPermissions',
|
|
58
60
|
'--max-turns', String(maxTurns),
|
|
59
61
|
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
|
62
|
+
// 페이즈별 추가 플래그(discover: --mcp-config 브라우저 MCP·--add-dir 산출 폴더).
|
|
63
|
+
// 가변 인자 플래그(--mcp-config/--add-dir)가 프롬프트를 삼키지 않도록 항상 마지막에 둔다.
|
|
64
|
+
...extraArgs,
|
|
60
65
|
];
|
|
61
66
|
// detached(POSIX)=신규 프로세스 그룹 — 취소·타임아웃이 claude 가 띄운 도구 자식(bash 등)까지
|
|
62
67
|
// 종료시킨다. detached 로 파이프(stdio)는 그대로 동작한다.
|
|
68
|
+
// stdin 은 닫는다 — 파이프로 열려 있으면 claude -p 가 입력을 3s 기다린 뒤 경고하고 진행한다.
|
|
63
69
|
const child = spawn(CLAUDE_BIN, args, {
|
|
64
|
-
cwd, env: process.env, detached: process.platform !== 'win32',
|
|
70
|
+
cwd, env: process.env, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'],
|
|
65
71
|
});
|
|
66
72
|
let settled = false;
|
|
67
73
|
let cancelled = false;
|
package/discover.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// 기능 지도(Feature Map) 추출 — discover 페이즈 실행기.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code 를 프로젝트 폴더(소스 코드 정찰)에서 실행하되, 브라우저 MCP(Playwright)를 붙여
|
|
4
|
+
// 라이브 사이트에 직접 접속해 화면(스크린샷·접근성 스냅샷)을 보며 기능을 확인하게 한다.
|
|
5
|
+
// Claude 는 러너가 만든 임시 산출 폴더에 라우트별 JSON(routes/NNN.json)과 map.json 을
|
|
6
|
+
// 점진적으로 기록하고, 러너는 종료 후(시간 초과·실패 포함) 기록분을 수집해 artifact 로 보고한다.
|
|
7
|
+
//
|
|
8
|
+
// 읽기 전용 페이즈: git 브랜치·프리플라이트·커밋 없음. 저장소가 수정되면 경고 이벤트만 남긴다
|
|
9
|
+
// (변경을 되돌리지 않는다 — 사용자 파일일 수 있으므로 판단은 사용자에게).
|
|
10
|
+
//
|
|
11
|
+
// env:
|
|
12
|
+
// E2E_BROWSER_MCP off=브라우저 없이(코드만) | <JSON 파일 경로>=사용자 MCP 설정 | 미설정=Playwright MCP
|
|
13
|
+
// E2E_PLAYWRIGHT_MCP Playwright MCP 패키지 스펙(기본 @playwright/mcp@0.0.84 — 고정 버전)
|
|
14
|
+
// E2E_BROWSER_HEADED=1 브라우저 창 표시(디버깅)
|
|
15
|
+
// E2E_KEEP_FM_OUT=1 산출 폴더 보존(디버깅)
|
|
16
|
+
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { promisify } from 'node:util';
|
|
21
|
+
|
|
22
|
+
const exec = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
const PLAYWRIGHT_MCP = process.env.E2E_PLAYWRIGHT_MCP || '@playwright/mcp@0.0.84';
|
|
25
|
+
const ARTIFACT_MAX_BYTES = 6 * 1024 * 1024; // step_result 본문 상한(서버가 다시 정규화·절단)
|
|
26
|
+
const ROUTE_FILE_MAX_BYTES = 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/** 산출 폴더 준비 + 브라우저 MCP 설정 → { outDir, routesDir, extraArgs, browser, promptAddon, cleanup } */
|
|
29
|
+
export function prepareDiscover({ siteUrl }) {
|
|
30
|
+
const outDir = mkdtempSync(join(tmpdir(), 'e2e-fm-'));
|
|
31
|
+
const routesDir = join(outDir, 'routes');
|
|
32
|
+
const shotsDir = join(outDir, 'shots');
|
|
33
|
+
mkdirSync(routesDir, { recursive: true });
|
|
34
|
+
mkdirSync(shotsDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const mode = (process.env.E2E_BROWSER_MCP || '').trim();
|
|
37
|
+
let mcpPath = null;
|
|
38
|
+
let browser = 'playwright';
|
|
39
|
+
if (mode === 'off') {
|
|
40
|
+
browser = 'off';
|
|
41
|
+
} else if (mode && existsSync(mode)) {
|
|
42
|
+
mcpPath = mode; // 사용자 제공 MCP 설정(예: 사내 프록시·기존 Chrome 프로필)
|
|
43
|
+
browser = 'custom';
|
|
44
|
+
} else {
|
|
45
|
+
const pwArgs = [
|
|
46
|
+
'-y', PLAYWRIGHT_MCP,
|
|
47
|
+
'--isolated', // 매 실행 깨끗한 프로필 — 사용자 브라우저 세션·쿠키와 격리
|
|
48
|
+
'--viewport-size', '1440,900',
|
|
49
|
+
'--output-dir', shotsDir,
|
|
50
|
+
...(process.env.E2E_BROWSER_HEADED === '1' ? [] : ['--headless']),
|
|
51
|
+
];
|
|
52
|
+
// MCP 서버의 cwd 를 스크린샷 폴더로 고정 — Playwright MCP 는 상대 filename 을 자기 cwd(=claude cwd=
|
|
53
|
+
// 사용자 저장소) 기준으로 저장해 읽기 전용 탐색이 저장소를 오염시킨다(실검증에서 발견). mcp 설정엔
|
|
54
|
+
// cwd 필드가 없으므로 셸 래퍼로 이동 후 exec 한다(경로는 위치 인자로 전달 — 따옴표 주입 없음).
|
|
55
|
+
const server = process.platform === 'win32'
|
|
56
|
+
? { command: 'cmd', args: ['/c', `cd /d "${shotsDir}" && npx ${pwArgs.map((a) => `"${a}"`).join(' ')}`] }
|
|
57
|
+
: { command: 'sh', args: ['-c', 'cd "$0" && exec npx "$@"', shotsDir, ...pwArgs] };
|
|
58
|
+
mcpPath = join(outDir, 'mcp.json');
|
|
59
|
+
writeFileSync(mcpPath, JSON.stringify({ mcpServers: { playwright: server } }, null, 2));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const extraArgs = ['--add-dir', outDir];
|
|
63
|
+
if (mcpPath) extraArgs.unshift('--mcp-config', mcpPath);
|
|
64
|
+
|
|
65
|
+
const promptAddon = [
|
|
66
|
+
'',
|
|
67
|
+
'# 산출물 위치(러너 지정 — 이 경로에만 쓰기)',
|
|
68
|
+
`- 라우트 파일 폴더: \`${routesDir}\` (예: ${join(routesDir, '001.json')})`,
|
|
69
|
+
`- 서비스 요약 파일: \`${join(outDir, 'map.json')}\``,
|
|
70
|
+
`- 스크린샷 등 브라우저 산출물은 \`${shotsDir}\` 에 저장됩니다(저장소에 저장 금지).`,
|
|
71
|
+
'',
|
|
72
|
+
'# 브라우저 도구',
|
|
73
|
+
browser === 'off'
|
|
74
|
+
? '- 이 머신은 브라우저 도구가 비활성화되어 있습니다(E2E_BROWSER_MCP=off). 코드 기반으로 추출하고 coverage.browser 를 "unavailable" 로 기록하세요.'
|
|
75
|
+
: [
|
|
76
|
+
`- 브라우저 MCP 도구(mcp__playwright__browser_* 등)를 사용해 ${siteUrl || '대상 사이트'} 에 직접 접속하세요.`,
|
|
77
|
+
' browser_navigate 로 이동 → browser_take_screenshot 으로 화면을 직접 보고 → browser_snapshot 으로 요소를 확인 →',
|
|
78
|
+
' browser_click/browser_type 등으로 비파괴적 상호작용을 확인합니다. 화면 폭 확인은 browser_resize.',
|
|
79
|
+
`- browser_take_screenshot 의 filename 을 지정할 때는 반드시 절대 경로(${shotsDir} 아래)를 쓰거나 생략하세요 — 저장소 폴더에 이미지를 저장하면 안 됩니다.`,
|
|
80
|
+
'- 브라우저 실행 파일이 없다는 오류가 나면 browser_install 도구를 한 번 실행한 뒤 다시 시도하세요.',
|
|
81
|
+
'- 브라우저 도구가 끝내 동작하지 않으면 코드 기반으로 계속하고 coverage.browser 를 "unavailable" 로 기록하세요.',
|
|
82
|
+
].join('\n'),
|
|
83
|
+
].join('\n');
|
|
84
|
+
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
if (process.env.E2E_KEEP_FM_OUT === '1') return;
|
|
87
|
+
try {
|
|
88
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
89
|
+
} catch {
|
|
90
|
+
/* 임시 폴더 — OS 가 정리 */
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return { outDir, routesDir, extraArgs, browser, promptAddon, cleanup };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 기록된 라우트 파일 수(진행률 이벤트용). */
|
|
98
|
+
export function countRouteFiles(routesDir) {
|
|
99
|
+
try {
|
|
100
|
+
return readdirSync(routesDir).filter((f) => f.endsWith('.json')).length;
|
|
101
|
+
} catch {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readJson(path, maxBytes) {
|
|
107
|
+
const size = statSync(path).size;
|
|
108
|
+
if (size > maxBytes) throw new Error(`파일 과대(${size}B)`);
|
|
109
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 최종 보고 텍스트의 마지막 ```json 블록 — 파일 기록이 전혀 없을 때의 폴백. */
|
|
113
|
+
function fallbackFromText(text) {
|
|
114
|
+
if (!text) return null;
|
|
115
|
+
const blocks = [...text.matchAll(/```json\s*([\s\S]*?)```/g)];
|
|
116
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
117
|
+
try {
|
|
118
|
+
const v = JSON.parse(blocks[i][1]);
|
|
119
|
+
if (v && Array.isArray(v.routes)) return v;
|
|
120
|
+
if (v && typeof v.path === 'string') return { routes: [v] };
|
|
121
|
+
} catch {
|
|
122
|
+
/* 다음 블록 */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 산출 폴더 수집 → artifact {map, routes[], partial, errors[], browser}. */
|
|
129
|
+
export function collectArtifact({ outDir, routesDir, browser, finalText, partial }) {
|
|
130
|
+
const errors = [];
|
|
131
|
+
let map = null;
|
|
132
|
+
const mapPath = join(outDir, 'map.json');
|
|
133
|
+
if (existsSync(mapPath)) {
|
|
134
|
+
try {
|
|
135
|
+
map = readJson(mapPath, ROUTE_FILE_MAX_BYTES);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
errors.push(`map.json 파싱 실패: ${e.message}`.slice(0, 300));
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
errors.push('map.json 없음(요약·커버리지 누락)');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const routes = [];
|
|
144
|
+
let files = [];
|
|
145
|
+
try {
|
|
146
|
+
files = readdirSync(routesDir).filter((f) => f.endsWith('.json')).sort();
|
|
147
|
+
} catch {
|
|
148
|
+
/* 폴더 없음 */
|
|
149
|
+
}
|
|
150
|
+
for (const f of files) {
|
|
151
|
+
try {
|
|
152
|
+
const v = readJson(join(routesDir, f), ROUTE_FILE_MAX_BYTES);
|
|
153
|
+
if (Array.isArray(v)) routes.push(...v); // 한 파일에 여러 라우트를 쓴 경우도 수용
|
|
154
|
+
else routes.push(v);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
errors.push(`${f} 파싱 실패: ${e.message}`.slice(0, 300));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!routes.length) {
|
|
160
|
+
const fb = fallbackFromText(finalText);
|
|
161
|
+
if (fb) {
|
|
162
|
+
routes.push(...fb.routes);
|
|
163
|
+
if (!map && fb.map) map = fb.map;
|
|
164
|
+
errors.push('라우트 파일이 없어 최종 보고의 JSON 블록을 사용');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const artifact = { map, routes, partial: Boolean(partial), errors, browser };
|
|
169
|
+
// 본문 상한 — 초과 시 뒤쪽 라우트부터 제외(Claude 는 핵심 화면부터 기록한다).
|
|
170
|
+
let size = Buffer.byteLength(JSON.stringify(artifact));
|
|
171
|
+
while (size > ARTIFACT_MAX_BYTES && artifact.routes.length > 1) {
|
|
172
|
+
artifact.routes.pop();
|
|
173
|
+
artifact.partial = true;
|
|
174
|
+
size = Buffer.byteLength(JSON.stringify(artifact));
|
|
175
|
+
}
|
|
176
|
+
if (artifact.routes.length < routes.length) {
|
|
177
|
+
artifact.errors.push(`산출물 용량 상한으로 라우트 ${routes.length - artifact.routes.length}개 제외`);
|
|
178
|
+
}
|
|
179
|
+
return artifact;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 저장소 변경 감지(읽기 전용 위반 경고용) — git 저장소가 아니면 null. */
|
|
183
|
+
export async function porcelain(cwd) {
|
|
184
|
+
try {
|
|
185
|
+
const { stdout } = await exec('git', ['status', '--porcelain'], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
186
|
+
return stdout.trim();
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
package/git.mjs
CHANGED
|
@@ -41,13 +41,23 @@ export async function probeFolder(cwd) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** 실행 전 검증 — 폴더 존재·git 저장소·클린 워킹트리(사용자 미커밋 변경 보호).
|
|
44
|
+
/** 실행 전 검증 — 폴더 존재·git 저장소·클린 워킹트리(사용자 미커밋 변경 보호).
|
|
45
|
+
* dirty 거부 시 어떤 파일이 걸렸는지 목록을 에러에 포함(porcelain 행, ≤8건 표시+외 N건)
|
|
46
|
+
* — 원인 파일을 바로 알게 해 "정리"의 실행 오류를 줄인다. */
|
|
45
47
|
export async function preflight(cwd) {
|
|
46
48
|
if (!existsSync(cwd)) throw new GitError(`프로젝트 폴더가 없습니다: ${cwd}`);
|
|
47
49
|
const inside = await run(cwd, 'rev-parse', '--is-inside-work-tree');
|
|
48
50
|
if (inside !== 'true') throw new GitError('git 저장소가 아닙니다(작업 브랜치·병합 불가).');
|
|
49
51
|
const dirty = await run(cwd, 'status', '--porcelain');
|
|
50
|
-
if (dirty)
|
|
52
|
+
if (dirty) {
|
|
53
|
+
const files = dirty.split('\n').filter(Boolean).map((l) => l.trim().slice(0, 80));
|
|
54
|
+
const shown = files.slice(0, 8);
|
|
55
|
+
const more = files.length - shown.length;
|
|
56
|
+
const list = shown.join(', ') + (more > 0 ? ` 외 ${more}건` : '');
|
|
57
|
+
throw new GitError(
|
|
58
|
+
`커밋되지 않은 변경(${files.length}건)이 있어 작업을 시작할 수 없습니다 — 폴더를 정리(커밋/stash)한 뒤 다시 시도하세요. 대상: ${list}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
export async function currentBranch(cwd) {
|
|
@@ -77,6 +87,13 @@ export function taskBranchName(taskId) {
|
|
|
77
87
|
return `e2e-task/${String(taskId).slice(0, 8)}`;
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
/** 작업 브랜치가 base 에 쌓은 커밋 수 — 0이면 무변경(질문·조사) 과제.
|
|
91
|
+
* 서버 상태머신이 테스트/리뷰/배포 생략(short-circuit)을 결정하는 결정론 신호다. */
|
|
92
|
+
export async function commitCount(cwd, base) {
|
|
93
|
+
const n = await run(cwd, 'rev-list', '--count', `${base}..HEAD`);
|
|
94
|
+
return Number(n) || 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
/** 작업 브랜치 생성(있으면 체크아웃) — implement 단계 전 1회. */
|
|
81
98
|
export async function ensureTaskBranch(cwd, taskId) {
|
|
82
99
|
const branch = taskBranchName(taskId);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "e2e-agent-runner",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "suseona e2e 작업 에이전트 로컬 러너 — 내 컴퓨터의 Claude Code 로
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "suseona e2e 작업 에이전트 로컬 러너 — 내 컴퓨터의 Claude Code 로 구현→테스트→리뷰→배포와 웹사이트 기능 지도 추출을 자동 수행",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"private": false,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"api.mjs",
|
|
18
18
|
"claude.mjs",
|
|
19
19
|
"git.mjs",
|
|
20
|
+
"discover.mjs",
|
|
20
21
|
"README.md"
|
|
21
22
|
],
|
|
22
23
|
"repository": {
|
package/runner.mjs
CHANGED
|
@@ -17,8 +17,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import { api, runnerIdFromToken, sleep } from './api.mjs';
|
|
19
19
|
import { parsePhaseMarker, probeClaude, runClaude } from './claude.mjs';
|
|
20
|
+
import { collectArtifact, countRouteFiles, porcelain, prepareDiscover } from './discover.mjs';
|
|
20
21
|
import {
|
|
21
|
-
ensureTaskBranch, escapeTaskBranch, GitError, mergeToBase, preflight, probeFolder, taskBranchName,
|
|
22
|
+
commitCount, ensureTaskBranch, escapeTaskBranch, GitError, mergeToBase, preflight, probeFolder, taskBranchName,
|
|
22
23
|
} from './git.mjs';
|
|
23
24
|
|
|
24
25
|
// runner/.env.local 자동 로드 — 이미 설정된 env 는 덮지 않는다(셀 export 우선).
|
|
@@ -49,6 +50,9 @@ const STEP_TIMEOUT_S = num(process.env.RUNNER_STEP_TIMEOUT_S, 2700); // 서버 w
|
|
|
49
50
|
const DEPLOY_TIMEOUT_S = num(process.env.DEPLOY_TIMEOUT_S, 600);
|
|
50
51
|
const MAX_TURNS_IMPLEMENT = num(process.env.MAX_TURNS_IMPLEMENT, 150);
|
|
51
52
|
const MAX_TURNS_OTHER = num(process.env.MAX_TURNS_OTHER, 60);
|
|
53
|
+
const MAX_TURNS_DISCOVER = num(process.env.MAX_TURNS_DISCOVER, 500);
|
|
54
|
+
// 기능 지도는 사이트 규모에 비례해 길다 — 서버 와치독은 하트비트(30s)로 갱신되므로 이보다 길어도 안전.
|
|
55
|
+
const DISCOVER_TIMEOUT_S = num(process.env.DISCOVER_TIMEOUT_S, 5400);
|
|
52
56
|
const BUSY_PROBE_POLL_S = 10; // 단계 실행 중 경로 확인(probe) 폴링 주기
|
|
53
57
|
|
|
54
58
|
function num(v, dflt) {
|
|
@@ -162,6 +166,8 @@ async function handleStep({ step, task, project }) {
|
|
|
162
166
|
result = await runImplement({ step, task, project, buf, cancelled });
|
|
163
167
|
} else if (step.phase === 'deploy') {
|
|
164
168
|
result = await runDeploy({ step, task, project, buf, cancelled });
|
|
169
|
+
} else if (step.phase === 'discover') {
|
|
170
|
+
result = await runDiscover({ step, task, project, buf, cancelled });
|
|
165
171
|
} else {
|
|
166
172
|
// test / remediate / review — 서버 프롬프트(prompt_md)·세션(resume) 그대로 실행.
|
|
167
173
|
result = await runPhase({ step, task, project, buf, cancelled });
|
|
@@ -176,6 +182,10 @@ async function handleStep({ step, task, project }) {
|
|
|
176
182
|
session_id: result.sessionId ?? null,
|
|
177
183
|
error: cancelledNow ? 'cancelled' : (result.error ?? null),
|
|
178
184
|
cancelled: Boolean(cancelledNow),
|
|
185
|
+
// implement 전용 — 작업 브랜치 커밋 수(무변경 단축 신호). 다른 페이즈는 미첨부.
|
|
186
|
+
commits: result.commits,
|
|
187
|
+
// discover 전용 — 기능 지도 산출물(서버가 정규화·기록).
|
|
188
|
+
artifact: result.artifact,
|
|
179
189
|
usage: result.usage ?? undefined,
|
|
180
190
|
});
|
|
181
191
|
log(`단계 종결: ${task.id.slice(0, 8)}/${step.phase} → ${cancelledNow ? 'cancelled' : result.ok ? 'done' : 'fail'}`);
|
|
@@ -194,14 +204,80 @@ async function handleStep({ step, task, project }) {
|
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
/** implement — git 프리플라이트 → (이전 작업 브랜치 탈출) → 작업 브랜치 → claude(신규 세션).
|
|
197
|
-
* base_branch 를 서버에 보고(유실 방지: EventBuffer 가 재전송한다).
|
|
207
|
+
* base_branch 를 서버에 보고(유실 방지: EventBuffer 가 재전송한다).
|
|
208
|
+
* 종결 시 작업 브랜치의 커밋 수(base..HEAD)를 함께 보고 — 0이면 서버가 무변경 단축한다. */
|
|
198
209
|
async function runImplement({ step, task, project, buf, cancelled }) {
|
|
199
210
|
await preflight(project.folder_path);
|
|
200
211
|
const base = await escapeTaskBranch(project.folder_path);
|
|
201
212
|
const branch = await ensureTaskBranch(project.folder_path, task.id);
|
|
202
213
|
buf.taskMeta = { base_branch: base, branch };
|
|
203
214
|
log(`브랜치 준비: ${branch} (base ${base})`);
|
|
204
|
-
|
|
215
|
+
const result = await runPhase({ step, task, project, buf, cancelled, extraMeta: buf.taskMeta });
|
|
216
|
+
let commits;
|
|
217
|
+
try {
|
|
218
|
+
commits = await commitCount(project.folder_path, base);
|
|
219
|
+
} catch {
|
|
220
|
+
/* 커밋 수 계산 실패 — 미보고 시 서버는 기존 전체 파이프라인으로 동작 */
|
|
221
|
+
}
|
|
222
|
+
return { ...result, commits };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** discover — 기능 지도 추출(읽기 전용). 브랜치·프리플라이트 없이 프로젝트 폴더에서 claude 를
|
|
226
|
+
* 브라우저 MCP 와 함께 실행하고, 임시 산출 폴더의 기록분을 artifact 로 수집해 보고한다.
|
|
227
|
+
* 시간 초과·실패여도 수집분이 있으면 보고(서버가 partial 로 보존). */
|
|
228
|
+
async function runDiscover({ step, task, project, buf, cancelled }) {
|
|
229
|
+
if (!state.claudeBin) throw new Error('claude CLI 를 찾을 수 없습니다 — CLAUDE_BIN 확인 후 러너를 재시작하세요.');
|
|
230
|
+
const prep = prepareDiscover({ siteUrl: project.site_url });
|
|
231
|
+
log(`기능 지도 탐색 시작: ${project.site_url ?? '(사이트 없음)'} — browser=${prep.browser}, out=${prep.outDir}`);
|
|
232
|
+
buf.push(step.id, 'status', { event: 'fm_started', browser: prep.browser });
|
|
233
|
+
const before = await porcelain(project.folder_path);
|
|
234
|
+
let lastCount = 0;
|
|
235
|
+
const progress = setInterval(() => {
|
|
236
|
+
const n = countRouteFiles(prep.routesDir);
|
|
237
|
+
if (n !== lastCount) {
|
|
238
|
+
lastCount = n;
|
|
239
|
+
buf.push(step.id, 'status', { event: 'fm_progress', routes: n });
|
|
240
|
+
}
|
|
241
|
+
}, 10_000);
|
|
242
|
+
try {
|
|
243
|
+
const result = await runClaude({
|
|
244
|
+
cwd: project.folder_path,
|
|
245
|
+
prompt: `${step.prompt_md}\n${prep.promptAddon}\n`,
|
|
246
|
+
resumeSessionId: null,
|
|
247
|
+
maxTurns: MAX_TURNS_DISCOVER,
|
|
248
|
+
timeoutS: DISCOVER_TIMEOUT_S,
|
|
249
|
+
extraArgs: prep.extraArgs,
|
|
250
|
+
onEvent: (ev) => buf.push(step.id, ev.kind, ev.data),
|
|
251
|
+
shouldCancel: cancelled,
|
|
252
|
+
onSessionId: (sessionId) => {
|
|
253
|
+
buf.taskMeta = { session_id: sessionId };
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
const flag = parsePhaseMarker(result.finalText);
|
|
257
|
+
const artifact = collectArtifact({
|
|
258
|
+
outDir: prep.outDir,
|
|
259
|
+
routesDir: prep.routesDir,
|
|
260
|
+
browser: prep.browser,
|
|
261
|
+
finalText: result.finalText,
|
|
262
|
+
partial: !result.ok || flag !== 'pass',
|
|
263
|
+
});
|
|
264
|
+
buf.push(step.id, 'status', {
|
|
265
|
+
event: 'fm_collected', routes: artifact.routes.length, errors: artifact.errors.slice(0, 5),
|
|
266
|
+
});
|
|
267
|
+
const after = await porcelain(project.folder_path);
|
|
268
|
+
if (before !== null && after !== null && after !== before) {
|
|
269
|
+
buf.push(step.id, 'status', {
|
|
270
|
+
event: 'repo_modified',
|
|
271
|
+
note: '읽기 전용 탐색 중 저장소가 변경되었습니다 — git status 로 확인하세요.',
|
|
272
|
+
files: after.split('\n').slice(0, 8),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
log(`기능 지도 수집: 라우트 ${artifact.routes.length}개, 오류 ${artifact.errors.length}건`);
|
|
276
|
+
return { ...result, flag, artifact };
|
|
277
|
+
} finally {
|
|
278
|
+
clearInterval(progress);
|
|
279
|
+
prep.cleanup();
|
|
280
|
+
}
|
|
205
281
|
}
|
|
206
282
|
|
|
207
283
|
/** test/remediate/review 공통 — prompt_md + resume 세션. */
|