coxpit 2.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/.env.example +10 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/coxpit.js +15 -0
- package/package.json +36 -0
- package/src/auth.ts +26 -0
- package/src/board.ts +602 -0
- package/src/config.ts +23 -0
- package/src/db/index.ts +73 -0
- package/src/db/schema.ts +69 -0
- package/src/design.ts +89 -0
- package/src/exec.ts +75 -0
- package/src/hub.ts +21 -0
- package/src/index.ts +14 -0
- package/src/orchestrator.ts +304 -0
- package/src/server.ts +409 -0
- package/src/term.ts +60 -0
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createClient } from '@libsql/client';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/libsql';
|
|
3
|
+
import { config } from '../config';
|
|
4
|
+
import * as schema from './schema';
|
|
5
|
+
|
|
6
|
+
// libSQL(NAPI 프리빌드 = 노드버전 무관). 로컬 파일 모드.
|
|
7
|
+
const client = createClient({ url: `file:${config.dbPath}` });
|
|
8
|
+
|
|
9
|
+
export const db = drizzle(client, { schema });
|
|
10
|
+
|
|
11
|
+
/** 스키마 부트스트랩(멱등). 정식 마이그레이션은 drizzle-kit(추후). */
|
|
12
|
+
export async function ensureSchema(): Promise<void> {
|
|
13
|
+
await client.executeMultiple(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS machines (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
slug TEXT NOT NULL UNIQUE,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
address TEXT NOT NULL DEFAULT '',
|
|
19
|
+
ssh_user TEXT NOT NULL DEFAULT '',
|
|
20
|
+
kind TEXT NOT NULL DEFAULT 'local',
|
|
21
|
+
online INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
last_seen INTEGER
|
|
23
|
+
);
|
|
24
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
machine_id INTEGER NOT NULL,
|
|
27
|
+
path TEXT NOT NULL,
|
|
28
|
+
name TEXT NOT NULL,
|
|
29
|
+
default_branch TEXT NOT NULL DEFAULT 'main'
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS design_captures (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
url TEXT NOT NULL DEFAULT '',
|
|
34
|
+
selector TEXT NOT NULL DEFAULT '',
|
|
35
|
+
html TEXT NOT NULL DEFAULT '',
|
|
36
|
+
css TEXT NOT NULL DEFAULT '',
|
|
37
|
+
note TEXT NOT NULL DEFAULT '',
|
|
38
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
repo_id INTEGER NOT NULL,
|
|
43
|
+
title TEXT NOT NULL,
|
|
44
|
+
prompt TEXT NOT NULL DEFAULT '',
|
|
45
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
46
|
+
design_capture_id INTEGER,
|
|
47
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
48
|
+
);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS agent_runs (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
task_id INTEGER NOT NULL,
|
|
52
|
+
machine_id INTEGER NOT NULL,
|
|
53
|
+
agent TEXT NOT NULL DEFAULT 'claude-code',
|
|
54
|
+
worktree_path TEXT NOT NULL DEFAULT '',
|
|
55
|
+
branch TEXT NOT NULL DEFAULT '',
|
|
56
|
+
tmux_window TEXT NOT NULL DEFAULT '',
|
|
57
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
58
|
+
files_changed INTEGER NOT NULL DEFAULT 0,
|
|
59
|
+
started_at INTEGER,
|
|
60
|
+
ended_at INTEGER,
|
|
61
|
+
exit_summary TEXT NOT NULL DEFAULT ''
|
|
62
|
+
);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS agent_events (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
run_id INTEGER NOT NULL,
|
|
66
|
+
kind TEXT NOT NULL,
|
|
67
|
+
payload TEXT NOT NULL DEFAULT '',
|
|
68
|
+
ts INTEGER DEFAULT (unixepoch())
|
|
69
|
+
);
|
|
70
|
+
`);
|
|
71
|
+
// 기존 DB 마이그레이션(멱등) — tasks.design_capture_id
|
|
72
|
+
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
73
|
+
}
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
|
|
3
|
+
/** 접근 대상 머신. address 빈값 = 로컬(데몬이 도는 머신), 그 외 = 원격(ssh). */
|
|
4
|
+
export const machines = sqliteTable('machines', {
|
|
5
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
6
|
+
slug: text('slug').notNull().unique(),
|
|
7
|
+
name: text('name').notNull(),
|
|
8
|
+
address: text('address').notNull().default(''), // tailscale/LAN host; '' = local
|
|
9
|
+
sshUser: text('ssh_user').notNull().default(''),
|
|
10
|
+
kind: text('kind').notNull().default('local'), // local | remote
|
|
11
|
+
online: integer('online', { mode: 'boolean' }).notNull().default(false),
|
|
12
|
+
lastSeen: integer('last_seen', { mode: 'timestamp' }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** 머신 위의 git 저장소. */
|
|
16
|
+
export const repos = sqliteTable('repos', {
|
|
17
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
18
|
+
machineId: integer('machine_id').notNull(),
|
|
19
|
+
path: text('path').notNull(),
|
|
20
|
+
name: text('name').notNull(),
|
|
21
|
+
defaultBranch: text('default_branch').notNull().default('main'),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Design Mode 캡처 — 북마클릿 인스펙터가 보낸 UI 요소 컨텍스트. */
|
|
25
|
+
export const designCaptures = sqliteTable('design_captures', {
|
|
26
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
27
|
+
url: text('url').notNull().default(''),
|
|
28
|
+
selector: text('selector').notNull().default(''),
|
|
29
|
+
html: text('html').notNull().default(''),
|
|
30
|
+
css: text('css').notNull().default(''),
|
|
31
|
+
note: text('note').notNull().default(''),
|
|
32
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** 하나의 요청. 여러 AgentRun 으로 병렬 시도됨. */
|
|
36
|
+
export const tasks = sqliteTable('tasks', {
|
|
37
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
38
|
+
repoId: integer('repo_id').notNull(),
|
|
39
|
+
title: text('title').notNull(),
|
|
40
|
+
prompt: text('prompt').notNull().default(''),
|
|
41
|
+
status: text('status').notNull().default('open'), // open | done
|
|
42
|
+
designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
|
|
43
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** 에이전트 1 = worktree 1 = tmux 창 1 = 브랜치 1. 태스크의 한 병렬 시도. */
|
|
47
|
+
export const agentRuns = sqliteTable('agent_runs', {
|
|
48
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
49
|
+
taskId: integer('task_id').notNull(),
|
|
50
|
+
machineId: integer('machine_id').notNull(),
|
|
51
|
+
agent: text('agent').notNull().default('claude-code'),
|
|
52
|
+
worktreePath: text('worktree_path').notNull().default(''),
|
|
53
|
+
branch: text('branch').notNull().default(''),
|
|
54
|
+
tmuxWindow: text('tmux_window').notNull().default(''),
|
|
55
|
+
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
56
|
+
filesChanged: integer('files_changed').notNull().default(0),
|
|
57
|
+
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
58
|
+
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
|
59
|
+
exitSummary: text('exit_summary').notNull().default(''),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** 감사·재생용 이벤트(라이브는 WebSocket). */
|
|
63
|
+
export const agentEvents = sqliteTable('agent_events', {
|
|
64
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
65
|
+
runId: integer('run_id').notNull(),
|
|
66
|
+
kind: text('kind').notNull(), // output | status | diff | prompt
|
|
67
|
+
payload: text('payload').notNull().default(''),
|
|
68
|
+
ts: integer('ts', { mode: 'timestamp' }),
|
|
69
|
+
});
|
package/src/design.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Design Mode 북마클릿 인스펙터 — 사용자의 개발 중 웹앱에 주입되어
|
|
2
|
+
// 요소 호버 하이라이트 → 클릭 캡처(selector·HTML·computed CSS) → 데몬으로 POST.
|
|
3
|
+
// 자신의 <script src> 에서 엔드포인트와 캡처 키를 읽는다.
|
|
4
|
+
export const BOOKMARKLET_JS = `(function(){
|
|
5
|
+
if (window.__coxpitInspector) { window.__coxpitInspector.stop(); return; }
|
|
6
|
+
var script = document.currentScript || Array.from(document.scripts).find(function(s){return s.src.indexOf('/design/bookmarklet.js')>-1;});
|
|
7
|
+
if (!script) { alert('coxpit: cannot locate script origin'); return; }
|
|
8
|
+
var u = new URL(script.src);
|
|
9
|
+
var endpoint = u.origin + '/api/design/capture' + (u.search || '');
|
|
10
|
+
|
|
11
|
+
var box = document.createElement('div');
|
|
12
|
+
box.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483646;border:2px solid #4ec9b0;background:rgba(78,201,176,.08);border-radius:3px;transition:all .06s;display:none';
|
|
13
|
+
var tag = document.createElement('div');
|
|
14
|
+
tag.style.cssText = 'position:fixed;z-index:2147483647;background:#0b0d12;color:#4ec9b0;font:11px ui-monospace,monospace;padding:3px 8px;border-radius:4px;pointer-events:none;display:none;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:1px solid #4ec9b0';
|
|
15
|
+
document.documentElement.appendChild(box); document.documentElement.appendChild(tag);
|
|
16
|
+
|
|
17
|
+
function cssPath(el){
|
|
18
|
+
var parts = [];
|
|
19
|
+
while (el && el.nodeType === 1 && parts.length < 6) {
|
|
20
|
+
var p = el.tagName.toLowerCase();
|
|
21
|
+
if (el.id) { parts.unshift(p + '#' + el.id); break; }
|
|
22
|
+
var cls = (el.className && typeof el.className === 'string') ? el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
|
|
23
|
+
if (cls) p += '.' + cls;
|
|
24
|
+
var parent = el.parentElement;
|
|
25
|
+
if (parent) {
|
|
26
|
+
var sibs = Array.from(parent.children).filter(function(c){return c.tagName===el.tagName;});
|
|
27
|
+
if (sibs.length > 1) p += ':nth-of-type(' + (sibs.indexOf(el)+1) + ')';
|
|
28
|
+
}
|
|
29
|
+
parts.unshift(p); el = parent;
|
|
30
|
+
}
|
|
31
|
+
return parts.join(' > ');
|
|
32
|
+
}
|
|
33
|
+
var CSS_PROPS = ['display','position','width','height','margin','padding','color','background-color','font-family','font-size','font-weight','line-height','border','border-radius','box-shadow','flex-direction','justify-content','align-items','gap','grid-template-columns','text-align','opacity','overflow','z-index'];
|
|
34
|
+
function styleOf(el){
|
|
35
|
+
var cs = getComputedStyle(el), out = {};
|
|
36
|
+
CSS_PROPS.forEach(function(k){ var v = cs.getPropertyValue(k); if (v) out[k]=v; });
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function toast(msg, ok){
|
|
40
|
+
var t = document.createElement('div');
|
|
41
|
+
t.textContent = msg;
|
|
42
|
+
t.style.cssText = 'position:fixed;top:16px;right:16px;z-index:2147483647;background:#0b0d12;color:'+(ok?'#4ec9b0':'#e25b67')+';font:12px ui-monospace,monospace;padding:9px 14px;border-radius:7px;border:1px solid '+(ok?'#4ec9b0':'#e25b67');
|
|
43
|
+
document.documentElement.appendChild(t);
|
|
44
|
+
setTimeout(function(){ t.remove(); }, 2200);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var current = null;
|
|
48
|
+
function onMove(e){
|
|
49
|
+
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
50
|
+
if (!el || el===box || el===tag || el===document.documentElement || el===document.body) return;
|
|
51
|
+
current = el;
|
|
52
|
+
var r = el.getBoundingClientRect();
|
|
53
|
+
box.style.display='block';
|
|
54
|
+
box.style.left=r.left+'px'; box.style.top=r.top+'px'; box.style.width=r.width+'px'; box.style.height=r.height+'px';
|
|
55
|
+
tag.style.display='block';
|
|
56
|
+
tag.style.left=Math.max(4,r.left)+'px'; tag.style.top=Math.max(4,r.top-26)+'px';
|
|
57
|
+
tag.textContent = cssPath(el) + ' · ' + Math.round(r.width)+'×'+Math.round(r.height);
|
|
58
|
+
}
|
|
59
|
+
function onClick(e){
|
|
60
|
+
if (!current) return;
|
|
61
|
+
e.preventDefault(); e.stopPropagation();
|
|
62
|
+
var el = current;
|
|
63
|
+
var body = {
|
|
64
|
+
url: location.href,
|
|
65
|
+
selector: cssPath(el),
|
|
66
|
+
html: (el.outerHTML||'').slice(0, 4000),
|
|
67
|
+
css: JSON.stringify(styleOf(el), null, 1).slice(0, 3000),
|
|
68
|
+
note: document.title,
|
|
69
|
+
};
|
|
70
|
+
fetch(endpoint, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) })
|
|
71
|
+
.then(function(r){ if(!r.ok) throw new Error(r.status); toast('coxpit: captured ' + body.selector, true); })
|
|
72
|
+
.catch(function(err){ toast('coxpit: capture failed ('+err.message+')', false); });
|
|
73
|
+
stop();
|
|
74
|
+
}
|
|
75
|
+
function onKey(e){ if (e.key==='Escape') stop(); }
|
|
76
|
+
function stop(){
|
|
77
|
+
document.removeEventListener('mousemove', onMove, true);
|
|
78
|
+
document.removeEventListener('click', onClick, true);
|
|
79
|
+
document.removeEventListener('keydown', onKey, true);
|
|
80
|
+
box.remove(); tag.remove();
|
|
81
|
+
window.__coxpitInspector = null;
|
|
82
|
+
}
|
|
83
|
+
document.addEventListener('mousemove', onMove, true);
|
|
84
|
+
document.addEventListener('click', onClick, true);
|
|
85
|
+
document.addEventListener('keydown', onKey, true);
|
|
86
|
+
window.__coxpitInspector = { stop: stop };
|
|
87
|
+
toast('coxpit: click an element to capture · Esc to exit', true);
|
|
88
|
+
})();
|
|
89
|
+
`;
|
package/src/exec.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execFile, spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { config } from './config';
|
|
3
|
+
|
|
4
|
+
export interface RunResult {
|
|
5
|
+
ok: boolean; // 프로세스가 exit 0 인가
|
|
6
|
+
code: number; // exit code (-1 = spawn/timeout 실패)
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 단일 프로세스 실행(promise). shell 없음 — 인자는 그대로 전달. */
|
|
12
|
+
function run(file: string, args: string[], timeoutMs = 12000): Promise<RunResult> {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
execFile(file, args, { timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
15
|
+
if (!err) return resolve({ ok: true, code: 0, stdout: String(stdout), stderr: String(stderr) });
|
|
16
|
+
const code = typeof (err as NodeJS.ErrnoException).code === 'number'
|
|
17
|
+
? (err as unknown as { code: number }).code
|
|
18
|
+
: -1;
|
|
19
|
+
resolve({ ok: false, code, stdout: String(stdout), stderr: String(stderr) });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** POSIX 싱글쿼트 이스케이프 — 사용자 경로를 셸 커맨드에 안전하게 삽입. */
|
|
25
|
+
export function shq(s: string): string {
|
|
26
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MachineTarget {
|
|
30
|
+
slug: string;
|
|
31
|
+
kind: string;
|
|
32
|
+
address: string;
|
|
33
|
+
sshUser: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isLocal(m: MachineTarget): boolean {
|
|
37
|
+
return m.kind === 'local' || m.address === '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 머신에서 셸 커맨드 1줄 실행.
|
|
42
|
+
* 로컬 → `sh -c`, 원격 → `ssh [-i key] user@addr <cmd>`.
|
|
43
|
+
* BatchMode=yes 라 비밀번호 프롬프트로 매달리지 않음(키/에이전트 없으면 즉시 실패).
|
|
44
|
+
*/
|
|
45
|
+
export async function runShellOn(m: MachineTarget, shellCmd: string, timeoutMs = 12000): Promise<RunResult> {
|
|
46
|
+
if (isLocal(m)) return run('sh', ['-c', shellCmd], timeoutMs);
|
|
47
|
+
const args: string[] = [
|
|
48
|
+
'-o', 'BatchMode=yes',
|
|
49
|
+
'-o', 'ConnectTimeout=6',
|
|
50
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
51
|
+
];
|
|
52
|
+
if (config.sshKey) args.push('-i', config.sshKey);
|
|
53
|
+
const target = m.sshUser ? `${m.sshUser}@${m.address}` : m.address;
|
|
54
|
+
args.push(target, shellCmd);
|
|
55
|
+
return run('ssh', args, timeoutMs);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 스트리밍 실행 — 자식 프로세스를 반환(stdout/stderr pipe).
|
|
60
|
+
* 오케스트레이터가 stdout 라인을 실시간 파싱하는 용도.
|
|
61
|
+
*/
|
|
62
|
+
export function spawnShellOn(m: MachineTarget, shellCmd: string): ChildProcess {
|
|
63
|
+
// detached → 자체 프로세스 그룹. stop 시 그룹 전체(-pid) SIGTERM 으로
|
|
64
|
+
// sh 의 손자(실제 에이전트)까지 확실히 종료.
|
|
65
|
+
if (isLocal(m)) return spawn('sh', ['-c', shellCmd], { stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
66
|
+
const args: string[] = [
|
|
67
|
+
'-o', 'BatchMode=yes',
|
|
68
|
+
'-o', 'ConnectTimeout=6',
|
|
69
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
70
|
+
];
|
|
71
|
+
if (config.sshKey) args.push('-i', config.sshKey);
|
|
72
|
+
const target = m.sshUser ? `${m.sshUser}@${m.address}` : m.address;
|
|
73
|
+
args.push(target, shellCmd);
|
|
74
|
+
return spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
75
|
+
}
|
package/src/hub.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// 라이브 이벤트 팬아웃 — /ws 소켓과 오케스트레이터를 잇는 초경량 허브.
|
|
2
|
+
export interface Sink {
|
|
3
|
+
send: (data: string) => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const sinks = new Set<Sink>();
|
|
7
|
+
|
|
8
|
+
export function addSink(s: Sink): void {
|
|
9
|
+
sinks.add(s);
|
|
10
|
+
}
|
|
11
|
+
export function removeSink(s: Sink): void {
|
|
12
|
+
sinks.delete(s);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 모든 구독 소켓에 JSON 이벤트 push. 개별 send 실패는 무시(끊긴 소켓). */
|
|
16
|
+
export function broadcast(obj: unknown): void {
|
|
17
|
+
const data = JSON.stringify(obj);
|
|
18
|
+
for (const s of sinks) {
|
|
19
|
+
try { s.send(data); } catch { /* dead socket */ }
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { config } from './config';
|
|
2
|
+
import { db, ensureSchema } from './db';
|
|
3
|
+
import { machines } from './db/schema';
|
|
4
|
+
import { buildServer } from './server';
|
|
5
|
+
|
|
6
|
+
await ensureSchema();
|
|
7
|
+
|
|
8
|
+
// 첫 실행 시 로컬 머신 시드(데몬이 도는 이 기계).
|
|
9
|
+
if ((await db.select().from(machines)).length === 0) {
|
|
10
|
+
await db.insert(machines).values({ slug: 'local', name: 'This machine', kind: 'local', online: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const app = await buildServer();
|
|
14
|
+
await app.listen({ host: config.host, port: config.port });
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { posix as ppath } from 'node:path';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import type { ChildProcess } from 'node:child_process';
|
|
4
|
+
import { eq } from 'drizzle-orm';
|
|
5
|
+
import { config } from './config';
|
|
6
|
+
import { db } from './db';
|
|
7
|
+
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures } from './db/schema';
|
|
8
|
+
import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
|
|
9
|
+
import { broadcast } from './hub';
|
|
10
|
+
|
|
11
|
+
/** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
|
|
12
|
+
function agentCommand(prompt: string, real: boolean): string {
|
|
13
|
+
if (real) {
|
|
14
|
+
// claude-code headless. stream-json 라인이 stdout 으로 흐른다.
|
|
15
|
+
return `${config.agent.bin} -p ${shq(prompt)} --output-format stream-json --verbose` +
|
|
16
|
+
` --permission-mode ${config.agent.perm}`;
|
|
17
|
+
}
|
|
18
|
+
// 모의: init → assistant → (파일 변경) → result. 진짜 stream-json 라인 형태.
|
|
19
|
+
return [
|
|
20
|
+
`printf '%s\\n' '{"type":"system","subtype":"init","session":"dryrun"}'`,
|
|
21
|
+
`printf '%s\\n' '{"type":"assistant","text":"planning: '"$(printf %s ${shq(prompt)} | cut -c1-40)"'"}'`,
|
|
22
|
+
`printf '%s\\n' 'coxpit dry-run' > COXPIT_DRYRUN.txt`,
|
|
23
|
+
`printf '%s\\n' '{"type":"assistant","text":"edited COXPIT_DRYRUN.txt"}'`,
|
|
24
|
+
`printf '%s\\n' '{"type":"result","subtype":"success","num_turns":1}'`,
|
|
25
|
+
].join('; ');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function recordEvent(runId: number, kind: string, payload: string): Promise<void> {
|
|
29
|
+
await db.insert(agentEvents).values({ runId, kind, payload });
|
|
30
|
+
broadcast({ type: 'event', runId, kind, payload });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInsert>): Promise<void> {
|
|
34
|
+
await db.update(agentRuns).set(patch).where(eq(agentRuns.id, runId));
|
|
35
|
+
broadcast({ type: 'run', runId, ...patch });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 실행 중 run 의 자식 프로세스(stop 용). stoppedRuns = 사용자가 멈춘 run 표식.
|
|
39
|
+
const liveChildren = new Map<number, ChildProcess>();
|
|
40
|
+
const stoppedRuns = new Set<number>();
|
|
41
|
+
|
|
42
|
+
interface RunContext {
|
|
43
|
+
runId: number;
|
|
44
|
+
machine: MachineTarget;
|
|
45
|
+
repoPath: string;
|
|
46
|
+
baseBranch: string;
|
|
47
|
+
prompt: string;
|
|
48
|
+
real: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function loadContext(runId: number): Promise<RunContext | null> {
|
|
52
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
53
|
+
const run = rr[0];
|
|
54
|
+
if (!run) return null;
|
|
55
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
|
|
56
|
+
const task = tr[0];
|
|
57
|
+
if (!task) return null;
|
|
58
|
+
const rp = await db.select().from(repos).where(eq(repos.id, task.repoId)).limit(1);
|
|
59
|
+
const repo = rp[0];
|
|
60
|
+
if (!repo) return null;
|
|
61
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
62
|
+
const m = mr[0];
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
|
|
65
|
+
// Design Mode — 태스크에 캡처가 연결돼 있으면 프롬프트에 컨텍스트 블록 주입
|
|
66
|
+
let prompt = task.prompt;
|
|
67
|
+
if (task.designCaptureId) {
|
|
68
|
+
const dc = (await db.select().from(designCaptures).where(eq(designCaptures.id, task.designCaptureId)).limit(1))[0];
|
|
69
|
+
if (dc) {
|
|
70
|
+
prompt += `\n\n--- DESIGN CONTEXT (captured from the running app) ---\n` +
|
|
71
|
+
`Page: ${dc.url}\nSelector: ${dc.selector}\n` +
|
|
72
|
+
`Element HTML:\n${dc.html}\n` +
|
|
73
|
+
`Computed styles:\n${dc.css}\n` +
|
|
74
|
+
`--- END DESIGN CONTEXT ---`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
runId,
|
|
80
|
+
machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
|
|
81
|
+
repoPath: repo.path,
|
|
82
|
+
baseBranch: repo.defaultBranch,
|
|
83
|
+
prompt,
|
|
84
|
+
real: run.agent === 'claude-code' ? config.agent.real : config.agent.real,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 한 AgentRun 실행: worktree 생성 → tmux 창(best-effort) → 에이전트 spawn →
|
|
90
|
+
* stdout 라인 파싱하며 이벤트 적재 → 종료 시 files_changed 집계 + status 전이.
|
|
91
|
+
* fire-and-forget. 실패는 status='error' 로 봉인.
|
|
92
|
+
*/
|
|
93
|
+
export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
94
|
+
const ctx = await loadContext(runId);
|
|
95
|
+
if (!ctx) return;
|
|
96
|
+
const useReal = real ?? ctx.real;
|
|
97
|
+
|
|
98
|
+
const branch = `coxpit/r${runId}`;
|
|
99
|
+
const wtParent = ppath.join(ppath.dirname(ctx.repoPath), '.coxpit-worktrees');
|
|
100
|
+
const wtPath = ppath.join(wtParent, `r${runId}`);
|
|
101
|
+
const session = `coxpit-r${runId}`;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await setRun(runId, { status: 'preparing', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
|
|
105
|
+
|
|
106
|
+
// 1) worktree 생성(격리 브랜치)
|
|
107
|
+
const prep = await runShellOn(
|
|
108
|
+
ctx.machine,
|
|
109
|
+
`mkdir -p ${shq(wtParent)} && git -C ${shq(ctx.repoPath)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(ctx.baseBranch)}`,
|
|
110
|
+
20000,
|
|
111
|
+
);
|
|
112
|
+
if (!prep.ok) {
|
|
113
|
+
await recordEvent(runId, 'error', (prep.stderr || prep.stdout).trim().slice(0, 500));
|
|
114
|
+
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'worktree add failed' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort
|
|
119
|
+
await runShellOn(ctx.machine, `tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
|
|
120
|
+
|
|
121
|
+
await setRun(runId, { status: 'running' });
|
|
122
|
+
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
|
|
123
|
+
|
|
124
|
+
// 3) 에이전트 spawn(스트리밍)
|
|
125
|
+
// 원격은 ssh 채널이 죽어도 프로세스가 남을 수 있어 pid 파일을 남긴다(stop 시 원격 kill).
|
|
126
|
+
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
127
|
+
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
128
|
+
const cmd = `cd ${shq(wtPath)} && ${pidPrefix}{ ${agentCommand(ctx.prompt, useReal)}; }`;
|
|
129
|
+
const child = spawnShellOn(ctx.machine, cmd);
|
|
130
|
+
liveChildren.set(runId, child);
|
|
131
|
+
|
|
132
|
+
let lastResult = '';
|
|
133
|
+
if (child.stdout) {
|
|
134
|
+
const rl = createInterface({ input: child.stdout });
|
|
135
|
+
rl.on('line', (line: string) => {
|
|
136
|
+
const s = line.trim();
|
|
137
|
+
if (!s) return;
|
|
138
|
+
let kind = 'log';
|
|
139
|
+
try {
|
|
140
|
+
const obj = JSON.parse(s) as { type?: string; result?: string };
|
|
141
|
+
if (obj.type) kind = obj.type;
|
|
142
|
+
// result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
|
|
143
|
+
if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
|
|
144
|
+
} catch { /* 비-JSON 로그 라인 */ }
|
|
145
|
+
void recordEvent(runId, kind, s.slice(0, 2000));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (child.stderr) {
|
|
149
|
+
const rle = createInterface({ input: child.stderr });
|
|
150
|
+
rle.on('line', (line: string) => {
|
|
151
|
+
const s = line.trim();
|
|
152
|
+
if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const code: number = await new Promise((resolve) => {
|
|
157
|
+
child.on('close', (c) => resolve(c ?? 0));
|
|
158
|
+
child.on('error', () => resolve(-1));
|
|
159
|
+
});
|
|
160
|
+
liveChildren.delete(runId);
|
|
161
|
+
|
|
162
|
+
// 4) 변경 파일 수 집계
|
|
163
|
+
const stat = await runShellOn(ctx.machine, `git -C ${shq(wtPath)} status --porcelain | wc -l`, 10000);
|
|
164
|
+
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
165
|
+
|
|
166
|
+
const wasStopped = stoppedRuns.delete(runId);
|
|
167
|
+
await setRun(runId, {
|
|
168
|
+
status: wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed',
|
|
169
|
+
endedAt: new Date(),
|
|
170
|
+
filesChanged,
|
|
171
|
+
exitSummary: wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`,
|
|
172
|
+
});
|
|
173
|
+
} catch (e) {
|
|
174
|
+
await recordEvent(runId, 'error', String(e).slice(0, 500));
|
|
175
|
+
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 터미널 attach 용 — run 의 머신 타깃 + tmux 세션명. */
|
|
180
|
+
export async function getRunTermInfo(runId: number): Promise<{ machine: MachineTarget; session: string } | null> {
|
|
181
|
+
const ctx = await loadContext(runId);
|
|
182
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
183
|
+
const run = rr[0];
|
|
184
|
+
if (!ctx || !run || !run.tmuxWindow) return null;
|
|
185
|
+
return { machine: ctx.machine, session: run.tmuxWindow };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 실행 중 run 중지 — 자식 프로세스 SIGTERM. close 핸들러가 status='stopped' 로 봉인.
|
|
190
|
+
*/
|
|
191
|
+
export async function stopRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
192
|
+
const child = liveChildren.get(runId);
|
|
193
|
+
if (!child) return { ok: false, detail: 'not running' };
|
|
194
|
+
stoppedRuns.add(runId);
|
|
195
|
+
|
|
196
|
+
// 원격이면 먼저 원격 프로세스를 pid 파일로 죽인다(ssh 채널만 끊으면 잔존 가능).
|
|
197
|
+
const ctx = await loadContext(runId);
|
|
198
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
199
|
+
const run = rr[0];
|
|
200
|
+
if (ctx && run && ctx.machine.kind !== 'local' && ctx.machine.address !== '' && run.worktreePath) {
|
|
201
|
+
const pidFile = shq(`${run.worktreePath}/.coxpit-agent.pid`);
|
|
202
|
+
// 그룹 kill(-P, sshd 는 커맨드 셸을 세션리더로 띄워 성립) → 실패 시 자식(pkill -P)+본체 순.
|
|
203
|
+
await runShellOn(
|
|
204
|
+
ctx.machine,
|
|
205
|
+
`P=$(cat ${pidFile} 2>/dev/null) && { kill -TERM -"$P" 2>/dev/null || { pkill -TERM -P "$P" 2>/dev/null; kill -TERM "$P" 2>/dev/null; }; } || true`,
|
|
206
|
+
8000,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 로컬(또는 ssh 채널) 프로세스 그룹 종료 — sh 손자(실제 에이전트) 포함.
|
|
211
|
+
try {
|
|
212
|
+
if (child.pid) process.kill(-child.pid, 'SIGTERM');
|
|
213
|
+
else child.kill('SIGTERM');
|
|
214
|
+
} catch {
|
|
215
|
+
child.kill('SIGTERM');
|
|
216
|
+
}
|
|
217
|
+
return { ok: true, detail: 'SIGTERM sent' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* run worktree 의 변경 diff — tracked 는 diff HEAD, untracked 는 /dev/null 대비.
|
|
222
|
+
*/
|
|
223
|
+
export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: string; stat: string }> {
|
|
224
|
+
const ctx = await loadContext(runId);
|
|
225
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
226
|
+
const run = rr[0];
|
|
227
|
+
if (!ctx || !run || !run.worktreePath) return { ok: false, diff: '', stat: 'no worktree' };
|
|
228
|
+
const wt = shq(run.worktreePath);
|
|
229
|
+
const cmd =
|
|
230
|
+
`git -C ${wt} status --porcelain` +
|
|
231
|
+
` ; echo '---DIFF---'` +
|
|
232
|
+
` ; git -C ${wt} diff HEAD` +
|
|
233
|
+
` ; git -C ${wt} ls-files --others --exclude-standard | while IFS= read -r f; do` +
|
|
234
|
+
` git -C ${wt} diff --no-index -- /dev/null "$f"; done ; true`;
|
|
235
|
+
const r = await runShellOn(ctx.machine, cmd, 20000);
|
|
236
|
+
const [stat = '', diff = ''] = r.stdout.split('---DIFF---\n');
|
|
237
|
+
return { ok: true, stat: stat.trim(), diff: diff.slice(0, 200_000) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 승자 run 머지 — worktree 미커밋 변경을 자동 커밋 후 run 브랜치를
|
|
242
|
+
* repo 기본 브랜치에 merge. 본 repo 가 기본 브랜치+클린일 때만, 충돌 시 abort.
|
|
243
|
+
*/
|
|
244
|
+
export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
245
|
+
const ctx = await loadContext(runId);
|
|
246
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
247
|
+
const run = rr[0];
|
|
248
|
+
if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
|
|
249
|
+
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
|
|
250
|
+
const wt = shq(run.worktreePath);
|
|
251
|
+
const repo = shq(ctx.repoPath);
|
|
252
|
+
const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
|
|
253
|
+
|
|
254
|
+
// 1) worktree 미커밋 변경 자동 커밋(있을 때만)
|
|
255
|
+
const c1 = await runShellOn(
|
|
256
|
+
ctx.machine,
|
|
257
|
+
`git -C ${wt} add -A && (git -C ${wt} diff --cached --quiet || git -C ${wt} ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`,
|
|
258
|
+
20000,
|
|
259
|
+
);
|
|
260
|
+
if (!c1.ok) return { ok: false, detail: 'worktree commit failed: ' + (c1.stderr || c1.stdout).trim().slice(0, 300) };
|
|
261
|
+
|
|
262
|
+
// 2) 본 repo 가드 — 기본 브랜치 위 + 클린
|
|
263
|
+
const guard = await runShellOn(
|
|
264
|
+
ctx.machine,
|
|
265
|
+
`git -C ${repo} rev-parse --abbrev-ref HEAD && echo '---S---' && git -C ${repo} status --porcelain`,
|
|
266
|
+
10000,
|
|
267
|
+
);
|
|
268
|
+
if (!guard.ok) return { ok: false, detail: 'repo check failed' };
|
|
269
|
+
const [head = '', dirty = ''] = guard.stdout.split('---S---');
|
|
270
|
+
if (head.trim() !== ctx.baseBranch) {
|
|
271
|
+
return { ok: false, detail: `repo is on '${head.trim()}', expected '${ctx.baseBranch}'` };
|
|
272
|
+
}
|
|
273
|
+
if (dirty.trim() !== '') return { ok: false, detail: 'repo working tree not clean' };
|
|
274
|
+
|
|
275
|
+
// 3) merge (충돌 시 abort)
|
|
276
|
+
const mg = await runShellOn(
|
|
277
|
+
ctx.machine,
|
|
278
|
+
`git -C ${repo} ${ident} -c commit.gpgsign=false merge --no-ff -m ${shq(`coxpit: merge r${runId} (${run.branch})`)} ${shq(run.branch)} 2>&1 || (git -C ${repo} merge --abort 2>/dev/null; echo COXPIT_MERGE_FAILED)`,
|
|
279
|
+
30000,
|
|
280
|
+
);
|
|
281
|
+
if (mg.stdout.includes('COXPIT_MERGE_FAILED')) {
|
|
282
|
+
return { ok: false, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
|
|
283
|
+
}
|
|
284
|
+
await setRun(runId, { status: 'merged' });
|
|
285
|
+
return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
|
|
290
|
+
*/
|
|
291
|
+
export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
292
|
+
const ctx = await loadContext(runId);
|
|
293
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
294
|
+
const run = rr[0];
|
|
295
|
+
if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
|
|
296
|
+
await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
|
|
297
|
+
const rm = await runShellOn(
|
|
298
|
+
ctx.machine,
|
|
299
|
+
`git -C ${shq(ctx.repoPath)} worktree remove --force ${shq(run.worktreePath)} 2>&1` +
|
|
300
|
+
` ; git -C ${shq(ctx.repoPath)} branch -D ${shq(run.branch)} 2>&1 || true`,
|
|
301
|
+
20000,
|
|
302
|
+
);
|
|
303
|
+
return { ok: true, detail: rm.stdout.trim().slice(0, 300) };
|
|
304
|
+
}
|