coxpit 4.6.0 → 4.7.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/src/db/index.ts CHANGED
@@ -44,6 +44,7 @@ export async function ensureSchema(): Promise<void> {
44
44
  prompt TEXT NOT NULL DEFAULT '',
45
45
  status TEXT NOT NULL DEFAULT 'open',
46
46
  design_capture_id INTEGER,
47
+ outputs TEXT NOT NULL DEFAULT '[]',
47
48
  created_at INTEGER DEFAULT (unixepoch())
48
49
  );
49
50
  CREATE TABLE IF NOT EXISTS agent_runs (
@@ -99,4 +100,5 @@ export async function ensureSchema(): Promise<void> {
99
100
  try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
100
101
  try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
101
102
  try { await client.execute("ALTER TABLE task_groups ADD COLUMN coord_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
103
+ try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
102
104
  }
package/src/db/schema.ts CHANGED
@@ -49,6 +49,7 @@ export const tasks = sqliteTable('tasks', {
49
49
  prompt: text('prompt').notNull().default(''),
50
50
  status: text('status').notNull().default('open'), // open | done
51
51
  designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
52
+ outputs: text('outputs').notNull().default('[]'), // 산출물 계약 — 선언한 타입 JSON 배열(answer|code|doc|page|file). 빈 배열 = 계약 없음.
52
53
  parentRunId: integer('parent_run_id'), // 에이전트 셀프 오케스트레이션 — 이 태스크를 발사한 run
53
54
  groupId: integer('group_id'), // task_groups — goal/swarm 형제 묶음(수동 태스크는 NULL)
54
55
  closedAt: integer('closed_at', { mode: 'timestamp' }), // 닫힌 시각(아카이브 정렬·표시)
@@ -13,6 +13,57 @@ import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
13
13
  import { broadcast } from './hub';
14
14
  import { getProvider, type Provider } from './providers';
15
15
 
16
+ // ── 산출물 계약(deliverable contract) ─────────────────────────
17
+ /** 산출물 타입 5종 — 태스크가 선언할 수 있는 계약 항목. */
18
+ export const OUTPUT_TYPES = ['answer', 'code', 'doc', 'page', 'file'] as const;
19
+ export type OutputType = (typeof OUTPUT_TYPES)[number];
20
+ const OUTPUT_SET = new Set<string>(OUTPUT_TYPES);
21
+
22
+ /** 임의 입력 → 유효한 산출물 타입 배열(중복 제거, 순서 보존). API·저장 공용. */
23
+ export function normalizeOutputs(input: unknown): OutputType[] {
24
+ if (!Array.isArray(input)) return [];
25
+ const seen = new Set<string>();
26
+ const out: OutputType[] = [];
27
+ for (const v of input) {
28
+ if (typeof v === 'string' && OUTPUT_SET.has(v) && !seen.has(v)) {
29
+ seen.add(v);
30
+ out.push(v as OutputType);
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** tasks.outputs(JSON 문자열) → 산출물 타입 배열. 파싱 실패는 빈 배열. */
37
+ export function parseOutputs(raw: string | null | undefined): OutputType[] {
38
+ if (!raw) return [];
39
+ try { return normalizeOutputs(JSON.parse(raw)); } catch { return []; }
40
+ }
41
+
42
+ /** 프롬프트에 붙는 Deliverables 블록(A3). declared 는 비어있지 않다. */
43
+ function deliverablesNote(declared: OutputType[]): string {
44
+ const human: Record<OutputType, string> = {
45
+ answer: 'an answer', code: 'code changes', doc: 'a Markdown doc',
46
+ page: 'an HTML page', file: 'a file',
47
+ };
48
+ const list = declared.map((t) => human[t]).join(', ');
49
+ return '\n\n--- COXPIT DELIVERABLES (required) ---\n' +
50
+ `Deliverables (required): ${list}.\n` +
51
+ 'Produce each as a real file in the repo (docs as .md, pages as .html). Register every ' +
52
+ 'deliverable in .coxpit/outputs.json as a JSON array of {path,type,title}. End with a final ' +
53
+ 'message stating the answer.\n' +
54
+ '--- END COXPIT DELIVERABLES ---';
55
+ }
56
+
57
+ /** 계산된 출력 카드(computeRunOutputs 반환) — 보드가 오른쪽 컬럼에 렌더. */
58
+ export interface RunOutputCard {
59
+ type: OutputType;
60
+ title: string;
61
+ path?: string;
62
+ required: boolean;
63
+ present: boolean;
64
+ meta: string;
65
+ }
66
+
16
67
  /** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
17
68
  function agentCommand(provider: Provider, prompt: string, real: boolean, model = ''): string {
18
69
  if (real) return provider.launchCmd(prompt, model || undefined);
@@ -241,6 +292,11 @@ async function loadContext(runId: number): Promise<RunContext | null> {
241
292
  }
242
293
  }
243
294
 
295
+ // 산출물 계약 — task.outputs 가 비어있지 않으면 Deliverables 블록 주입(디자인 캡처와 같은 시임).
296
+ // launchRun/launchGroupTask 는 모두 loadContext 를 거치므로 여기서 한 번에 커버.
297
+ const declared = parseOutputs(task.outputs);
298
+ if (declared.length) prompt += deliverablesNote(declared);
299
+
244
300
  return {
245
301
  runId,
246
302
  machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
@@ -554,6 +610,152 @@ export async function getRunDocs(runId: number): Promise<{
554
610
  return { ok: true, docs };
555
611
  }
556
612
 
613
+ // ── computeRunOutputs — 산출물 카드 계산(A2/A4) ──────────────────
614
+ interface OutputsManifestItem { path?: string; type?: string; title?: string }
615
+
616
+ /** run 의 최종 답변 텍스트 — result 이벤트(payload JSON) 우선, 없으면 exitSummary. */
617
+ async function runAnswerText(run: typeof agentRuns.$inferSelect): Promise<string> {
618
+ const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
619
+ for (let i = evs.length - 1; i >= 0; i--) {
620
+ if (evs[i]!.kind !== 'result') continue;
621
+ try {
622
+ const o = JSON.parse(evs[i]!.payload) as { result?: string };
623
+ if (typeof o.result === 'string' && o.result.trim()) return o.result.trim();
624
+ } catch { /* 비-JSON result — exitSummary 로 폴백 */ }
625
+ }
626
+ return (run.exitSummary || '').trim();
627
+ }
628
+
629
+ /** 확장자 → 파생 카드 타입. code/file 은 group 만, 실제 타입은 code|page|doc|file. */
630
+ function extType(p: string): OutputType {
631
+ if (/\.(md|markdown)$/i.test(p)) return 'doc';
632
+ if (/\.(html?|htm)$/i.test(p)) return 'page';
633
+ if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(p)) return 'file';
634
+ return 'code';
635
+ }
636
+
637
+ /** status --porcelain 한 줄 → 상대경로(따옴표·상태코드 제거). */
638
+ function porcelainPath(line: string): string {
639
+ return line.slice(3).trim().replace(/^"|"$/g, '');
640
+ }
641
+
642
+ /**
643
+ * run 의 산출물 카드 목록을 계산(주문형, A4). 병합 순서:
644
+ * (a) worktree 의 .coxpit/outputs.json 매니페스트(있으면) — declared 딜리버러블
645
+ * (b) git status --porcelain → 확장자 분류(.md=doc·.html=page·이미지=file·그 외=code 집계 1장)
646
+ * (c) result 이벤트의 answer 텍스트
647
+ * 각 카드: required = type ∈ task.outputs. declared 인데 산출물 없으면 present:false 플레이스홀더.
648
+ * worktree 소멸 → doc/page 는 loadRunDocs 스냅샷 폴백, code/file 은 unavailable, answer 는 이벤트.
649
+ */
650
+ export async function computeRunOutputs(runId: number): Promise<RunOutputCard[]> {
651
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
652
+ const run = rr[0];
653
+ if (!run) return [];
654
+ const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
655
+ const task = tr[0];
656
+ const declared = parseOutputs(task?.outputs);
657
+ const declaredSet = new Set<OutputType>(declared);
658
+
659
+ const ctx = await loadContext(runId);
660
+ const wt = run.worktreePath;
661
+ // worktree 가 실제로 머신에 살아있는가(정리 후에는 스냅샷 폴백).
662
+ let wtAlive = false;
663
+ if (ctx && wt) {
664
+ const t = await runShellOn(ctx.machine, `test -d ${shq(wt)} && echo yes`, 8000).catch(() => ({ stdout: '' }));
665
+ wtAlive = (t.stdout || '').includes('yes');
666
+ }
667
+
668
+ const cards: RunOutputCard[] = [];
669
+ const present = new Set<OutputType>();
670
+ const codeFiles: string[] = [];
671
+ const fileCards: RunOutputCard[] = [];
672
+ const docByPath = new Map<string, RunOutputCard>();
673
+
674
+ // (c) answer — 이벤트에서(worktree 생사 무관하게 항상 회수 가능)
675
+ const answer = await runAnswerText(run);
676
+ if (answer) {
677
+ present.add('answer');
678
+ cards.push({ type: 'answer', title: 'Final answer', required: declaredSet.has('answer'), present: true, meta: 'from the run\'s final message' });
679
+ }
680
+
681
+ // (a) 매니페스트 — path→title 힌트로 파생 카드 제목을 예쁘게(있으면). 없으면 git 분류로 폴백.
682
+ const manifestTitle = new Map<string, string>();
683
+ if (wtAlive && ctx && wt) {
684
+ const mf = await runShellOn(ctx.machine, `head -c 200000 ${shq(ppath.join(wt, '.coxpit/outputs.json'))} 2>/dev/null`, 8000)
685
+ .catch(() => ({ ok: false, stdout: '' }));
686
+ if (mf.ok && mf.stdout.trim()) {
687
+ try {
688
+ const arr = JSON.parse(mf.stdout) as unknown;
689
+ for (const it of (Array.isArray(arr) ? arr : []) as OutputsManifestItem[]) {
690
+ if (it && typeof it === 'object' && typeof it.path === 'string' && typeof it.title === 'string') {
691
+ manifestTitle.set(it.path, it.title);
692
+ }
693
+ }
694
+ } catch { /* 매니페스트 깨짐 — git 분류로 폴백 */ }
695
+ }
696
+ }
697
+
698
+ if (wtAlive && ctx && wt) {
699
+ // (b) git status --porcelain 분류
700
+ const ls = await runShellOn(ctx.machine, `git -C ${shq(wt)} status --porcelain`, 15000).catch(() => ({ ok: false, stdout: '' }));
701
+ if (ls.ok) {
702
+ const paths = ls.stdout.split('\n').map(porcelainPath).filter(Boolean);
703
+ for (const p of paths) {
704
+ if (p.startsWith('.coxpit/')) continue; // 오케스트레이션 파일은 산출물 아님
705
+ const t = extType(p);
706
+ if (t === 'doc' || t === 'page') {
707
+ present.add(t);
708
+ docByPath.set(p, { type: t, title: manifestTitle.get(p) || p, path: p, required: declaredSet.has(t), present: true, meta: t === 'doc' ? 'rendered markdown' : 'live HTML page' });
709
+ } else if (t === 'file') {
710
+ present.add('file');
711
+ fileCards.push({ type: 'file', title: manifestTitle.get(p) || p, path: p, required: declaredSet.has('file'), present: true, meta: 'file preview' });
712
+ } else {
713
+ codeFiles.push(p);
714
+ }
715
+ }
716
+ }
717
+ } else {
718
+ // worktree 소멸 — doc/page 는 스냅샷 폴백, code/file 은 unavailable.
719
+ const snap = await loadRunDocs(runId);
720
+ for (const d of snap.docs) {
721
+ const t: OutputType = d.kind === 'html' ? 'page' : 'doc';
722
+ present.add(t);
723
+ docByPath.set(d.path, { type: t, title: manifestTitle.get(d.path) || d.path, path: d.path, required: declaredSet.has(t), present: true, meta: 'worktree cleaned — snapshot only' });
724
+ }
725
+ if (run.filesChanged > 0) {
726
+ // 변경은 있었으나 worktree 가 사라져 code/file diff 를 못 준다.
727
+ if (declaredSet.has('code')) cards.push({ type: 'code', title: 'Code changes', required: true, present: false, meta: 'worktree cleaned — diff unavailable' });
728
+ if (declaredSet.has('file')) cards.push({ type: 'file', title: 'File', required: true, present: false, meta: 'worktree cleaned — file unavailable' });
729
+ }
730
+ }
731
+
732
+ // doc/page 카드 편입(경로 순)
733
+ for (const c of docByPath.values()) cards.push(c);
734
+ // file 카드 편입
735
+ for (const c of fileCards) cards.push(c);
736
+ // code — 여러 파일을 한 장의 diff 카드로 집계(worktree 라이브일 때만 생성됨)
737
+ if (codeFiles.length) {
738
+ present.add('code');
739
+ cards.push({
740
+ type: 'code',
741
+ title: 'Code changes',
742
+ required: declaredSet.has('code'),
743
+ present: true,
744
+ meta: `${codeFiles.length} file(s) — colored diff`,
745
+ });
746
+ }
747
+
748
+ // declared 인데 산출물이 없으면 ⚠ present:false 플레이스홀더(soft policy).
749
+ // future: strict mode — auto-steer "produce the missing <type>"
750
+ for (const t of declared) {
751
+ if (present.has(t)) continue;
752
+ if (cards.some((c) => c.type === t && c.present === false)) continue; // worktree-gone 폴백이 이미 추가
753
+ cards.push({ type: t, title: `Missing ${t}`, required: true, present: false, meta: '산출물 미충족' });
754
+ }
755
+
756
+ return cards;
757
+ }
758
+
557
759
  /**
558
760
  * base 동기화 — 오래 사는 세션의 worktree 에 base 브랜치 최신을 머지한다.
559
761
  * 충돌 시 자동 abort — 그땐 steer 로 에이전트에게 머지를 맡기라고 안내.
@@ -1109,5 +1311,120 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
1109
1311
  ` ; git -C ${shq(ctx.repoPath)} branch -D ${shq(run.branch)} 2>&1 || true`,
1110
1312
  20000,
1111
1313
  );
1314
+ // 세션·worktree 는 이제 없다 — 스테일 포인터를 비운다. 안 그러면 getRunTermInfo 가
1315
+ // 죽은 tmux 이름을 계속 돌려줘 /ws/term/:id 가 없는 세션에 attach 를 시도한다(closed task 버그).
1316
+ await setRun(runId, { worktreePath: '', tmuxWindow: '' });
1112
1317
  return { ok: true, detail: rm.stdout.trim().slice(0, 300) };
1113
1318
  }
1319
+
1320
+ // ── 고아 worktree 회수(reclaim) ────────────────────────────────
1321
+ // run worktree 는 종종 node_modules 를 품어 ~180MB 의 디스크 빚이 된다. Close 는
1322
+ // cleanupRun 으로 이미 정리하지만, 실패·에러·데몬 재시작으로 고아가 된 run 은
1323
+ // (검수용으로) worktree 를 남겨두므로 쌓인다. 이를 안전하게 되찾는 길.
1324
+ //
1325
+ // 안전 규칙(핵심): running/preparing/pending/'done' run 은 절대 대상 아님 —
1326
+ // 활성 작업이거나(진행 중), 성공했지만 아직 머지 안 됐을 수 있는 작업이므로.
1327
+
1328
+ /** 회수 대상 판정용 안전 상태 집합 — task 가 closed 이거나 run 상태가 이 중 하나. */
1329
+ const RECLAIM_STATUSES = new Set(['failed', 'error', 'stopped']);
1330
+
1331
+ export interface ReclaimableWorktree {
1332
+ runId: number;
1333
+ path: string;
1334
+ branch: string;
1335
+ taskId: number;
1336
+ reason: string; // 'task closed' | 'failed' | 'error' | 'stopped'
1337
+ exists: boolean; // worktree dir 가 아직 디스크에 있나(false = 이미 수동 삭제됨)
1338
+ sizeKb?: number; // best-effort du -sk (실패 시 생략)
1339
+ }
1340
+
1341
+ /**
1342
+ * 안전하게 회수 가능한 worktree 목록. worktreePath 가 비어있지 않은 모든 run 중
1343
+ * (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped} 인 것만.
1344
+ * running/preparing/pending/'done'/'open'/'merged' 는 절대 포함하지 않는다
1345
+ * (활성 또는 성공-미머지 가능성). exists=디스크 잔존 여부, sizeKb=best-effort du.
1346
+ */
1347
+ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]> {
1348
+ const allRuns = (await db.select().from(agentRuns)).filter((r) => !!r.worktreePath);
1349
+ // task 상태 룩업(closed 판정용)
1350
+ const taskById = new Map<number, typeof tasks.$inferSelect>();
1351
+ for (const t of await db.select().from(tasks)) taskById.set(t.id, t);
1352
+
1353
+ const out: ReclaimableWorktree[] = [];
1354
+ for (const run of allRuns) {
1355
+ if (isRunLive(run.id)) continue; // 라이브 자식 보유 = 실행 중, 절대 건드리지 않음
1356
+ const task = taskById.get(run.taskId);
1357
+ const taskClosed = task?.status === 'closed';
1358
+ const statusReclaim = RECLAIM_STATUSES.has(run.status);
1359
+ if (!taskClosed && !statusReclaim) continue; // done/open/merged/running/preparing/pending 제외
1360
+ const reason = taskClosed ? 'task closed' : run.status;
1361
+
1362
+ // worktree 잔존 여부 + best-effort 사이즈(로컬만 정확; 원격은 machine 경유).
1363
+ const ctx = await loadContext(run.id).catch(() => null);
1364
+ let exists = false;
1365
+ let sizeKb: number | undefined;
1366
+ if (ctx) {
1367
+ const chk = await runShellOn(ctx.machine, `test -d ${shq(run.worktreePath)} && echo yes`, 8000)
1368
+ .catch(() => ({ stdout: '' as string }));
1369
+ exists = (chk.stdout || '').includes('yes');
1370
+ if (exists) {
1371
+ // du 는 큰 트리에서 느릴 수 있어 타임아웃으로 가드 — 실패해도 목록은 낸다.
1372
+ const du = await runShellOn(ctx.machine, `du -sk ${shq(run.worktreePath)} 2>/dev/null | cut -f1`, 8000)
1373
+ .catch(() => ({ ok: false as boolean, stdout: '' as string }));
1374
+ const n = parseInt((du.stdout || '').trim(), 10);
1375
+ if (du.ok && Number.isFinite(n) && n > 0) sizeKb = n;
1376
+ }
1377
+ }
1378
+ out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb });
1379
+ }
1380
+ return out;
1381
+ }
1382
+
1383
+ /**
1384
+ * 회수 실행 — 대상 run(전체 또는 runIds 부분집합)의 worktree 를 되찾는다.
1385
+ * dir 가 아직 있으면 cleanupRun 재사용(tmux kill + git worktree remove + branch -D + 포인터 blank).
1386
+ * dir 가 이미 수동 삭제됐으면 git worktree prune + branch -D + DB 포인터 blank 만.
1387
+ * 마지막에 영향받은 repo 마다 git worktree prune 1회(스테일 메타데이터 정리).
1388
+ * 멱등 — 다시 돌려도 안전(이미 회수된 run 은 worktreePath 가 비어 목록에서 빠짐).
1389
+ */
1390
+ export async function pruneWorktrees(runIds?: number[]): Promise<{
1391
+ removed: Array<{ runId: number; detail: string }>; count: number;
1392
+ }> {
1393
+ const reclaimable = await listReclaimableWorktrees();
1394
+ const want = runIds && runIds.length ? new Set(runIds) : null;
1395
+ const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable;
1396
+
1397
+ const removed: Array<{ runId: number; detail: string }> = [];
1398
+ const affectedRepoPaths = new Map<string, MachineTarget>(); // repoPath -> machine (prune 대상)
1399
+
1400
+ for (const t of targets) {
1401
+ const ctx = await loadContext(t.runId).catch(() => null);
1402
+ if (ctx) affectedRepoPaths.set(ctx.repoPath, ctx.machine);
1403
+ if (t.exists) {
1404
+ // dir 잔존 — cleanupRun 이 tmux·worktree remove --force·branch -D·포인터 blank 를 모두 처리.
1405
+ const res = await cleanupRun(t.runId).catch((e) => ({ ok: false, detail: String(e).slice(0, 200) }));
1406
+ removed.push({ runId: t.runId, detail: res.detail || (res.ok ? 'removed' : 'cleanup failed') });
1407
+ } else if (ctx) {
1408
+ // dir 는 이미 수동 삭제 — git 이 여전히 스테일 worktree 를 물고 있다. prune + branch -D + 포인터 blank.
1409
+ const r = await runShellOn(
1410
+ ctx.machine,
1411
+ `git -C ${shq(ctx.repoPath)} worktree prune 2>&1` +
1412
+ `${t.branch ? ` ; git -C ${shq(ctx.repoPath)} branch -D ${shq(t.branch)} 2>&1 || true` : ''}`,
1413
+ 20000,
1414
+ ).catch(() => ({ stdout: '' as string }));
1415
+ await setRun(t.runId, { worktreePath: '', tmuxWindow: '' });
1416
+ removed.push({ runId: t.runId, detail: (r.stdout || '').trim().slice(0, 200) || 'dir already gone — pruned metadata' });
1417
+ } else {
1418
+ // context 없음(repo/machine 소실) — 최소한 DB 포인터라도 비운다.
1419
+ await setRun(t.runId, { worktreePath: '', tmuxWindow: '' });
1420
+ removed.push({ runId: t.runId, detail: 'context missing — cleared DB pointer' });
1421
+ }
1422
+ }
1423
+
1424
+ // repo 마다 worktree prune 1회 — 스테일 메타데이터를 확실히 청소(멱등).
1425
+ for (const [repoPath, machine] of affectedRepoPaths) {
1426
+ await runShellOn(machine, `git -C ${shq(repoPath)} worktree prune 2>/dev/null || true`, 15000).catch(() => { /* best-effort */ });
1427
+ }
1428
+
1429
+ return { removed, count: removed.length };
1430
+ }
package/src/server.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { readFile, readdir } from 'node:fs/promises';
1
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { resolve as presolve, dirname as pdirname, join as pjoin } from 'node:path';
4
+ import { resolve as presolve, dirname as pdirname, join as pjoin, sep as psep } from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import Fastify, { type FastifyInstance } from 'fastify';
@@ -13,7 +13,7 @@ import { db } from './db';
13
13
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
14
14
  import { BOOKMARKLET_JS } from './design';
15
15
  import { runShellOn, shq } from './exec';
16
- import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator } from './orchestrator';
16
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees } from './orchestrator';
17
17
  import { openTerm } from './term';
18
18
  import { addSink, removeSink, broadcast } from './hub';
19
19
  import { getProvider, listProviders } from './providers';
@@ -50,6 +50,19 @@ function mdLiteHTML(src: string): string {
50
50
  return s;
51
51
  }
52
52
 
53
+ /** 확장자 → content-type 추론(파일 미리보기용). 미지 = octet-stream. */
54
+ function contentTypeFor(path: string): string {
55
+ const ext = (path.split('.').pop() ?? '').toLowerCase();
56
+ const map: Record<string, string> = {
57
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
58
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
59
+ avif: 'image/avif', pdf: 'application/pdf', txt: 'text/plain; charset=utf-8',
60
+ md: 'text/plain; charset=utf-8', json: 'application/json', csv: 'text/csv; charset=utf-8',
61
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
62
+ };
63
+ return map[ext] ?? 'application/octet-stream';
64
+ }
65
+
53
66
  /** 공유 페이지 Documents 섹션 — md 는 mdLiteHTML, html 은 sandbox iframe. */
54
67
  function shareDocsHTML(docs: Array<{ path: string; kind: string; content: string }>): string {
55
68
  if (!docs.length) return '';
@@ -242,6 +255,23 @@ export async function buildServer(): Promise<FastifyInstance> {
242
255
  return { total: total0, rows };
243
256
  });
244
257
 
258
+ // 회수 가능한 고아 worktree — closed task 또는 failed/error/stopped run 의 worktree 만.
259
+ // running/preparing/pending/done 은 절대 포함 안 됨(활성·성공-미머지 보호). authGate 뒤.
260
+ app.get('/api/worktrees', async () => {
261
+ const items = await listReclaimableWorktrees();
262
+ const totalKb = items.reduce((s, w) => s + (w.sizeKb || 0), 0);
263
+ return { items, totalKb };
264
+ });
265
+
266
+ // 회수 실행 — body.runIds(선택 부분집합) 또는 전체. cleanupRun 재사용 + git worktree prune.
267
+ app.post('/api/worktrees/prune', async (req) => {
268
+ const b = (req.body ?? {}) as { runIds?: number[] };
269
+ const runIds = Array.isArray(b.runIds)
270
+ ? b.runIds.map((n) => Number(n)).filter((n) => Number.isInteger(n))
271
+ : undefined;
272
+ return pruneWorktrees(runIds);
273
+ });
274
+
245
275
  // ─── 머신 레지스트리 ────────────────────────────────────────────
246
276
  app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
247
277
 
@@ -578,7 +608,7 @@ export async function buildServer(): Promise<FastifyInstance> {
578
608
  });
579
609
 
580
610
  app.post('/api/tasks', async (req, reply) => {
581
- const b = (req.body ?? {}) as { repoId?: number; title?: string; prompt?: string; designCaptureId?: number };
611
+ const b = (req.body ?? {}) as { repoId?: number; title?: string; prompt?: string; designCaptureId?: number; outputs?: unknown };
582
612
  const repoId = Number(b.repoId);
583
613
  const title = (b.title ?? '').trim();
584
614
  if (!repoId || !title) return reply.code(400).send({ error: 'repoId and title required' });
@@ -590,7 +620,9 @@ export async function buildServer(): Promise<FastifyInstance> {
590
620
  if (!dc[0]) return reply.code(404).send({ error: 'design capture not found' });
591
621
  designCaptureId = dc[0].id;
592
622
  }
593
- const ins = await db.insert(tasks).values({ repoId, title, prompt: b.prompt ?? '', designCaptureId }).returning();
623
+ // 산출물 계약(선택) {answer,code,doc,page,file} 허용, 중복 제거, JSON 문자열로 저장.
624
+ const outputs = normalizeOutputs(b.outputs);
625
+ const ins = await db.insert(tasks).values({ repoId, title, prompt: b.prompt ?? '', designCaptureId, outputs: JSON.stringify(outputs) }).returning();
594
626
  return reply.code(201).send({ ok: true, task: ins[0] });
595
627
  });
596
628
 
@@ -791,6 +823,8 @@ export async function buildServer(): Promise<FastifyInstance> {
791
823
  runId: run.id, taskId: task.id, title: task.title, status: run.status,
792
824
  agent: run.agent, model: run.model, branch: run.branch, filesChanged: run.filesChanged,
793
825
  live: isRunLive(run.id), steerable: isSteerable(run),
826
+ // 수렴 콕핏 결정 행용: 태스크 닫힘 여부 + worktree 생존(터미널 가드·머지 가능성 판단).
827
+ taskStatus: task.status, hasWorktree: !!run.worktreePath,
794
828
  }));
