coxpit 6.2.0 → 6.3.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 +1 -1
- package/src/activity.ts +29 -0
- package/src/board.ts +116 -13
- package/src/cockpit.ts +92 -27
- package/src/db/index.ts +22 -0
- package/src/db/schema.ts +4 -0
- package/src/hud.ts +672 -0
- package/src/orchestrator.ts +116 -27
- package/src/server.ts +85 -19
package/src/orchestrator.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from '
|
|
|
5
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
|
-
import { eq, inArray, and } from 'drizzle-orm';
|
|
8
|
+
import { eq, inArray, and, desc } from 'drizzle-orm';
|
|
9
9
|
import { config } from './config';
|
|
10
10
|
import { db } from './db';
|
|
11
11
|
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups, secrets } from './db/schema';
|
|
@@ -104,8 +104,17 @@ async function recordEvent(runId: number, kind: string, payload: string): Promis
|
|
|
104
104
|
broadcast({ type: 'event', runId, kind, payload });
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* run 레코드가 움직인 횟수. 오케스트레이션 워처(1.5초 간격)가 "다시 읽을 일이 생겼나"를
|
|
109
|
+
* 쿼리 없이 O(1) 로 판단하는 데 쓴다. setRun 은 run 의 수명 전환에서만 불린다(줄당이 아니다)
|
|
110
|
+
* — 그래서 이 카운터는 드물게 움직이고, 조용한 틱은 DB 를 아예 건드리지 않는다.
|
|
111
|
+
* 부모별로 좁히지 않고 하나로 두는 건 의도다: 넘치게 칠해 한 번 더 읽는 쪽이 놓치는 쪽보다 낫다.
|
|
112
|
+
*/
|
|
113
|
+
let runRev = 0;
|
|
114
|
+
|
|
107
115
|
async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInsert>): Promise<void> {
|
|
108
116
|
await db.update(agentRuns).set(patch).where(eq(agentRuns.id, runId));
|
|
117
|
+
runRev++;
|
|
109
118
|
broadcast({ type: 'run', runId, ...patch });
|
|
110
119
|
}
|
|
111
120
|
|
|
@@ -277,14 +286,17 @@ export function startOrchWatch(runId: number, wtPath: string, real: boolean): No
|
|
|
277
286
|
const dir = ppath.join(wtPath, '.coxpit');
|
|
278
287
|
let last = '';
|
|
279
288
|
let busy = false;
|
|
289
|
+
let seenRev = -1; // 현황을 마지막으로 읽은 시점의 runRev. -1 = 아직 한 번도 안 읽음(첫 틱은 읽는다)
|
|
280
290
|
return setInterval(() => {
|
|
281
291
|
if (busy) return;
|
|
282
292
|
busy = true;
|
|
283
293
|
void (async () => {
|
|
294
|
+
let consumed = false;
|
|
284
295
|
try {
|
|
285
296
|
const spawnPath = ppath.join(dir, 'spawn.json');
|
|
286
297
|
const txt = await readFile(spawnPath, 'utf8').catch(() => null);
|
|
287
298
|
if (txt !== null) {
|
|
299
|
+
consumed = true;
|
|
288
300
|
await rm(spawnPath).catch(() => { /* consumed */ });
|
|
289
301
|
try {
|
|
290
302
|
const req = JSON.parse(txt) as unknown;
|
|
@@ -298,14 +310,22 @@ export function startOrchWatch(runId: number, wtPath: string, real: boolean): No
|
|
|
298
310
|
await recordEvent(runId, 'error', 'spawn.json was not valid JSON — nothing spawned');
|
|
299
311
|
}
|
|
300
312
|
}
|
|
301
|
-
// 현황 파일 —
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
313
|
+
// 현황 파일 — **이번 틱에 뭔가 실제로 벌어졌을 때만** 읽고 쓴다.
|
|
314
|
+
// 조건 둘: spawn.json 을 소비했거나(위), run 레코드가 움직였거나(runRev).
|
|
315
|
+
// 조용한 틱(대부분)에는 쿼리조차 돌지 않는다 — 1.5초마다 도는 워처라 이 절약이
|
|
316
|
+
// run 이 사는 동안 계속 쌓인다. rev 를 **읽기 전에 떠 두고 성공 후에 저장**하는 건 의도다:
|
|
317
|
+
// 실패하면 여전히 더러운 상태로 남아 다음 틱에 재시도되고, 읽는 동안 생긴 변화도 안 놓친다.
|
|
318
|
+
const rev = runRev;
|
|
319
|
+
if (consumed || rev !== seenRev) {
|
|
320
|
+
const subs = await listSubtasks(runId);
|
|
321
|
+
seenRev = rev;
|
|
322
|
+
if (subs.length) {
|
|
323
|
+
const j = JSON.stringify(subs, null, 2);
|
|
324
|
+
if (j !== last) {
|
|
325
|
+
last = j;
|
|
326
|
+
await mkdir(dir, { recursive: true });
|
|
327
|
+
await writeFile(ppath.join(dir, 'subtasks.json'), j);
|
|
328
|
+
}
|
|
309
329
|
}
|
|
310
330
|
}
|
|
311
331
|
} catch { /* 워처 오류는 조용히 — 다음 틱에 재시도 */ }
|
|
@@ -344,13 +364,15 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
|
|
|
344
364
|
const runIds: number[] = [];
|
|
345
365
|
for (let i = 0; i < n; i++) {
|
|
346
366
|
const rIns = await db.insert(agentRuns).values({
|
|
347
|
-
taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending',
|
|
367
|
+
taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending', real,
|
|
348
368
|
}).returning();
|
|
349
369
|
const run = rIns[0]!;
|
|
350
|
-
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
|
|
370
|
+
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, real, branch: '', filesChanged: 0 });
|
|
351
371
|
void launchRun(run.id, real);
|
|
352
372
|
runIds.push(run.id);
|
|
353
373
|
}
|
|
374
|
+
// 새 자식이 생겼다 — 부모 워처가 다음 틱에 현황을 다시 읽도록 표시한다(HTTP 쌍둥이 경로 포함).
|
|
375
|
+
runRev++;
|
|
354
376
|
await recordEvent(parentRunId, 'meta', JSON.stringify({ subtask: task.id, title: task.title, runs: runIds }));
|
|
355
377
|
return { ok: true, detail: `spawned task #${task.id} (${runIds.length} run(s))`, taskId: task.id, runIds };
|
|
356
378
|
}
|
|
@@ -484,7 +506,9 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
484
506
|
const session = `coxpit-r${runId}`;
|
|
485
507
|
|
|
486
508
|
try {
|
|
487
|
-
|
|
509
|
+
// real 은 여기서 각인한다 — 명령을 고르는 그 값이 곧 run 의 사실이다(v5.28 H1).
|
|
510
|
+
// 'preparing' 에 실어 두면 worktree 단계에서 넘어져도 "이 run 은 모의였다"가 남는다.
|
|
511
|
+
await setRun(runId, { status: 'preparing', real: !!useReal, branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
|
|
488
512
|
|
|
489
513
|
// 1) worktree 생성(격리 브랜치) — in-place 는 건너뛴다(격리가 없는 것이 요점).
|
|
490
514
|
if (!inPlace) {
|
|
@@ -607,6 +631,17 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
607
631
|
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
608
632
|
}
|
|
609
633
|
|
|
634
|
+
/**
|
|
635
|
+
* verify 러너 — 명령 한 번, 꼬리 한 줌. 어디서 돌리든 판정 규칙은 하나여야 해서
|
|
636
|
+
* verifyRun(run 의 worktree)·verifyBase(머지된 base)가 이 함수를 같이 쓴다.
|
|
637
|
+
*/
|
|
638
|
+
async function runVerifyCmd(cmd: string, cwd: string, machine: MachineTarget): Promise<{ status: string; output: string }> {
|
|
639
|
+
const r = await runShellOn(machine, `cd ${shq(cwd)} && ( ${cmd} )`, 180000);
|
|
640
|
+
const merged = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
|
|
641
|
+
const output = merged.length > 6000 ? '…' + merged.slice(-6000) : merged;
|
|
642
|
+
return { status: r.ok ? 'pass' : r.code === -1 ? 'error' : 'fail', output };
|
|
643
|
+
}
|
|
644
|
+
|
|
610
645
|
/**
|
|
611
646
|
* Verify in-loop — repo.verifyCmd 를 run 의 worktree 에서 실행해 pass/fail 을 기록.
|
|
612
647
|
* verifyCmd 미설정이면 상태를 비우고 no-op. 정착 훅이 자동 호출(done+변경), 수동 재검증도 지원.
|
|
@@ -626,12 +661,34 @@ export async function verifyRun(runId: number): Promise<{ ok: boolean; status: s
|
|
|
626
661
|
return { ok: false, status: 'error', detail: 'worktree missing' };
|
|
627
662
|
}
|
|
628
663
|
await setRun(runId, { verifyStatus: 'running', verifyOutput: '' });
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
664
|
+
const v = await runVerifyCmd(cmd, run.worktreePath, ctx.machine);
|
|
665
|
+
await setRun(runId, { verifyStatus: v.status, verifyOutput: v.output });
|
|
666
|
+
return { ok: true, status: v.status };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* 머지된 base 검증(v5.28 J1) — repo.verifyCmd 를 **repo.path**(머지가 막 내려앉은 기본 브랜치)에서
|
|
671
|
+
* 돌린다. 승자가 내려앉은 그 호흡에 같은 명령을 돌려 pass/fail 을 그 자리에서 말하기 위한 것이라,
|
|
672
|
+
* 지나간 worktree 가 아니라 base 를 본다.
|
|
673
|
+
* - verifyCmd 가 비어 있으면 아무 명령도 추측하지 않고 no-op(status '').
|
|
674
|
+
* - best-effort — 실패는 보고일 뿐, 머지를 되돌리지 않는다.
|
|
675
|
+
*/
|
|
676
|
+
export async function verifyBase(repoId: number): Promise<{ status: string; output: string }> {
|
|
677
|
+
const none = { status: '', output: '' };
|
|
678
|
+
const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
|
|
679
|
+
const repo = rp[0];
|
|
680
|
+
if (!repo) return none;
|
|
681
|
+
const cmd = (repo.verifyCmd ?? '').trim();
|
|
682
|
+
if (!cmd) return none;
|
|
683
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
684
|
+
const m = mr[0];
|
|
685
|
+
if (!m) return none;
|
|
686
|
+
const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
687
|
+
try {
|
|
688
|
+
return await runVerifyCmd(cmd, repo.path, machine);
|
|
689
|
+
} catch (e) {
|
|
690
|
+
return { status: 'error', output: String(e).slice(0, 300) };
|
|
691
|
+
}
|
|
635
692
|
}
|
|
636
693
|
|
|
637
694
|
/**
|
|
@@ -795,6 +852,25 @@ export async function getScrollback(runId: number, lines: number): Promise<{ ok:
|
|
|
795
852
|
return { ok: true, text: r.stdout };
|
|
796
853
|
}
|
|
797
854
|
|
|
855
|
+
/**
|
|
856
|
+
* 살아 있는 tmux 페인에 한 줄 써 넣기 (v5.28 K) — getScrollback 의 **쓰기 쌍둥이**.
|
|
857
|
+
* HUD 가 코크핏을 열지 않고 "대기 중인 에이전트"에게 답하는 유일한 길이다(steer 는 정착한 run 전용).
|
|
858
|
+
* 타깃 규율은 capture-pane 과 똑같이 맞춘다: send-keys 도 '=' 접두사를 못 받는 tmux 가 있어
|
|
859
|
+
* 세션명을 그대로 쓴다. 사람이 친 것과 같게 하려고 Enter 를 따로 한 번 더 보낸다.
|
|
860
|
+
* 세션이 없거나(정리됨) 죽었으면 ok:false — 라우트가 409 로 돌려준다. 지어내지 않는다.
|
|
861
|
+
*/
|
|
862
|
+
export async function sendRunInput(runId: number, text: string): Promise<{ ok: boolean; detail: string }> {
|
|
863
|
+
const info = await getRunTermInfo(runId);
|
|
864
|
+
if (!info) return { ok: false, detail: 'no terminal session' };
|
|
865
|
+
const r = await runShellOn(
|
|
866
|
+
info.machine,
|
|
867
|
+
`tmux send-keys -t ${shq(info.session)} ${shq(text)} Enter`,
|
|
868
|
+
10000,
|
|
869
|
+
);
|
|
870
|
+
if (!r.ok) return { ok: false, detail: (r.stderr || r.stdout).trim().slice(0, 300) || 'send-keys failed' };
|
|
871
|
+
return { ok: true, detail: 'sent' };
|
|
872
|
+
}
|
|
873
|
+
|
|
798
874
|
/**
|
|
799
875
|
* 이 run 의 페인이 **지금 서 있는 폴더** (v5.28 D-fix).
|
|
800
876
|
* 터미널이 찍은 상대경로를 열려면 worktree 루트가 아니라 페인의 cwd 가 기준이어야 한다 —
|
|
@@ -911,11 +987,16 @@ interface OutputsManifestItem { path?: string; type?: string; title?: string }
|
|
|
911
987
|
|
|
912
988
|
/** run 의 최종 답변 텍스트 — result 이벤트(payload JSON) 우선, 없으면 exitSummary. */
|
|
913
989
|
async function runAnswerText(run: typeof agentRuns.$inferSelect): Promise<string> {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
990
|
+
// result 이벤트만, 최신 순으로 — 전량 로드 후 뒤에서 훑던 것과 결과는 같고 읽는 행은 훨씬 적다
|
|
991
|
+
// (run 당 result 는 보통 한 줄, resume 한 run 이면 몇 줄). LIMIT 1 을 쓰지 않는 건 의도다:
|
|
992
|
+
// 가장 최신 result 의 payload 에 쓸 만한 result 문자열이 없으면 예전 result 로 내려가야 하고,
|
|
993
|
+
// 그게 원래 동작이다 — 같은 답을 내는 것이 이 변경의 조건이다.
|
|
994
|
+
const evs = await db.select().from(agentEvents)
|
|
995
|
+
.where(and(eq(agentEvents.runId, run.id), eq(agentEvents.kind, 'result')))
|
|
996
|
+
.orderBy(desc(agentEvents.id));
|
|
997
|
+
for (const e of evs) {
|
|
917
998
|
try {
|
|
918
|
-
const o = JSON.parse(
|
|
999
|
+
const o = JSON.parse(e.payload) as { result?: string };
|
|
919
1000
|
if (typeof o.result === 'string' && o.result.trim()) return o.result.trim();
|
|
920
1001
|
} catch { /* 비-JSON result — exitSummary 로 폴백 */ }
|
|
921
1002
|
}
|
|
@@ -1267,9 +1348,9 @@ export async function launchGroupTask(
|
|
|
1267
1348
|
const machineId = rp[0]!.machineId;
|
|
1268
1349
|
const tIns = await db.insert(tasks).values({ repoId, title: title.slice(0, 140), prompt, groupId }).returning();
|
|
1269
1350
|
const task = tIns[0]!;
|
|
1270
|
-
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending' }).returning();
|
|
1351
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending', real }).returning();
|
|
1271
1352
|
const run = rIns[0]!;
|
|
1272
|
-
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
|
|
1353
|
+
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, real, branch: '', filesChanged: 0 });
|
|
1273
1354
|
void launchRun(run.id, real);
|
|
1274
1355
|
return { id: task.id, title: task.title, runId: run.id };
|
|
1275
1356
|
}
|
|
@@ -1512,6 +1593,8 @@ export interface IntegrateResult {
|
|
|
1512
1593
|
detail?: string;
|
|
1513
1594
|
integrationTaskId?: number;
|
|
1514
1595
|
integrationRunId?: number;
|
|
1596
|
+
/** 머지된 건에 한해 base 검증 결과(v5.28 J1). verifyCmd 가 없으면 status ''. */
|
|
1597
|
+
verify?: { status: string; output: string };
|
|
1515
1598
|
}
|
|
1516
1599
|
|
|
1517
1600
|
/**
|
|
@@ -1529,7 +1612,13 @@ export async function integrateRuns(runIds: number[], real?: boolean): Promise<I
|
|
|
1529
1612
|
if (run.status === 'merged') { results.push({ runId: id, status: 'skipped', detail: 'already merged' }); continue; }
|
|
1530
1613
|
|
|
1531
1614
|
const m = await mergeRun(id);
|
|
1532
|
-
if (m.ok) {
|
|
1615
|
+
if (m.ok) {
|
|
1616
|
+
// 내려앉은 그 호흡에 base 를 검증한다(J1) — 실패해도 머지는 그대로 서 있고, 보고만 된다.
|
|
1617
|
+
const mc = await loadContext(id);
|
|
1618
|
+
const verify = mc ? await verifyBase(mc.repoId) : { status: '', output: '' };
|
|
1619
|
+
results.push({ runId: id, status: 'merged', verify });
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1533
1622
|
if (!m.conflict) { results.push({ runId: id, status: 'skipped', detail: m.detail }); continue; }
|
|
1534
1623
|
|
|
1535
1624
|
// 충돌 → 통합 태스크 자동 발사 (에이전트가 머지를 대신 푼다)
|
|
@@ -1544,9 +1633,9 @@ export async function integrateRuns(runIds: number[], real?: boolean): Promise<I
|
|
|
1544
1633
|
`Do not modify files unrelated to the conflicts.`;
|
|
1545
1634
|
const tIns = await db.insert(tasks).values({ repoId: ctx.repoId, title, prompt }).returning();
|
|
1546
1635
|
const newTask = tIns[0]!;
|
|
1547
|
-
const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending' }).returning();
|
|
1636
|
+
const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending', real: real ?? true }).returning();
|
|
1548
1637
|
const newRun = rIns[0]!;
|
|
1549
|
-
broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, branch: '', filesChanged: 0 });
|
|
1638
|
+
broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, real: real ?? true, branch: '', filesChanged: 0 });
|
|
1550
1639
|
void launchRun(newRun.id, real ?? true);
|
|
1551
1640
|
results.push({ runId: id, status: 'conflict', detail: m.detail, integrationTaskId: newTask.id, integrationRunId: newRun.id });
|
|
1552
1641
|
}
|
package/src/server.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
|
|
9
9
|
import websocket from '@fastify/websocket';
|
|
10
|
-
import { eq, inArray, and, like, desc } from 'drizzle-orm';
|
|
10
|
+
import { eq, inArray, and, like, desc, asc, sql } from 'drizzle-orm';
|
|
11
11
|
import { authGate } from './auth';
|
|
12
12
|
import { loginPageHTML } from './login';
|
|
13
13
|
import {
|
|
@@ -21,7 +21,7 @@ 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, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getRunPwd, 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, verifyBase, openSessionAt, deleteSession, getScrollback, sendRunInput, getRunPwd, getSessionChat } from './orchestrator';
|
|
25
25
|
import { openTerm } from './term';
|
|
26
26
|
import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates, spottedPorts } from './agentstate';
|
|
27
27
|
import { scanListeners, scanPort, killPid, dropCache as dropListenerCache } from './procscan';
|
|
@@ -30,6 +30,7 @@ import { getProvider, listProviders } from './providers';
|
|
|
30
30
|
import { remoteState, setServe, setFunnel } from './remote';
|
|
31
31
|
import { BOARD_HTML } from './board';
|
|
32
32
|
import { COCKPIT_HTML } from './cockpit';
|
|
33
|
+
import { HUD_HTML } from './hud';
|
|
33
34
|
import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile, withinFilesRoot } from './files';
|
|
34
35
|
import { ensureWorkDoc, readWorkDoc, writeWorkDoc, removeWorkDoc, workDocPath, workDocSize } from './workdoc';
|
|
35
36
|
|
|
@@ -47,6 +48,12 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
|
47
48
|
'marked.js': { pkg: 'marked/package.json', rel: 'marked.min.js', type: 'text/javascript' },
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
// 버전 치환은 **모듈 로드 때 한 번** — config.version 은 부트 후 변하지 않고, cockpit.ts 는
|
|
52
|
+
// 281KB 짜리 한 문자열이다. 요청마다 전역 정규식으로 다시 훑던 것을 상수로 굳혔다
|
|
53
|
+
// (보드는 원래 상수를 그대로 내보낸다 — 세 페이지가 같은 방식이 됐다).
|
|
54
|
+
const COCKPIT_PAGE = COCKPIT_HTML.replace(/__COXPIT_VER__/g, config.version);
|
|
55
|
+
const HUD_PAGE = HUD_HTML.replace(/__COXPIT_VER__/g, config.version);
|
|
56
|
+
|
|
50
57
|
// ─── 읽기 전용 공유 페이지 (서버 렌더 스냅샷 — 스크립트 0, 액션 0) ───────────
|
|
51
58
|
const escH = (x: unknown): string =>
|
|
52
59
|
String(x ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
|
@@ -250,7 +257,10 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
250
257
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
|
|
251
258
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
252
259
|
// 터미널 우선 셸(병행 개발) — 백엔드는 보드와 공유. Phase 5에서 데스크톱 기본을 여기로 플립 예정.
|
|
253
|
-
app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(
|
|
260
|
+
app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_PAGE));
|
|
261
|
+
// HUD(v5.28 K) — 플릿을 작게 다시 내놓는 한 장. 데스크톱의 떠 있는 작은 창이 이걸 띄운다.
|
|
262
|
+
// 서빙되는 **페이지**라 /cockpit 과 같은 게이트 뒤다(무인증 예외 아님 — 헬스가 아니다).
|
|
263
|
+
app.get('/hud', async (_req, reply) => reply.type('text/html').send(HUD_PAGE));
|
|
254
264
|
|
|
255
265
|
// ─── 접근키 인증(access-key) ────────────────────────────────────
|
|
256
266
|
// 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.
|
|
@@ -366,24 +376,40 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
366
376
|
const activeTasks = allTasks.filter((t) => t.status !== 'closed');
|
|
367
377
|
const closedCount = allTasks.length - activeTasks.length;
|
|
368
378
|
const ts = view === 'all' ? allTasks : activeTasks;
|
|
369
|
-
const taskIds =
|
|
370
|
-
|
|
371
|
-
|
|
379
|
+
const taskIds = ts.map((t) => t.id);
|
|
380
|
+
// run 도 **보는 뷰만** 가져온다 — 전량 로드 후 JS 필터가 아니라 SQL WHERE.
|
|
381
|
+
// 정렬을 명시하는 건 필터가 끼어도 클라이언트가 받던 id 오름차순을 그대로 지키기 위함이다.
|
|
382
|
+
const rns: Array<typeof agentRuns.$inferSelect> = view === 'all'
|
|
383
|
+
? await db.select().from(agentRuns).orderBy(asc(agentRuns.id))
|
|
384
|
+
: taskIds.length
|
|
385
|
+
? await db.select().from(agentRuns).where(inArray(agentRuns.taskId, taskIds)).orderBy(asc(agentRuns.id))
|
|
386
|
+
: [];
|
|
372
387
|
const runIds = rns.map((r) => r.id);
|
|
373
|
-
// 이벤트는
|
|
374
|
-
|
|
388
|
+
// 이벤트는 run 당 최근 EVENT_CAP 개만 **SQL 에서** 잘라 온다. 전량 로드 후 JS 슬라이스가
|
|
389
|
+
// 정확히 고치려던 그 낭비다(활성 run 의 모든 이벤트 행을 메모리에 올렸다).
|
|
390
|
+
// 창 함수로 run 별 역순 번호를 매겨 상한 안쪽만 남기고, 클라이언트가 기대하는 id 오름차순으로 낸다.
|
|
391
|
+
const evs: Array<{ run_id: number; kind: string; payload: string }> = runIds.length
|
|
392
|
+
? await db.all<{ run_id: number; kind: string; payload: string }>(sql`
|
|
393
|
+
SELECT id, run_id, kind, payload FROM (
|
|
394
|
+
SELECT id, run_id, kind, payload,
|
|
395
|
+
ROW_NUMBER() OVER (PARTITION BY run_id ORDER BY id DESC) AS rn
|
|
396
|
+
FROM agent_events WHERE ${inArray(agentEvents.runId, runIds)}
|
|
397
|
+
) WHERE rn <= ${EVENT_CAP} ORDER BY run_id ASC, id ASC`)
|
|
398
|
+
: [];
|
|
375
399
|
const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
|
|
376
400
|
for (const e of evs) {
|
|
377
|
-
const arr = byRun.get(e.
|
|
401
|
+
const arr = byRun.get(e.run_id) ?? [];
|
|
378
402
|
arr.push({ kind: e.kind, payload: e.payload });
|
|
379
|
-
byRun.set(e.
|
|
403
|
+
byRun.set(e.run_id, arr);
|
|
380
404
|
}
|
|
381
405
|
const taskOut = new Map(ts.map((t) => [t.id, t.outputs]));
|
|
382
406
|
return {
|
|
383
407
|
machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
|
|
384
408
|
runs: rns.map((r) => {
|
|
385
409
|
const sig = noopSignal(r.status, r.filesChanged, r.exitSummary, taskOut.get(r.taskId) ?? '[]');
|
|
386
|
-
|
|
410
|
+
// real 은 언제나 불리언으로 나간다 — 클라이언트는 real===false 하나만 보고 dry 칩을 그린다.
|
|
411
|
+
// events 는 이미 SQL 에서 EVENT_CAP 으로 잘려 왔다 — 여기서 또 자르지 않는다.
|
|
412
|
+
return { ...r, real: !!r.real, events: byRun.get(r.id) ?? [], noop: sig.noop, noopReason: sig.reason };
|
|
387
413
|
}),
|
|
388
414
|
counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
|
|
389
415
|
// 지금 터미널이 붙어 있는 run 의 에이전트 상태(runId → {state,detail,ts}).
|
|
@@ -492,15 +518,28 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
492
518
|
const grpTitle = new Map((await db.select().from(taskGroups)).map((g) => [g.id, g.title]));
|
|
493
519
|
const total0 = closed.length;
|
|
494
520
|
const page = closed.slice(offset, offset + limit);
|
|
521
|
+
// 페이지에 실릴 태스크의 run 을 **한 번에** 긁어 taskId 로 묶는다.
|
|
522
|
+
// 태스크마다 한 방씩 쏘던 N+1(50행 페이지 = 51 쿼리)을 한 쿼리로 접었다.
|
|
523
|
+
const pageIds = page.map((t) => t.id);
|
|
524
|
+
const runsByTask = new Map<number, Array<typeof agentRuns.$inferSelect>>();
|
|
525
|
+
if (pageIds.length) {
|
|
526
|
+
const pageRuns = await db.select().from(agentRuns)
|
|
527
|
+
.where(inArray(agentRuns.taskId, pageIds)).orderBy(asc(agentRuns.id));
|
|
528
|
+
for (const r of pageRuns) {
|
|
529
|
+
const arr = runsByTask.get(r.taskId) ?? [];
|
|
530
|
+
arr.push(r);
|
|
531
|
+
runsByTask.set(r.taskId, arr);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
495
534
|
const rows = [];
|
|
496
535
|
for (const t of page) {
|
|
497
|
-
const rs =
|
|
536
|
+
const rs = runsByTask.get(t.id) ?? [];
|
|
498
537
|
if (q.status && !rs.some((r) => r.status === q.status)) continue;
|
|
499
538
|
rows.push({
|
|
500
539
|
taskId: t.id, title: t.title, repoName: repoName.get(t.repoId) ?? '?',
|
|
501
540
|
groupTitle: t.groupId != null ? grpTitle.get(t.groupId) ?? null : null,
|
|
502
541
|
closedAt: t.closedAt ? Math.floor(t.closedAt.getTime() / 1000) : (t.createdAt ? Math.floor(t.createdAt.getTime() / 1000) : 0),
|
|
503
|
-
runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model })),
|
|
542
|
+
runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model, real: !!r.real })),
|
|
504
543
|
});
|
|
505
544
|
}
|
|
506
545
|
// status 필터가 있으면 total 은 근사(페이지 내 필터) — UI 는 rows 로만 판단하니 total0 유지.
|
|
@@ -958,7 +997,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
958
997
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
959
998
|
if (!tr[0]) return reply.code(404).send({ error: 'not found' });
|
|
960
999
|
const runs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
|
|
961
|
-
return { task: tr[0], runs };
|
|
1000
|
+
return { task: tr[0], runs: runs.map((r) => ({ ...r, real: !!r.real })) };
|
|
962
1001
|
});
|
|
963
1002
|
|
|
964
1003
|
// 태스크 이름 변경(=세션 이름 변경) + v6.0 S2 승격(repoId 재부모화).
|
|
@@ -1089,16 +1128,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1089
1128
|
});
|
|
1090
1129
|
}
|
|
1091
1130
|
}
|
|
1131
|
+
// dry/real 은 run 의 성질이다 — 만들 때부터 각인한다(v5.28 H1). body 가 말이 없으면
|
|
1132
|
+
// launchRun 이 쓰게 될 그 기본값(config.agent.real)을 그대로 쓴다: 행과 명령이 갈리면 안 된다.
|
|
1133
|
+
const useReal = b.real === undefined ? config.agent.real : !!b.real;
|
|
1092
1134
|
const created: Array<typeof agentRuns.$inferSelect> = [];
|
|
1093
1135
|
for (let i = 0; i < count; i++) {
|
|
1094
1136
|
const ins = await db.insert(agentRuns)
|
|
1095
|
-
.values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending' })
|
|
1137
|
+
.values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending', real: useReal })
|
|
1096
1138
|
.returning();
|
|
1097
1139
|
created.push(ins[0]!);
|
|
1098
1140
|
}
|
|
1099
1141
|
// 보드가 taskId 를 알도록 생성 브로드캐스트 후 백그라운드 시작.
|
|
1100
1142
|
for (const r of created) {
|
|
1101
|
-
broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, branch: '', filesChanged: 0 });
|
|
1143
|
+
broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, real: useReal, branch: '', filesChanged: 0 });
|
|
1102
1144
|
void launchRun(r.id, b.real);
|
|
1103
1145
|
}
|
|
1104
1146
|
return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
|
|
@@ -1124,7 +1166,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1124
1166
|
const runsOut = [];
|
|
1125
1167
|
for (const r of trs) {
|
|
1126
1168
|
const d = await getRunDiff(r.id);
|
|
1127
|
-
runsOut.push({ ...r, diff: d.ok ? d.diff : '', stat: d.ok ? d.stat : d.stat });
|
|
1169
|
+
runsOut.push({ ...r, real: !!r.real, diff: d.ok ? d.diff : '', stat: d.ok ? d.stat : d.stat });
|
|
1128
1170
|
}
|
|
1129
1171
|
return { task: tr[0], runs: runsOut };
|
|
1130
1172
|
});
|
|
@@ -1165,7 +1207,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1165
1207
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1166
1208
|
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1167
1209
|
const events = await db.select().from(agentEvents).where(eq(agentEvents.runId, id));
|
|
1168
|
-
return { run: rr[0], events };
|
|
1210
|
+
return { run: { ...rr[0], real: !!rr[0].real }, events };
|
|
1169
1211
|
});
|
|
1170
1212
|
|
|
1171
1213
|
// v6.0 T4 — run 의 역할 이름 변경(탭 더블클릭). title 만 받는다.
|
|
@@ -1226,7 +1268,11 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1226
1268
|
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1227
1269
|
const res = await mergeRun(id);
|
|
1228
1270
|
if (!res.ok) return reply.code(409).send(res);
|
|
1229
|
-
|
|
1271
|
+
// 내려앉은 그 호흡에 머지된 base 를 검증한다(v5.28 J1). verifyCmd 가 없으면 status ''(no-op),
|
|
1272
|
+
// 실패해도 머지는 되돌리지 않는다 — 보고만 한다.
|
|
1273
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, rr[0].taskId)).limit(1);
|
|
1274
|
+
const verify = tr[0] ? await verifyBase(tr[0].repoId) : { status: '', output: '' };
|
|
1275
|
+
return { ...res, verify };
|
|
1230
1276
|
});
|
|
1231
1277
|
|
|
1232
1278
|
// 후속 지시(steer) — 정착한 run 을 같은 세션(--resume)·같은 worktree 로 계속.
|
|
@@ -1261,6 +1307,25 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1261
1307
|
return res;
|
|
1262
1308
|
});
|
|
1263
1309
|
|
|
1310
|
+
// 살아 있는 run 의 tmux 에 한 줄 써 넣기 (v5.28 K) — 위 scrollback 의 **쓰기 쌍둥이**.
|
|
1311
|
+
// HUD 가 코크핏을 열지 않고 "대기 중인 에이전트"에게 답하는 길이다(steer 는 정착한 run 전용이라
|
|
1312
|
+
// 프롬프트 앞에 서 있는 run 에는 쓸 수 없다). 보내는 것은 언제나 사람이 고른 문자열이다 —
|
|
1313
|
+
// 서버는 에이전트의 질문을 읽지도, 답을 고르지도 않는다.
|
|
1314
|
+
// 세션이 없으면(정리됐거나 애초에 없음) 409, 모르는 run 은 404.
|
|
1315
|
+
app.post('/api/runs/:id/input', async (req, reply) => {
|
|
1316
|
+
const id = Number((req.params as { id: string }).id);
|
|
1317
|
+
if (!Number.isInteger(id)) return reply.code(400).send({ error: 'bad id' });
|
|
1318
|
+
const b = (req.body ?? {}) as { text?: string };
|
|
1319
|
+
const text = String(b.text ?? '');
|
|
1320
|
+
if (!text.trim()) return reply.code(400).send({ error: 'text required' });
|
|
1321
|
+
if (text.length > 4000) return reply.code(400).send({ error: 'text too long (max 4000)' });
|
|
1322
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1323
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1324
|
+
const res = await sendRunInput(id, text);
|
|
1325
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
1326
|
+
return res;
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1264
1329
|
// 페인이 지금 서 있는 폴더 (v5.28 D-fix) — 터미널이 찍은 상대경로를 무엇 기준으로 풀지.
|
|
1265
1330
|
// 못 알아내면 200 + { ok:false, pwd:'' } — 클라이언트가 worktree 로 폴백한다(지어낸 경로는 없다).
|
|
1266
1331
|
app.get('/api/runs/:id/pwd', async (req, reply) => {
|
|
@@ -1445,6 +1510,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1445
1510
|
const runs = g.rows.map(({ run, task }) => ({
|
|
1446
1511
|
runId: run.id, taskId: task.id, title: task.title, status: run.status,
|
|
1447
1512
|
agent: run.agent, model: run.model, branch: run.branch, filesChanged: run.filesChanged,
|
|
1513
|
+
real: !!run.real,
|
|
1448
1514
|
live: isRunLive(run.id), steerable: isSteerable(run),
|
|
1449
1515
|
// 수렴 콕핏 결정 행용: 태스크 닫힘 여부 + worktree 생존(터미널 가드·머지 가능성 판단).
|
|
1450
1516
|
taskStatus: task.status, hasWorktree: !!run.worktreePath,
|