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 +1 -1
- package/src/board.ts +68 -4
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +88 -0
- package/src/server.ts +50 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "2.
|
|
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 & 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
|
|
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+'"
|
|
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-
|
|
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' }),
|
package/src/orchestrator.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
162
|
+
`${g} rev-parse --is-inside-work-tree 2>&1` +
|
|
160
163
|
` && echo '---B---'` +
|
|
161
|
-
` &&
|
|
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
|
|
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.
|
|
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
|
|