@suseona/runner 0.0.0-stage → 1.0.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.
package/README.md CHANGED
@@ -1,3 +1,97 @@
1
- # Temporary Holding Version
1
+ # e2e-runner — suseona 작업 에이전트 로컬 러너
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 내 컴퓨터의 **Claude Code** 로 `구현 → 테스트 → 리뷰 → 배포` 파이프라인을 자동 수행하는
4
+ 상주 데몬입니다. 코드는 내 폴더에, 실행은 내 Claude 구독으로 — 서버(e2e)는 오케스트레이션만
5
+ 담당합니다. 아웃바운드 폴링만 쓰므로 인바운드 포트·포트포워딩이 필요 없습니다.
6
+
7
+ ## 사전 조건
8
+
9
+ - **Node.js ≥ 18.17**
10
+ - **git**
11
+ - **Claude Code CLI** 설치 + 로그인:
12
+ ```bash
13
+ npm install -g @anthropic-ai/claude-code
14
+ claude login # 구독 계정으로 로그인
15
+ ```
16
+
17
+ ## 시작하기
18
+
19
+ 1. **웹에서 머신 등록**: [e2e](https://e2e.suseona.com) 로그인 → **머신** 페이지 →
20
+ "머신 추가" → 이름 입력(예: "맥북프로") → 발급된 토큰(`erun_…`) 복사.
21
+ 토큰은 **이때 한 번만** 표시됩니다(분실 시 폐기 후 재등록).
22
+
23
+ 2. **내 컴퓨터에서 러너 시작**:
24
+ ```bash
25
+ E2E_RUNNER_TOKEN=erun_… npx @suseona/runner
26
+ ```
27
+ 머신 상의 `runner/.env.local`(또는 셸 프로파일)에 넣어두면 env 없이 실행됩니다:
28
+ ```
29
+ E2E_RUNNER_TOKEN=erun_…
30
+ ```
31
+
32
+ 3. 웹 머신 페이지에서 **온라인** 배지와 `claude CLI` 상태를 확인하세요.
33
+
34
+ 4. **프로젝트 등록**(프로젝트 페이지) — 머신 선택 + 그 머신의 git 폴더 절대경로.
35
+ '경로 확인' 버튼으로 실제 존재·git 저장소·클린 트리를 미리 검증합니다.
36
+
37
+ ## 상시 구동(선택)
38
+
39
+ **macOS — launchd** (`~/Library/LaunchAgents/com.suseona.e2e-runner.plist`):
40
+ `PATH`에 node/claude가 보이도록 명시해야 합니다(launchd 기본 PATH엔 없음).
41
+ ```xml
42
+ <?xml version="1.0" encoding="UTF-8"?>
43
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
44
+ <plist version="1.0"><dict>
45
+ <key>Label</key><string>com.suseona.e2e-runner</string>
46
+ <key>ProgramArguments</key><array>
47
+ <string>/usr/local/bin/node</string>
48
+ <string>/opt/e2e-runner/runner.mjs</string>
49
+ </array>
50
+ <key>RunAtLoad</key><true/>
51
+ <key>KeepAlive</key><true/>
52
+ <key>EnvironmentVariables</key><dict>
53
+ <key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
54
+ <key>HOME</key><string>/Users/YOU</string>
55
+ <key>E2E_RUNNER_TOKEN</key><string>erun_…</string>
56
+ </dict>
57
+ <key>StandardOutPath</key><string>/tmp/e2e-runner.log</string>
58
+ <key>StandardErrorPath</key><string>/tmp/e2e-runner.err</string>
59
+ </dict></plist>
60
+ ```
61
+ ```bash
62
+ launchctl load ~/Library/LaunchAgents/com.suseona.e2e-runner.plist
63
+ ```
64
+
65
+ **Linux — systemd** (`~/.config/systemd/user/e2e-runner.service`):
66
+ ```ini
67
+ [Unit]
68
+ Description=suseona e2e runner
69
+ After=network-online.target
70
+
71
+ [Service]
72
+ Environment=E2E_RUNNER_TOKEN=erun_…
73
+ ExecStart=/usr/bin/node /opt/e2e-runner/runner.mjs
74
+ Restart=always
75
+ RestartSec=5
76
+
77
+ [Install]
78
+ WantedBy=default.target
79
+ ```
80
+
81
+ **Windows** — 작업 스케줄러(로그인 시 `node runner.mjs`) 또는 NSSM 서비스.
82
+
83
+ ## 환경변수
84
+
85
+ | 변수 | 필수 | 기본 | 설명 |
86
+ |---|---|---|---|
87
+ | `E2E_RUNNER_TOKEN` | ✅ | — | 웹에서 발급한 머신 토큰(`erun_…`) |
88
+ | `AI_BASE_URL` | | `https://ai.suseona.com` | ai-backend 주소(자체 호스팅 시 변경) |
89
+ | `CLAUDE_BIN` | | `claude` | claude CLI 경로(비표준 설치 위치) |
90
+ | `POLL_S` / `HEARTBEAT_S` | | 3 / 30 | 폴링·하트비트 주기 |
91
+ | `RUNNER_STEP_TIMEOUT_S` | | 2700 | 단계 타임아웃(서버 watchdog 3600s 보다 작게) |
92
+
93
+ ## 보안 노트
94
+
95
+ - 토큰은 머신 단위 신원입니다 — 저장소·채팅에 커밋하지 마세요. 유출 시 웹에서 즉시 폐기.
96
+ - 러너는 등록된 **내 프로젝트 폴더**에서만 작동하며, `bypassPermissions`로 Claude Code 가
97
+ 파일 수정·커밋·(지정 시) 배포 커맨드를 실행합니다. 배포 커맨드는 내 머신 자격으로 실행됩니다.
package/api.mjs ADDED
@@ -0,0 +1,70 @@
1
+ // ai-backend 작업 에이전트 채널 클라이얍트 — /ai/e2e/agent/runner/*.
2
+ // 인증: 머신 개인 토큰(Authorization: Bearer erun_…) — 웹(머신 페이지)에서 발급.
3
+ // 전역 시크릿(x-ai-secret)·자기선언 x-user-id 는 쓰지 않는다(다인 서비스).
4
+ // 러너 경로는 사용자 rate limit 제외(main.py gate) — 3s 폴링·1s 플러시에 걸리지 않는다.
5
+ //
6
+ // ⚠️ BASE/헤더를 호출 시점에 읽는다(지연 평가) — runner.mjs 의 .env.local 로더가
7
+ // import 평가 순서상 api.mjs top-level 상수보다 늦게 실행되기 때문.
8
+
9
+ export const TOKEN_RE = /^erun_([0-9a-f]{32})_([0-9a-f]{64})$/;
10
+
11
+ export function runnerToken() {
12
+ return (process.env.E2E_RUNNER_TOKEN || '').trim();
13
+ }
14
+
15
+ /** 토큰에서 머신 id 추출 — 로깅·표시용(신원 검증은 서버가 한다). */
16
+ export function runnerIdFromToken(token = runnerToken()) {
17
+ const m = TOKEN_RE.exec(token);
18
+ return m ? m[1] : null;
19
+ }
20
+
21
+ function base() {
22
+ return (process.env.AI_BASE_URL || 'https://ai.suseona.com').replace(/\/+$/, '');
23
+ }
24
+
25
+ function headers() {
26
+ return {
27
+ 'content-type': 'application/json',
28
+ authorization: `Bearer ${runnerToken()}`,
29
+ };
30
+ }
31
+
32
+ export class ApiError extends Error {
33
+ constructor(status, detail, path) {
34
+ super(`POST ${path} → ${status}: ${JSON.stringify(detail)}`);
35
+ this.status = status;
36
+ this.detail = detail;
37
+ }
38
+ }
39
+
40
+ /** POST 래퍼 — 네트워크 오류·5xx 만 2회 재시도(4xx 는 즉시 throw). */
41
+ export async function api(path, body = {}, { retries = 2 } = {}) {
42
+ let lastErr;
43
+ for (let attempt = 0; attempt <= retries; attempt++) {
44
+ try {
45
+ const res = await fetch(`${base()}${path}`, {
46
+ method: 'POST',
47
+ headers: headers(),
48
+ body: JSON.stringify(body),
49
+ });
50
+ const text = await res.text();
51
+ const data = text ? JSON.parse(text) : {};
52
+ if (!res.ok) {
53
+ const err = new ApiError(res.status, data.detail ?? data.error, path);
54
+ if (res.status >= 500 && attempt < retries) {
55
+ lastErr = err;
56
+ continue;
57
+ }
58
+ throw err;
59
+ }
60
+ return data;
61
+ } catch (e) {
62
+ if (e instanceof ApiError) throw e;
63
+ lastErr = e; // 네트워크 오류 — 재시도
64
+ }
65
+ await sleep(500 * (attempt + 1));
66
+ }
67
+ throw lastErr;
68
+ }
69
+
70
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
package/claude.mjs ADDED
@@ -0,0 +1,183 @@
1
+ // Claude Code CLI(headless) 실행기 — stream-json 파싱·취소·타임아웃.
2
+ // 인증은 Mac 의 Claude 로그인(구독) 그대로, 권한은 bypassPermissions(본인 프로젝트 전제).
3
+ import { spawn } from 'node:child_process';
4
+ import { createInterface } from 'node:readline';
5
+ import { sleep } from './api.mjs';
6
+
7
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';
8
+
9
+ // 러너와 서버(src/e2e/agent_prompts.py)가 동일 정규식을 유지 — 규약 변경 시 양쪽 수정.
10
+ // (matchAll 사용 — g 플래그 필수)
11
+ export const PHASE_RESULT_RE = /^PHASE_RESULT:\s*(PASS|FAIL)\s*$/gm;
12
+
13
+ export function parsePhaseMarker(text) {
14
+ if (!text) return null; // 서버가 페이즈별 폴백 적용(implement=pass, 나머지=fail)
15
+ const m = [...text.matchAll(PHASE_RESULT_RE)];
16
+ return m.length ? m[m.length - 1][1].toLowerCase() : null;
17
+ }
18
+
19
+ /** `claude --version` 프로브 — 시작 시 1회(미설치 시 heartbeat ok:false 로 알림). */
20
+ export function probeClaude() {
21
+ return new Promise((resolve) => {
22
+ const p = spawn(CLAUDE_BIN, ['--version'], { stdio: 'ignore' });
23
+ p.on('error', () => resolve(null));
24
+ p.on('exit', (code) => resolve(code === 0 ? CLAUDE_BIN : null));
25
+ setTimeout(() => {
26
+ p.kill('SIGKILL');
27
+ resolve(null);
28
+ }, 5000).unref?.();
29
+ });
30
+ }
31
+
32
+ const TEXT_MAX = 2000; // message 이벤트 1건 텍스트 상한
33
+ const BRIEF_MAX = 240; // tool_use 요약 상한
34
+
35
+ /**
36
+ * Claude Code 1세션 실행.
37
+ *
38
+ * @param {object} opts
39
+ * @param {string} opts.cwd 작업 폴더
40
+ * @param {string} opts.prompt 서버가 조립한 페이즈 프롬프트(prompt_md)
41
+ * @param {string|null} opts.resumeSessionId 이어받을 세션(null=신규)
42
+ * @param {number} opts.maxTurns 턴 상한(폭주 보호)
43
+ * @param {number} opts.timeoutS 세션 타임아웃(서버 watchdog 보다 작게)
44
+ * @param {(ev:{kind:string,data:object})=>void} opts.onEvent message/tool 이벤트 콜백
45
+ * @param {()=>boolean} opts.shouldCancel 취소 플래그 폴링(라인 수신·1s 인터벌)
46
+ * @param {(sessionId:string)=>void} opts.onSessionId init 이벤트 수신(session 저장·task_meta 전송)
47
+ * @returns {Promise<{ok:boolean, cancelled?:boolean, timedOut?:boolean, finalText?:string,
48
+ * sessionId?:string|null, usage?:object|null, error?:string|null}>}
49
+ */
50
+ export function runClaude(opts) {
51
+ const { cwd, prompt, resumeSessionId, maxTurns, timeoutS, onEvent, shouldCancel, onSessionId } = opts;
52
+ return new Promise((resolve) => {
53
+ const args = [
54
+ '-p', prompt,
55
+ '--output-format', 'stream-json',
56
+ '--verbose',
57
+ '--permission-mode', 'bypassPermissions',
58
+ '--max-turns', String(maxTurns),
59
+ ...(resumeSessionId ? ['--resume', resumeSessionId] : []),
60
+ ];
61
+ const child = spawn(CLAUDE_BIN, args, { cwd, env: process.env });
62
+ let settled = false;
63
+ let cancelled = false;
64
+ let timedOut = false;
65
+ let finalText = '';
66
+ let usage = null;
67
+ let sessionId = resumeSessionId ?? null;
68
+ let stderrTail = '';
69
+
70
+ const finish = (out) => {
71
+ if (settled) return;
72
+ settled = true;
73
+ clearInterval(cancelTimer);
74
+ clearTimeout(timeoutTimer);
75
+ try {
76
+ child.stdout?.destroy();
77
+ child.stderr?.destroy();
78
+ } catch { /* already closed */ }
79
+ resolve(out);
80
+ };
81
+
82
+ // 타임아웃 — SIGTERM → 5s → SIGKILL
83
+ const timeoutTimer = setTimeout(() => {
84
+ timedOut = true;
85
+ killTree(child);
86
+ setTimeout(() => finish({
87
+ ok: false, timedOut: true,
88
+ finalText, sessionId, usage,
89
+ error: `단계 시간 초과(${timeoutS}s) — 세션을 종료했습니다.`,
90
+ }), 5000);
91
+ }, timeoutS * 1000);
92
+
93
+ // 취소 폴링(1s) — 이벤트 흐름이 뜸할 때도 반응
94
+ const cancelTimer = setInterval(() => {
95
+ if (!cancelled && shouldCancel()) {
96
+ cancelled = true;
97
+ killTree(child);
98
+ }
99
+ }, 1000);
100
+
101
+ const rl = createInterface({ input: child.stdout });
102
+ rl.on('line', (line) => {
103
+ line = line.trim();
104
+ if (!line) return;
105
+ if (!cancelled && shouldCancel()) {
106
+ cancelled = true;
107
+ killTree(child);
108
+ return;
109
+ }
110
+ let ev;
111
+ try {
112
+ ev = JSON.parse(line);
113
+ } catch {
114
+ return; // stream-json 이 아니면 무시(경고 스트림 등)
115
+ }
116
+ if (ev.type === 'system' && ev.subtype === 'init' && ev.session_id) {
117
+ sessionId = ev.session_id;
118
+ onSessionId?.(ev.session_id);
119
+ return;
120
+ }
121
+ if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
122
+ for (const block of ev.message.content) {
123
+ if (block.type === 'text' && block.text) {
124
+ onEvent?.({ kind: 'message', data: { text: block.text.slice(0, TEXT_MAX) } });
125
+ } else if (block.type === 'tool_use') {
126
+ onEvent?.({
127
+ kind: 'tool',
128
+ data: { name: block.name, brief: JSON.stringify(block.input ?? {}).slice(0, BRIEF_MAX) },
129
+ });
130
+ }
131
+ }
132
+ return;
133
+ }
134
+ if (ev.type === 'result') {
135
+ finalText = ev.result ?? finalText;
136
+ usage = ev.usage
137
+ ? {
138
+ input_tokens: ev.usage.input_tokens ?? 0,
139
+ output_tokens: ev.usage.output_tokens ?? 0,
140
+ total_tokens: (ev.usage.input_tokens ?? 0) + (ev.usage.output_tokens ?? 0),
141
+ }
142
+ : usage;
143
+ if (ev.session_id) sessionId = ev.session_id;
144
+ return;
145
+ }
146
+ });
147
+
148
+ child.stderr?.on('data', (chunk) => {
149
+ stderrTail = `${stderrTail}${chunk}`.slice(-2000);
150
+ });
151
+
152
+ child.on('error', (e) => {
153
+ finish({ ok: false, finalText, sessionId, usage, error: `claude 실행 실패: ${e.message}` });
154
+ });
155
+
156
+ child.on('close', (code) => {
157
+ if (cancelled) {
158
+ finish({ ok: false, cancelled: true, finalText, sessionId, usage, error: 'cancelled' });
159
+ } else if (timedOut) {
160
+ return; // 타임아웃 finish 가 처리
161
+ } else if (code === 0) {
162
+ finish({ ok: true, finalText, sessionId, usage, error: null });
163
+ } else {
164
+ finish({
165
+ ok: false, finalText, sessionId, usage,
166
+ error: stderrTail.trim() || `claude 종료 코드 ${code}`,
167
+ });
168
+ }
169
+ });
170
+ });
171
+ }
172
+
173
+ /** 프로세스 그룹 종료(SIGTERM) — 자식 셸까지 남지 않게 detached 옵션은 쓰지 않고 프로세스만. */
174
+ function killTree(child) {
175
+ try {
176
+ child.kill('SIGTERM');
177
+ setTimeout(() => {
178
+ try {
179
+ child.kill('SIGKILL');
180
+ } catch { /* already dead */ }
181
+ }, 5000).unref?.();
182
+ } catch { /* already dead */ }
183
+ }
package/git.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // 작업 폴더 git 조작 — 결정적 조작은 러너가(LLM 아님): 브랜치 격리·병합·클린 트리 검증.
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { basename } from 'node:path';
5
+ import { existsSync, statSync } from 'node:fs';
6
+
7
+ const exec = promisify(execFile);
8
+
9
+ export class GitError extends Error {}
10
+
11
+ async function run(cwd, ...args) {
12
+ try {
13
+ const { stdout } = await exec('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 });
14
+ return stdout.trim();
15
+ } catch (e) {
16
+ throw new GitError(`git ${args.join(' ')} 실패: ${(e.stderr || e.message || '').trim().slice(0, 500)}`);
17
+ }
18
+ }
19
+
20
+ /** 경로 확인(프로젝트 생성 다이얼로그) — 존재·git 저장소·브랜치·클린 트리·레포명 조사.
21
+ * preflight 와 달리 절대 throw 하지 않는다: 결과 객체(ok/error)를 서버에 그대로 보고. */
22
+ export async function probeFolder(cwd) {
23
+ const base = { path: cwd, name: basename(cwd) };
24
+ try {
25
+ if (!existsSync(cwd)) return { ...base, ok: false, error: '이 경로는 머신에 존재하지 않습니다.', code: 'not_found' };
26
+ if (!statSync(cwd).isDirectory()) return { ...base, ok: false, error: '폴더가 아닙니다(파일).', code: 'not_dir' };
27
+ const inside = await run(cwd, 'rev-parse', '--is-inside-work-tree');
28
+ if (inside !== 'true') return { ...base, ok: false, error: 'git 저장소가 아닙니다.', code: 'not_git' };
29
+ const branch = await run(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
30
+ let remote = null;
31
+ try {
32
+ remote = await run(cwd, 'remote', 'get-url', 'origin');
33
+ } catch {
34
+ /* origin 없음 — 로컬 전용 저장소 */
35
+ }
36
+ const dirty = await run(cwd, 'status', '--porcelain');
37
+ const dirtyCount = dirty ? dirty.split('\n').filter(Boolean).length : 0;
38
+ return { ...base, ok: true, branch, remote, clean: dirtyCount === 0, dirty_count: dirtyCount };
39
+ } catch (e) {
40
+ return { ...base, ok: false, error: e.message?.slice(0, 300) || String(e), code: 'error' };
41
+ }
42
+ }
43
+
44
+ /** 실행 전 검증 — 폴더 존재·git 저장소·클린 워킹트리(사용자 미커밋 변경 보호). */
45
+ export async function preflight(cwd) {
46
+ if (!existsSync(cwd)) throw new GitError(`프로젝트 폴더가 없습니다: ${cwd}`);
47
+ const inside = await run(cwd, 'rev-parse', '--is-inside-work-tree');
48
+ if (inside !== 'true') throw new GitError('git 저장소가 아닙니다(작업 브랜치·병합 불가).');
49
+ const dirty = await run(cwd, 'status', '--porcelain');
50
+ if (dirty) throw new GitError('커밋되지 않은 변경이 있어 작업을 시작할 수 없습니다 — 폴더를 정리(커밋/stash)한 뒤 다시 시도하세요.');
51
+ }
52
+
53
+ export async function currentBranch(cwd) {
54
+ return run(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
55
+ }
56
+
57
+ export function taskBranchName(taskId) {
58
+ return `e2e-task/${String(taskId).slice(0, 8)}`;
59
+ }
60
+
61
+ /** 작업 브랜치 생성(있으면 체크아웃) — implement 단계 전 1회. */
62
+ export async function ensureTaskBranch(cwd, taskId) {
63
+ const branch = taskBranchName(taskId);
64
+ const branches = await run(cwd, 'branch', '--list', branch);
65
+ if (branches) {
66
+ await run(cwd, 'checkout', branch);
67
+ } else {
68
+ await run(cwd, 'checkout', '-b', branch);
69
+ }
70
+ return branch;
71
+ }
72
+
73
+ /** review 통과 후 base 로 병합(--no-ff — 작업 단위 커밋 흔적 보존). 충돌 시 GitError. */
74
+ export async function mergeToBase(cwd, base, branch) {
75
+ await run(cwd, 'checkout', base);
76
+ try {
77
+ await run(cwd, 'merge', '--no-ff', branch, '-m', `merge ${branch} (e2e 작업 완료)`);
78
+ } catch (e) {
79
+ // 충돌 남은 상태 정리 — 다음 시도/수동 개입이 깨끗한 트리에서 시작되게.
80
+ try {
81
+ await run(cwd, 'merge', '--abort');
82
+ } catch {
83
+ /* already resolved/aborted */
84
+ }
85
+ throw new GitError(`base(${base}) 병합 충돌: 수동 해결이 필요합니다. 브랜치 \`${branch}\` 참조. ${e.message}`);
86
+ }
87
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@suseona/runner",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "suseona e2e 작업 에이전트 로컬 러너 — 내 컴퓨터의 Claude Code 로 구현→테스트→리뷰→배포를 자동 수행",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "private": false,
8
+ "bin": {
9
+ "runner": "./runner.mjs",
10
+ "e2e-runner": "./runner.mjs"
11
+ },
12
+ "engines": {
13
+ "node": ">=18.17"
14
+ },
15
+ "files": [
16
+ "runner.mjs",
17
+ "api.mjs",
18
+ "claude.mjs",
19
+ "git.mjs",
20
+ "README.md"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/variouscafe/e2e",
25
+ "directory": "runner"
26
+ }
27
+ }
package/runner.mjs ADDED
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+ // e2e 작업 에이전트 로컬 러너 — 사용자 머신에서 Claude Code 파이프라인을 실행하는 상주 데몬.
3
+ //
4
+ // 역할: ai-backend(/ai/e2e/agent/runner/*) 폴링 → 단계 클레임 → 프로젝트 폴더에서
5
+ // claude -p(또는 deploy_command) 실행 → 이벤트 스트리밍 → 종결 보고. 판단(다음 단계)은
6
+ // 서버의 무상태 상태머신이 담당 — 러너는 충실한 실행기다.
7
+ //
8
+ // 인증: 머신 개인 토큰 E2E_RUNNER_TOKEN(erun_… — 웹 '머신' 페이지에서 발급).
9
+ // 사전 조건: Node ≥18, git, Claude Code CLI 설치+로그인(claude login).
10
+ //
11
+ // 실행: npx @suseona/runner 또는 node runner.mjs — env 는 셸 export 또는
12
+ // runner/.env.local(KEY=VALUE, gitignored).
13
+ import { hostname } from 'node:os';
14
+ import { spawn } from 'node:child_process';
15
+ import { readFileSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { dirname, join } from 'node:path';
18
+ import { api, runnerIdFromToken, sleep } from './api.mjs';
19
+ import { parsePhaseMarker, probeClaude, runClaude } from './claude.mjs';
20
+ import {
21
+ currentBranch, ensureTaskBranch, GitError, mergeToBase, preflight, probeFolder, taskBranchName,
22
+ } from './git.mjs';
23
+
24
+ // runner/.env.local 자동 로드 — 이미 설정된 env 는 덮지 않는다(셀 export 우선).
25
+ {
26
+ const envPath = join(dirname(fileURLToPath(import.meta.url)), '.env.local');
27
+ try {
28
+ for (const line of readFileSync(envPath, 'utf8').split('\n')) {
29
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
30
+ if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
31
+ }
32
+ } catch {
33
+ /* .env.local 없음 — 정상 */
34
+ }
35
+ }
36
+
37
+ const RUNNER_VERSION = (() => {
38
+ try {
39
+ return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
40
+ } catch {
41
+ return '0.0.0';
42
+ }
43
+ })();
44
+
45
+ const POLL_S = num(process.env.POLL_S, 3);
46
+ const HEARTBEAT_S = num(process.env.HEARTBEAT_S, 30);
47
+ const FLUSH_S = num(process.env.FLUSH_S, 1);
48
+ const STEP_TIMEOUT_S = num(process.env.RUNNER_STEP_TIMEOUT_S, 2700); // 서버 watchdog(3600s)보다 작게
49
+ const DEPLOY_TIMEOUT_S = num(process.env.DEPLOY_TIMEOUT_S, 600);
50
+ const MAX_TURNS_IMPLEMENT = num(process.env.MAX_TURNS_IMPLEMENT, 150);
51
+ const MAX_TURNS_OTHER = num(process.env.MAX_TURNS_OTHER, 60);
52
+ const BUSY_PROBE_POLL_S = 10; // 단계 실행 중 경로 확인(probe) 폴링 주기
53
+
54
+ function num(v, dflt) {
55
+ const n = Number(v);
56
+ return Number.isFinite(n) && n > 0 ? n : dflt;
57
+ }
58
+
59
+ // ─── 공유 상태 ────────────────────────────────────────────────────────────────
60
+
61
+ const state = {
62
+ runnerId: null, // 토큰에서 유도(서버 발급 머신 id)
63
+ claudeBin: null, // probe 결과(null=미설치)
64
+ cancelTaskIds: [], // 서버 응답마다 갱신 — claude 세션 취소 플래그로 사용
65
+ currentTaskId: null,
66
+ currentStepId: null,
67
+ busy: false,
68
+ lastBusyProbeAt: 0,
69
+ };
70
+
71
+ function mergeCancels(res) {
72
+ if (Array.isArray(res?.cancel_task_ids)) state.cancelTaskIds = res.cancel_task_ids;
73
+ }
74
+
75
+ function log(...args) {
76
+ console.log(new Date().toISOString(), ...args);
77
+ }
78
+
79
+ // ─── 경로 확인(probe) — 프로젝트 생성 다이얼로그 '경로 확인' 버튼 ─────────────
80
+
81
+ async function runProbe({ probe_id, folder_path }) {
82
+ try {
83
+ const result = await probeFolder(folder_path);
84
+ await api('/ai/e2e/agent/runner/probe_result', { probe_id, result });
85
+ log(`probe 완료: ${folder_path} → ${result.ok ? 'ok' : result.error}`);
86
+ } catch (e) {
87
+ log(`probe 실패(probe_id=${probe_id}): ${e.message}`);
88
+ }
89
+ }
90
+
91
+ function handleProbe(res) {
92
+ if (res?.probe?.probe_id && res.probe.folder_path) void runProbe(res.probe);
93
+ }
94
+
95
+ // ─── 이벤트 버퍼(1s 플러시) ───────────────────────────────────────────────────
96
+
97
+ class EventBuffer {
98
+ constructor(taskId) {
99
+ this.taskId = taskId;
100
+ this.events = [];
101
+ this.taskMeta = null; // init 수신 즉시 1회 전송(base_branch/branch/session_id)
102
+ this.timer = null;
103
+ }
104
+
105
+ start() {
106
+ this.timer = setInterval(() => this.flush(), FLUSH_S * 1000);
107
+ }
108
+
109
+ async stop() {
110
+ clearInterval(this.timer);
111
+ await this.flush();
112
+ }
113
+
114
+ push(stepId, kind, data) {
115
+ this.events.push({ step_id: stepId, kind, data });
116
+ if (this.events.length >= 100) this.flush().catch(() => {});
117
+ }
118
+
119
+ async flush() {
120
+ const events = this.events.splice(0);
121
+ const taskMeta = this.taskMeta;
122
+ this.taskMeta = null;
123
+ if (!events.length && !taskMeta) return;
124
+ try {
125
+ const res = await api('/ai/e2e/agent/runner/events', {
126
+ task_id: this.taskId, events, ...(taskMeta ? { task_meta: taskMeta } : {}),
127
+ });
128
+ mergeCancels(res);
129
+ } catch (e) {
130
+ log(`event flush 실패(${e.message}) — ${events.length}건 유실`);
131
+ }
132
+ }
133
+ }
134
+
135
+ // ─── 단계 실행 ────────────────────────────────────────────────────────────────
136
+
137
+ async function handleStep({ step, task, project }) {
138
+ const buf = new EventBuffer(task.id);
139
+ buf.start();
140
+ state.currentTaskId = task.id;
141
+ state.currentStepId = step.id;
142
+ const cancelled = () => state.cancelTaskIds.includes(task.id);
143
+
144
+ try {
145
+ // 시작 알림 — 서버가 step→running·task 진행률 갱신(상태머신 트리거)
146
+ buf.push(step.id, 'status', { event: 'phase_started', phase: step.phase });
147
+
148
+ let result;
149
+ if (step.phase === 'implement') {
150
+ result = await runImplement({ step, task, project, buf, cancelled });
151
+ } else if (step.phase === 'deploy') {
152
+ result = await runDeploy({ step, task, project, buf, cancelled });
153
+ } else {
154
+ // test / remediate / review — 서버 프롬프트(prompt_md)·세션(resume) 그대로 실행.
155
+ result = await runPhase({ step, task, project, buf, cancelled });
156
+ }
157
+
158
+ const cancelledNow = cancelled() || result?.cancelled;
159
+ await api('/ai/e2e/agent/runner/step_result', {
160
+ step_id: step.id,
161
+ status: cancelledNow ? 'fail' : result.ok ? 'done' : 'fail',
162
+ result_md: result.finalText ?? null,
163
+ result_flag: result.flag ?? null, // null이면 서버 폴백(implement=pass, 나머지=fail)
164
+ session_id: result.sessionId ?? null,
165
+ error: cancelledNow ? 'cancelled' : (result.error ?? null),
166
+ cancelled: Boolean(cancelledNow),
167
+ usage: result.usage ?? undefined,
168
+ });
169
+ log(`단계 종결: ${task.id.slice(0, 8)}/${step.phase} → ${cancelledNow ? 'cancelled' : result.ok ? 'done' : 'fail'}`);
170
+ } catch (e) {
171
+ // 프리플라이트 실패·예상치 못한 오류 — 단계 실패로 보고(상태머신이 task 수렴).
172
+ log(`단계 오류: ${e.message}`);
173
+ await api('/ai/e2e/agent/runner/step_result', {
174
+ step_id: step.id, status: 'fail', error: String(e.message).slice(0, 500),
175
+ cancelled: cancelled(),
176
+ }).catch(() => {});
177
+ } finally {
178
+ await buf.stop();
179
+ state.currentTaskId = null;
180
+ state.currentStepId = null;
181
+ }
182
+ }
183
+
184
+ /** implement — git 프리플라이트 → 작업 브랜치 → claude(신규 세션). base_branch 를 서버에 보고. */
185
+ async function runImplement({ step, task, project, buf, cancelled }) {
186
+ await preflight(project.folder_path);
187
+ const base = await currentBranch(project.folder_path);
188
+ const branch = await ensureTaskBranch(project.folder_path, task.id);
189
+ buf.taskMeta = { base_branch: base, branch };
190
+ log(`브랜치 준비: ${branch} (base ${base})`);
191
+ return runPhase({ step, task, project, buf, cancelled, extraMeta: buf.taskMeta });
192
+ }
193
+
194
+ /** test/remediate/review 공통 — prompt_md + resume 세션. */
195
+ async function runPhase({ step, task, project, buf, cancelled, extraMeta }) {
196
+ if (!state.claudeBin) throw new Error('claude CLI 를 찾을 수 없습니다 — CLAUDE_BIN 확인 후 러너를 재시작하세요.');
197
+ return runClaude({
198
+ cwd: project.folder_path,
199
+ prompt: step.prompt_md,
200
+ resumeSessionId: step.session_id ?? null,
201
+ maxTurns: step.phase === 'implement' ? MAX_TURNS_IMPLEMENT : MAX_TURNS_OTHER,
202
+ timeoutS: STEP_TIMEOUT_S,
203
+ onEvent: (ev) => buf.push(step.id, ev.kind, ev.data),
204
+ shouldCancel: cancelled,
205
+ onSessionId: (sessionId) => {
206
+ if (extraMeta) buf.taskMeta = { ...extraMeta, session_id: sessionId };
207
+ else buf.taskMeta = { session_id: sessionId };
208
+ },
209
+ }).then((r) => ({ ...r, flag: parsePhaseMarker(r.finalText) }));
210
+ }
211
+
212
+ /** deploy — 항상 base 병합 먼저(결정적 조작) → deploy_command 직접 실행 또는 claude 배포. */
213
+ async function runDeploy({ step, task, project, buf, cancelled }) {
214
+ const base = task.base_branch || 'main';
215
+ const branch = task.branch || taskBranchName(task.id);
216
+ await mergeToBase(project.folder_path, base, branch);
217
+ log(`병합 완료: ${branch} → ${base}`);
218
+ buf.push(step.id, 'status', { event: 'merged', base, branch });
219
+
220
+ if (project.deploy_command) {
221
+ const output = await new Promise((resolve) => {
222
+ const child = spawn(project.deploy_command, {
223
+ cwd: project.folder_path, shell: true, env: process.env,
224
+ });
225
+ let out = '';
226
+ const onData = (c) => {
227
+ out = `${out}${c}`.slice(-8000);
228
+ };
229
+ child.stdout.on('data', onData);
230
+ child.stderr.on('data', onData);
231
+ const cancelTimer = setInterval(() => {
232
+ if (cancelled()) child.kill('SIGTERM');
233
+ }, 1000);
234
+ const killer = setTimeout(() => child.kill('SIGKILL'), DEPLOY_TIMEOUT_S * 1000);
235
+ child.on('close', (code) => {
236
+ clearInterval(cancelTimer);
237
+ clearTimeout(killer);
238
+ resolve({ code, out });
239
+ });
240
+ });
241
+ const ok = output.code === 0 && !cancelled();
242
+ buf.push(step.id, 'result', { name: 'deploy_command', exit: output.code, tail: output.out.slice(-1000) });
243
+ log(`배포 커맨드 종료 코드 ${output.code}`);
244
+ return {
245
+ ok,
246
+ cancelled: cancelled(),
247
+ finalText: `# 배포 커맨드 결과\n\n종료 코드: ${output.code}\n\n\`\`\`\n${output.out.trim() || '(출력 없음)'}\n\`\`\`\n\nPHASE_RESULT: ${ok ? 'PASS' : 'FAIL'}`,
248
+ flag: ok ? 'pass' : 'fail',
249
+ sessionId: step.session_id ?? null,
250
+ };
251
+ }
252
+
253
+ // deploy_command 없음 — Claude 가 프로젝트 관례(CLAUDE.md/스크립트)를 파악해 배포.
254
+ return runPhase({ step, task, project, buf, cancelled });
255
+ }
256
+
257
+ // ─── 메인 루프 ────────────────────────────────────────────────────────────────
258
+
259
+ async function heartbeat() {
260
+ try {
261
+ const res = await api('/ai/e2e/agent/runner/heartbeat', {
262
+ version: RUNNER_VERSION,
263
+ platform: process.platform,
264
+ current_task_id: state.currentTaskId,
265
+ current_step_id: state.currentStepId,
266
+ ok: Boolean(state.claudeBin),
267
+ reason: state.claudeBin ? null : 'claude CLI not found (CLAUDE_BIN 확인 필요)',
268
+ });
269
+ mergeCancels(res);
270
+ } catch (e) {
271
+ log(`heartbeat 실패: ${e.message}`);
272
+ }
273
+ }
274
+
275
+ async function main() {
276
+ const token = (process.env.E2E_RUNNER_TOKEN || '').trim();
277
+ if (!token) {
278
+ console.error('필수 env 누락: E2E_RUNNER_TOKEN — 웹 "머신" 페이지에서 머신을 등록하고 발급된 토큰(erun_…)을 설정하세요.');
279
+ process.exit(1);
280
+ }
281
+ state.runnerId = runnerIdFromToken(token);
282
+ if (!state.runnerId) {
283
+ console.error('E2E_RUNNER_TOKEN 형식이 잘못되었습니다 — 웹에서 발급된 전체 토큰(erun_…)을 그대로 붙여넣세요.');
284
+ process.exit(1);
285
+ }
286
+ state.claudeBin = await probeClaude();
287
+ console.log(`e2e 러너 시작 — 머신 id ${state.runnerId} (${hostname()})`);
288
+ console.log(`ai-backend: ${process.env.AI_BASE_URL || 'https://ai.suseona.com'} / runner v${RUNNER_VERSION}`);
289
+ console.log(`claude: ${state.claudeBin ?? '미발견(heartbeat ok:false 알림)'}`);
290
+ setInterval(heartbeat, HEARTBEAT_S * 1000);
291
+ heartbeat();
292
+
293
+ for (;;) {
294
+ if (!state.busy) {
295
+ try {
296
+ const res = await api('/ai/e2e/agent/runner/poll', {});
297
+ mergeCancels(res);
298
+ handleProbe(res);
299
+ if (res.step) {
300
+ state.busy = true;
301
+ try {
302
+ await handleStep(res);
303
+ } finally {
304
+ state.busy = false;
305
+ }
306
+ continue; // 대기 없이 즉시 다음 폴링(연속 작업)
307
+ }
308
+ } catch (e) {
309
+ if (e.status !== 503 && e.status !== 401) log(`poll 실패: ${e.message}`);
310
+ else if (e.status === 503) log(`서버 미구성(E2E_D1_DATABASE_ID): ${e.message}`);
311
+ else if (e.status === 401) {
312
+ console.error('토큰이 거부되었습니다(폐기·오타) — 웹에서 머신을 재등록하고 E2E_RUNNER_TOKEN 을 갱신하세요.');
313
+ process.exit(1);
314
+ }
315
+ }
316
+ } else if (Date.now() - state.lastBusyProbeAt > BUSY_PROBE_POLL_S * 1000) {
317
+ // 단계 실행 중에도 경로 확인(probe)은 응답 가능 — probe_only 폴링(단계 클레임 없음).
318
+ state.lastBusyProbeAt = Date.now();
319
+ try {
320
+ const res = await api('/ai/e2e/agent/runner/poll', { probe_only: true });
321
+ mergeCancels(res);
322
+ handleProbe(res);
323
+ } catch {
324
+ /* probe 폴링 실패는 무시 — 다음 주기 재시도 */
325
+ }
326
+ }
327
+ await sleep(POLL_S * 1000);
328
+ }
329
+ }
330
+
331
+ main().catch((e) => {
332
+ console.error('러너 치명 오류:', e);
333
+ process.exit(1);
334
+ });