coxpit 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
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",
package/src/board.ts CHANGED
@@ -350,6 +350,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
350
350
  <button class="btn-ghost sm" id="mTerm">Terminal</button>
351
351
  <button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
352
352
  <button class="btn-ghost sm" id="mCompare">Compare runs</button>
353
+ <button class="btn-ghost sm" id="mExport">Export files…</button>
353
354
  <span class="spacer"></span>
354
355
  <button class="btn-danger sm" id="mStop">Stop</button>
355
356
  <button class="btn-ghost sm" id="mCleanup">Cleanup</button>
@@ -399,6 +400,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
399
400
 
400
401
  <div class="toasts" id="toasts"></div>
401
402
 
403
+ <div class="overlay" id="expOverlay">
404
+ <div class="cfm">
405
+ <div class="cfm-b">
406
+ <div class="m">Export this run's changed files</div>
407
+ <div class="s">Copies changed &amp; new files (with their folder structure) out of the worktree — no merge. Good for reports and one-off artifacts.</div>
408
+ <p class="flabel" style="margin-top:12px">destination folder</p>
409
+ <input id="expDest" placeholder="empty = ~/coxpit-exports/r<id>" />
410
+ </div>
411
+ <div class="cfm-f">
412
+ <button class="btn-ghost sm" id="expCancel">Cancel</button>
413
+ <button class="btn sm" id="expOk">Export</button>
414
+ </div>
415
+ </div>
416
+ </div>
417
+
402
418
  <div class="overlay" id="cfmOverlay">
403
419
  <div class="cfm">
404
420
  <div class="cfm-b"><div class="m" id="cfmMsg"></div><div class="s" id="cfmSub"></div></div>
@@ -624,7 +640,9 @@ function cardHTML(r){
624
640
  + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'+chipHTML(r.status)+'</div>'
625
641
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
626
642
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
627
- + '<span>'+esc(r.agent||'')+'</span></div>'
643
+ + '<span>'+esc(r.agent||'')+'</span>'
644
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
645
+ + '</div>'
628
646
  + '<div class="log">'+evs+'</div></div>';
629
647
  }
630
648
  function flash(id){ const el=$('card-'+id); if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); } }
