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/server.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
4
|
+
import websocket from '@fastify/websocket';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import { authGate } from './auth';
|
|
7
|
+
import { config } from './config';
|
|
8
|
+
import { db } from './db';
|
|
9
|
+
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
|
|
10
|
+
import { BOOKMARKLET_JS } from './design';
|
|
11
|
+
import { runShellOn, shq } from './exec';
|
|
12
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo } from './orchestrator';
|
|
13
|
+
import { openTerm } from './term';
|
|
14
|
+
import { addSink, removeSink, broadcast } from './hub';
|
|
15
|
+
import { BOARD_HTML } from './board';
|
|
16
|
+
|
|
17
|
+
const require_ = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
// 자가완결 서빙 — CDN 없이 node_modules 의 xterm 배포본을 그대로 낸다.
|
|
20
|
+
const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
21
|
+
'xterm.js': { pkg: '@xterm/xterm/package.json', rel: 'lib/xterm.js', type: 'text/javascript' },
|
|
22
|
+
'xterm.css': { pkg: '@xterm/xterm/package.json', rel: 'css/xterm.css', type: 'text/css' },
|
|
23
|
+
'addon-fit.js': { pkg: '@xterm/addon-fit/package.json', rel: 'lib/addon-fit.js', type: 'text/javascript' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function buildServer(): Promise<FastifyInstance> {
|
|
27
|
+
const app = Fastify({ logger: true });
|
|
28
|
+
await app.register(websocket);
|
|
29
|
+
app.addHook('onRequest', authGate);
|
|
30
|
+
|
|
31
|
+
// 무인증 헬스(외부 감시용)
|
|
32
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.1.0' }));
|
|
33
|
+
|
|
34
|
+
// 플릿 보드(단일 페이지). 인증 게이트 적용됨.
|
|
35
|
+
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
36
|
+
|
|
37
|
+
// 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
|
|
38
|
+
app.get('/api/fleet', async () => {
|
|
39
|
+
const [ms, rs, ts, rns, evs, dcs] = await Promise.all([
|
|
40
|
+
db.select().from(machines),
|
|
41
|
+
db.select().from(repos),
|
|
42
|
+
db.select().from(tasks),
|
|
43
|
+
db.select().from(agentRuns),
|
|
44
|
+
db.select().from(agentEvents),
|
|
45
|
+
db.select().from(designCaptures),
|
|
46
|
+
]);
|
|
47
|
+
const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
|
|
48
|
+
for (const e of evs) {
|
|
49
|
+
const arr = byRun.get(e.runId) ?? [];
|
|
50
|
+
arr.push({ kind: e.kind, payload: e.payload });
|
|
51
|
+
byRun.set(e.runId, arr);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
machines: ms, repos: rs, tasks: ts, captures: dcs,
|
|
55
|
+
runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ─── 머신 레지스트리 ────────────────────────────────────────────
|
|
60
|
+
app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
|
|
61
|
+
|
|
62
|
+
app.post('/api/machines', async (req, reply) => {
|
|
63
|
+
const b = (req.body ?? {}) as {
|
|
64
|
+
slug?: string; name?: string; address?: string; sshUser?: string; kind?: string;
|
|
65
|
+
};
|
|
66
|
+
const slug = (b.slug ?? '').trim();
|
|
67
|
+
if (!slug) return reply.code(400).send({ error: 'slug required' });
|
|
68
|
+
try {
|
|
69
|
+
await db.insert(machines).values({
|
|
70
|
+
slug,
|
|
71
|
+
name: b.name ?? slug,
|
|
72
|
+
address: b.address ?? '',
|
|
73
|
+
sshUser: b.sshUser ?? '',
|
|
74
|
+
kind: b.kind ?? (b.address ? 'remote' : 'local'),
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
return reply.code(409).send({ error: 'slug exists' });
|
|
78
|
+
}
|
|
79
|
+
return reply.code(201).send({ ok: true, slug });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// 머신 상세 + 소속 repo
|
|
83
|
+
app.get('/api/machines/:slug', async (req, reply) => {
|
|
84
|
+
const { slug } = req.params as { slug: string };
|
|
85
|
+
const rows = await db.select().from(machines).where(eq(machines.slug, slug)).limit(1);
|
|
86
|
+
const m = rows[0];
|
|
87
|
+
if (!m) return reply.code(404).send({ error: 'not found' });
|
|
88
|
+
const mrepos = await db.select().from(repos).where(eq(repos.machineId, m.id));
|
|
89
|
+
return { machine: m, repos: mrepos };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 도달성 프로브 — SSH(또는 로컬)로 git/tmux/os 확인, online/lastSeen 갱신.
|
|
93
|
+
app.post('/api/machines/:slug/probe', async (req, reply) => {
|
|
94
|
+
const { slug } = req.params as { slug: string };
|
|
95
|
+
const rows = await db.select().from(machines).where(eq(machines.slug, slug)).limit(1);
|
|
96
|
+
const m = rows[0];
|
|
97
|
+
if (!m) return reply.code(404).send({ error: 'not found' });
|
|
98
|
+
|
|
99
|
+
const cmd = [
|
|
100
|
+
'echo GIT:$(git --version 2>&1)',
|
|
101
|
+
'echo TMUX:$(tmux -V 2>&1)',
|
|
102
|
+
'echo OS:$(uname -sr 2>&1)',
|
|
103
|
+
].join('; ');
|
|
104
|
+
const r = await runShellOn(m, cmd);
|
|
105
|
+
|
|
106
|
+
const pick = (key: string): string => {
|
|
107
|
+
const line = r.stdout.split('\n').find((l) => l.startsWith(`${key}:`));
|
|
108
|
+
return line ? line.slice(key.length + 1).trim() : '';
|
|
109
|
+
};
|
|
110
|
+
const gitStr = pick('GIT');
|
|
111
|
+
const tmuxStr = pick('TMUX');
|
|
112
|
+
const reachable = r.ok;
|
|
113
|
+
const git = { ok: /git version/i.test(gitStr), version: gitStr };
|
|
114
|
+
const tmux = { ok: /tmux \d/i.test(tmuxStr), version: tmuxStr };
|
|
115
|
+
|
|
116
|
+
await db.update(machines)
|
|
117
|
+
.set({ online: reachable, lastSeen: new Date() })
|
|
118
|
+
.where(eq(machines.id, m.id));
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
slug, reachable,
|
|
122
|
+
git, tmux, os: pick('OS'),
|
|
123
|
+
ready: reachable && git.ok && tmux.ok,
|
|
124
|
+
error: reachable ? undefined : (r.stderr.trim() || `ssh exit ${r.code}`),
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ─── Repo 레지스트리 ────────────────────────────────────────────
|
|
129
|
+
app.get('/api/repos', async (req) => {
|
|
130
|
+
const q = (req.query ?? {}) as { machine?: string };
|
|
131
|
+
if (q.machine) {
|
|
132
|
+
const mr = await db.select().from(machines).where(eq(machines.slug, q.machine)).limit(1);
|
|
133
|
+
if (!mr[0]) return { repos: [] };
|
|
134
|
+
return { repos: await db.select().from(repos).where(eq(repos.machineId, mr[0].id)) };
|
|
135
|
+
}
|
|
136
|
+
return { repos: await db.select().from(repos) };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// repo 등록 — 경로가 실제 git work-tree 인지 원격/로컬 검증 후 insert.
|
|
140
|
+
app.post('/api/repos', async (req, reply) => {
|
|
141
|
+
const b = (req.body ?? {}) as { machineSlug?: string; path?: string; name?: string };
|
|
142
|
+
const machineSlug = (b.machineSlug ?? '').trim();
|
|
143
|
+
const path = (b.path ?? '').trim();
|
|
144
|
+
if (!machineSlug || !path) return reply.code(400).send({ error: 'machineSlug and path required' });
|
|
145
|
+
|
|
146
|
+
const mr = await db.select().from(machines).where(eq(machines.slug, machineSlug)).limit(1);
|
|
147
|
+
const m = mr[0];
|
|
148
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
149
|
+
|
|
150
|
+
const cmd =
|
|
151
|
+
`git -C ${shq(path)} rev-parse --is-inside-work-tree 2>&1` +
|
|
152
|
+
` && echo '---B---'` +
|
|
153
|
+
` && git -C ${shq(path)} rev-parse --abbrev-ref HEAD 2>&1`;
|
|
154
|
+
const r = await runShellOn(m, cmd);
|
|
155
|
+
const isRepo = r.ok && /(^|\n)true(\n|$)/.test(r.stdout);
|
|
156
|
+
if (!isRepo) {
|
|
157
|
+
return reply.code(400).send({
|
|
158
|
+
error: 'not a git work-tree',
|
|
159
|
+
detail: (r.stdout || r.stderr).trim().slice(0, 400),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const branch = (r.stdout.split('---B---')[1] ?? '').trim() || 'main';
|
|
163
|
+
const name = (b.name ?? '').trim() || path.split('/').filter(Boolean).pop() || path;
|
|
164
|
+
|
|
165
|
+
const ins = await db.insert(repos).values({
|
|
166
|
+
machineId: m.id, path, name, defaultBranch: branch,
|
|
167
|
+
}).returning();
|
|
168
|
+
|
|
169
|
+
return reply.code(201).send({ ok: true, repo: ins[0] });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ─── Design Mode ───────────────────────────────────────────────
|
|
173
|
+
// 캡처 키: 인증 off 면 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
|
|
174
|
+
const captureKeyOk = (req: { query?: unknown }): boolean => {
|
|
175
|
+
if (config.auth.disabled || config.auth.pass === '') return config.auth.disabled;
|
|
176
|
+
return ((req.query ?? {}) as { k?: string }).k === config.auth.pass;
|
|
177
|
+
};
|
|
178
|
+
const cors = (reply: { header: (k: string, v: string) => unknown }) => {
|
|
179
|
+
reply.header('access-control-allow-origin', '*');
|
|
180
|
+
reply.header('access-control-allow-methods', 'POST, OPTIONS');
|
|
181
|
+
reply.header('access-control-allow-headers', 'content-type');
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
app.options('/api/design/capture', async (_req, reply) => { cors(reply); return reply.code(204).send(); });
|
|
185
|
+
|
|
186
|
+
app.post('/api/design/capture', async (req, reply) => {
|
|
187
|
+
cors(reply);
|
|
188
|
+
if (!captureKeyOk(req)) return reply.code(401).send({ error: 'bad capture key' });
|
|
189
|
+
const b = (req.body ?? {}) as { url?: string; selector?: string; html?: string; css?: string; note?: string };
|
|
190
|
+
const ins = await db.insert(designCaptures).values({
|
|
191
|
+
url: (b.url ?? '').slice(0, 500),
|
|
192
|
+
selector: (b.selector ?? '').slice(0, 500),
|
|
193
|
+
html: (b.html ?? '').slice(0, 8000),
|
|
194
|
+
css: (b.css ?? '').slice(0, 4000),
|
|
195
|
+
note: (b.note ?? '').slice(0, 200),
|
|
196
|
+
}).returning();
|
|
197
|
+
broadcast({ type: 'capture', capture: ins[0] });
|
|
198
|
+
return reply.code(201).send({ ok: true, id: ins[0]!.id });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
app.get('/api/design', async () => ({ captures: await db.select().from(designCaptures) }));
|
|
202
|
+
|
|
203
|
+
app.delete('/api/design/:id', async (req, reply) => {
|
|
204
|
+
const id = Number((req.params as { id: string }).id);
|
|
205
|
+
await db.delete(designCaptures).where(eq(designCaptures.id, id));
|
|
206
|
+
return reply.code(200).send({ ok: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// 북마클릿 본체 — 외부 앱 <script> 로 로드됨(인증 예외, 키는 src 쿼리로 전달)
|
|
210
|
+
app.get('/design/bookmarklet.js', async (_req, reply) =>
|
|
211
|
+
reply.type('text/javascript').header('cache-control', 'no-store').send(BOOKMARKLET_JS));
|
|
212
|
+
|
|
213
|
+
// ─── Task ──────────────────────────────────────────────────────
|
|
214
|
+
app.get('/api/tasks', async (req) => {
|
|
215
|
+
const q = (req.query ?? {}) as { repo?: string };
|
|
216
|
+
if (q.repo) {
|
|
217
|
+
const id = Number(q.repo);
|
|
218
|
+
return { tasks: await db.select().from(tasks).where(eq(tasks.repoId, id)) };
|
|
219
|
+
}
|
|
220
|
+
return { tasks: await db.select().from(tasks) };
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
app.post('/api/tasks', async (req, reply) => {
|
|
224
|
+
const b = (req.body ?? {}) as { repoId?: number; title?: string; prompt?: string; designCaptureId?: number };
|
|
225
|
+
const repoId = Number(b.repoId);
|
|
226
|
+
const title = (b.title ?? '').trim();
|
|
227
|
+
if (!repoId || !title) return reply.code(400).send({ error: 'repoId and title required' });
|
|
228
|
+
const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
|
|
229
|
+
if (!rp[0]) return reply.code(404).send({ error: 'repo not found' });
|
|
230
|
+
let designCaptureId: number | null = null;
|
|
231
|
+
if (b.designCaptureId) {
|
|
232
|
+
const dc = await db.select().from(designCaptures).where(eq(designCaptures.id, Number(b.designCaptureId))).limit(1);
|
|
233
|
+
if (!dc[0]) return reply.code(404).send({ error: 'design capture not found' });
|
|
234
|
+
designCaptureId = dc[0].id;
|
|
235
|
+
}
|
|
236
|
+
const ins = await db.insert(tasks).values({ repoId, title, prompt: b.prompt ?? '', designCaptureId }).returning();
|
|
237
|
+
return reply.code(201).send({ ok: true, task: ins[0] });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
app.get('/api/tasks/:id', async (req, reply) => {
|
|
241
|
+
const id = Number((req.params as { id: string }).id);
|
|
242
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
243
|
+
if (!tr[0]) return reply.code(404).send({ error: 'not found' });
|
|
244
|
+
const runs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
|
|
245
|
+
return { task: tr[0], runs };
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// N개의 에이전트 run 을 만들고 각자 오케스트레이션 시작(fire-and-forget).
|
|
249
|
+
app.post('/api/tasks/:id/run', async (req, reply) => {
|
|
250
|
+
const id = Number((req.params as { id: string }).id);
|
|
251
|
+
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean };
|
|
252
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
253
|
+
const task = tr[0];
|
|
254
|
+
if (!task) return reply.code(404).send({ error: 'task not found' });
|
|
255
|
+
const rp = await db.select().from(repos).where(eq(repos.id, task.repoId)).limit(1);
|
|
256
|
+
if (!rp[0]) return reply.code(404).send({ error: 'repo missing' });
|
|
257
|
+
|
|
258
|
+
const count = Math.max(1, Math.min(8, Number(b.count) || 1));
|
|
259
|
+
const agent = b.agent ?? 'claude-code';
|
|
260
|
+
const created: Array<typeof agentRuns.$inferSelect> = [];
|
|
261
|
+
for (let i = 0; i < count; i++) {
|
|
262
|
+
const ins = await db.insert(agentRuns)
|
|
263
|
+
.values({ taskId: id, machineId: rp[0].machineId, agent, status: 'pending' })
|
|
264
|
+
.returning();
|
|
265
|
+
created.push(ins[0]!);
|
|
266
|
+
}
|
|
267
|
+
// 보드가 taskId 를 알도록 생성 브로드캐스트 후 백그라운드 시작.
|
|
268
|
+
for (const r of created) {
|
|
269
|
+
broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, branch: '', filesChanged: 0 });
|
|
270
|
+
void launchRun(r.id, b.real);
|
|
271
|
+
}
|
|
272
|
+
return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// 비교 뷰 — 태스크의 모든 run + 각 diff 를 한 방에 (승자 고르기용).
|
|
276
|
+
app.get('/api/tasks/:id/compare', async (req, reply) => {
|
|
277
|
+
const id = Number((req.params as { id: string }).id);
|
|
278
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
279
|
+
if (!tr[0]) return reply.code(404).send({ error: 'task not found' });
|
|
280
|
+
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
|
|
281
|
+
const runsOut = [];
|
|
282
|
+
for (const r of trs) {
|
|
283
|
+
const d = await getRunDiff(r.id);
|
|
284
|
+
runsOut.push({ ...r, diff: d.ok ? d.diff : '', stat: d.ok ? d.stat : d.stat });
|
|
285
|
+
}
|
|
286
|
+
return { task: tr[0], runs: runsOut };
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// 태스크 닫기 — 살아있는 run 중지 후 소속 run 전체 worktree/브랜치 정리.
|
|
290
|
+
app.post('/api/tasks/:id/close', async (req, reply) => {
|
|
291
|
+
const id = Number((req.params as { id: string }).id);
|
|
292
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
293
|
+
if (!tr[0]) return reply.code(404).send({ error: 'task not found' });
|
|
294
|
+
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
|
|
295
|
+
|
|
296
|
+
let anyStopped = false;
|
|
297
|
+
for (const r of trs) {
|
|
298
|
+
if ((await stopRun(r.id)).ok) anyStopped = true;
|
|
299
|
+
}
|
|
300
|
+
// SIGTERM 직후 worktree 파일 잠금이 풀리도록 잠깐 양보
|
|
301
|
+
if (anyStopped) await new Promise((res) => setTimeout(res, 400));
|
|
302
|
+
|
|
303
|
+
const cleanups = [];
|
|
304
|
+
for (const r of trs) cleanups.push({ runId: r.id, ...(await cleanupRun(r.id)) });
|
|
305
|
+
|
|
306
|
+
await db.update(tasks).set({ status: 'closed' }).where(eq(tasks.id, id));
|
|
307
|
+
broadcast({ type: 'task', taskId: id, status: 'closed' });
|
|
308
|
+
return { ok: true, taskId: id, cleanups };
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ─── Run ───────────────────────────────────────────────────────
|
|
312
|
+
app.get('/api/runs/:id', async (req, reply) => {
|
|
313
|
+
const id = Number((req.params as { id: string }).id);
|
|
314
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
315
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
316
|
+
const events = await db.select().from(agentEvents).where(eq(agentEvents.runId, id));
|
|
317
|
+
return { run: rr[0], events };
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
app.post('/api/runs/:id/cleanup', async (req, reply) => {
|
|
321
|
+
const id = Number((req.params as { id: string }).id);
|
|
322
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
323
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
324
|
+
const res = await cleanupRun(id);
|
|
325
|
+
return res;
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// 승자 run 머지 — run 브랜치를 repo 기본 브랜치로.
|
|
329
|
+
app.post('/api/runs/:id/merge', async (req, reply) => {
|
|
330
|
+
const id = Number((req.params as { id: string }).id);
|
|
331
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
332
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
333
|
+
const res = await mergeRun(id);
|
|
334
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
335
|
+
return res;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
|
|
339
|
+
app.post('/api/runs/:id/stop', async (req, reply) => {
|
|
340
|
+
const id = Number((req.params as { id: string }).id);
|
|
341
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
342
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
343
|
+
return stopRun(id);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// run worktree 의 변경 diff(tracked + untracked)
|
|
347
|
+
app.get('/api/runs/:id/diff', async (req, reply) => {
|
|
348
|
+
const id = Number((req.params as { id: string }).id);
|
|
349
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
350
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
351
|
+
return getRunDiff(id);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
355
|
+
app.get('/ws', { websocket: true }, (socket) => {
|
|
356
|
+
addSink(socket);
|
|
357
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.1.0' }));
|
|
358
|
+
socket.on('close', () => removeSink(socket));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// xterm 배포본 서빙(브라우저 터미널용, CDN 없음)
|
|
362
|
+
app.get('/vendor/:file', async (req, reply) => {
|
|
363
|
+
const { file } = req.params as { file: string };
|
|
364
|
+
const v = VENDOR[file];
|
|
365
|
+
if (!v) return reply.code(404).send({ error: 'not found' });
|
|
366
|
+
const path = require_.resolve(v.pkg).replace(/package\.json$/, v.rel);
|
|
367
|
+
const body = await readFile(path);
|
|
368
|
+
return reply.type(v.type).header('cache-control', 'public, max-age=86400').send(body);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// run 터미널 — tmux 세션에 PTY attach, WS 로 중계.
|
|
372
|
+
// client → {t:'i',d:string} 입력 · {t:'r',cols,rows} 리사이즈 / server → {t:'o',d} 출력 · {t:'exit'}
|
|
373
|
+
app.get('/ws/term/:id', { websocket: true }, async (socket, req) => {
|
|
374
|
+
const id = Number((req.params as { id: string }).id);
|
|
375
|
+
const info = await getRunTermInfo(id);
|
|
376
|
+
if (!info) {
|
|
377
|
+
socket.send(JSON.stringify({ t: 'err', d: 'run or tmux session not found' }));
|
|
378
|
+
socket.close();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// 세션 생존 확인(정리됐거나 머신 재부팅이면 attach 가 바로 죽는다)
|
|
382
|
+
const has = await runShellOn(info.machine, `tmux has-session -t ${shq(info.session)} 2>&1`, 8000);
|
|
383
|
+
if (!has.ok) {
|
|
384
|
+
socket.send(JSON.stringify({ t: 'err', d: `tmux session '${info.session}' not available` }));
|
|
385
|
+
socket.close();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
let term;
|
|
389
|
+
try {
|
|
390
|
+
term = openTerm(info.machine, info.session, 80, 24);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
socket.send(JSON.stringify({ t: 'err', d: 'pty spawn failed: ' + String(e).slice(0, 200) }));
|
|
393
|
+
socket.close();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
term.onData((d) => { try { socket.send(JSON.stringify({ t: 'o', d })); } catch { /* closed */ } });
|
|
397
|
+
term.onExit(() => { try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
|
|
398
|
+
socket.on('message', (raw: Buffer) => {
|
|
399
|
+
try {
|
|
400
|
+
const m = JSON.parse(raw.toString()) as { t?: string; d?: string; cols?: number; rows?: number };
|
|
401
|
+
if (m.t === 'i' && typeof m.d === 'string') term.write(m.d);
|
|
402
|
+
else if (m.t === 'r' && m.cols && m.rows) term.resize(Math.max(20, Math.min(500, m.cols)), Math.max(5, Math.min(200, m.rows)));
|
|
403
|
+
} catch { /* ignore */ }
|
|
404
|
+
});
|
|
405
|
+
socket.on('close', () => { try { term.kill(); } catch { /* gone */ } });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return app;
|
|
409
|
+
}
|
package/src/term.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { chmodSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import type { IPty } from 'node-pty';
|
|
5
|
+
import { config } from './config';
|
|
6
|
+
import type { MachineTarget } from './exec';
|
|
7
|
+
|
|
8
|
+
const require_ = createRequire(import.meta.url);
|
|
9
|
+
|
|
10
|
+
// node-pty prebuilt spawn-helper 는 npm 패키징에서 실행 비트가 빠져 오는 경우가 있어
|
|
11
|
+
// (posix_spawnp failed) 로드 전에 best-effort 로 보정한다.
|
|
12
|
+
function fixSpawnHelper(): void {
|
|
13
|
+
try {
|
|
14
|
+
const ptyPkg = require_.resolve('node-pty/package.json');
|
|
15
|
+
const base = dirname(ptyPkg);
|
|
16
|
+
for (const dir of [`darwin-${process.arch}`, `linux-${process.arch}`]) {
|
|
17
|
+
try { chmodSync(join(base, 'prebuilds', dir, 'spawn-helper'), 0o755); } catch { /* absent */ }
|
|
18
|
+
}
|
|
19
|
+
} catch { /* node-pty missing — openTerm 에서 에러 */ }
|
|
20
|
+
}
|
|
21
|
+
fixSpawnHelper();
|
|
22
|
+
|
|
23
|
+
// eslint 없는 프로젝트 — 동적 require 로 native 로드 실패를 호출 시점 에러로 미룬다.
|
|
24
|
+
type PtyModule = typeof import('node-pty');
|
|
25
|
+
let ptyMod: PtyModule | null = null;
|
|
26
|
+
function pty(): PtyModule {
|
|
27
|
+
if (!ptyMod) ptyMod = require_('node-pty') as PtyModule;
|
|
28
|
+
return ptyMod;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isLocal(m: MachineTarget): boolean {
|
|
32
|
+
return m.kind === 'local' || m.address === '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 머신의 tmux 세션에 PTY 로 attach.
|
|
37
|
+
* 로컬 → tmux attach 직접. 원격 → PTY 안에서 ssh -tt (리사이즈 SIGWINCH 전파됨).
|
|
38
|
+
*/
|
|
39
|
+
export function openTerm(m: MachineTarget, session: string, cols: number, rows: number): IPty {
|
|
40
|
+
const opts = {
|
|
41
|
+
name: 'xterm-256color',
|
|
42
|
+
cols: Math.max(20, Math.min(500, cols || 80)),
|
|
43
|
+
rows: Math.max(5, Math.min(200, rows || 24)),
|
|
44
|
+
env: { ...process.env, TERM: 'xterm-256color' } as Record<string, string>,
|
|
45
|
+
};
|
|
46
|
+
if (isLocal(m)) {
|
|
47
|
+
return pty().spawn('tmux', ['attach-session', '-t', session], opts);
|
|
48
|
+
}
|
|
49
|
+
const args: string[] = [
|
|
50
|
+
'-tt',
|
|
51
|
+
'-o', 'BatchMode=yes',
|
|
52
|
+
'-o', 'ConnectTimeout=6',
|
|
53
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
54
|
+
];
|
|
55
|
+
if (config.sshKey) args.push('-i', config.sshKey);
|
|
56
|
+
const target = m.sshUser ? `${m.sshUser}@${m.address}` : m.address;
|
|
57
|
+
// 세션명은 우리가 만든 coxpit-rN 형식이라 셸 주입 여지 없음 — 그래도 인용.
|
|
58
|
+
args.push(target, `tmux attach-session -t '${session.replace(/'/g, "'\\''")}'`);
|
|
59
|
+
return pty().spawn('ssh', args, opts);
|
|
60
|
+
}
|