coxpit 2.5.0 → 2.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/package.json +1 -1
- package/src/board.ts +137 -8
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +141 -2
- package/src/server.ts +66 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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
|
@@ -159,6 +159,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
159
159
|
.ev{display:flex;gap:9px;align-items:baseline;min-width:0}
|
|
160
160
|
.ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
|
|
161
161
|
.ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
|
|
162
|
+
/* ── select mode (integrate) ── */
|
|
163
|
+
.toolbar{display:flex;justify-content:flex-end;gap:8px;margin-bottom:12px}
|
|
164
|
+
.card.selmode{cursor:copy}
|
|
165
|
+
.card .selbox{display:none;width:20px;height:20px;border-radius:50%;border:1px solid var(--line-hi);
|
|
166
|
+
align-items:center;justify-content:center;font-size:11px;color:transparent;flex:none}
|
|
167
|
+
.card.selmode .selbox{display:flex}
|
|
168
|
+
.card.selected{border-color:var(--brand)}
|
|
169
|
+
.card.selected .selbox{background:var(--brand);border-color:var(--brand);color:var(--brand-ink)}
|
|
170
|
+
.selbar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:45;display:none;
|
|
171
|
+
align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line-hi);border-radius:12px;
|
|
172
|
+
padding:10px 16px;box-shadow:var(--shadow)}
|
|
173
|
+
.selbar.on{display:flex}
|
|
174
|
+
.selbar .cnt{font-family:var(--mono);font-size:12px;color:var(--brand)}
|
|
175
|
+
.selbar .note{font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
176
|
+
|
|
162
177
|
.empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
|
|
163
178
|
display:flex;flex-direction:column;gap:10px;align-items:center}
|
|
164
179
|
.empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
|
|
@@ -300,7 +315,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
300
315
|
</div>
|
|
301
316
|
<input type="checkbox" id="taskReal" hidden />
|
|
302
317
|
<div class="row">
|
|
303
|
-
<input id="taskCount" class="narrow" type="number" min="1" max="8" value="
|
|
318
|
+
<input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="number of agents — 1 for a job, N to explore variants" />
|
|
304
319
|
<button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
|
|
305
320
|
</div>
|
|
306
321
|
</form>
|
|
@@ -315,6 +330,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
315
330
|
</div>
|
|
316
331
|
</aside>
|
|
317
332
|
<main>
|
|
333
|
+
<div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
|
|
318
334
|
<div class="grid" id="grid"></div>
|
|
319
335
|
<div class="empty" id="empty">
|
|
320
336
|
<span class="glyph">▚▞▚</span>
|
|
@@ -350,6 +366,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
350
366
|
<button class="btn-ghost sm" id="mTerm">Terminal</button>
|
|
351
367
|
<button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
|
|
352
368
|
<button class="btn-ghost sm" id="mCompare">Compare runs</button>
|
|
369
|
+
<button class="btn-ghost sm" id="mExport">Export files…</button>
|
|
353
370
|
<span class="spacer"></span>
|
|
354
371
|
<button class="btn-danger sm" id="mStop">Stop</button>
|
|
355
372
|
<button class="btn-ghost sm" id="mCleanup">Cleanup</button>
|
|
@@ -399,6 +416,28 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
399
416
|
|
|
400
417
|
<div class="toasts" id="toasts"></div>
|
|
401
418
|
|
|
419
|
+
<div class="selbar" id="selbar">
|
|
420
|
+
<span class="cnt" id="selCnt">0 selected</span>
|
|
421
|
+
<span class="note">merges in selection order · conflicts spawn an integration agent</span>
|
|
422
|
+
<button class="btn sm" id="selGo">Integrate → base</button>
|
|
423
|
+
<button class="btn-ghost sm" id="selCancel">Cancel</button>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<div class="overlay" id="expOverlay">
|
|
427
|
+
<div class="cfm">
|
|
428
|
+
<div class="cfm-b">
|
|
429
|
+
<div class="m">Export this run's changed files</div>
|
|
430
|
+
<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>
|
|
431
|
+
<p class="flabel" style="margin-top:12px">destination folder</p>
|
|
432
|
+
<input id="expDest" placeholder="empty = ~/coxpit-exports/r<id>" />
|
|
433
|
+
</div>
|
|
434
|
+
<div class="cfm-f">
|
|
435
|
+
<button class="btn-ghost sm" id="expCancel">Cancel</button>
|
|
436
|
+
<button class="btn sm" id="expOk">Export</button>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
402
441
|
<div class="overlay" id="cfmOverlay">
|
|
403
442
|
<div class="cfm">
|
|
404
443
|
<div class="cfm-b"><div class="m" id="cfmMsg"></div><div class="s" id="cfmSub"></div></div>
|
|
@@ -620,11 +659,15 @@ function cardHTML(r){
|
|
|
620
659
|
const evs = (r.events||[]).slice(-8).map(e =>
|
|
621
660
|
'<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload)).slice(0,140)+'</span></div>'
|
|
622
661
|
).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
|
|
623
|
-
|
|
624
|
-
|
|
662
|
+
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
|
|
663
|
+
return '<div class="card'+selCls+'" id="card-'+r.id+'">'
|
|
664
|
+
+ '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
|
|
665
|
+
+ '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
|
|
625
666
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
626
667
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
627
|
-
+ '<span>'+esc(r.agent||'')+'</span
|
|
668
|
+
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
669
|
+
+ (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
|
|
670
|
+
+ '</div>'
|
|
628
671
|
+ '<div class="log">'+evs+'</div></div>';
|
|
629
672
|
}
|
|
630
673
|
function flash(id){ const el=$('card-'+id); if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); } }
|
|
@@ -650,6 +693,7 @@ function paintSidebar(){
|
|
|
650
693
|
'<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
|
|
651
694
|
$('repos').innerHTML = repos.map(r =>
|
|
652
695
|
'<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
|
|
696
|
+
+ '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
|
|
653
697
|
+ '<div class="path">'+esc(r.path)+'</div></div>').join('')
|
|
654
698
|
|| '<div class="repo" style="color:var(--faint)">none registered</div>';
|
|
655
699
|
$('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
|
|
@@ -676,6 +720,16 @@ $('captures').addEventListener('click', async (e)=>{
|
|
|
676
720
|
await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
|
|
677
721
|
hydrate();
|
|
678
722
|
});
|
|
723
|
+
$('repos').addEventListener('click', async (e)=>{
|
|
724
|
+
const b = e.target.closest('button[data-delrepo]'); if(!b) return;
|
|
725
|
+
const yes = await confirmUI('Remove this repository from coxpit?',
|
|
726
|
+
{ sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
|
|
727
|
+
if (!yes) return;
|
|
728
|
+
const res = await fetch('/api/repos/'+b.dataset.delrepo,{method:'DELETE'});
|
|
729
|
+
const j = await res.json().catch(()=>({}));
|
|
730
|
+
if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
|
|
731
|
+
else toast('remove: '+(j.detail||res.status), 'error');
|
|
732
|
+
});
|
|
679
733
|
|
|
680
734
|
function connectWS(){
|
|
681
735
|
const proto = location.protocol==='https:'?'wss':'ws';
|
|
@@ -688,6 +742,7 @@ function connectWS(){
|
|
|
688
742
|
const known = runs.has(ev.runId ?? ev.id);
|
|
689
743
|
upsertRun(ev);
|
|
690
744
|
if (!known && ev.taskId==null){ hydrate(); return; }
|
|
745
|
+
if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
|
|
691
746
|
render(); flash(ev.runId ?? ev.id); paintModal();
|
|
692
747
|
if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
|
|
693
748
|
if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
|
|
@@ -737,14 +792,72 @@ async function loadDiff(){
|
|
|
737
792
|
}
|
|
738
793
|
function openModal(id){ openRunId = id; paintModal(); $('overlay').classList.add('open'); loadDiff(); }
|
|
739
794
|
function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
|
|
795
|
+
/* ── select mode (integrate) ── */
|
|
796
|
+
let selectMode = false;
|
|
797
|
+
const selected = new Set();
|
|
798
|
+
const selOrder = [];
|
|
799
|
+
function setSelectMode(on){
|
|
800
|
+
selectMode = on;
|
|
801
|
+
if (!on){ selected.clear(); selOrder.length = 0; }
|
|
802
|
+
$('selToggle').textContent = on ? 'Exit select' : 'Select runs';
|
|
803
|
+
$('selbar').classList.toggle('on', on);
|
|
804
|
+
$('selCnt').textContent = selected.size + ' selected';
|
|
805
|
+
render();
|
|
806
|
+
}
|
|
807
|
+
$('selToggle').addEventListener('click', ()=>setSelectMode(!selectMode));
|
|
808
|
+
$('selCancel').addEventListener('click', ()=>setSelectMode(false));
|
|
809
|
+
$('selGo').addEventListener('click', async ()=>{
|
|
810
|
+
if (!selOrder.length){ toast('select settled runs with changes first', 'error'); return; }
|
|
811
|
+
const yes = await confirmUI('Integrate '+selOrder.length+' run(s) into the base branch?',
|
|
812
|
+
{ sub: 'Merged one by one in selection order. A run that conflicts spawns an integration agent (real, spends credits) to resolve it.', okLabel: 'Integrate' });
|
|
813
|
+
if (!yes) return;
|
|
814
|
+
const res = await fetch('/api/integrate',{method:'POST',headers:{'content-type':'application/json'},
|
|
815
|
+
body:JSON.stringify({runIds:selOrder})});
|
|
816
|
+
const j = await res.json().catch(()=>({}));
|
|
817
|
+
if (res.ok){
|
|
818
|
+
toast('integrate: '+j.merged+' merged · '+j.conflicts+' conflict→agent · '+j.skipped+' skipped', j.conflicts||j.skipped ? undefined : 'ok');
|
|
819
|
+
setSelectMode(false); hydrate();
|
|
820
|
+
} else toast('integrate: '+(j.error||res.status), 'error');
|
|
821
|
+
});
|
|
822
|
+
|
|
740
823
|
$('grid').addEventListener('click',(e)=>{
|
|
741
824
|
const card = e.target.closest('.card'); if(!card) return;
|
|
742
|
-
|
|
825
|
+
const id = Number(card.id.replace('card-',''));
|
|
826
|
+
if (selectMode){
|
|
827
|
+
const r = runs.get(id);
|
|
828
|
+
const eligible = r && ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0) > 0 && r.status!=='merged';
|
|
829
|
+
if (!eligible){ toast('r'+id+': only settled runs with changes can be integrated', 'error'); return; }
|
|
830
|
+
if (selected.has(id)){ selected.delete(id); selOrder.splice(selOrder.indexOf(id),1); }
|
|
831
|
+
else { selected.add(id); selOrder.push(id); }
|
|
832
|
+
$('selCnt').textContent = selected.size + ' selected';
|
|
833
|
+
render();
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
openModal(id);
|
|
743
837
|
});
|
|
744
838
|
$('mClose').addEventListener('click', closeModal);
|
|
745
839
|
$('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'); } });
|
|
840
|
+
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
841
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
842
|
+
$('mExport').addEventListener('click', ()=>{
|
|
843
|
+
if (openRunId==null) return;
|
|
844
|
+
$('expDest').value='';
|
|
845
|
+
$('expDest').placeholder='empty = ~/coxpit-exports/r'+openRunId;
|
|
846
|
+
$('expOverlay').classList.add('open');
|
|
847
|
+
});
|
|
848
|
+
async function doExport(){
|
|
849
|
+
if (openRunId==null) return;
|
|
850
|
+
const res = await fetch('/api/runs/'+openRunId+'/export',{method:'POST',
|
|
851
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({dest:$('expDest').value.trim()})});
|
|
852
|
+
const j = await res.json().catch(()=>({}));
|
|
853
|
+
$('expOverlay').classList.remove('open');
|
|
854
|
+
if (res.ok) toast(j.copied+' file(s) → '+j.dest, 'ok');
|
|
855
|
+
else toast('export: '+(j.detail||res.status), 'error');
|
|
856
|
+
}
|
|
857
|
+
$('expOk').addEventListener('click', doExport);
|
|
858
|
+
$('expDest').addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); doExport(); } });
|
|
859
|
+
$('expCancel').addEventListener('click', ()=>$('expOverlay').classList.remove('open'));
|
|
860
|
+
$('expOverlay').addEventListener('click',(e)=>{ if(e.target===$('expOverlay')) $('expOverlay').classList.remove('open'); });
|
|
748
861
|
async function sendSteer(){
|
|
749
862
|
if (openRunId==null) return;
|
|
750
863
|
const msg = $('steerInput').value.trim(); if(!msg) return;
|
|
@@ -804,14 +917,30 @@ async function paintCompare(){
|
|
|
804
917
|
+ '<span class="files">'+files+' file'+(files===1?'':'s')+'</span></div>'
|
|
805
918
|
+ '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
|
|
806
919
|
+ '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
|
|
807
|
-
+ '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'"
|
|
920
|
+
+ '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
|
|
921
|
+
+ (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR ↗ '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
|
|
922
|
+
+ '</span>'
|
|
808
923
|
+ (merged
|
|
809
924
|
? chipHTML('merged')
|
|
810
|
-
: '<button class="btn sm" data-
|
|
925
|
+
: (r.prUrl ? '' : '<button class="btn-ghost sm" data-pr="'+r.id+'"'+(mergeable?'':' disabled')+'>Open PR</button>')
|
|
926
|
+
+ '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
|
|
811
927
|
+ '</div></div>';
|
|
812
928
|
}).join('');
|
|
813
929
|
}
|
|
814
930
|
$('cmpBody').addEventListener('click', async (e)=>{
|
|
931
|
+
const prBtn = e.target.closest('button[data-pr]');
|
|
932
|
+
if (prBtn){
|
|
933
|
+
const rid = Number(prBtn.dataset.pr);
|
|
934
|
+
const yes = await confirmUI('Open a pull request from r'+rid+'?',
|
|
935
|
+
{ 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' });
|
|
936
|
+
if (!yes) return;
|
|
937
|
+
prBtn.disabled = true;
|
|
938
|
+
const res = await fetch('/api/runs/'+rid+'/pr',{method:'POST'});
|
|
939
|
+
const j = await res.json().catch(()=>({}));
|
|
940
|
+
if (res.ok){ toast('PR opened: '+j.url, 'ok'); await paintCompare(); hydrate(); }
|
|
941
|
+
else { toast('PR: '+(j.detail||res.status), 'error'); prBtn.disabled = false; }
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
815
944
|
const btn = e.target.closest('button[data-merge]'); if(!btn) return;
|
|
816
945
|
const rid = Number(btn.dataset.merge);
|
|
817
946
|
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';
|
|
@@ -42,6 +45,8 @@ const stoppedRuns = new Set<number>();
|
|
|
42
45
|
interface RunContext {
|
|
43
46
|
runId: number;
|
|
44
47
|
machine: MachineTarget;
|
|
48
|
+
machineId: number;
|
|
49
|
+
repoId: number;
|
|
45
50
|
repoPath: string;
|
|
46
51
|
baseBranch: string;
|
|
47
52
|
prompt: string;
|
|
@@ -78,6 +83,8 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
78
83
|
return {
|
|
79
84
|
runId,
|
|
80
85
|
machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
|
|
86
|
+
machineId: m.id,
|
|
87
|
+
repoId: repo.id,
|
|
81
88
|
repoPath: repo.path,
|
|
82
89
|
baseBranch: repo.defaultBranch,
|
|
83
90
|
prompt,
|
|
@@ -282,7 +289,7 @@ export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: st
|
|
|
282
289
|
* 승자 run 머지 — worktree 미커밋 변경을 자동 커밋 후 run 브랜치를
|
|
283
290
|
* repo 기본 브랜치에 merge. 본 repo 가 기본 브랜치+클린일 때만, 충돌 시 abort.
|
|
284
291
|
*/
|
|
285
|
-
export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
292
|
+
export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string; conflict?: boolean }> {
|
|
286
293
|
const ctx = await loadContext(runId);
|
|
287
294
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
288
295
|
const run = rr[0];
|
|
@@ -320,12 +327,144 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
|
|
|
320
327
|
30000,
|
|
321
328
|
);
|
|
322
329
|
if (mg.stdout.includes('COXPIT_MERGE_FAILED')) {
|
|
323
|
-
return { ok: false, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
|
|
330
|
+
return { ok: false, conflict: true, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
|
|
324
331
|
}
|
|
325
332
|
await setRun(runId, { status: 'merged' });
|
|
326
333
|
return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
|
|
327
334
|
}
|
|
328
335
|
|
|
336
|
+
export interface IntegrateResult {
|
|
337
|
+
runId: number;
|
|
338
|
+
status: 'merged' | 'conflict' | 'skipped';
|
|
339
|
+
detail?: string;
|
|
340
|
+
integrationTaskId?: number;
|
|
341
|
+
integrationRunId?: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 통합(Integrate) — 여러 run(태스크 무관)을 base 에 순차 머지한다.
|
|
346
|
+
* 충돌 나는 run 은 멈추지 않고 "통합 태스크"를 자동 발사 — 에이전트가
|
|
347
|
+
* base 에서 분기한 새 worktree 에서 해당 브랜치를 머지·충돌 해소한다.
|
|
348
|
+
* (스웜의 수렴 단계: 분업한 결과를 다시 하나로.)
|
|
349
|
+
*/
|
|
350
|
+
export async function integrateRuns(runIds: number[], real?: boolean): Promise<IntegrateResult[]> {
|
|
351
|
+
const results: IntegrateResult[] = [];
|
|
352
|
+
for (const id of runIds) {
|
|
353
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
354
|
+
const run = rr[0];
|
|
355
|
+
if (!run) { results.push({ runId: id, status: 'skipped', detail: 'not found' }); continue; }
|
|
356
|
+
if (run.status === 'merged') { results.push({ runId: id, status: 'skipped', detail: 'already merged' }); continue; }
|
|
357
|
+
|
|
358
|
+
const m = await mergeRun(id);
|
|
359
|
+
if (m.ok) { results.push({ runId: id, status: 'merged' }); continue; }
|
|
360
|
+
if (!m.conflict) { results.push({ runId: id, status: 'skipped', detail: m.detail }); continue; }
|
|
361
|
+
|
|
362
|
+
// 충돌 → 통합 태스크 자동 발사 (에이전트가 머지를 대신 푼다)
|
|
363
|
+
const ctx = await loadContext(id);
|
|
364
|
+
if (!ctx) { results.push({ runId: id, status: 'skipped', detail: 'context missing for integration' }); continue; }
|
|
365
|
+
const title = `Integrate r${id} into ${ctx.baseBranch}`;
|
|
366
|
+
const prompt =
|
|
367
|
+
`This worktree is branched from '${ctx.baseBranch}'. Merge the branch '${run.branch}' into it:\n` +
|
|
368
|
+
`1. Run: git merge ${run.branch}\n` +
|
|
369
|
+
`2. Resolve every conflict by preserving the intent of BOTH sides — do not simply pick one side.\n` +
|
|
370
|
+
`3. Make sure the result is consistent (typecheck/tests if available), then commit the merge.\n` +
|
|
371
|
+
`Do not modify files unrelated to the conflicts.`;
|
|
372
|
+
const tIns = await db.insert(tasks).values({ repoId: ctx.repoId, title, prompt }).returning();
|
|
373
|
+
const newTask = tIns[0]!;
|
|
374
|
+
const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending' }).returning();
|
|
375
|
+
const newRun = rIns[0]!;
|
|
376
|
+
broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, branch: '', filesChanged: 0 });
|
|
377
|
+
void launchRun(newRun.id, real ?? true);
|
|
378
|
+
results.push({ runId: id, status: 'conflict', detail: m.detail, integrationTaskId: newTask.id, integrationRunId: newRun.id });
|
|
379
|
+
}
|
|
380
|
+
return results;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 결과 파일 회수(export) — 조회성 태스크용: worktree 의 변경·신규 파일을
|
|
385
|
+
* 지정 폴더로 복사한다. 머지 없이 산출물만 가져오는 길. (v1: 로컬 머신 전용)
|
|
386
|
+
*/
|
|
387
|
+
export async function exportRun(runId: number, destIn?: string): Promise<{ ok: boolean; detail: string; dest?: string; copied?: number; skipped?: number }> {
|
|
388
|
+
const ctx = await loadContext(runId);
|
|
389
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
390
|
+
const run = rr[0];
|
|
391
|
+
if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
|
|
392
|
+
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — wait for it to settle' };
|
|
393
|
+
if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
|
|
394
|
+
return { ok: false, detail: 'remote export not yet supported — files are on the remote machine' };
|
|
395
|
+
}
|
|
396
|
+
const wt = run.worktreePath;
|
|
397
|
+
if (!existsSync(wt)) return { ok: false, detail: 'worktree missing (cleaned up)' };
|
|
398
|
+
|
|
399
|
+
// 회수 대상 = 분기점 이후 커밋된 변경(머지 시도가 auto-commit 했을 수 있음) ∪ 미커밋·신규
|
|
400
|
+
const list = await runShellOn(
|
|
401
|
+
ctx.machine,
|
|
402
|
+
`cd ${shq(wt)} && { git diff --name-only ${shq(ctx.baseBranch)}...HEAD -z 2>/dev/null; git ls-files -mo --exclude-standard -z; }`,
|
|
403
|
+
15000,
|
|
404
|
+
);
|
|
405
|
+
if (!list.ok) return { ok: false, detail: 'could not list changed files' };
|
|
406
|
+
const files = [...new Set(list.stdout.split('\0').filter(Boolean))];
|
|
407
|
+
if (!files.length) return { ok: false, detail: 'no changed files in this run' };
|
|
408
|
+
|
|
409
|
+
const dest = (destIn ?? '').trim() || ppath.join(homedir(), 'coxpit-exports', `r${runId}`);
|
|
410
|
+
if (!dest.startsWith('/')) return { ok: false, detail: 'destination must be an absolute path' };
|
|
411
|
+
|
|
412
|
+
let copied = 0, skipped = 0;
|
|
413
|
+
for (const f of files) {
|
|
414
|
+
const src = ppath.join(wt, f);
|
|
415
|
+
if (!existsSync(src)) { skipped++; continue; } // 삭제된 파일 등
|
|
416
|
+
const out = ppath.join(dest, f);
|
|
417
|
+
await mkdir(ppath.dirname(out), { recursive: true });
|
|
418
|
+
await copyFile(src, out);
|
|
419
|
+
copied++;
|
|
420
|
+
}
|
|
421
|
+
await recordEvent(runId, 'export', JSON.stringify({ dest, copied, skipped }));
|
|
422
|
+
return { ok: true, detail: `${copied} file(s) exported`, dest, copied, skipped };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* PR 모드 — run 브랜치를 origin 에 push 하고 gh 로 pull request 를 연다.
|
|
427
|
+
* 팀 repo·리뷰 흐름용: 로컬 merge 대신 PR 로 결과를 보낸다.
|
|
428
|
+
*/
|
|
429
|
+
export async function prRun(runId: number): Promise<{ ok: boolean; detail: string; url?: string }> {
|
|
430
|
+
const ctx = await loadContext(runId);
|
|
431
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
432
|
+
const run = rr[0];
|
|
433
|
+
if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
|
|
434
|
+
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
|
|
435
|
+
if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
|
|
436
|
+
const wt = shq(run.worktreePath);
|
|
437
|
+
|
|
438
|
+
// 0) 사전 조건: origin 리모트 + gh CLI
|
|
439
|
+
const pre = await runShellOn(ctx.machine,
|
|
440
|
+
`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);
|
|
441
|
+
if (!pre.stdout.includes('R1')) return { ok: false, detail: 'no origin remote on this repo' };
|
|
442
|
+
if (!pre.stdout.includes('G1')) return { ok: false, detail: 'GitHub CLI (gh) not found on the machine' };
|
|
443
|
+
|
|
444
|
+
// 1) worktree 미커밋 변경 자동 커밋
|
|
445
|
+
const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
|
|
446
|
+
const c1 = await runShellOn(ctx.machine,
|
|
447
|
+
`cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`, 20000);
|
|
448
|
+
if (!c1.ok) return { ok: false, detail: 'worktree commit failed: ' + (c1.stderr || c1.stdout).trim().slice(0, 300) };
|
|
449
|
+
|
|
450
|
+
// 2) push
|
|
451
|
+
const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(run.branch)} 2>&1`, 60000);
|
|
452
|
+
if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
|
|
453
|
+
|
|
454
|
+
// 3) PR 생성 (동일 브랜치 PR 이 이미 있으면 그 URL 재사용)
|
|
455
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
|
|
456
|
+
const title = `${tr[0]?.title ?? 'coxpit run'} (r${runId})`;
|
|
457
|
+
const body = (run.exitSummary ? run.exitSummary + '\n\n' : '') + '🤖 Opened from a coxpit agent run';
|
|
458
|
+
const pr = await runShellOn(ctx.machine,
|
|
459
|
+
`cd ${wt} && gh pr create -B ${shq(ctx.baseBranch)} -H ${shq(run.branch)} -t ${shq(title)} -b ${shq(body)} 2>&1 || true`, 60000);
|
|
460
|
+
const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
|
|
461
|
+
if (!m) return { ok: false, detail: 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 300) };
|
|
462
|
+
|
|
463
|
+
await setRun(runId, { prUrl: m[0] });
|
|
464
|
+
await recordEvent(runId, 'pr', m[0]);
|
|
465
|
+
return { ok: true, detail: 'pull request opened', url: m[0] };
|
|
466
|
+
}
|
|
467
|
+
|
|
329
468
|
/**
|
|
330
469
|
* worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
|
|
331
470
|
*/
|
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, integrateRuns } 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.7.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,43 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
378
401
|
return reply.code(202).send(res);
|
|
379
402
|
});
|
|
380
403
|
|
|
404
|
+
// 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
|
|
405
|
+
app.post('/api/integrate', async (req, reply) => {
|
|
406
|
+
const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };
|
|
407
|
+
const ids = (b.runIds ?? []).map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
|
408
|
+
if (!ids.length) return reply.code(400).send({ error: 'runIds required' });
|
|
409
|
+
if (ids.length > 20) return reply.code(400).send({ error: 'too many runs (max 20)' });
|
|
410
|
+
const results = await integrateRuns(ids, b.real);
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
merged: results.filter((r) => r.status === 'merged').length,
|
|
414
|
+
conflicts: results.filter((r) => r.status === 'conflict').length,
|
|
415
|
+
skipped: results.filter((r) => r.status === 'skipped').length,
|
|
416
|
+
results,
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// 결과 파일 회수 — 머지 없이 worktree 산출물만 지정 폴더로 복사(조회성 태스크).
|
|
421
|
+
app.post('/api/runs/:id/export', async (req, reply) => {
|
|
422
|
+
const id = Number((req.params as { id: string }).id);
|
|
423
|
+
const b = (req.body ?? {}) as { dest?: string };
|
|
424
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
425
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
426
|
+
const res = await exportRun(id, b.dest);
|
|
427
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
428
|
+
return res;
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// PR 모드 — run 브랜치 push + gh pr create.
|
|
432
|
+
app.post('/api/runs/:id/pr', async (req, reply) => {
|
|
433
|
+
const id = Number((req.params as { id: string }).id);
|
|
434
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
435
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
436
|
+
const res = await prRun(id);
|
|
437
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
438
|
+
return res;
|
|
439
|
+
});
|
|
440
|
+
|
|
381
441
|
// 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
|
|
382
442
|
app.post('/api/runs/:id/stop', async (req, reply) => {
|
|
383
443
|
const id = Number((req.params as { id: string }).id);
|
|
@@ -397,7 +457,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
397
457
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
398
458
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
399
459
|
addSink(socket);
|
|
400
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.
|
|
460
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.7.0' }));
|
|
401
461
|
socket.on('close', () => removeSink(socket));
|
|
402
462
|
});
|
|
403
463
|
|