coxpit 5.3.2 → 5.4.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/package.json +1 -1
- package/src/board.ts +5 -1
- package/src/cockpit.ts +125 -0
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +2 -0
- package/src/orchestrator.ts +183 -35
- package/src/server.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/board.ts
CHANGED
|
@@ -57,6 +57,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
57
57
|
.daemon-badge{font-family:var(--mono);font-size:10.5px;color:var(--faint);padding:3px 9px;
|
|
58
58
|
border:1px solid var(--line);border-radius:999px;background:var(--surface);cursor:default}
|
|
59
59
|
.daemon-badge b{color:var(--muted);font-weight:500}
|
|
60
|
+
.cockpit-link{text-decoration:none;color:var(--muted);font-family:var(--mono);font-size:11.5px;
|
|
61
|
+
border:1px solid var(--line);border-radius:7px;padding:5px 10px;display:inline-flex;align-items:center;gap:4px}
|
|
62
|
+
.cockpit-link:hover{color:var(--brand);border-color:var(--line-hi)}
|
|
60
63
|
.ws{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;color:var(--muted);
|
|
61
64
|
padding:4px 10px;border:1px solid var(--line);border-radius:999px;background:var(--surface)}
|
|
62
65
|
.dot{width:7px;height:7px;border-radius:50%;background:var(--s-failed);transition:background .3s}
|
|
@@ -842,7 +845,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
842
845
|
.fab{display:inline-flex}
|
|
843
846
|
#newBtn,.newnote{display:none}
|
|
844
847
|
header{padding:0 12px;gap:9px}
|
|
845
|
-
.brand .sub,.daemon-badge,.machines{display:none}
|
|
848
|
+
.brand .sub,.daemon-badge,.machines,.cockpit-link{display:none}
|
|
846
849
|
main{padding:14px}
|
|
847
850
|
.grid{grid-template-columns:1fr}
|
|
848
851
|
/* 모달 = 풀블리드 */
|
|
@@ -879,6 +882,7 @@ ${ICON_SPRITE}
|
|
|
879
882
|
<div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
|
|
880
883
|
<button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
|
|
881
884
|
<button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)"><svg class="ic"><use href="#i-external-link"/></svg></button>
|
|
885
|
+
<a class="cockpit-link" href="/cockpit" title="터미널 우선 셸 (preview)">◨ Cockpit</a>
|
|
882
886
|
<div class="machines" id="machines"></div>
|
|
883
887
|
</header>
|
|
884
888
|
<div class="scrim" id="scrim"></div>
|
package/src/cockpit.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Cockpit — 터미널 우선 셸 (병행 개발용, /cockpit). board.ts 처럼 자가완결 단일 HTML(빌드 0).
|
|
2
|
+
// 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
|
|
3
|
+
// Phase 0 = 스캐폴드(레이아웃 골격 + Board 토글 + 모바일 보드 리다이렉트). 트리·페인 그리드·요청바는 Phase 2~3.
|
|
4
|
+
export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8" />
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
9
|
+
<title>coxpit · cockpit</title>
|
|
10
|
+
<link rel="icon" href="/brand/favicon.ico" sizes="any" />
|
|
11
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
|
|
12
|
+
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
|
|
13
|
+
<style>
|
|
14
|
+
@font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
|
|
15
|
+
:root{
|
|
16
|
+
--bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --panel:#0e1118;
|
|
17
|
+
--line:#222835; --line-hi:#2f3648;
|
|
18
|
+
--ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
|
|
19
|
+
--brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
|
|
20
|
+
--running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67;
|
|
21
|
+
--mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
|
|
22
|
+
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
|
|
23
|
+
}
|
|
24
|
+
*{box-sizing:border-box}
|
|
25
|
+
html,body{height:100%}
|
|
26
|
+
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
|
|
27
|
+
-webkit-font-smoothing:antialiased;overflow:hidden}
|
|
28
|
+
|
|
29
|
+
/* ── topbar ── */
|
|
30
|
+
header{display:flex;align-items:center;gap:13px;height:46px;padding:0 14px;
|
|
31
|
+
border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);font-family:var(--mono)}
|
|
32
|
+
.brand{display:inline-flex;align-items:center;gap:8px;text-decoration:none}
|
|
33
|
+
.brand img{height:22px;width:auto;display:block}
|
|
34
|
+
.brand .wm{font-family:'Pixelify';font-weight:600;font-size:19px;color:var(--ink);letter-spacing:.01em;margin-left:-2px}
|
|
35
|
+
.mach{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:7px;padding:4px 10px}
|
|
36
|
+
.mach .dot{width:6px;height:6px;border-radius:50%;background:var(--done)}
|
|
37
|
+
.vtabs{display:flex;gap:4px;margin-left:4px}
|
|
38
|
+
.vtab{font-size:12px;color:var(--muted);padding:6px 11px;border-radius:7px;display:inline-flex;align-items:center;gap:7px;cursor:pointer;background:none;border:none;font-family:var(--mono)}
|
|
39
|
+
.vtab.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.28)}
|
|
40
|
+
.vtab .g{color:var(--brand)}
|
|
41
|
+
.right{margin-left:auto;display:flex;align-items:center;gap:9px}
|
|
42
|
+
.wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
|
|
43
|
+
.toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px}
|
|
44
|
+
.toggle:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
45
|
+
|
|
46
|
+
/* ── body: tree | main ── */
|
|
47
|
+
.layout{display:grid;grid-template-columns:264px 1fr;height:calc(100vh - 46px)}
|
|
48
|
+
.rail{border-right:1px solid var(--line);overflow:auto;padding:12px 10px}
|
|
49
|
+
.lbl{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:5px 8px 10px}
|
|
50
|
+
.skel{height:11px;border-radius:5px;background:linear-gradient(90deg,var(--surface) 0%,var(--surface2) 50%,var(--surface) 100%);
|
|
51
|
+
background-size:200% 100%;animation:sh 1.4s linear infinite;margin:9px 8px}
|
|
52
|
+
@keyframes sh{to{background-position:-200% 0}}
|
|
53
|
+
@media (prefers-reduced-motion:reduce){.skel{animation:none}}
|
|
54
|
+
|
|
55
|
+
.main{display:flex;flex-direction:column;min-width:0;background:var(--bg)}
|
|
56
|
+
.stage{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
|
|
57
|
+
.stage .card{max-width:460px}
|
|
58
|
+
.stage .glyph{font-family:var(--mono);font-size:26px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
|
|
59
|
+
.stage h1{font-family:var(--mono);font-size:17px;margin:0 0 8px;color:var(--ink);font-weight:700}
|
|
60
|
+
.stage p{color:var(--muted);font-size:13.5px;margin:0 0 6px;line-height:1.6}
|
|
61
|
+
.stage .steps{font-family:var(--mono);font-size:12px;color:var(--faint);margin-top:16px;text-align:left;
|
|
62
|
+
border:1px solid var(--line);border-radius:10px;background:var(--surface);padding:14px 16px;line-height:1.9}
|
|
63
|
+
.stage .steps b{color:var(--brand)}
|
|
64
|
+
.reqbar{display:flex;align-items:center;gap:10px;border-top:1px solid var(--line);background:var(--surface);padding:11px 14px;font-family:var(--mono)}
|
|
65
|
+
.reqbar .ctx{font-size:11px;color:var(--brand-ink);background:var(--brand);border-radius:6px;padding:3px 9px;font-weight:600}
|
|
66
|
+
.reqbar .caret{color:var(--brand)}
|
|
67
|
+
.reqbar .ph{color:var(--faint);font-size:13px;flex:1}
|
|
68
|
+
.reqbar .kbd{border:1px solid var(--line-hi);border-radius:5px;padding:1px 6px;color:var(--faint);font-size:11px}
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<header>
|
|
73
|
+
<a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
|
|
74
|
+
<span class="mach"><span class="dot"></span><span id="mach">local</span></span>
|
|
75
|
+
<div class="vtabs">
|
|
76
|
+
<button type="button" class="vtab on"><span class="g">⌗</span>Terminal</button>
|
|
77
|
+
<button type="button" class="vtab"><span class="g">⧉</span>Review</button>
|
|
78
|
+
<button type="button" class="vtab"><span class="g">▤</span>Docs</button>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="right">
|
|
81
|
+
<span class="wip">preview · Phase 0</span>
|
|
82
|
+
<a class="toggle" href="/">← Board</a>
|
|
83
|
+
</div>
|
|
84
|
+
</header>
|
|
85
|
+
|
|
86
|
+
<div class="layout">
|
|
87
|
+
<aside class="rail">
|
|
88
|
+
<div class="lbl">Workspace</div>
|
|
89
|
+
<div class="skel" style="width:70%"></div>
|
|
90
|
+
<div class="skel" style="width:52%"></div>
|
|
91
|
+
<div class="skel" style="width:60%"></div>
|
|
92
|
+
<div class="skel" style="width:44%"></div>
|
|
93
|
+
</aside>
|
|
94
|
+
|
|
95
|
+
<main class="main">
|
|
96
|
+
<div class="stage">
|
|
97
|
+
<div class="card">
|
|
98
|
+
<div class="glyph">⌗ ⌗ ⌗</div>
|
|
99
|
+
<h1>Cockpit — 터미널 우선 셸</h1>
|
|
100
|
+
<p>이 화면은 새 셸의 골격입니다. 백엔드는 보드와 공유하고, 여기에 트리·페인 그리드 터미널·요청바를 단계로 얹습니다.</p>
|
|
101
|
+
<div class="steps">
|
|
102
|
+
<div><b>Phase 1</b> · tmux 재-adopt (재시작 내성 토대)</div>
|
|
103
|
+
<div><b>Phase 2</b> · 워크스페이스 트리 + 페인 그리드 터미널</div>
|
|
104
|
+
<div><b>Phase 3</b> · 요청바 · 팬아웃 타일 · Review · 브로드캐스트</div>
|
|
105
|
+
<div><b>Phase 4</b> · verify in-loop</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="reqbar">
|
|
110
|
+
<span class="ctx">cockpit</span><span class="caret">›</span>
|
|
111
|
+
<span class="ph">요청바 — Phase 3</span>
|
|
112
|
+
<span class="kbd">⏎</span>
|
|
113
|
+
</div>
|
|
114
|
+
</main>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<script>
|
|
118
|
+
// 모바일은 터미널 우선 대신 보드(모니터)로 — Cockpit 은 데스크톱 우선.
|
|
119
|
+
// (뷰포트 폭 + 터치 힌트로 판정 — screen.width 는 헤드리스/멀티모니터서 부정확)
|
|
120
|
+
if (window.innerWidth <= 860 && matchMedia('(pointer:coarse)').matches) location.replace('/');
|
|
121
|
+
// health 로 머신명 정도만 채운다(스캐폴드).
|
|
122
|
+
fetch('/api/health').then(function(r){return r.json()}).then(function(){}).catch(function(){});
|
|
123
|
+
</script>
|
|
124
|
+
</body>
|
|
125
|
+
</html>`;
|
package/src/db/index.ts
CHANGED
|
@@ -101,4 +101,6 @@ export async function ensureSchema(): Promise<void> {
|
|
|
101
101
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
|
|
102
102
|
try { await client.execute("ALTER TABLE task_groups ADD COLUMN coord_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
103
103
|
try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
|
|
104
|
+
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN agent_pid INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
105
|
+
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN log_offset INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
104
106
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -88,6 +88,8 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
88
88
|
prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
|
|
89
89
|
model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
|
|
90
90
|
filesChanged: integer('files_changed').notNull().default(0),
|
|
91
|
+
agentPid: integer('agent_pid').notNull().default(0), // detached sh pgid — 재시작 후 생존 판정/stop
|
|
92
|
+
logOffset: integer('log_offset').notNull().default(0), // 내구 로그에서 tail 이 소비한 바이트(재-adopt 시 여기부터)
|
|
91
93
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
92
94
|
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
|
93
95
|
exitSummary: text('exit_summary').notNull().default(''),
|
package/src/orchestrator.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { posix as ppath } from 'node:path';
|
|
3
3
|
import { createInterface } from 'node:readline';
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
5
|
-
import { mkdir, copyFile, readFile, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { mkdir, copyFile, readFile, writeFile, rm, unlink } from 'node:fs/promises';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import type { ChildProcess } from 'node:child_process';
|
|
8
8
|
import { eq, inArray } from 'drizzle-orm';
|
|
@@ -111,6 +111,80 @@ async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInser
|
|
|
111
111
|
// 실행 중 run 의 자식 프로세스(stop 용). stoppedRuns = 사용자가 멈춘 run 표식.
|
|
112
112
|
const liveChildren = new Map<number, ChildProcess>();
|
|
113
113
|
const stoppedRuns = new Set<number>();
|
|
114
|
+
// 재시작 후 재-adopt 되어 로그를 tail 중인 run(자식 객체는 없지만 살아있음).
|
|
115
|
+
const adoptedRuns = new Set<number>();
|
|
116
|
+
|
|
117
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
118
|
+
function pidAlive(pid: number): boolean {
|
|
119
|
+
if (!pid) return false;
|
|
120
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── 내구 실행 로그(재시작 내성 토대) ──────────────────────────
|
|
124
|
+
// 에이전트 출력은 데몬 파이프가 아니라 이 파일로 리다이렉트된다 → 데몬이 죽어도
|
|
125
|
+
// detached 에이전트가 계속 파일에 쓰고, 데몬은 부팅 후 offset 부터 재-tail 한다.
|
|
126
|
+
function runsDir(): string {
|
|
127
|
+
const d = ppath.join(ppath.dirname(config.dbPath), 'runs');
|
|
128
|
+
try { mkdirSync(d, { recursive: true }); } catch { /* best effort */ }
|
|
129
|
+
return d;
|
|
130
|
+
}
|
|
131
|
+
function runLogPath(runId: number): string { return ppath.join(runsDir(), `r${runId}.log`); }
|
|
132
|
+
const DONE_RE = /^__COXPIT_DONE__:(-?\d+)\s*$/;
|
|
133
|
+
/** 로그 파일 tail — offset 부터 새 바이트를 읽어 라인 파싱→이벤트, 종료 센티넬(__COXPIT_DONE__) 감지.
|
|
134
|
+
* aliveCheck 가 false 면(센티넬 없이 프로세스 사망) code=-1 로 종료. offset 은 주기적으로 영속. */
|
|
135
|
+
async function tailRunLog(
|
|
136
|
+
runId: number, provider: Provider, fromOffset: number,
|
|
137
|
+
aliveCheck?: () => boolean,
|
|
138
|
+
): Promise<{ code: number; lastResult: string; offset: number }> {
|
|
139
|
+
const log = runLogPath(runId);
|
|
140
|
+
let offset = Math.max(0, fromOffset);
|
|
141
|
+
let buf = '';
|
|
142
|
+
let lastResult = '';
|
|
143
|
+
let code = 0;
|
|
144
|
+
let done = false;
|
|
145
|
+
let sincePersist = 0;
|
|
146
|
+
const consume = () => {
|
|
147
|
+
if (!existsSync(log)) return;
|
|
148
|
+
const size = statSync(log).size;
|
|
149
|
+
if (size <= offset) return;
|
|
150
|
+
const fd = openSync(log, 'r');
|
|
151
|
+
try {
|
|
152
|
+
const b = Buffer.alloc(size - offset);
|
|
153
|
+
readSync(fd, b, 0, b.length, offset);
|
|
154
|
+
offset = size; buf += b.toString('utf8');
|
|
155
|
+
} finally { closeSync(fd); }
|
|
156
|
+
let nl: number;
|
|
157
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
158
|
+
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
159
|
+
const m = DONE_RE.exec(line);
|
|
160
|
+
if (m) { code = parseInt(m[1] ?? '0', 10) || 0; done = true; continue; }
|
|
161
|
+
const p = provider.parseLine(line);
|
|
162
|
+
if (p) {
|
|
163
|
+
if (p.sessionId) void setRun(runId, { sessionId: p.sessionId });
|
|
164
|
+
if (p.resultText != null) lastResult = p.resultText;
|
|
165
|
+
void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// stdout+stderr 를 한 로그로 합쳤으므로(2>&1), stream-json 이 아닌 non-json 라인 = stderr 로 살린다.
|
|
169
|
+
const t = line.trim();
|
|
170
|
+
if (t && t[0] !== '{') void recordEvent(runId, 'stderr', t.slice(0, 2000));
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
while (!done) {
|
|
174
|
+
consume();
|
|
175
|
+
if (done) break;
|
|
176
|
+
if (aliveCheck && !aliveCheck()) { consume(); if (!done) { code = -1; } break; }
|
|
177
|
+
if (++sincePersist >= 8) { sincePersist = 0; void persistLogOffset(runId, offset); }
|
|
178
|
+
await sleep(250);
|
|
179
|
+
}
|
|
180
|
+
consume();
|
|
181
|
+
void persistLogOffset(runId, offset);
|
|
182
|
+
return { code, lastResult, offset };
|
|
183
|
+
}
|
|
184
|
+
/** offset 만 가볍게 영속(broadcast 없이 — setRun 은 매번 broadcast 라 부적합). */
|
|
185
|
+
async function persistLogOffset(runId: number, offset: number): Promise<void> {
|
|
186
|
+
try { await db.update(agentRuns).set({ logOffset: offset }).where(eq(agentRuns.id, runId)); } catch { /* best effort */ }
|
|
187
|
+
}
|
|
114
188
|
|
|
115
189
|
// ── 에이전트 셀프 오케스트레이션 ──────────────────────────────
|
|
116
190
|
// run 마다 1회용 토큰을 발급해 에이전트 env 로 준다. 에이전트는 그 토큰으로
|
|
@@ -130,9 +204,9 @@ export function resolveAgentToken(token: string): number | null {
|
|
|
130
204
|
return agentTokens.get(token) ?? null;
|
|
131
205
|
}
|
|
132
206
|
|
|
133
|
-
/** run 이 지금 살아 있는가(자식 프로세스 보유). aggregate 뷰의 live/steerable 판정용. */
|
|
207
|
+
/** run 이 지금 살아 있는가(자식 프로세스 보유 or 재-adopt tail 중). aggregate 뷰의 live/steerable 판정용. */
|
|
134
208
|
export function isRunLive(runId: number): boolean {
|
|
135
|
-
return liveChildren.has(runId);
|
|
209
|
+
return liveChildren.has(runId) || adoptedRuns.has(runId);
|
|
136
210
|
}
|
|
137
211
|
|
|
138
212
|
/** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
|
|
@@ -408,34 +482,48 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
408
482
|
* 에이전트 자식 프로세스 배선(공용) — 프로바이더가 stdout 라인을 정규화 이벤트로
|
|
409
483
|
* 파싱, session 캡처, 종료 시 files_changed 집계 + 상태 전이. launchRun/steerRun 공유.
|
|
410
484
|
*/
|
|
411
|
-
async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string, provider: Provider): Promise<void> {
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
485
|
+
async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string, provider: Provider, opts?: { fresh?: boolean }): Promise<void> {
|
|
486
|
+
const isRemote = machine.kind !== 'local' && machine.address !== '';
|
|
487
|
+
let code: number;
|
|
415
488
|
let lastResult = '';
|
|
416
|
-
if (child.stdout) {
|
|
417
|
-
const rl = createInterface({ input: child.stdout });
|
|
418
|
-
rl.on('line', (line: string) => {
|
|
419
|
-
const p = provider.parseLine(line);
|
|
420
|
-
if (!p) return;
|
|
421
|
-
if (p.sessionId) void setRun(runId, { sessionId: p.sessionId }); // steer(resume)용 세션 키
|
|
422
|
-
if (p.resultText != null) lastResult = p.resultText;
|
|
423
|
-
void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
if (child.stderr) {
|
|
427
|
-
const rle = createInterface({ input: child.stderr });
|
|
428
|
-
rle.on('line', (line: string) => {
|
|
429
|
-
const s = line.trim();
|
|
430
|
-
if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000));
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
489
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
child
|
|
437
|
-
|
|
438
|
-
|
|
490
|
+
if (isRemote) {
|
|
491
|
+
// 원격: ssh stdout 파이프를 실시간 파싱(기존 동작). 로그 파일/재-adopt 는 로컬 전용.
|
|
492
|
+
const child = spawnShellOn(machine, cmd);
|
|
493
|
+
liveChildren.set(runId, child);
|
|
494
|
+
if (child.stdout) {
|
|
495
|
+
const rl = createInterface({ input: child.stdout });
|
|
496
|
+
rl.on('line', (line: string) => {
|
|
497
|
+
const p = provider.parseLine(line);
|
|
498
|
+
if (!p) return;
|
|
499
|
+
if (p.sessionId) void setRun(runId, { sessionId: p.sessionId });
|
|
500
|
+
if (p.resultText != null) lastResult = p.resultText;
|
|
501
|
+
void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (child.stderr) {
|
|
505
|
+
const rle = createInterface({ input: child.stderr });
|
|
506
|
+
rle.on('line', (line: string) => { const s = line.trim(); if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000)); });
|
|
507
|
+
}
|
|
508
|
+
code = await new Promise((resolve) => { child.on('close', (c) => resolve(c ?? 0)); child.on('error', () => resolve(-1)); });
|
|
509
|
+
liveChildren.delete(runId);
|
|
510
|
+
} else {
|
|
511
|
+
// 로컬: 에이전트 출력을 내구 로그로 리다이렉트 + 파일 tail(재시작 내성). 종료는 __COXPIT_DONE__ 센티넬.
|
|
512
|
+
const fresh = opts?.fresh !== false; // launch=새 로그 / steer=이어쓰기
|
|
513
|
+
const log = runLogPath(runId);
|
|
514
|
+
const wrapped = `{ ${cmd} ; } ${fresh ? '>' : '>>'} ${shq(log)} 2>&1; printf '\\n__COXPIT_DONE__:%s\\n' "$?" >> ${shq(log)}`;
|
|
515
|
+
let fromOffset = 0;
|
|
516
|
+
if (fresh) { await setRun(runId, { logOffset: 0, agentPid: 0 }); }
|
|
517
|
+
else { const r = (await db.select({ o: agentRuns.logOffset }).from(agentRuns).where(eq(agentRuns.id, runId)).limit(1))[0]; fromOffset = r?.o ?? 0; }
|
|
518
|
+
const child = spawnShellOn(machine, wrapped);
|
|
519
|
+
liveChildren.set(runId, child);
|
|
520
|
+
child.on('error', () => { /* 파이프 미사용 — 로그 tail 이 진실 */ });
|
|
521
|
+
const pid = child.pid ?? 0;
|
|
522
|
+
if (pid) await setRun(runId, { agentPid: pid });
|
|
523
|
+
const r = await tailRunLog(runId, provider, fromOffset, () => pidAlive(pid));
|
|
524
|
+
code = r.code; lastResult = r.lastResult;
|
|
525
|
+
liveChildren.delete(runId);
|
|
526
|
+
}
|
|
439
527
|
|
|
440
528
|
const stat = await runShellOn(machine, `git -C ${shq(wtPath)} status --porcelain | wc -l`, 10000);
|
|
441
529
|
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
@@ -443,7 +531,7 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
443
531
|
const wasStopped = stoppedRuns.delete(runId);
|
|
444
532
|
const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
|
|
445
533
|
const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
|
|
446
|
-
await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
|
|
534
|
+
await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
|
|
447
535
|
// 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
|
|
448
536
|
if (filesChanged > 0) void snapshotRunDocs(runId);
|
|
449
537
|
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
@@ -528,9 +616,9 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
|
|
|
528
616
|
const cmd = `cd ${shq(wt)} && ${envPrefix}${pidPrefix}{ ${resume}; }`;
|
|
529
617
|
if (!isRemote && config.agentOrch) {
|
|
530
618
|
const orchTimer = startOrchWatch(runId, wt, true);
|
|
531
|
-
void runAgentChild(runId, ctx.machine, wt, cmd, provider).finally(() => clearInterval(orchTimer));
|
|
619
|
+
void runAgentChild(runId, ctx.machine, wt, cmd, provider, { fresh: false }).finally(() => clearInterval(orchTimer));
|
|
532
620
|
} else {
|
|
533
|
-
void runAgentChild(runId, ctx.machine, wt, cmd, provider);
|
|
621
|
+
void runAgentChild(runId, ctx.machine, wt, cmd, provider, { fresh: false });
|
|
534
622
|
}
|
|
535
623
|
return { ok: true, detail: 'steering' };
|
|
536
624
|
}
|
|
@@ -554,6 +642,12 @@ export async function stopRun(runId: number): Promise<{ ok: boolean; detail: str
|
|
|
554
642
|
// stop 요청을 정산으로 처리해 카드가 영원히 '진행 중'으로 남지 않게 한다.
|
|
555
643
|
const zr = (await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1))[0];
|
|
556
644
|
if (zr && (zr.status === 'running' || zr.status === 'starting')) {
|
|
645
|
+
// 재-adopt 되어 tail 중인 살아있는 로컬 에이전트 — 프로세스 그룹 종료. tailer 가 stopped 로 정산.
|
|
646
|
+
if (zr.agentPid && pidAlive(zr.agentPid)) {
|
|
647
|
+
stoppedRuns.add(runId);
|
|
648
|
+
try { process.kill(-zr.agentPid, 'SIGTERM'); } catch { try { process.kill(zr.agentPid, 'SIGTERM'); } catch { /* gone */ } }
|
|
649
|
+
return { ok: true, detail: 'stopping (re-adopted agent)' };
|
|
650
|
+
}
|
|
557
651
|
await setRun(runId, { status: 'stopped', endedAt: new Date(), exitSummary: 'orphaned (daemon restarted) — settled by stop' });
|
|
558
652
|
await recordEvent(runId, 'meta', JSON.stringify({ orphanSettled: true }));
|
|
559
653
|
return { ok: true, detail: 'no live process — settled as stopped' };
|
|
@@ -1446,15 +1540,67 @@ export async function listDocuments(): Promise<{ runs: Array<{
|
|
|
1446
1540
|
* 부팅 정산 — 데몬 재시작 후 살아있는 자식이 있을 수 없는데 DB 가 running/starting 인
|
|
1447
1541
|
* run(고아)을 failed 로 정리한다. workbench('open')는 에이전트가 없으므로 대상 아님.
|
|
1448
1542
|
*/
|
|
1543
|
+
/** 재-adopt: 살아있는 로컬 run 의 로그를 offset 부터 이어 tail → 완료 시 정산. status 는 running 유지. */
|
|
1544
|
+
async function reAdoptRun(run: typeof agentRuns.$inferSelect): Promise<void> {
|
|
1545
|
+
adoptedRuns.add(run.id);
|
|
1546
|
+
await recordEvent(run.id, 'meta', JSON.stringify({ reAdopted: true }));
|
|
1547
|
+
const ctx = await loadContext(run.id);
|
|
1548
|
+
const provider = getProvider(run.agent || 'claude-code');
|
|
1549
|
+
const machine: MachineTarget = ctx?.machine ?? { slug: 'local', kind: 'local', address: '', sshUser: '' };
|
|
1550
|
+
const wasStopped0 = stoppedRuns.has(run.id);
|
|
1551
|
+
const { code, lastResult } = await tailRunLog(run.id, provider, run.logOffset ?? 0, () => pidAlive(run.agentPid));
|
|
1552
|
+
adoptedRuns.delete(run.id);
|
|
1553
|
+
const stat = await runShellOn(machine, `git -C ${shq(run.worktreePath)} status --porcelain | wc -l`, 10000);
|
|
1554
|
+
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
1555
|
+
const wasStopped = stoppedRuns.delete(run.id) || wasStopped0;
|
|
1556
|
+
const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
|
|
1557
|
+
const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
|
|
1558
|
+
await setRun(run.id, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
|
|
1559
|
+
if (filesChanged > 0) void snapshotRunDocs(run.id);
|
|
1560
|
+
void notifySettle(run.id, status, filesChanged, exitSummary);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* 부팅 시 이전 인스턴스의 라이브 run 정산 — 이제 무조건 failed 가 아니라:
|
|
1565
|
+
* ① 에이전트 프로세스(agentPid)가 살아있으면 로그를 이어 tail(재-adopt) → running 유지
|
|
1566
|
+
* ② 이미 로그에 __COXPIT_DONE__ 이 있거나 프로세스 죽음 → 그 결과로 정산
|
|
1567
|
+
* ③ 그 외(원격·pid 없음) → orphaned failed (기존 동작, worktree/diff 보존)
|
|
1568
|
+
* 반환 = 재-adopt 된 run 수(정산된 고아는 미포함).
|
|
1569
|
+
*/
|
|
1449
1570
|
export async function reconcileOrphanRuns(): Promise<number> {
|
|
1450
1571
|
const stale = (await db.select().from(agentRuns)).filter(
|
|
1451
1572
|
(r) => r.status === 'running' || r.status === 'starting',
|
|
1452
1573
|
);
|
|
1574
|
+
let adopted = 0;
|
|
1453
1575
|
for (const r of stale) {
|
|
1454
|
-
|
|
1576
|
+
if (r.agentPid && pidAlive(r.agentPid)) {
|
|
1577
|
+
// 아직 살아있는 로컬 에이전트 — 이어받는다(백그라운드 tail).
|
|
1578
|
+
void reAdoptRun(r);
|
|
1579
|
+
adopted++;
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
// 죽었지만 로그에 완료 센티넬이 있으면 그 결과로 정산(다운타임 중 끝난 경우).
|
|
1583
|
+
const log = runLogPath(r.id);
|
|
1584
|
+
let doneCode: number | null = null;
|
|
1585
|
+
try {
|
|
1586
|
+
if (existsSync(log)) {
|
|
1587
|
+
const tail = (await readFile(log, 'utf8')).slice(-4000);
|
|
1588
|
+
const m = /__COXPIT_DONE__:(-?\d+)/.exec(tail);
|
|
1589
|
+
if (m) doneCode = parseInt(m[1] ?? '0', 10) || 0;
|
|
1590
|
+
}
|
|
1591
|
+
} catch { /* ignore */ }
|
|
1592
|
+
if (doneCode !== null) {
|
|
1593
|
+
const stat = await runShellOn({ slug: 'local', kind: 'local', address: '', sshUser: '' }, `git -C ${shq(r.worktreePath)} status --porcelain | wc -l`, 10000);
|
|
1594
|
+
const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
|
|
1595
|
+
const status = doneCode === 0 ? 'done' : 'failed';
|
|
1596
|
+
await setRun(r.id, { status, endedAt: new Date(), filesChanged, exitSummary: `settled on restart (exit ${doneCode})`, agentPid: 0 });
|
|
1597
|
+
if (filesChanged > 0) void snapshotRunDocs(r.id);
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
await setRun(r.id, { status: 'failed', endedAt: new Date(), exitSummary: 'orphaned by daemon restart', agentPid: 0 });
|
|
1455
1601
|
await recordEvent(r.id, 'error', 'daemon restarted while this run was live — settled as failed (worktree/branch preserved; diff still reviewable)');
|
|
1456
1602
|
}
|
|
1457
|
-
return
|
|
1603
|
+
return adopted;
|
|
1458
1604
|
}
|
|
1459
1605
|
|
|
1460
1606
|
/**
|
|
@@ -1614,6 +1760,8 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
|
|
|
1614
1760
|
if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
|
|
1615
1761
|
// worktree 를 지우기 전에 문서 스냅샷(정착 안 하는 워크벤치·수정편집도 포착). best-effort.
|
|
1616
1762
|
await snapshotRunDocs(runId).catch(() => { /* 스냅샷 실패는 정리를 막지 않음 */ });
|
|
1763
|
+
// 내구 실행 로그도 정리(무한 증가 방지). best-effort. (아래 지역변수 rm 과 이름 충돌 회피로 unlink 사용)
|
|
1764
|
+
try { await unlink(runLogPath(runId)); } catch { /* gone */ }
|
|
1617
1765
|
// 원격에 잔존 에이전트가 있으면 worktree 제거 전에 죽인다(파일 잠금·좀비 방지).
|
|
1618
1766
|
if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
|
|
1619
1767
|
await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
|
package/src/server.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { addSink, removeSink, broadcast } from './hub';
|
|
|
25
25
|
import { getProvider, listProviders } from './providers';
|
|
26
26
|
import { remoteState, setServe, setFunnel } from './remote';
|
|
27
27
|
import { BOARD_HTML } from './board';
|
|
28
|
+
import { COCKPIT_HTML } from './cockpit';
|
|
28
29
|
|
|
29
30
|
const require_ = createRequire(import.meta.url);
|
|
30
31
|
|
|
@@ -203,6 +204,8 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
203
204
|
|
|
204
205
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
|
|
205
206
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
207
|
+
// 터미널 우선 셸(병행 개발) — 백엔드는 보드와 공유. Phase 5에서 데스크톱 기본을 여기로 플립 예정.
|
|
208
|
+
app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_HTML));
|
|
206
209
|
|
|
207
210
|
// ─── 접근키 인증(access-key) ────────────────────────────────────
|
|
208
211
|
// 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.
|