coxpit 3.2.0 → 3.2.2
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 +4 -4
- package/src/config.ts +12 -0
- package/src/index.ts +5 -0
- package/src/orchestrator.ts +32 -5
- package/src/server.ts +3 -3
- package/src/term.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.2",
|
|
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",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@fastify/websocket": "^11",
|
|
46
46
|
"@libsql/client": "^0.14",
|
|
47
|
-
"@xterm/addon-fit": "^0.
|
|
48
|
-
"@xterm/addon-unicode11": "^0.
|
|
49
|
-
"@xterm/xterm": "^
|
|
47
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
48
|
+
"@xterm/addon-unicode11": "^0.8.0",
|
|
49
|
+
"@xterm/xterm": "^5.5.0",
|
|
50
50
|
"dotenv": "^16",
|
|
51
51
|
"drizzle-orm": "^0.38",
|
|
52
52
|
"fastify": "^5",
|
package/src/config.ts
CHANGED
|
@@ -14,8 +14,20 @@ import 'dotenv/config';
|
|
|
14
14
|
process.env.PATH = cur.join(':');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// launchd/systemd 데몬은 LANG 미설정(C 로케일)로 뜬다 — 그 상태로 tmux 에 attach 하면
|
|
18
|
+
// tmux 가 클라이언트를 UTF-8 불가로 판단해 CJK(한글 등)를 '_'/8진수로 뭉갠다.
|
|
19
|
+
// UTF-8 로케일을 부팅 시 1회 보장한다 (COXPIT_LANG 으로 재정의 가능).
|
|
20
|
+
{
|
|
21
|
+
const want = process.env.COXPIT_LANG
|
|
22
|
+
?? (/utf-?8/i.test(process.env.LANG ?? '') ? process.env.LANG! : 'en_US.UTF-8');
|
|
23
|
+
process.env.LANG = want;
|
|
24
|
+
if (process.env.LC_ALL && !/utf-?8/i.test(process.env.LC_ALL)) delete process.env.LC_ALL;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
/** 런타임 설정. 시크릿은 전부 env 주입(번들 0). */
|
|
18
28
|
export const config = {
|
|
29
|
+
// UTF-8 보장된 로케일 — PTY/원격 셸에 명시 전달용
|
|
30
|
+
lang: process.env.LANG!,
|
|
19
31
|
host: process.env.COXPIT_HOST ?? '127.0.0.1',
|
|
20
32
|
port: Number(process.env.COXPIT_PORT ?? 8210),
|
|
21
33
|
dbPath: process.env.COXPIT_DB ?? './coxpit.db',
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { config } from './config';
|
|
2
2
|
import { db, ensureSchema } from './db';
|
|
3
3
|
import { machines } from './db/schema';
|
|
4
|
+
import { reconcileOrphanRuns } from './orchestrator';
|
|
4
5
|
import { buildServer } from './server';
|
|
5
6
|
|
|
6
7
|
await ensureSchema();
|
|
@@ -10,5 +11,9 @@ if ((await db.select().from(machines)).length === 0) {
|
|
|
10
11
|
await db.insert(machines).values({ slug: 'local', name: 'This machine', kind: 'local', online: true });
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
// 재시작으로 고아가 된 running run 정산(카드가 영원히 '진행 중'으로 남는 것 방지).
|
|
15
|
+
const orphans = await reconcileOrphanRuns();
|
|
16
|
+
if (orphans > 0) console.log(`[coxpit] settled ${orphans} orphaned run(s) from a previous daemon instance`);
|
|
17
|
+
|
|
13
18
|
const app = await buildServer();
|
|
14
19
|
await app.listen({ host: config.host, port: config.port });
|
package/src/orchestrator.ts
CHANGED
|
@@ -122,9 +122,10 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
// 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치)
|
|
125
|
+
// 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치).
|
|
126
|
+
// export LANG: 이 명령이 tmux 서버를 처음 띄우는 경우(특히 원격) C 로케일로 뜨면 CJK 가 깨진다.
|
|
126
127
|
await runShellOn(ctx.machine,
|
|
127
|
-
`tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
|
|
128
|
+
`export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
|
|
128
129
|
|
|
129
130
|
await setRun(runId, { status: 'running' });
|
|
130
131
|
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
|
|
@@ -294,7 +295,17 @@ export async function getRunTermInfo(runId: number): Promise<{ machine: MachineT
|
|
|
294
295
|
*/
|
|
295
296
|
export async function stopRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
296
297
|
const child = liveChildren.get(runId);
|
|
297
|
-
if (!child)
|
|
298
|
+
if (!child) {
|
|
299
|
+
// 데몬 재시작 등으로 고아가 된 좀비 run — 프로세스는 없는데 DB 만 running.
|
|
300
|
+
// stop 요청을 정산으로 처리해 카드가 영원히 '진행 중'으로 남지 않게 한다.
|
|
301
|
+
const zr = (await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1))[0];
|
|
302
|
+
if (zr && (zr.status === 'running' || zr.status === 'starting')) {
|
|
303
|
+
await setRun(runId, { status: 'stopped', endedAt: new Date(), exitSummary: 'orphaned (daemon restarted) — settled by stop' });
|
|
304
|
+
await recordEvent(runId, 'meta', JSON.stringify({ orphanSettled: true }));
|
|
305
|
+
return { ok: true, detail: 'no live process — settled as stopped' };
|
|
306
|
+
}
|
|
307
|
+
return { ok: false, detail: 'not running' };
|
|
308
|
+
}
|
|
298
309
|
stoppedRuns.add(runId);
|
|
299
310
|
|
|
300
311
|
// 원격이면 먼저 원격 프로세스를 pid 파일로 죽인다(ssh 채널만 끊으면 잔존 가능).
|
|
@@ -444,8 +455,9 @@ export async function openWorkbench(repoId: number, title: string): Promise<{
|
|
|
444
455
|
|
|
445
456
|
const prep = await runShellOn(
|
|
446
457
|
machine,
|
|
447
|
-
// 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확
|
|
448
|
-
|
|
458
|
+
// 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확 일치만.
|
|
459
|
+
// export LANG: tmux 서버 첫 기동이 C 로케일이면 세션 셸의 CJK 입력·표시가 깨진다.
|
|
460
|
+
`export LANG=${shq(config.lang)}; mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
|
|
449
461
|
` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
450
462
|
` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
|
|
451
463
|
20000,
|
|
@@ -717,6 +729,21 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
|
|
|
717
729
|
/**
|
|
718
730
|
* worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
|
|
719
731
|
*/
|
|
732
|
+
/**
|
|
733
|
+
* 부팅 정산 — 데몬 재시작 후 살아있는 자식이 있을 수 없는데 DB 가 running/starting 인
|
|
734
|
+
* run(고아)을 failed 로 정리한다. workbench('open')는 에이전트가 없으므로 대상 아님.
|
|
735
|
+
*/
|
|
736
|
+
export async function reconcileOrphanRuns(): Promise<number> {
|
|
737
|
+
const stale = (await db.select().from(agentRuns)).filter(
|
|
738
|
+
(r) => r.status === 'running' || r.status === 'starting',
|
|
739
|
+
);
|
|
740
|
+
for (const r of stale) {
|
|
741
|
+
await setRun(r.id, { status: 'failed', endedAt: new Date(), exitSummary: 'orphaned by daemon restart' });
|
|
742
|
+
await recordEvent(r.id, 'error', 'daemon restarted while this run was live — settled as failed (worktree/branch preserved; diff still reviewable)');
|
|
743
|
+
}
|
|
744
|
+
return stale.length;
|
|
745
|
+
}
|
|
746
|
+
|
|
720
747
|
export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
721
748
|
const ctx = await loadContext(runId);
|
|
722
749
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
package/src/server.ts
CHANGED
|
@@ -33,7 +33,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
33
33
|
app.addHook('onRequest', authGate);
|
|
34
34
|
|
|
35
35
|
// 무인증 헬스(외부 감시용)
|
|
36
|
-
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.2.
|
|
36
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.2.2' }));
|
|
37
37
|
|
|
38
38
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨.
|
|
39
39
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
@@ -502,7 +502,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
502
502
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
503
503
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
504
504
|
addSink(socket);
|
|
505
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.2.
|
|
505
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.2.2' }));
|
|
506
506
|
socket.on('close', () => removeSink(socket));
|
|
507
507
|
});
|
|
508
508
|
|
|
@@ -536,7 +536,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
536
536
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
537
537
|
const wt = rr[0]?.worktreePath ?? '';
|
|
538
538
|
const revive = wt
|
|
539
|
-
? await runShellOn(info.machine, `test -d ${shq(wt)} && tmux new-session -d -s ${shq(info.session)} -c ${shq(wt)}`, 10000)
|
|
539
|
+
? await runShellOn(info.machine, `export LANG=${shq(config.lang)}; test -d ${shq(wt)} && tmux new-session -d -s ${shq(info.session)} -c ${shq(wt)}`, 10000)
|
|
540
540
|
: { ok: false } as { ok: boolean };
|
|
541
541
|
if (!revive.ok) {
|
|
542
542
|
socket.send(JSON.stringify({ t: 'err', d: `tmux session '${info.session}' gone and could not be revived (worktree missing?)` }));
|
package/src/term.ts
CHANGED
|
@@ -41,7 +41,8 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
|
|
|
41
41
|
name: 'xterm-256color',
|
|
42
42
|
cols: Math.max(20, Math.min(500, cols || 80)),
|
|
43
43
|
rows: Math.max(5, Math.min(200, rows || 24)),
|
|
44
|
-
|
|
44
|
+
// LANG: C 로케일 클라이언트로 attach 하면 tmux 가 CJK 를 '_' 로 뭉갠다 (config 에서 UTF-8 보장)
|
|
45
|
+
env: { ...process.env, TERM: 'xterm-256color', LANG: config.lang } as Record<string, string>,
|
|
45
46
|
};
|
|
46
47
|
if (isLocal(m)) {
|
|
47
48
|
return pty().spawn('tmux', ['attach-session', '-t', '=' + session], opts);
|
|
@@ -55,6 +56,7 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
|
|
|
55
56
|
if (config.sshKey) args.push('-i', config.sshKey);
|
|
56
57
|
const target = m.sshUser ? `${m.sshUser}@${m.address}` : m.address;
|
|
57
58
|
// 세션명은 우리가 만든 coxpit-rN 형식이라 셸 주입 여지 없음 — 그래도 인용.
|
|
58
|
-
|
|
59
|
+
// 원격도 UTF-8 로케일 명시 (비대화 ssh 는 LANG 미설정이 보통)
|
|
60
|
+
args.push(target, `export LANG='${config.lang.replace(/'/g, '')}'; tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`);
|
|
59
61
|
return pty().spawn('ssh', args, opts);
|
|
60
62
|
}
|