795
829
  const runIds = g.rows.map((x) => x.run.id);
796
830
  // 이벤트: 그룹 run 전체에서 최근 200개(id 순, 오래된 것 먼저 — 방 피드는 append-only).
@@ -930,6 +964,96 @@ export async function buildServer(): Promise<FastifyInstance> {
930
964
  return { ok: true, docs, source };
931
965
  });
932
966
 
967
+ // ─── 산출물 계약(v4.7 P1) — 카드 목록 · 뷰어 콘텐츠 · 파일 바이트 ──
968
+ // 카드 목록: computeRunOutputs 로 병합(매니페스트+git status+answer). 404 = run 없음.
969
+ app.get('/api/runs/:id/outputs', async (req, reply) => {
970
+ const id = Number((req.params as { id: string }).id);
971
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
972
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
973
+ const outputs = await computeRunOutputs(id);
974
+ return { outputs };
975
+ });
976
+
977
+ // 뷰어 콘텐츠: answer/doc → md, page → html(둘 다 loadRunDocs 폴백 재사용).
978
+ // code 는 별도 콘텐츠 없음 — 클라이언트가 기존 /api/runs/:id/diff 를 재사용한다.
979
+ app.get('/api/runs/:id/output', async (req, reply) => {
980
+ const id = Number((req.params as { id: string }).id);
981
+ const q = (req.query ?? {}) as { type?: string; path?: string };
982
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
983
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
984
+ const type = (q.type ?? '').trim();
985
+ if (type === 'answer') {
986
+ const cards = await computeRunOutputs(id);
987
+ const has = cards.some((c) => c.type === 'answer' && c.present);
988
+ const content = has ? (rr[0].exitSummary || '') : '';
989
+ // answer 본문은 result 이벤트가 원천 — exitSummary 는 그 클립(≤500자)이라 여기선 이벤트 우선.
990
+ const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, id));
991
+ let answer = content;
992
+ for (let i = evs.length - 1; i >= 0; i--) {
993
+ if (evs[i]!.kind !== 'result') continue;
994
+ try { const o = JSON.parse(evs[i]!.payload) as { result?: string }; if (typeof o.result === 'string' && o.result.trim()) { answer = o.result.trim(); break; } } catch { /* skip */ }
995
+ }
996
+ return { kind: 'md', content: answer };
997
+ }
998
+ if (type === 'doc' || type === 'page') {
999
+ const wantKind = type === 'doc' ? 'md' : 'html';
1000
+ const { docs } = await loadRunDocs(id); // worktree→snapshot 폴백 내장
1001
+ const path = (q.path ?? '').trim();
1002
+ const doc = path ? docs.find((d) => d.path === path) : docs.find((d) => d.kind === wantKind);
1003
+ if (!doc) return reply.code(404).send({ error: 'output not found' });
1004
+ return { kind: doc.kind === 'html' ? 'html' : 'md', content: doc.content };
1005
+ }
1006
+ if (type === 'code') {
1007
+ // code 는 콘텐츠 뷰어가 없다 — 컬러 diff 는 클라이언트가 /api/runs/:id/diff 로 재사용.
1008
+ return { kind: 'diff', diffUrl: `/api/runs/${id}/diff` };
1009
+ }
1010
+ return reply.code(400).send({ error: 'type must be one of answer|doc|page|code' });
1011
+ });
1012
+
1013
+ // 파일 바이트(NEW · 보안 임계) — 이미지/바이너리 미리보기용 raw bytes.
1014
+ // 가드: worktree 루트 기준으로 path 를 해석하고, realpath 가 worktree 밖으로
1015
+ // 벗어나면(.. / 절대경로 / 심볼릭링크 탈출) 거부. 크기 상한 ~10MB. worktree 소멸 후 404.
1016
+ const FILE_MAX = 10 * 1024 * 1024;
1017
+ app.get('/api/runs/:id/file', async (req, reply) => {
1018
+ const id = Number((req.params as { id: string }).id);
1019
+ const q = (req.query ?? {}) as { path?: string };
1020
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1021
+ const run = rr[0];
1022
+ if (!run) return reply.code(404).send({ error: 'not found' });
1023
+ const rel = (q.path ?? '').trim();
1024
+ if (!rel) return reply.code(400).send({ error: 'path required' });
1025
+ // 절대경로 즉시 거부(worktree 밖 강제)
1026
+ if (rel.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(rel)) return reply.code(400).send({ error: 'path must be relative' });
1027
+ if (!run.worktreePath) return reply.code(404).send({ error: 'no worktree' });
1028
+
1029
+ // 원격 머신 파일은 데몬 파일시스템에 없다 — export 와 동일하게 로컬 전용.
1030
+ const mr = await db.select().from(machines).where(eq(machines.id, run.machineId)).limit(1);
1031
+ const mach = mr[0];
1032
+ const isRemote = !!mach && mach.kind !== 'local' && (mach.address ?? '') !== '';
1033
+ if (isRemote) return reply.code(400).send({ error: 'remote file preview not supported' });
1034
+
1035
+ // worktree 루트를 realpath 로 정규화(심링크 해소된 canonical base).
1036
+ let rootReal: string;
1037
+ try { rootReal = await realpath(run.worktreePath); }
1038
+ catch { return reply.code(404).send({ error: 'worktree gone' }); }
1039
+ // 요청 경로 = 루트에 join 후 realpath — 심링크 탈출까지 잡는다.
1040
+ const joined = presolve(rootReal, rel);
1041
+ let targetReal: string;
1042
+ try { targetReal = await realpath(joined); }
1043
+ catch { return reply.code(404).send({ error: 'file not found' }); }
1044
+ // realpath 결과가 worktree 루트 하위가 아니면 탈출 — 거부.
1045
+ const rootWithSep = rootReal.endsWith(psep) ? rootReal : rootReal + psep;
1046
+ if (targetReal !== rootReal && !targetReal.startsWith(rootWithSep)) {
1047
+ return reply.code(403).send({ error: 'path escapes the worktree' });
1048
+ }
1049
+ let st;
1050
+ try { st = await stat(targetReal); } catch { return reply.code(404).send({ error: 'file not found' }); }
1051
+ if (!st.isFile()) return reply.code(404).send({ error: 'not a file' });
1052
+ if (st.size > FILE_MAX) return reply.code(413).send({ error: 'file too large (>10MB)' });
1053
+ const buf = await readFile(targetReal);
1054
+ return reply.type(contentTypeFor(rel)).send(buf);
1055
+ });
1056
+
933
1057
  // ─── 에이전트 셀프 오케스트레이션 (run 별 Bearer 토큰 — authGate 예외, 여기서 자체 검증) ──
934
1058
  const agentAuth = (req: { headers: { authorization?: string } }): number | null => {
935
1059
  const h = req.headers.authorization ?? '';
@@ -1043,7 +1167,13 @@ export async function buildServer(): Promise<FastifyInstance> {
1043
1167
  const rows = Math.max(5, Math.min(200, Number(q.rows) || 24));
1044
1168
  const info = await getRunTermInfo(id);
1045
1169
  if (!info) {
1046
- socket.send(JSON.stringify({ t: 'err', d: 'run or tmux session not found' }));
1170
+ // tmuxWindow 없음 = 세션이 정리됐거나(닫힌 task·cleanup) run 미존재.
1171
+ // 죽은 세션에 attach 를 시도하지 말고 명확한 사유로 닫는다.
1172
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1173
+ const d = rr[0]
1174
+ ? 'terminal unavailable — worktree cleaned (run closed or cleaned up)'
1175
+ : 'run not found';
1176
+ socket.send(JSON.stringify({ t: 'err', d }));
1047
1177
  socket.close();
1048
1178
  return;
1049
1179
  }