@@ -650,6 +668,7 @@ function paintSidebar(){
650
668
  '<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
651
669
  $('repos').innerHTML = repos.map(r =>
652
670
  '<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
671
+ + '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
653
672
  + '<div class="path">'+esc(r.path)+'</div></div>').join('')
654
673
  || '<div class="repo" style="color:var(--faint)">none registered</div>';
655
674
  $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
@@ -676,6 +695,16 @@ $('captures').addEventListener('click', async (e)=>{
676
695
  await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
677
696
  hydrate();
678
697
  });
698
+ $('repos').addEventListener('click', async (e)=>{
699
+ const b = e.target.closest('button[data-delrepo]'); if(!b) return;
700
+ const yes = await confirmUI('Remove this repository from coxpit?',
701
+ { sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
702
+ if (!yes) return;
703
+ const res = await fetch('/api/repos/'+b.dataset.delrepo,{method:'DELETE'});
704
+ const j = await res.json().catch(()=>({}));
705
+ if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
706
+ else toast('remove: '+(j.detail||res.status), 'error');
707
+ });
679
708
 
680
709
  function connectWS(){
681
710
  const proto = location.protocol==='https:'?'wss':'ws';
@@ -743,8 +772,27 @@ $('grid').addEventListener('click',(e)=>{
743
772
  });
744
773
  $('mClose').addEventListener('click', closeModal);
745
774
  $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
746
- document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
775
+ document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
747
776
  $('mRefreshDiff').addEventListener('click', loadDiff);
777
+ $('mExport').addEventListener('click', ()=>{
778
+ if (openRunId==null) return;
779
+ $('expDest').value='';
780
+ $('expDest').placeholder='empty = ~/coxpit-exports/r'+openRunId;
781
+ $('expOverlay').classList.add('open');
782
+ });
783
+ async function doExport(){
784
+ if (openRunId==null) return;
785
+ const res = await fetch('/api/runs/'+openRunId+'/export',{method:'POST',
786
+ headers:{'content-type':'application/json'}, body:JSON.stringify({dest:$('expDest').value.trim()})});
787
+ const j = await res.json().catch(()=>({}));
788
+ $('expOverlay').classList.remove('open');
789
+ if (res.ok) toast(j.copied+' file(s) → '+j.dest, 'ok');
790
+ else toast('export: '+(j.detail||res.status), 'error');
791
+ }
792
+ $('expOk').addEventListener('click', doExport);
793
+ $('expDest').addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); doExport(); } });
794
+ $('expCancel').addEventListener('click', ()=>$('expOverlay').classList.remove('open'));
795
+ $('expOverlay').addEventListener('click',(e)=>{ if(e.target===$('expOverlay')) $('expOverlay').classList.remove('open'); });
748
796
  async function sendSteer(){
749
797
  if (openRunId==null) return;
750
798
  const msg = $('steerInput').value.trim(); if(!msg) return;
@@ -804,14 +852,30 @@ async function paintCompare(){
804
852
  + '<span class="files">'+files+' file'+(files===1?'':'s')+'</span></div>'
805
853
  + '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
806
854
  + '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
807
- + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'"></span>'
855
+ + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
856
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR ↗ '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
857
+ + '</span>'
808
858
  + (merged
809
859
  ? chipHTML('merged')
810
- : '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
860
+ : (r.prUrl ? '' : '<button class="btn-ghost sm" data-pr="'+r.id+'"'+(mergeable?'':' disabled')+'>Open PR</button>')
861
+ + '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
811
862
  + '</div></div>';
812
863
  }).join('');
813
864
  }
