coxpit 6.0.0 → 6.2.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/agentstate.ts +35 -3
- package/src/board.ts +16 -66
- package/src/cockpit.ts +1189 -60
- package/src/humanize.ts +62 -0
- package/src/orchestrator.ts +113 -11
- package/src/procscan.ts +241 -0
- package/src/server.ts +89 -3
package/src/humanize.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// 이벤트 인간화 — 보드와 코크핏이 **같은 한 벌**을 쓴다(사본 금지).
|
|
2
|
+
// 보드 모달의 타임라인과 코크핏 활동 페인(v5.28 E)이 같은 줄을 같은 규칙으로 보여야 하므로,
|
|
3
|
+
// 클라이언트 JS 를 여기 한 곳에 두고 두 페이지의 <script> 에 그대로 끼워 넣는다(icons.ts 전례).
|
|
4
|
+
// 문자열 안의 이스케이프는 **클라이언트 기준**이다: \\n 은 클라이언트의 \n 이 된다.
|
|
5
|
+
export const HUMANIZE_JS = `/* 이벤트 인간화 — JSON 원문 대신 사람이 읽는 한 줄로. null = 표시 생략(노이즈). */
|
|
6
|
+
function humanize(e){
|
|
7
|
+
const kind = e.kind, payload = e.payload;
|
|
8
|
+
if (kind === 'rate_limit_event') return null;
|
|
9
|
+
if (kind === 'steer') return { k:'steer', t:'→ '+payload };
|
|
10
|
+
if (kind === 'ask') return { k:'ask', t:'? '+payload };
|
|
11
|
+
if (kind === 'sync') return { k:'sync', t:payload };
|
|
12
|
+
if (kind === 'export'){ try{ const o=JSON.parse(payload); return { k:'export', t:o.copied+' file(s) → '+o.dest }; }catch{ return { k:'export', t:payload }; } }
|
|
13
|
+
if (kind === 'pr') return { k:'pr', t:payload };
|
|
14
|
+
if (kind === 'stderr') return { k:'stderr', t:payload };
|
|
15
|
+
try{
|
|
16
|
+
const o = JSON.parse(payload);
|
|
17
|
+
if (o.type === 'system'){
|
|
18
|
+
if (o.subtype === 'init' || !o.subtype) return { k:'session',
|
|
19
|
+
t:'started'+(o.model?' · '+String(o.model).replace(/\\u001b\\[[0-9;]*m/g,'')
|
|
20
|
+
.replace(/\\x1b\\[[0-9;]*m/g,'') : '') };
|
|
21
|
+
if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
|
|
22
|
+
return null; // thinking_tokens 등 스트림 잡음
|
|
23
|
+
}
|
|
24
|
+
if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
|
|
25
|
+
if (o.type === 'assistant' && o.message){
|
|
26
|
+
const parts = [];
|
|
27
|
+
for (const x of (o.message.content||[])){
|
|
28
|
+
if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
|
|
29
|
+
else if (x.type === 'tool_use'){
|
|
30
|
+
const i = x.input || {};
|
|
31
|
+
const arg = i.file_path || i.command || i.path || i.pattern || '';
|
|
32
|
+
parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return parts.length ? parts : null;
|
|
36
|
+
}
|
|
37
|
+
if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
|
|
38
|
+
if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
|
|
39
|
+
if (kind === 'meta' && o.subtask) return { k:'swarm', t:'↳ spawned task #'+o.subtask+' — '+String(o.title||'').slice(0,60)+' ('+((o.runs||[]).map(x=>'r'+x).join(' '))+')' };
|
|
40
|
+
if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
|
|
41
|
+
return { k:kind, t:payload.slice(0,140) };
|
|
42
|
+
}catch{
|
|
43
|
+
// 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
|
|
44
|
+
// text 조각만 구제하고, 없으면 생략.
|
|
45
|
+
if (payload.trim().startsWith('{')){
|
|
46
|
+
const texts = [];
|
|
47
|
+
const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
|
|
48
|
+
while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
|
|
49
|
+
return texts.length ? { k:'said', t:texts.join(' · ') } : null;
|
|
50
|
+
}
|
|
51
|
+
return { k:kind, t:payload };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function humanLines(events){
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const e of (events||[])){
|
|
57
|
+
const h = humanize(e);
|
|
58
|
+
if (!h) continue;
|
|
59
|
+
if (Array.isArray(h)) out.push(...h); else out.push(h);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}`;
|
package/src/orchestrator.ts
CHANGED
|
@@ -795,6 +795,25 @@ export async function getScrollback(runId: number, lines: number): Promise<{ ok:
|
|
|
795
795
|
return { ok: true, text: r.stdout };
|
|
796
796
|
}
|
|
797
797
|
|
|
798
|
+
/**
|
|
799
|
+
* 이 run 의 페인이 **지금 서 있는 폴더** (v5.28 D-fix).
|
|
800
|
+
* 터미널이 찍은 상대경로를 열려면 worktree 루트가 아니라 페인의 cwd 가 기준이어야 한다 —
|
|
801
|
+
* 모노레포 하위 패키지나 `cd` 뒤에는 루트가 답이 아니고, 그때 링크는 없는 파일을 가리켰다.
|
|
802
|
+
* 타깃은 언제나 '=' 정확 일치(coxpit-r5 가 coxpit-r50 을 집는 사고를 두 번 겪었다).
|
|
803
|
+
* display-message 가 페인 타깃으로 '=' 를 못 받는 tmux 가 있어 list-panes -s(타깃-세션) 로 한 번 더 받친다.
|
|
804
|
+
* 못 알아내면 빈 문자열 — 클라이언트는 worktree 로 폴백한다. 지어내지 않는다.
|
|
805
|
+
*/
|
|
806
|
+
export async function getRunPwd(runId: number): Promise<{ ok: boolean; pwd: string }> {
|
|
807
|
+
const info = await getRunTermInfo(runId);
|
|
808
|
+
if (!info) return { ok: false, pwd: '' };
|
|
809
|
+
const t = shq('=' + info.session);
|
|
810
|
+
const cmd = `tmux display -p -t ${t} '#{pane_current_path}' 2>/dev/null || tmux list-panes -s -t ${t} -F '#{pane_current_path}' 2>/dev/null | head -1`;
|
|
811
|
+
const r = await runShellOn(info.machine, cmd, 8000);
|
|
812
|
+
const pwd = ((r.stdout || '').split('\n')[0] || '').trim();
|
|
813
|
+
if (!r.ok || !pwd) return { ok: false, pwd: '' };
|
|
814
|
+
return { ok: true, pwd };
|
|
815
|
+
}
|
|
816
|
+
|
|
798
817
|
/**
|
|
799
818
|
* 실행 중 run 중지 — 자식 프로세스 SIGTERM. close 핸들러가 status='stopped' 로 봉인.
|
|
800
819
|
*/
|
|
@@ -2037,33 +2056,51 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
|
|
|
2037
2056
|
// cleanupRun 으로 이미 정리하지만, 실패·에러·데몬 재시작으로 고아가 된 run 은
|
|
2038
2057
|
// (검수용으로) worktree 를 남겨두므로 쌓인다. 이를 안전하게 되찾는 길.
|
|
2039
2058
|
//
|
|
2040
|
-
// 안전 규칙(핵심): running/preparing/pending
|
|
2041
|
-
//
|
|
2059
|
+
// 안전 규칙(핵심): running/preparing/pending run 은 절대 대상 아님 — 활성 작업이므로.
|
|
2060
|
+
//
|
|
2061
|
+
// v6.0 T6b — 빚이 실제로 쌓이는 자리는 **끝났는데 머지도 닫지도 않은 run** 이었다.
|
|
2062
|
+
// 그래서 done/merged 도 목록에는 올린다. 다만 하나를 갈라 본다:
|
|
2063
|
+
// 산출물이 이미 빠져나갔나(머지됐거나 export·PR 됐나) = 지워도 되는 사본
|
|
2064
|
+
// 아직 아무 데도 없나 = 이 worktree 가 **유일한 사본** → reclaimRisk:true
|
|
2065
|
+
// 위험한 것은 **보여주되 미리 고르지 않고**, 전체 회수(runIds 없음)에서도 빠진다.
|
|
2066
|
+
// 사람이 직접 찍어 보낸 runIds 만 그 선을 넘는다.
|
|
2042
2067
|
|
|
2043
2068
|
/** 회수 대상 판정용 안전 상태 집합 — task 가 closed 이거나 run 상태가 이 중 하나. */
|
|
2044
2069
|
const RECLAIM_STATUSES = new Set(['failed', 'error', 'stopped']);
|
|
2070
|
+
/** v6.0 T6b — 정착한 성공 run. 격리 worktree 를 가진 것만 목록에 든다(위험 표시와 함께). */
|
|
2071
|
+
const RECLAIM_SETTLED_STATUSES = new Set(['done', 'merged']);
|
|
2072
|
+
/** taskCloseRisk 와 **같은** 신호를 쓰는 상태 집합 — 정착 + 변경있음 + 미탈출 = 위험. */
|
|
2073
|
+
const RISK_STATUSES = new Set(['done', 'failed', 'stopped']);
|
|
2045
2074
|
|
|
2046
2075
|
export interface ReclaimableWorktree {
|
|
2047
2076
|
runId: number;
|
|
2048
2077
|
path: string;
|
|
2049
2078
|
branch: string;
|
|
2050
2079
|
taskId: number;
|
|
2051
|
-
reason: string; // 'task closed' | 'failed' | 'error' | 'stopped'
|
|
2080
|
+
reason: string; // 'task closed' | 'failed' | 'error' | 'stopped' | 'done' | 'merged'
|
|
2052
2081
|
exists: boolean; // worktree dir 가 아직 디스크에 있나(false = 이미 수동 삭제됨)
|
|
2053
2082
|
sizeKb?: number; // best-effort du -sk (실패 시 생략)
|
|
2083
|
+
/** v6.0 T6b — 이 worktree 가 미머지·미탈출 산출물의 **유일한 사본**인가(v4.1 close 가드와 같은 신호). */
|
|
2084
|
+
reclaimRisk: boolean;
|
|
2054
2085
|
}
|
|
2055
2086
|
|
|
2056
2087
|
/**
|
|
2057
2088
|
* 안전하게 회수 가능한 worktree 목록. worktreePath 가 비어있지 않은 모든 run 중
|
|
2058
|
-
* (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped}
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2089
|
+
* (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped} 이거나
|
|
2090
|
+
* (c) 격리 worktree 를 가진 채 정착한 done/merged(T6b — 쌓이던 그 빚).
|
|
2091
|
+
* running/preparing/pending/'open' 은 절대 포함하지 않는다(활성 작업).
|
|
2092
|
+
* exists=디스크 잔존 여부, sizeKb=best-effort du, reclaimRisk=유일 사본 경고.
|
|
2061
2093
|
*/
|
|
2062
2094
|
export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]> {
|
|
2063
2095
|
const allRuns = (await db.select().from(agentRuns)).filter((r) => !!r.worktreePath);
|
|
2064
2096
|
// task 상태 룩업(closed 판정용)
|
|
2065
2097
|
const taskById = new Map<number, typeof tasks.$inferSelect>();
|
|
2066
2098
|
for (const t of await db.select().from(tasks)) taskById.set(t.id, t);
|
|
2099
|
+
// 산출물이 이미 빠져나간 run — export·PR 이벤트 한 번이면 worktree 는 유일 사본이 아니다.
|
|
2100
|
+
const escaped = new Set<number>();
|
|
2101
|
+
for (const e of await db.select().from(agentEvents).where(inArray(agentEvents.kind, ['export', 'pr']))) {
|
|
2102
|
+
escaped.add(e.runId);
|
|
2103
|
+
}
|
|
2067
2104
|
|
|
2068
2105
|
const out: ReclaimableWorktree[] = [];
|
|
2069
2106
|
for (const run of allRuns) {
|
|
@@ -2071,11 +2108,18 @@ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]>
|
|
|
2071
2108
|
const task = taskById.get(run.taskId);
|
|
2072
2109
|
const taskClosed = task?.status === 'closed';
|
|
2073
2110
|
const statusReclaim = RECLAIM_STATUSES.has(run.status);
|
|
2074
|
-
|
|
2075
|
-
|
|
2111
|
+
const settled = RECLAIM_SETTLED_STATUSES.has(run.status);
|
|
2112
|
+
if (!taskClosed && !statusReclaim && !settled) continue; // open/running/preparing/pending 제외
|
|
2076
2113
|
|
|
2077
2114
|
// worktree 잔존 여부 + best-effort 사이즈(로컬만 정확; 원격은 machine 경유).
|
|
2078
2115
|
const ctx = await loadContext(run.id).catch(() => null);
|
|
2116
|
+
// done/merged 로 새로 들어온 run 은 **격리 worktree 를 가진 것만** — 루트 세션·in-place run 의
|
|
2117
|
+
// worktreePath 는 repo 체크아웃 그 자체라 회수할 디스크가 애초에 없다(기존 경로는 그대로 둔다).
|
|
2118
|
+
if (settled && !taskClosed && !statusReclaim && (!run.branch || (ctx && run.worktreePath === ctx.repoPath))) continue;
|
|
2119
|
+
const reason = taskClosed ? 'task closed' : run.status;
|
|
2120
|
+
// v4.1 close 가드와 같은 판정 — 정착 ∧ 변경있음 ∧ export·PR 없음. merged 는 이미 빠져나갔다.
|
|
2121
|
+
const reclaimRisk = RISK_STATUSES.has(run.status) && run.filesChanged > 0 && !escaped.has(run.id);
|
|
2122
|
+
|
|
2079
2123
|
let exists = false;
|
|
2080
2124
|
let sizeKb: number | undefined;
|
|
2081
2125
|
if (ctx) {
|
|
@@ -2090,24 +2134,82 @@ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]>
|
|
|
2090
2134
|
if (du.ok && Number.isFinite(n) && n > 0) sizeKb = n;
|
|
2091
2135
|
}
|
|
2092
2136
|
}
|
|
2093
|
-
out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb });
|
|
2137
|
+
out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb, reclaimRisk });
|
|
2094
2138
|
}
|
|
2095
2139
|
return out;
|
|
2096
2140
|
}
|
|
2097
2141
|
|
|
2142
|
+
// ── v6.0 T6b — 디스크 빚을 눈에 보이게 ──────────────────────────
|
|
2143
|
+
// 볼 수 없는 것은 관리할 수 없다. `.coxpit-worktrees` 의 총량·개수를 /api/health 와
|
|
2144
|
+
// 회수 판에 한 줄로 싣는다. du 는 큰 트리에서 느리므로 **health 는 절대 기다리지 않는다** —
|
|
2145
|
+
// 값은 캐시에서 나오고, 낡았으면 배경에서 다시 잰다(첫 호출은 값 없이 지나간다).
|
|
2146
|
+
|
|
2147
|
+
export interface WorktreeDisk { count: number; sizeKb: number }
|
|
2148
|
+
const DISK_TTL_MS = 30_000;
|
|
2149
|
+
let diskCache: { at: number; value: WorktreeDisk } | null = null;
|
|
2150
|
+
let diskInflight: Promise<WorktreeDisk> | null = null;
|
|
2151
|
+
|
|
2152
|
+
/** 로컬 머신 repo 들의 `.coxpit-worktrees` 부모 폴더를 한 번에 du -sk. 원격은 세지 않는다(health 는 이 머신의 디스크다). */
|
|
2153
|
+
async function measureWorktreeDisk(): Promise<WorktreeDisk> {
|
|
2154
|
+
const machineRows = await db.select().from(machines);
|
|
2155
|
+
const localIds = new Set(machineRows.filter((m) => m.kind === 'local' || m.address === '').map((m) => m.id));
|
|
2156
|
+
const dirs: string[] = [...new Set<string>(
|
|
2157
|
+
(await db.select().from(repos))
|
|
2158
|
+
.filter((r) => localIds.has(r.machineId))
|
|
2159
|
+
.map((r) => ppath.join(ppath.dirname(r.path), '.coxpit-worktrees')),
|
|
2160
|
+
)];
|
|
2161
|
+
if (!dirs.length) return { count: 0, sizeKb: 0 };
|
|
2162
|
+
// 폴더마다 "<KB> <개수>" 한 줄. 없는 폴더는 건너뛴다.
|
|
2163
|
+
const script = `for d in ${dirs.map(shq).join(' ')}; do [ -d "$d" ] || continue; ` +
|
|
2164
|
+
`s=$(du -sk "$d" 2>/dev/null | tail -1 | cut -f1); [ -n "$s" ] || s=0; ` +
|
|
2165
|
+
`c=$(ls -1 "$d" 2>/dev/null | wc -l); [ -n "$c" ] || c=0; echo "$s $c"; done`;
|
|
2166
|
+
const out = await runShellOn(LOCAL_MACHINE, script, 20000).catch(() => ({ ok: false as boolean, stdout: '' as string }));
|
|
2167
|
+
let count = 0, sizeKb = 0;
|
|
2168
|
+
for (const line of String(out.stdout || '').split('\n')) {
|
|
2169
|
+
const [s = '', c = ''] = line.trim().split(/\s+/);
|
|
2170
|
+
const kb = parseInt(s, 10), n = parseInt(c, 10);
|
|
2171
|
+
if (Number.isFinite(kb) && kb > 0) sizeKb += kb;
|
|
2172
|
+
if (Number.isFinite(n) && n > 0) count += n;
|
|
2173
|
+
}
|
|
2174
|
+
return { count, sizeKb };
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* 캐시된 worktree 디스크 사용량. darwin/linux 만(du 전제) — 그 밖은 null.
|
|
2179
|
+
* **절대 기다리지 않는다** — 낡았으면 배경에서 다시 재고 마지막 값을 그대로 돌려준다.
|
|
2180
|
+
* 아직 한 번도 못 쟀으면 null(=health 에 싣지 않는다). 디스크가 꽉 찬 머신에서 du 가
|
|
2181
|
+
* 오래 걸릴수록 health 는 더 빨라야 하지, 같이 멈추면 안 된다.
|
|
2182
|
+
*/
|
|
2183
|
+
export async function worktreeDisk(): Promise<WorktreeDisk | null> {
|
|
2184
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') return null;
|
|
2185
|
+
const cur = diskCache;
|
|
2186
|
+
if ((!cur || Date.now() - cur.at >= DISK_TTL_MS) && !diskInflight) {
|
|
2187
|
+
const p: Promise<WorktreeDisk> = measureWorktreeDisk()
|
|
2188
|
+
.then((v) => { diskCache = { at: Date.now(), value: v }; return v; })
|
|
2189
|
+
.catch(() => diskCache?.value ?? { count: 0, sizeKb: 0 })
|
|
2190
|
+
.finally(() => { if (diskInflight === p) diskInflight = null; });
|
|
2191
|
+
diskInflight = p;
|
|
2192
|
+
}
|
|
2193
|
+
return cur ? cur.value : null;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2098
2196
|
/**
|
|
2099
2197
|
* 회수 실행 — 대상 run(전체 또는 runIds 부분집합)의 worktree 를 되찾는다.
|
|
2100
2198
|
* dir 가 아직 있으면 cleanupRun 재사용(tmux kill + git worktree remove + branch -D + 포인터 blank).
|
|
2101
2199
|
* dir 가 이미 수동 삭제됐으면 git worktree prune + branch -D + DB 포인터 blank 만.
|
|
2102
2200
|
* 마지막에 영향받은 repo 마다 git worktree prune 1회(스테일 메타데이터 정리).
|
|
2103
2201
|
* 멱등 — 다시 돌려도 안전(이미 회수된 run 은 worktreePath 가 비어 목록에서 빠짐).
|
|
2202
|
+
*
|
|
2203
|
+
* v6.0 T6b — **전체 회수(runIds 없음)는 위험 표시가 없는 것만** 지운다. 미머지·미탈출
|
|
2204
|
+
* 산출물의 유일한 사본이 "전부 지우기" 한 번에 사라지는 일은 없다. 사람이 그 id 를
|
|
2205
|
+
* 직접 찍어 보냈다면(runIds) 그건 고른 것이므로 그대로 따른다. 라이브 run 은 어느 쪽도 아니다.
|
|
2104
2206
|
*/
|
|
2105
2207
|
export async function pruneWorktrees(runIds?: number[]): Promise<{
|
|
2106
2208
|
removed: Array<{ runId: number; detail: string }>; count: number;
|
|
2107
2209
|
}> {
|
|
2108
|
-
const reclaimable = await listReclaimableWorktrees();
|
|
2210
|
+
const reclaimable = (await listReclaimableWorktrees()).filter((r) => !isRunLive(r.runId));
|
|
2109
2211
|
const want = runIds && runIds.length ? new Set(runIds) : null;
|
|
2110
|
-
const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable;
|
|
2212
|
+
const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable.filter((r) => !r.reclaimRisk);
|
|
2111
2213
|
|
|
2112
2214
|
const removed: Array<{ runId: number; detail: string }> = [];
|
|
2113
2215
|
const affectedRepoPaths = new Map<string, MachineTarget>(); // repoPath -> machine (prune 대상)
|
package/src/procscan.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// 무엇이 지금 듣고 있고, 언제 시작했나 (spec v5.28 B) — 리스너 조사기.
|
|
2
|
+
//
|
|
3
|
+
// 하루의 고리는 이렇다: 에이전트가 코드를 고치고, 터미널에서 서버를 다시 띄우고, 확인한다.
|
|
4
|
+
// 덫은 **옛 프로세스가 포트를 그대로 물고 있는 것**이다(재시작이 실패했거나, 다른 페인에서
|
|
5
|
+
// 돌고 있거나, 새 것이 뜨다 죽고 옛 것만 살아남았거나). 그러면 새 코드를 옛 프로세스에 대고
|
|
6
|
+
// 디버깅하며 한 시간을 버린다.
|
|
7
|
+
//
|
|
8
|
+
// 자세(spec B4) — **증거를 보이고, 판정하지 않는다.** coxpit 은 "네가 의도한 빌드"를 알 수 없다.
|
|
9
|
+
// 알 수 있는 것은 싸고 정직한 사실뿐이다: 어떤 pid 가 어느 포트를 LISTEN 하고 있고(lsof),
|
|
10
|
+
// 얼마나 오래 떠 있었고(ps etime), 그 프로세스의 cwd/exec 가 이 페인 폴더 아래인가(underPane).
|
|
11
|
+
// **stale 이라는 판정은 이 파일 어디에도 없다.** 사람이 "3시간 전 시작"과 "2분 전 수정"을 나란히
|
|
12
|
+
// 보고 스스로 결론 내린다.
|
|
13
|
+
//
|
|
14
|
+
// 읽기 전용이고 싸다 — lsof 한 번 + ps 두 번(포맷 분리) + cwd lsof 한 번을, 원격이면 ssh 왕복
|
|
15
|
+
// **한 번**에 몰아 넣는다. du 도, 재귀도 없다. lsof 가 없는 머신은 **깨끗한 빈 결과 + note** 다
|
|
16
|
+
// (던지지 않는다 — 조사기가 터지면 조사할 수 없다).
|
|
17
|
+
|
|
18
|
+
import { runShellOn, shq, type MachineTarget } from './exec';
|
|
19
|
+
|
|
20
|
+
export interface Listener {
|
|
21
|
+
pid: number;
|
|
22
|
+
command: string; // ps comm (실행 파일 이름)
|
|
23
|
+
args: string; // exec + 첫 인자까지만, 시크릿 소독(clipArgs)
|
|
24
|
+
port: number;
|
|
25
|
+
etime: string; // ps etime — 시계 동기 가정 없는 정직한 "언제부터"
|
|
26
|
+
underPane: boolean; // cwd(또는 exec)가 이 페인 폴더 아래인가. 못 읽으면 false(행을 버리지 않는다)
|
|
27
|
+
machineId: string; // 머신 slug — 종료는 언제나 이 머신 하나에만 간다
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ScanResult {
|
|
31
|
+
listeners: Listener[];
|
|
32
|
+
note: string; // 빈 결과의 이유(예: lsof 없음). 정상이면 ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CACHE_MS = 5_000; // 같은 (머신, 범위) 를 연달아 열어도 다시 포크하지 않는다
|
|
36
|
+
|
|
37
|
+
interface CacheEntry { at: number; res: ScanResult }
|
|
38
|
+
const cache = new Map<string, CacheEntry>();
|
|
39
|
+
|
|
40
|
+
/** 시크릿이 실릴 만한 토큰은 값을 지운다 — 인자에 토큰을 그대로 붙여 띄우는 습관이 흔하다. */
|
|
41
|
+
const SECRETISH = /^(-{0,2}[\w.-]*(?:key|token|secret|password|passwd|pass|pwd|auth|cred)[\w.-]*)=.*/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* argv 를 **exec + 첫 인자**까지만 남긴다(rail detail 소독기와 같은 규율).
|
|
45
|
+
* 전체 argv 는 절대 표면에 내지 않는다 — 거기에 토큰이 실려 있을 수 있고, 이 패널은
|
|
46
|
+
* "무엇이 듣고 있나"를 답하는 자리이지 명령줄을 읽는 자리가 아니다.
|
|
47
|
+
*/
|
|
48
|
+
export function clipArgs(raw: string): string {
|
|
49
|
+
const parts = raw.trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
|
50
|
+
const safe = parts.map((p) => p.replace(SECRETISH, '$1=***'));
|
|
51
|
+
let out = safe.join(' ');
|
|
52
|
+
if (out.length > 120) out = out.slice(0, 117) + '...';
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `#L`/`#S`/`#A`/`#C` 마커로 구간을 나눈 한 방 스크립트. selector 로 포트를 좁힐 수 있다. */
|
|
57
|
+
function scanScript(selector: string): string {
|
|
58
|
+
return [
|
|
59
|
+
`command -v lsof >/dev/null 2>&1 || { echo '#NOLSOF'; exit 0; }`,
|
|
60
|
+
`L=$(lsof -nP -i${selector} -sTCP:LISTEN -Fpcn 2>/dev/null)`,
|
|
61
|
+
`echo '#L'`,
|
|
62
|
+
`printf '%s\\n' "$L"`,
|
|
63
|
+
`P=$(printf '%s\\n' "$L" | sed -n 's/^p//p' | sort -u | tr '\\n' ',' | sed 's/,$//')`,
|
|
64
|
+
`echo '#S'`,
|
|
65
|
+
`[ -n "$P" ] && ps -o pid=,etime=,comm= -p "$P" 2>/dev/null`,
|
|
66
|
+
`echo '#A'`,
|
|
67
|
+
`[ -n "$P" ] && ps -o pid=,args= -p "$P" 2>/dev/null`,
|
|
68
|
+
`echo '#C'`,
|
|
69
|
+
`[ -n "$P" ] && lsof -a -p "$P" -d cwd -Fpn 2>/dev/null`,
|
|
70
|
+
`echo '#E'`,
|
|
71
|
+
`exit 0`,
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 마커로 잘라 구간별 줄 묶음으로. 없는 구간은 빈 배열. */
|
|
76
|
+
function sections(out: string): Record<string, string[]> {
|
|
77
|
+
const secs: Record<string, string[]> = { L: [], S: [], A: [], C: [] };
|
|
78
|
+
let cur = '';
|
|
79
|
+
for (const line of out.split('\n')) {
|
|
80
|
+
if (line === '#L' || line === '#S' || line === '#A' || line === '#C') { cur = line.slice(1); continue; }
|
|
81
|
+
if (line === '#E') { cur = ''; continue; }
|
|
82
|
+
if (cur && line !== '') secs[cur]!.push(line);
|
|
83
|
+
}
|
|
84
|
+
return secs;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** lsof 이름 필드(`127.0.0.1:8210` · `[::1]:8210` · `*:8210`) 끝의 포트. 숫자가 아니면 null. */
|
|
88
|
+
function portOf(name: string): number | null {
|
|
89
|
+
const at = name.lastIndexOf(':');
|
|
90
|
+
if (at < 0) return null;
|
|
91
|
+
const n = Number(name.slice(at + 1).trim());
|
|
92
|
+
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 경로 p 가 base 아래(또는 같은 곳)인가 — 문자열 비교. 심링크까지 풀지 않는 best-effort. */
|
|
96
|
+
function under(p: string, base: string): boolean {
|
|
97
|
+
if (!p || !base) return false;
|
|
98
|
+
const b = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
99
|
+
return p === b || p.startsWith(b + '/');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseScan(stdout: string, machineId: string, pwd: string): ScanResult {
|
|
103
|
+
if (stdout.includes('#NOLSOF')) {
|
|
104
|
+
// 최소 리눅스 이미지엔 lsof 가 없다. 거짓말 대신 이유를 한 줄로 말하고 빈손으로 돌아온다.
|
|
105
|
+
return { listeners: [], note: 'lsof not found on this machine — install lsof to inspect listeners' };
|
|
106
|
+
}
|
|
107
|
+
const secs = sections(stdout);
|
|
108
|
+
|
|
109
|
+
// ── lsof -Fpcn: p<pid> / c<command> 뒤에 그 프로세스의 n<이름> 들이 따른다
|
|
110
|
+
const found: Array<{ pid: number; command: string; port: number }> = [];
|
|
111
|
+
const seen = new Set<string>();
|
|
112
|
+
let pid = 0;
|
|
113
|
+
let comm = '';
|
|
114
|
+
for (const line of secs.L!) {
|
|
115
|
+
const tag = line[0];
|
|
116
|
+
const val = line.slice(1);
|
|
117
|
+
if (tag === 'p') { pid = Number(val) || 0; comm = ''; continue; }
|
|
118
|
+
if (tag === 'c') { comm = val; continue; }
|
|
119
|
+
if (tag === 'n' && pid) {
|
|
120
|
+
const port = portOf(val);
|
|
121
|
+
if (port == null) continue;
|
|
122
|
+
const key = pid + ':' + port;
|
|
123
|
+
if (seen.has(key)) continue; // v4/v6 두 줄로 잡히는 같은 소켓은 한 행으로
|
|
124
|
+
seen.add(key);
|
|
125
|
+
found.push({ pid, command: comm, port });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── ps -o pid=,etime=,comm= → etime 은 공백이 없고, comm 은 줄 끝까지(경로에 공백이 있어도 안전)
|
|
130
|
+
const etime = new Map<number, string>();
|
|
131
|
+
const commOf = new Map<number, string>();
|
|
132
|
+
for (const line of secs.S!) {
|
|
133
|
+
// comm 이 비는 드문 경우에도 etime 은 건진다 — 세 번째 칸은 선택이다
|
|
134
|
+
const m = /^\s*(\d+)\s+(\S+)\s*(.*)$/.exec(line);
|
|
135
|
+
if (!m) continue;
|
|
136
|
+
etime.set(Number(m[1]), m[2]!);
|
|
137
|
+
commOf.set(Number(m[1]), (m[3] ?? '').trim());
|
|
138
|
+
}
|
|
139
|
+
// ── ps -o pid=,args= → args 도 줄 끝까지. 표면에 나가기 전에 반드시 clipArgs 를 거친다
|
|
140
|
+
const argsOf = new Map<number, string>();
|
|
141
|
+
for (const line of secs.A!) {
|
|
142
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
143
|
+
if (!m) continue;
|
|
144
|
+
argsOf.set(Number(m[1]), (m[2] ?? '').trim());
|
|
145
|
+
}
|
|
146
|
+
// ── lsof -d cwd -Fpn → 프로세스의 현재 폴더. 권한이 없어 못 읽으면 그냥 없는 채로 간다
|
|
147
|
+
const cwdOf = new Map<number, string>();
|
|
148
|
+
let cp = 0;
|
|
149
|
+
for (const line of secs.C!) {
|
|
150
|
+
if (line[0] === 'p') { cp = Number(line.slice(1)) || 0; continue; }
|
|
151
|
+
if (line[0] === 'n' && cp) cwdOf.set(cp, line.slice(1));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const listeners: Listener[] = found.map((f) => {
|
|
155
|
+
const rawArgs = argsOf.get(f.pid) ?? '';
|
|
156
|
+
const exec = rawArgs.split(/\s+/)[0] ?? '';
|
|
157
|
+
const cwd = cwdOf.get(f.pid) ?? '';
|
|
158
|
+
return {
|
|
159
|
+
pid: f.pid,
|
|
160
|
+
command: (commOf.get(f.pid) || f.command || '').split('/').pop() || f.command,
|
|
161
|
+
args: clipArgs(rawArgs),
|
|
162
|
+
port: f.port,
|
|
163
|
+
etime: etime.get(f.pid) ?? '',
|
|
164
|
+
// cwd 를 못 읽었으면 플래그 없이 **행은 그대로 낸다** — 떨어뜨리는 쪽이 더 나쁜 거짓말이다
|
|
165
|
+
underPane: !!pwd && (under(cwd, pwd) || under(exec, pwd)),
|
|
166
|
+
machineId,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
listeners.sort((a, b) => a.port - b.port || a.pid - b.pid);
|
|
170
|
+
return { listeners, note: '' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function scan(m: MachineTarget, selector: string, scope: string, pwd: string): Promise<ScanResult> {
|
|
174
|
+
// 캐시는 (머신, 범위) 별. underPane 은 pwd 에 딸린 값이라 pwd 도 키에 든다 —
|
|
175
|
+
// 같은 머신이라도 페인이 다르면 답이 다르다.
|
|
176
|
+
const key = `${m.slug}|${scope}|${pwd}`;
|
|
177
|
+
const hit = cache.get(key);
|
|
178
|
+
if (hit && Date.now() - hit.at < CACHE_MS) return hit.res;
|
|
179
|
+
const r = await runShellOn(m, scanScript(selector), 15000);
|
|
180
|
+
// 셸이 실패해도(원격 도달 불가 등) 던지지 않는다 — 이유를 한 줄로 실어 빈손으로 돌아온다.
|
|
181
|
+
const res = r.ok || r.stdout.includes('#L')
|
|
182
|
+
? parseScan(r.stdout, m.slug, pwd)
|
|
183
|
+
: { listeners: [], note: (r.stderr || '').trim().slice(0, 200) || `could not run lsof on ${m.slug}` };
|
|
184
|
+
cache.set(key, { at: Date.now(), res });
|
|
185
|
+
return res;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 이 머신에서 LISTEN 중인 것 전부. `pwd` 를 주면 그 폴더 아래인 것에 underPane 이 붙는다
|
|
190
|
+
* (페인의 살아있는 pwd — §D-fix 의 getRunPwd).
|
|
191
|
+
*/
|
|
192
|
+
export function scanListeners(m: MachineTarget, opts?: { pwd?: string }): Promise<ScanResult> {
|
|
193
|
+
return scan(m, 'TCP', 'all', (opts?.pwd ?? '').trim());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 포트 하나를 겨눈 조회 — "8210 은 누가 물고 있나". 같은 파서, 더 좁은 lsof. */
|
|
197
|
+
export function scanPort(m: MachineTarget, port: number): Promise<ScanResult> {
|
|
198
|
+
const p = Math.floor(port);
|
|
199
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
|
200
|
+
return Promise.resolve({ listeners: [], note: 'port must be 1..65535' });
|
|
201
|
+
}
|
|
202
|
+
return scan(m, `TCP:${p}`, `port:${p}`, '');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 종료 후에는 캐시가 거짓말이 된다 — 그 머신 것만 버린다(다시 훑어야 행이 사라진다). */
|
|
206
|
+
export function dropCache(slug: string): void {
|
|
207
|
+
for (const k of [...cache.keys()]) if (k.startsWith(slug + '|')) cache.delete(k);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 고른 pid 하나에 TERM → 유예 → KILL. **이 머신에서만.**
|
|
212
|
+
* 목록 검증(pid 1 거부·데몬 자신 거부·다시 훑은 목록에 없으면 거부)은 서버 라우트가 한다 —
|
|
213
|
+
* 이 함수는 사람이 이미 고른 pid 를 정확히 그것만 종료한다.
|
|
214
|
+
*/
|
|
215
|
+
export async function killPid(m: MachineTarget, pid: number): Promise<{ ok: boolean; gone: boolean; signal: string; detail: string }> {
|
|
216
|
+
const p = Math.floor(pid);
|
|
217
|
+
if (!Number.isInteger(p) || p <= 1) return { ok: false, gone: false, signal: '', detail: 'refused: pid must be > 1' };
|
|
218
|
+
const q = shq(String(p));
|
|
219
|
+
// 0.3 초를 못 재는 sleep 이면 1초로 — 유예는 짧아야 하지만 없으면 안 된다
|
|
220
|
+
const nap = `sleep 0.3 2>/dev/null || sleep 1`;
|
|
221
|
+
const cmd = [
|
|
222
|
+
`kill -TERM ${q} 2>/dev/null || true`,
|
|
223
|
+
`i=0; while [ $i -lt 6 ]; do kill -0 ${q} 2>/dev/null || { echo 'GONE TERM'; exit 0; }; ${nap}; i=$((i+1)); done`,
|
|
224
|
+
`kill -KILL ${q} 2>/dev/null || true`,
|
|
225
|
+
nap,
|
|
226
|
+
`kill -0 ${q} 2>/dev/null && echo 'ALIVE KILL' || echo 'GONE KILL'`,
|
|
227
|
+
].join('\n');
|
|
228
|
+
const r = await runShellOn(m, cmd, 15000);
|
|
229
|
+
dropCache(m.slug);
|
|
230
|
+
const out = (r.stdout || '').trim().split('\n').pop() ?? '';
|
|
231
|
+
const gone = out.startsWith('GONE');
|
|
232
|
+
const signal = out.endsWith('TERM') ? 'TERM' : out.endsWith('KILL') ? 'KILL' : '';
|
|
233
|
+
return {
|
|
234
|
+
ok: gone,
|
|
235
|
+
gone,
|
|
236
|
+
signal,
|
|
237
|
+
detail: gone
|
|
238
|
+
? `pid ${p} gone (${signal})`
|
|
239
|
+
: (out ? `pid ${p} survived SIGKILL` : `could not signal pid ${p} on ${m.slug}`),
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -21,9 +21,10 @@ import { db } from './db';
|
|
|
21
21
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
|
|
22
22
|
import { BOOKMARKLET_JS } from './design';
|
|
23
23
|
import { runShellOn, shq } from './exec';
|
|
24
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getSessionChat } from './orchestrator';
|
|
24
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getRunPwd, getSessionChat } from './orchestrator';
|
|
25
25
|
import { openTerm } from './term';
|
|
26
|
-
import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates } from './agentstate';
|
|
26
|
+
import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates, spottedPorts } from './agentstate';
|
|
27
|
+
import { scanListeners, scanPort, killPid, dropCache as dropListenerCache } from './procscan';
|
|
27
28
|
import { addSink, removeSink, broadcast } from './hub';
|
|
28
29
|
import { getProvider, listProviders } from './providers';
|
|
29
30
|
import { remoteState, setServe, setFunnel } from './remote';
|
|
@@ -236,7 +237,14 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
236
237
|
// 무인증 헬스(외부 감시용)
|
|
237
238
|
app.get('/api/health', async () => {
|
|
238
239
|
const max = ptyMax();
|
|
239
|
-
|
|
240
|
+
// v6.0 T6b — worktree 디스크 빚을 바깥(모니터링)에서도 볼 수 있게. 값은 캐시에서 나오고
|
|
241
|
+
// du 는 배경에서 돈다(health 는 기다리지 않는다). 없거나 0 이면 아예 싣지 않는다.
|
|
242
|
+
const wt = await worktreeDisk().catch(() => null);
|
|
243
|
+
return {
|
|
244
|
+
ok: true, name: 'coxpit', version: config.version, terminals: liveTerminals,
|
|
245
|
+
...(max ? { ptyMax: max } : {}),
|
|
246
|
+
...(wt && wt.count ? { worktrees: { count: wt.count, sizeKb: wt.sizeKb } } : {}),
|
|
247
|
+
};
|
|
240
248
|
});
|
|
241
249
|
|
|
242
250
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
|
|
@@ -1253,6 +1261,84 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1253
1261
|
return res;
|
|
1254
1262
|
});
|
|
1255
1263
|
|
|
1264
|
+
// 페인이 지금 서 있는 폴더 (v5.28 D-fix) — 터미널이 찍은 상대경로를 무엇 기준으로 풀지.
|
|
1265
|
+
// 못 알아내면 200 + { ok:false, pwd:'' } — 클라이언트가 worktree 로 폴백한다(지어낸 경로는 없다).
|
|
1266
|
+
app.get('/api/runs/:id/pwd', async (req, reply) => {
|
|
1267
|
+
const id = Number((req.params as { id: string }).id);
|
|
1268
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1269
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1270
|
+
return await getRunPwd(id);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
// ─── 무엇이 듣고 있나 (v5.28 B) — 증거만 보이고, 판정하지 않는다 ──────────────
|
|
1274
|
+
// 이 페인 체크아웃이 남긴 LISTEN 소켓 + 언제부터 떠 있는지(etime). stale 판정은 없다.
|
|
1275
|
+
// pwd 는 §D-fix 의 getRunPwd(페인이 지금 서 있는 폴더) 를 그대로 쓰고, 못 알아내면
|
|
1276
|
+
// worktree 로 물러선다 — 그러면 underPane 은 그 기준으로 읽힌다(기준을 응답에 같이 싣는다).
|
|
1277
|
+
app.get('/api/runs/:id/listeners', async (req, reply) => {
|
|
1278
|
+
const id = Number((req.params as { id: string }).id);
|
|
1279
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1280
|
+
const run = rr[0];
|
|
1281
|
+
if (!run) return reply.code(404).send({ error: 'not found' });
|
|
1282
|
+
const mr = await db.select().from(machines).where(eq(machines.id, run.machineId)).limit(1);
|
|
1283
|
+
const m = mr[0];
|
|
1284
|
+
if (!m) return reply.code(404).send({ error: 'machine gone' });
|
|
1285
|
+
const live = await getRunPwd(id);
|
|
1286
|
+
const pwd = live.pwd || run.worktreePath || '';
|
|
1287
|
+
const res = await scanListeners(m, { pwd });
|
|
1288
|
+
// 수동 포착 — Part A 가 이미 모으고 있는 꼬리를 한 번 훑을 뿐(탭을 더 달지 않는다).
|
|
1289
|
+
return { ...res, machine: m.slug, pwd, spotted: spottedPorts(id) };
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// 머신 + 포트 겨냥 조회 — "8210 은 누가 물고 있나". 어디서 왔든 그대로 보여준다.
|
|
1293
|
+
// 주의: 파라미터 이름은 위의 `/api/machines/:slug` 와 같은 자리라 `:slug` 로 맞춘다(라우터 충돌 회피).
|
|
1294
|
+
// 값은 slug 또는 숫자 id 둘 다 받는다.
|
|
1295
|
+
const findMachine = async (key: string) => {
|
|
1296
|
+
const bySlug = await db.select().from(machines).where(eq(machines.slug, key)).limit(1);
|
|
1297
|
+
if (bySlug[0]) return bySlug[0];
|
|
1298
|
+
const n = Number(key);
|
|
1299
|
+
if (!Number.isInteger(n)) return null;
|
|
1300
|
+
const byId = await db.select().from(machines).where(eq(machines.id, n)).limit(1);
|
|
1301
|
+
return byId[0] ?? null;
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
app.get('/api/machines/:slug/port/:port', async (req, reply) => {
|
|
1305
|
+
const { slug, port } = req.params as { slug: string; port: string };
|
|
1306
|
+
const m = await findMachine(slug);
|
|
1307
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
1308
|
+
const p = Number(port);
|
|
1309
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) return reply.code(400).send({ error: 'port must be 1..65535' });
|
|
1310
|
+
const res = await scanPort(m, p);
|
|
1311
|
+
return { ...res, machine: m.slug, port: p };
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
// 정확히 그 pid 하나만, 이 머신에서만. 가드는 **서버 쪽**이다(클라이언트를 믿지 않는다):
|
|
1315
|
+
// ① pid 1 거부 ② 데몬 자신 거부(로컬) ③ **지금 다시 훑은 목록에 없으면 거부**.
|
|
1316
|
+
// ③ 이 있어서 이건 "임의 pid kill 엔드포인트"가 아니다 — 사람이 본 그 행만 죽는다.
|
|
1317
|
+
// "포트 위 전부 죽이기" 같은 편의는 두지 않는다(무관한 프로세스를 같이 데려간다).
|
|
1318
|
+
app.post('/api/machines/:slug/kill', async (req, reply) => {
|
|
1319
|
+
const { slug } = req.params as { slug: string };
|
|
1320
|
+
const m = await findMachine(slug);
|
|
1321
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
1322
|
+
const b = (req.body ?? {}) as { pid?: unknown };
|
|
1323
|
+
const pid = Math.floor(Number(b.pid));
|
|
1324
|
+
if (!Number.isInteger(pid) || pid <= 0) return reply.code(400).send({ error: 'pid must be a positive integer' });
|
|
1325
|
+
if (pid === 1) return reply.code(403).send({ error: 'refused: pid 1 is init' });
|
|
1326
|
+
const isLocal = m.kind === 'local' || (m.address ?? '') === '';
|
|
1327
|
+
if (isLocal && pid === process.pid) return reply.code(403).send({ error: 'refused: that is the coxpit daemon itself' });
|
|
1328
|
+
// 지금 다시 훑는다 — 목록에 없는 pid 는 사람이 본 적 없는 pid 다.
|
|
1329
|
+
dropListenerCache(m.slug);
|
|
1330
|
+
const fresh = await scanListeners(m);
|
|
1331
|
+
const row = fresh.listeners.find((l) => l.pid === pid);
|
|
1332
|
+
if (!row) {
|
|
1333
|
+
return reply.code(409).send({
|
|
1334
|
+
error: 'refused: that pid is not listening on this machine right now',
|
|
1335
|
+
detail: fresh.note || 're-scan found no such listener — nothing was signalled',
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
const r = await killPid(m, pid);
|
|
1339
|
+
return reply.code(r.ok ? 200 : 409).send({ ...r, pid, port: row.port, machine: m.slug });
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1256
1342
|
// 수동 재검증 — repo.verifyCmd 를 이 run 의 worktree 에서 다시 실행(정착 자동검증과 동일 경로).
|
|
1257
1343
|
app.post('/api/runs/:id/verify', async (req, reply) => {
|
|
1258
1344
|
const id = Number((req.params as { id: string }).id);
|