814
865
  $('cmpBody').addEventListener('click', async (e)=>{
866
+ const prBtn = e.target.closest('button[data-pr]');
867
+ if (prBtn){
868
+ const rid = Number(prBtn.dataset.pr);
869
+ const yes = await confirmUI('Open a pull request from r'+rid+'?',
870
+ { sub: 'Commits the worktree, pushes the branch to origin, and opens a PR against the base branch (needs gh CLI signed in).', okLabel: 'Open PR' });
871
+ if (!yes) return;
872
+ prBtn.disabled = true;
873
+ const res = await fetch('/api/runs/'+rid+'/pr',{method:'POST'});
874
+ const j = await res.json().catch(()=>({}));
875
+ if (res.ok){ toast('PR opened: '+j.url, 'ok'); await paintCompare(); hydrate(); }
876
+ else { toast('PR: '+(j.detail||res.status), 'error'); prBtn.disabled = false; }
877
+ return;
878
+ }
815
879
  const btn = e.target.closest('button[data-merge]'); if(!btn) return;
816
880
  const rid = Number(btn.dataset.merge);
817
881
  const yes = await confirmUI('Merge r'+rid+' into the base branch?',
package/src/db/index.ts CHANGED
@@ -56,6 +56,7 @@ export async function ensureSchema(): Promise<void> {
56
56
  tmux_window TEXT NOT NULL DEFAULT '',
57
57
  status TEXT NOT NULL DEFAULT 'pending',
58
58
  session_id TEXT NOT NULL DEFAULT '',
59
+ pr_url TEXT NOT NULL DEFAULT '',
59
60
  files_changed INTEGER NOT NULL DEFAULT 0,
60
61
  started_at INTEGER,
61
62
  ended_at INTEGER,
@@ -72,4 +73,5 @@ export async function ensureSchema(): Promise<void> {
72
73
  // 기존 DB 마이그레이션(멱등)
73
74
  try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
74
75
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
76
+ try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
75
77
  }
package/src/db/schema.ts CHANGED
@@ -54,6 +54,7 @@ export const agentRuns = sqliteTable('agent_runs', {
54
54
  tmuxWindow: text('tmux_window').notNull().default(''),
55
55
  status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
56
56
  sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
57
+ prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
57
58
  filesChanged: integer('files_changed').notNull().default(0),
58
59
  startedAt: integer('started_at', { mode: 'timestamp' }),
59
60
  endedAt: integer('ended_at', { mode: 'timestamp' }),
@@ -1,5 +1,8 @@
1
1
  import { posix as ppath } from 'node:path';
2
2
  import { createInterface } from 'node:readline';
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, copyFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
3
6
  import type { ChildProcess } from 'node:child_process';
4
7
  import { eq } from 'drizzle-orm';
5
8
  import { config } from './config';
@@ -326,6 +329,91 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
326
329
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
327
330
  }
328
331
 
332
+ /**
333
+ * 결과 파일 회수(export) — 조회성 태스크용: worktree 의 변경·신규 파일을
334
+ * 지정 폴더로 복사한다. 머지 없이 산출물만 가져오는 길. (v1: 로컬 머신 전용)
335
+ */
336
+ export async function exportRun(runId: number, destIn?: string): Promise<{ ok: boolean; detail: string; dest?: string; copied?: number; skipped?: number }> {
337
+ const ctx = await loadContext(runId);
338
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
339
+ const run = rr[0];
340
+ if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
341
+ if (liveChildren.has(runId)) return { ok: false, detail: 'still running — wait for it to settle' };
342
+ if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
343
+ return { ok: false, detail: 'remote export not yet supported — files are on the remote machine' };
344
+ }
345
+ const wt = run.worktreePath;
346
+ if (!existsSync(wt)) return { ok: false, detail: 'worktree missing (cleaned up)' };
347
+
348
+ // 회수 대상 = 분기점 이후 커밋된 변경(머지 시도가 auto-commit 했을 수 있음) ∪ 미커밋·신규
349
+ const list = await runShellOn(
350
+ ctx.machine,
351
+ `cd ${shq(wt)} && { git diff --name-only ${shq(ctx.baseBranch)}...HEAD -z 2>/dev/null; git ls-files -mo --exclude-standard -z; }`,
352
+ 15000,
353
+ );
354
+ if (!list.ok) return { ok: false, detail: 'could not list changed files' };
355
+ const files = [...new Set(list.stdout.split('\0').filter(Boolean))];
356
+ if (!files.length) return { ok: false, detail: 'no changed files in this run' };
357
+
358
+ const dest = (destIn ?? '').trim() || ppath.join(homedir(), 'coxpit-exports', `r${runId}`);
359
+ if (!dest.startsWith('/')) return { ok: false, detail: 'destination must be an absolute path' };
360
+
361
+ let copied = 0, skipped = 0;
362
+ for (const f of files) {
363
+ const src = ppath.join(wt, f);
364
+ if (!existsSync(src)) { skipped++; continue; } // 삭제된 파일 등
365
+ const out = ppath.join(dest, f);
366
+ await mkdir(ppath.dirname(out), { recursive: true });
367
+ await copyFile(src, out);
368
+ copied++;
369
+ }
370
+ await recordEvent(runId, 'export', JSON.stringify({ dest, copied, skipped }));
371
+ return { ok: true, detail: `${copied} file(s) exported`, dest, copied, skipped };
372
+ }
373
+
374
+ /**
375
+ * PR 모드 — run 브랜치를 origin 에 push 하고 gh 로 pull request 를 연다.
376
+ * 팀 repo·리뷰 흐름용: 로컬 merge 대신 PR 로 결과를 보낸다.
377
+ */
378
+ export async function prRun(runId: number): Promise<{ ok: boolean; detail: string; url?: string }> {
379
+ const ctx = await loadContext(runId);
380
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
381
+ const run = rr[0];
382
+ if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
383
+ if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
384
+ if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
385
+ const wt = shq(run.worktreePath);
386
+
387
+ // 0) 사전 조건: origin 리모트 + gh CLI
388
+ const pre = await runShellOn(ctx.machine,
389
+ `cd ${wt} && { git remote get-url origin >/dev/null 2>&1 && echo R1 || echo R0; } && { command -v gh >/dev/null 2>&1 && echo G1 || echo G0; }`, 10000);
390
+ if (!pre.stdout.includes('R1')) return { ok: false, detail: 'no origin remote on this repo' };
391
+ if (!pre.stdout.includes('G1')) return { ok: false, detail: 'GitHub CLI (gh) not found on the machine' };
392
+
393
+ // 1) worktree 미커밋 변경 자동 커밋
394
+ const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
395
+ const c1 = await runShellOn(ctx.machine,
396
+ `cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`, 20000);
397
+ if (!c1.ok) return { ok: false, detail: 'worktree commit failed: ' + (c1.stderr || c1.stdout).trim().slice(0, 300) };
398
+
399
+ // 2) push
400
+ const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(run.branch)} 2>&1`, 60000);
401
+ if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
402
+
403
+ // 3) PR 생성 (동일 브랜치 PR 이 이미 있으면 그 URL 재사용)
404
+ const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
405
+ const title = `${tr[0]?.title ?? 'coxpit run'} (r${runId})`;
406
+ const body = (run.exitSummary ? run.exitSummary + '\n\n' : '') + '🤖 Opened from a coxpit agent run';
407
+ const pr = await runShellOn(ctx.machine,
408
+ `cd ${wt} && gh pr create -B ${shq(ctx.baseBranch)} -H ${shq(run.branch)} -t ${shq(title)} -b ${shq(body)} 2>&1 || true`, 60000);
409
+ const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
410
+ if (!m) return { ok: false, detail: 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 300) };
411
+
412
+ await setRun(runId, { prUrl: m[0] });
413
+ await recordEvent(runId, 'pr', m[0]);
414
+ return { ok: true, detail: 'pull request opened', url: m[0] };
415
+ }
416
+
329
417
  /**
330
418
  * worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
331
419
  */
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@ import { db } from './db';
12
12
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
13
13
  import { BOOKMARKLET_JS } from './design';
14
14
  import { runShellOn, shq } from './exec';
15
- import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun } from './orchestrator';
16
16
  import { openTerm } from './term';
17
17
  import { addSink, removeSink, broadcast } from './hub';
18
18
  import { BOARD_HTML } from './board';
@@ -32,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
32
32
  app.addHook('onRequest', authGate);
33
33
 
34
34
  // 무인증 헬스(외부 감시용)
35
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.5.0' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.6.0' }));
36
36
 
37
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -155,10 +155,17 @@ export async function buildServer(): Promise<FastifyInstance> {
155
155
  const m = mr[0];
156
156
  if (!m) return reply.code(404).send({ error: 'machine not found' });
157
157
 
158
+ // 기본 브랜치는 "지금 체크아웃된 브랜치"가 아니라 repo 의 진짜 기본값:
159
+ // origin/HEAD → 로컬 main/master → 현재 HEAD 순으로 감지.
160
+ const g = `git -C ${shq(path)}`;
158
161
  const cmd =
159
- `git -C ${shq(path)} rev-parse --is-inside-work-tree 2>&1` +
162
+ `${g} rev-parse --is-inside-work-tree 2>&1` +
160
163
  ` && echo '---B---'` +
161
- ` && git -C ${shq(path)} rev-parse --abbrev-ref HEAD 2>&1`;
164
+ ` && { ${g} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true; }` +
165
+ ` && echo '---C---'` +
166
+ ` && { ${g} show-ref --verify -q refs/heads/main && echo main || { ${g} show-ref --verify -q refs/heads/master && echo master; } || true; }` +
167
+ ` && echo '---D---'` +
168
+ ` && ${g} rev-parse --abbrev-ref HEAD 2>&1`;
162
169
  const r = await runShellOn(m, cmd);
163
170
  const isRepo = r.ok && /(^|\n)true(\n|$)/.test(r.stdout);
164
171
  if (!isRepo) {
@@ -167,7 +174,12 @@ export async function buildServer(): Promise<FastifyInstance> {
167
174
  detail: (r.stdout || r.stderr).trim().slice(0, 400),
168
175
  });
169
176
  }
170
- const branch = (r.stdout.split('---B---')[1] ?? '').trim() || 'main';
177
+ const seg = (a: string, b: string): string =>
178
+ ((r.stdout.split(a)[1] ?? '').split(b)[0] ?? '').trim();
179
+ const originHead = seg('---B---', '---C---').replace(/^origin\//, '');
180
+ const localMain = seg('---C---', '---D---');
181
+ const headNow = (r.stdout.split('---D---')[1] ?? '').trim();
182
+ const branch = originHead || localMain || headNow || 'main';
171
183
  const name = (b.name ?? '').trim() || path.split('/').filter(Boolean).pop() || path;
172
184
 
173
185
  const ins = await db.insert(repos).values({
@@ -177,6 +189,17 @@ export async function buildServer(): Promise<FastifyInstance> {
177
189
  return reply.code(201).send({ ok: true, repo: ins[0] });
178
190
  });
179
191
 
192
+ // repo 삭제 — 열린 태스크가 있으면 거부(이력 보호).
193
+ app.delete('/api/repos/:id', async (req, reply) => {
194
+ const id = Number((req.params as { id: string }).id);
195
+ const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
196
+ if (!rp[0]) return reply.code(404).send({ error: 'not found' });
197
+ const open = (await db.select().from(tasks).where(eq(tasks.repoId, id))).filter((t) => t.status !== 'closed');
198
+ if (open.length) return reply.code(409).send({ ok: false, detail: `close ${open.length} open task(s) on this repo first` });
199
+ await db.delete(repos).where(eq(repos.id, id));
200
+ return { ok: true };
201
+ });
202
+
180
203
  // 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
181
204
  app.get('/api/browse', async (req) => {
182
205
  const q = (req.query ?? {}) as { path?: string };
@@ -378,6 +401,27 @@ export async function buildServer(): Promise<FastifyInstance> {
378
401
  return reply.code(202).send(res);
379
402
  });
380
403
 
404
+ // 결과 파일 회수 — 머지 없이 worktree 산출물만 지정 폴더로 복사(조회성 태스크).
405
+ app.post('/api/runs/:id/export', async (req, reply) => {
406
+ const id = Number((req.params as { id: string }).id);
407
+ const b = (req.body ?? {}) as { dest?: string };
408
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
409
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
410
+ const res = await exportRun(id, b.dest);
411
+ if (!res.ok) return reply.code(409).send(res);
412
+ return res;
413
+ });
414
+
415
+ // PR 모드 — run 브랜치 push + gh pr create.
416
+ app.post('/api/runs/:id/pr', async (req, reply) => {
417
+ const id = Number((req.params as { id: string }).id);
418
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
419
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
420
+ const res = await prRun(id);
421
+ if (!res.ok) return reply.code(409).send(res);
422
+ return res;
423
+ });
424
+
381
425
  // 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
382
426
  app.post('/api/runs/:id/stop', async (req, reply) => {
383
427
  const id = Number((req.params as { id: string }).id);
@@ -397,7 +441,7 @@ export async function buildServer(): Promise<FastifyInstance> {
397
441
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
398
442
  app.get('/ws', { websocket: true }, (socket) => {
399
443
  addSink(socket);
400
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.5.0' }));
444
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.6.0' }));
401
445
  socket.on('close', () => removeSink(socket));
402
446
  });
403
447