coxpit 4.1.0 → 4.3.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/README.md +1 -1
- package/package.json +1 -1
- package/src/board.ts +220 -9
- package/src/db/index.ts +8 -0
- package/src/db/schema.ts +10 -0
- package/src/orchestrator.ts +15 -3
- package/src/server.ts +52 -9
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
|
|
|
110
110
|
|
|
111
111
|
## Status
|
|
112
112
|
|
|
113
|
-
`v4.
|
|
113
|
+
`v4.3` — everything in v4.2 plus **the logbook**: the board is Active-first (closed tasks leave the grid), an Archive view lists them as searchable rows, and `/api/fleet` now scopes to active work and caps events per run so it stays fast at hundreds of runs. Opening an archived task still renders its documents — from the v4.1 snapshot. All shipped and e2e-tested (38 checks). ⚠️ `/api/fleet` defaults to `view=active` now — pass `?view=all` for the previous everything-payload. Roadmap: ROADMAP.md.
|
|
114
114
|
|
|
115
115
|
## License
|
|
116
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.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
|
@@ -141,6 +141,33 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
141
141
|
|
|
142
142
|
/* ── run cards ──────────────────────────── */
|
|
143
143
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
|
|
144
|
+
/* ── group bands (goal/swarm 형제 묶음) — 점선 클러스터, 좌측 액센트 바·채움 금지 ── */
|
|
145
|
+
.gband{grid-column:1/-1;border:1px dashed var(--line-hi);border-radius:14px;padding:12px;margin-bottom:0}
|
|
146
|
+
.gband-h{display:flex;align-items:center;gap:10px;margin-bottom:10px;font-family:var(--mono)}
|
|
147
|
+
.gband-glyph{color:var(--brand);font-size:13px}
|
|
148
|
+
.gband-t{color:var(--ink);font-size:12.5px;font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:40%}
|
|
149
|
+
.gband-n{color:var(--faint);font-size:11px}
|
|
150
|
+
.gband-sp{flex:1}
|
|
151
|
+
.gband-fold{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px}
|
|
152
|
+
.gband.folded .gband-grid{display:none}
|
|
153
|
+
.gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
|
|
154
|
+
.attempt{color:var(--brand);opacity:.8}
|
|
155
|
+
/* ── active/archive view seg + archive list ── */
|
|
156
|
+
.view-seg{padding:2px;gap:2px}
|
|
157
|
+
.view-seg .seg-opt{padding:4px 12px;font-size:11.5px}
|
|
158
|
+
.view-seg .seg-hint{color:var(--faint);margin-left:4px}
|
|
159
|
+
.arch-bar{display:flex;gap:8px;margin-bottom:12px}
|
|
160
|
+
.arch-bar input{flex:1}
|
|
161
|
+
.arch-bar select{flex:0 0 auto}
|
|
162
|
+
.arch-row{display:flex;align-items:center;gap:14px;padding:10px 14px;border:1px solid var(--line);
|
|
163
|
+
border-radius:9px;background:var(--surface);margin-bottom:7px;cursor:pointer;transition:border-color .15s}
|
|
164
|
+
.arch-row:hover{border-color:var(--line-hi)}
|
|
165
|
+
.ar-title{flex:1;font-weight:600;font-size:13px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
166
|
+
.ar-runs{font-family:var(--mono);font-size:11px;color:var(--muted);flex:0 1 auto;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:34%}
|
|
167
|
+
.ar-repo{font-family:var(--mono);font-size:11px;color:var(--faint);flex:0 0 auto}
|
|
168
|
+
.ar-date{font-family:var(--mono);font-size:11px;color:var(--faint);flex:0 0 auto}
|
|
169
|
+
.arch-empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:30px 0;text-align:center}
|
|
170
|
+
@media (max-width:860px){.ar-repo{display:none}.ar-runs{max-width:44%}}
|
|
144
171
|
.card{border:1px solid var(--line);border-radius:var(--r-card);background:var(--surface);
|
|
145
172
|
overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
|
|
146
173
|
transition:border-color .18s,transform .18s,box-shadow .18s}
|
|
@@ -372,6 +399,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
372
399
|
<button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
|
|
373
400
|
<div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
|
|
374
401
|
<span class="daemon-badge" id="daemonBadge" style="display:none"></span>
|
|
402
|
+
<div class="seg view-seg" id="viewSeg">
|
|
403
|
+
<button type="button" class="seg-opt on" data-view="active">Active</button>
|
|
404
|
+
<button type="button" class="seg-opt" data-view="archive">Archive <span id="archN" class="seg-hint"></span></button>
|
|
405
|
+
</div>
|
|
375
406
|
<div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
|
|
376
407
|
<button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
|
|
377
408
|
<div class="machines" id="machines"></div>
|
|
@@ -457,6 +488,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
457
488
|
<span>No runs yet</span>
|
|
458
489
|
<span style="color:#3d4657">register a repo, write a task, hit Run fleet</span>
|
|
459
490
|
</div>
|
|
491
|
+
<div id="archive" hidden>
|
|
492
|
+
<div class="arch-bar">
|
|
493
|
+
<input id="archQ" placeholder="search title…" autocomplete="off" />
|
|
494
|
+
<select id="archRepo"><option value="">all repos</option></select>
|
|
495
|
+
</div>
|
|
496
|
+
<div id="archList"></div>
|
|
497
|
+
<div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
|
|
498
|
+
</div>
|
|
460
499
|
</main>
|
|
461
500
|
</div>
|
|
462
501
|
|
|
@@ -625,6 +664,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
625
664
|
<script>
|
|
626
665
|
const runs = new Map(); // runId -> run object
|
|
627
666
|
const tasks = new Map(); // taskId -> task
|
|
667
|
+
const groups = new Map(); // groupId -> {id, kind, title}
|
|
628
668
|
let repos = [], machines = [], captures = [];
|
|
629
669
|
|
|
630
670
|
const $ = (id) => document.getElementById(id);
|
|
@@ -828,15 +868,127 @@ function chipHTML(status){
|
|
|
828
868
|
return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
|
|
829
869
|
+ '<i></i>'+esc(s)+'</span>';
|
|
830
870
|
}
|
|
871
|
+
/* 같은 태스크에 run 이 여럿이면 "run i/n" — 같은 제목 카드가 중복이 아니라 시도로 읽히게 */
|
|
872
|
+
function attemptHTML(r){
|
|
873
|
+
const sib = [...runs.values()].filter(x=>x.taskId===r.taskId).sort((a,b)=>a.id-b.id);
|
|
874
|
+
if (sib.length<2) return '';
|
|
875
|
+
const i = sib.findIndex(x=>x.id===r.id)+1;
|
|
876
|
+
return '<span class="attempt" title="attempt within this task">run '+i+'/'+sib.length+'</span>';
|
|
877
|
+
}
|
|
831
878
|
|
|
879
|
+
/* ── group fold 상태(기기별, WS 재렌더에도 유지) ── */
|
|
880
|
+
let gfold = new Set();
|
|
881
|
+
try{ gfold = new Set(JSON.parse(localStorage.getItem('coxpit.gfold')||'[]')); }catch{}
|
|
882
|
+
function saveGfold(){ try{ localStorage.setItem('coxpit.gfold', JSON.stringify([...gfold])); }catch{} }
|
|
883
|
+
/* 태스크의 모든 run 이 정착했는가 */
|
|
884
|
+
function taskSettled(taskId){
|
|
885
|
+
const rs = [...runs.values()].filter(r=>r.taskId===taskId);
|
|
886
|
+
return rs.length>0 && rs.every(r=>['done','failed','stopped','merged'].includes(r.status));
|
|
887
|
+
}
|
|
888
|
+
function bandHTML(g, grpRuns){
|
|
889
|
+
const glyph = g.kind==='swarm' ? '↳' : '⌁';
|
|
890
|
+
const title = g.kind==='swarm' ? 'swarm of: '+esc(g.title) : esc(g.title);
|
|
891
|
+
const taskIds = [...new Set(grpRuns.map(r=>r.taskId))];
|
|
892
|
+
const settled = taskIds.filter(taskSettled).length;
|
|
893
|
+
const folded = gfold.has(g.id);
|
|
894
|
+
const cards = grpRuns.slice().sort((a,b)=>a.id-b.id).map(cardHTML).join('');
|
|
895
|
+
return '<div class="gband'+(folded?' folded':'')+'" data-g="'+g.id+'">'
|
|
896
|
+
+ '<div class="gband-h"><span class="gband-glyph">'+glyph+'</span>'
|
|
897
|
+
+ '<span class="gband-t">'+title+'</span>'
|
|
898
|
+
+ '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
|
|
899
|
+
+ '<span class="gband-sp"></span>'
|
|
900
|
+
+ '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
|
|
901
|
+
+ '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
|
|
902
|
+
+ '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
|
|
903
|
+
+ '<div class="gband-grid">'+cards+'</div></div>';
|
|
904
|
+
}
|
|
832
905
|
function render(){
|
|
906
|
+
if (boardView==='archive') return; // 아카이브 뷰는 자체 리스트 — grid 안 건드림
|
|
833
907
|
const list = [...runs.values()].sort((a,b)=>b.id-a.id);
|
|
834
908
|
$('empty').style.display = list.length ? 'none' : 'flex';
|
|
835
|
-
if (!list.length) paintOnboarding();
|
|
836
|
-
|
|
909
|
+
if (!list.length){ paintOnboarding(); $('grid').innerHTML=''; return; }
|
|
910
|
+
// 그룹 파티션 — grouped run 은 밴드로 클러스터, ungrouped 는 뒤에 flat.
|
|
911
|
+
const byGroup = new Map(); const flat = [];
|
|
912
|
+
for (const r of list){
|
|
913
|
+
const t = tasks.get(r.taskId);
|
|
914
|
+
const gid = t && t.groupId!=null && groups.has(t.groupId) ? t.groupId : null;
|
|
915
|
+
if (gid==null) flat.push(r);
|
|
916
|
+
else { if(!byGroup.has(gid)) byGroup.set(gid, []); byGroup.get(gid).push(r); }
|
|
917
|
+
}
|
|
918
|
+
let html = '';
|
|
919
|
+
for (const gid of [...byGroup.keys()].sort((a,b)=>b-a)) html += bandHTML(groups.get(gid), byGroup.get(gid));
|
|
920
|
+
html += flat.map(cardHTML).join('');
|
|
921
|
+
$('grid').innerHTML = html;
|
|
837
922
|
if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
|
|
838
923
|
}
|
|
839
924
|
|
|
925
|
+
/* ── active / archive view ─────────────────────────────── */
|
|
926
|
+
let boardView = 'active';
|
|
927
|
+
let archOffset = 0, archTotal = 0;
|
|
928
|
+
const ARCH_LIMIT = 50;
|
|
929
|
+
function setView(v){
|
|
930
|
+
boardView = v;
|
|
931
|
+
document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>{
|
|
932
|
+
const on = b.dataset.view===v;
|
|
933
|
+
b.classList.toggle('on', on); b.setAttribute('aria-pressed', on?'true':'false');
|
|
934
|
+
});
|
|
935
|
+
const archive = v==='archive';
|
|
936
|
+
$('archive').hidden = !archive;
|
|
937
|
+
$('grid').style.display = archive ? 'none' : '';
|
|
938
|
+
$('empty').style.display = archive ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
|
|
939
|
+
document.querySelector('.toolbar').style.display = archive ? 'none' : '';
|
|
940
|
+
if (archive){ paintArchRepos(); archFetch(true); }
|
|
941
|
+
else render();
|
|
942
|
+
}
|
|
943
|
+
document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
|
|
944
|
+
function paintArchRepos(){
|
|
945
|
+
const sel = $('archRepo'); const cur = sel.value;
|
|
946
|
+
sel.innerHTML = '<option value="">all repos</option>' + repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('');
|
|
947
|
+
sel.value = cur;
|
|
948
|
+
}
|
|
949
|
+
function archRowHTML(row){
|
|
950
|
+
const runs = row.runs.map(r=>'r'+r.id+' '+r.status).join(' · ');
|
|
951
|
+
const d = row.closedAt ? new Date(row.closedAt*1000) : null;
|
|
952
|
+
const date = d ? (String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')) : '';
|
|
953
|
+
const grp = row.groupTitle ? '<span class="ar-repo" title="group">⌁ '+esc(row.groupTitle.slice(0,24))+'</span>' : '';
|
|
954
|
+
return '<div class="arch-row" data-t="'+row.taskId+'">'
|
|
955
|
+
+ '<span class="ar-title">'+esc(row.title)+'</span>'
|
|
956
|
+
+ '<span class="ar-runs">'+esc(runs)+'</span>'+grp
|
|
957
|
+
+ '<span class="ar-repo">'+esc(row.repoName)+'</span>'
|
|
958
|
+
+ '<span class="ar-date">'+date+'</span></div>';
|
|
959
|
+
}
|
|
960
|
+
async function archFetch(reset){
|
|
961
|
+
if (reset){ archOffset = 0; $('archList').innerHTML = '<div class="arch-empty">loading…</div>'; }
|
|
962
|
+
const q = encodeURIComponent($('archQ').value.trim());
|
|
963
|
+
const repo = $('archRepo').value;
|
|
964
|
+
const url = '/api/archive?offset='+archOffset+'&limit='+ARCH_LIMIT
|
|
965
|
+
+ (q?'&q='+q:'') + (repo?'&repo='+repo:'');
|
|
966
|
+
try{
|
|
967
|
+
const j = await fetch(url).then(x=>x.json());
|
|
968
|
+
archTotal = j.total||0;
|
|
969
|
+
const rows = (j.rows||[]).map(archRowHTML).join('');
|
|
970
|
+
if (reset) $('archList').innerHTML = rows || '<div class="arch-empty">no closed tasks match</div>';
|
|
971
|
+
else $('archList').insertAdjacentHTML('beforeend', rows);
|
|
972
|
+
archOffset += (j.rows||[]).length;
|
|
973
|
+
$('archMore').hidden = archOffset >= archTotal || !(j.rows||[]).length;
|
|
974
|
+
}catch{ $('archList').innerHTML = '<div class="arch-empty">archive failed</div>'; }
|
|
975
|
+
}
|
|
976
|
+
$('archMore').addEventListener('click', ()=>archFetch(false));
|
|
977
|
+
let archTimer = null;
|
|
978
|
+
$('archQ').addEventListener('input', ()=>{ clearTimeout(archTimer); archTimer = setTimeout(()=>archFetch(true), 300); });
|
|
979
|
+
$('archRepo').addEventListener('change', ()=>archFetch(true));
|
|
980
|
+
$('archList').addEventListener('click', async (e)=>{
|
|
981
|
+
const row = e.target.closest('.arch-row'); if(!row) return;
|
|
982
|
+
const tid = Number(row.dataset.t);
|
|
983
|
+
try{
|
|
984
|
+
const j = await fetch('/api/tasks/'+tid).then(x=>x.json());
|
|
985
|
+
if (!j.task || !(j.runs||[]).length){ toast('task has no runs', 'error'); return; }
|
|
986
|
+
tasks.set(j.task.id, j.task);
|
|
987
|
+
j.runs.forEach(rn => { if(!runs.has(rn.id)) runs.set(rn.id, { ...rn, events: [] }); });
|
|
988
|
+
openModal(j.runs.map(r=>r.id).sort((a,b)=>a-b)[0]);
|
|
989
|
+
}catch{ toast('could not open task', 'error'); }
|
|
990
|
+
});
|
|
991
|
+
|
|
840
992
|
/* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
|
|
841
993
|
let readiness = null, probing = false;
|
|
842
994
|
async function probeFirstMachine(){
|
|
@@ -898,6 +1050,7 @@ function cardHTML(r){
|
|
|
898
1050
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
899
1051
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
900
1052
|
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
1053
|
+
+ attemptHTML(r)
|
|
901
1054
|
+ (r.model ? '<span title="model">⚙ '+esc(r.model)+'</span>' : '')
|
|
902
1055
|
+ (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
|
|
903
1056
|
+ (r.sessionId && ['done','failed','stopped'].includes(r.status)
|
|
@@ -916,10 +1069,13 @@ function upsertRun(patch){
|
|
|
916
1069
|
}
|
|
917
1070
|
|
|
918
1071
|
async function hydrate(){
|
|
919
|
-
const r = await fetch('/api/fleet').then(x=>x.json());
|
|
1072
|
+
const r = await fetch('/api/fleet?view=active').then(x=>x.json());
|
|
920
1073
|
machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
|
|
1074
|
+
if (r.counts){ const n = r.counts.closedTasks||0; $('archN').textContent = n ? '· '+n : ''; }
|
|
921
1075
|
tasks.clear();
|
|
922
1076
|
(r.tasks||[]).forEach(t => tasks.set(t.id, t));
|
|
1077
|
+
groups.clear();
|
|
1078
|
+
(r.groups||[]).forEach(g => groups.set(g.id, g));
|
|
923
1079
|
runs.clear();
|
|
924
1080
|
(r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
|
|
925
1081
|
if (r.daemon) {
|
|
@@ -1033,22 +1189,27 @@ function connectWS(){
|
|
|
1033
1189
|
ws.onclose = ()=>{ $('wsdot').classList.remove('on'); $('wstext').textContent='reconnecting'; setTimeout(connectWS,1500); };
|
|
1034
1190
|
ws.onmessage = (m)=>{
|
|
1035
1191
|
let ev; try{ ev = JSON.parse(m.data); }catch{ return; }
|
|
1192
|
+
// 아카이브 뷰는 정적 스냅샷 — 라이브 갱신으로 뒤엎지 않는다.
|
|
1193
|
+
if (boardView==='archive' && (ev.type==='run'||ev.type==='event'||ev.type==='task')) return;
|
|
1036
1194
|
if (ev.type==='run'){
|
|
1037
|
-
const
|
|
1195
|
+
const rid = ev.runId ?? ev.id;
|
|
1196
|
+
const known = runs.has(rid);
|
|
1197
|
+
// 아카이브된(맵에 없는) run 의 뒤늦은 echo 는 무시 — 신규는 항상 pending 으로 먼저 온다.
|
|
1198
|
+
if (!known && ev.status && ev.status!=='pending') return;
|
|
1038
1199
|
upsertRun(ev);
|
|
1039
1200
|
if (!known && ev.taskId==null){ hydrate(); return; }
|
|
1040
1201
|
if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
|
|
1041
|
-
render(); flash(
|
|
1042
|
-
if (openRunId===
|
|
1202
|
+
render(); flash(rid); paintModal();
|
|
1203
|
+
if (openRunId===rid && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
|
|
1043
1204
|
if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
|
|
1044
1205
|
if (['done','failed','error','stopped'].includes(ev.status)) notifySettleUI(ev);
|
|
1045
1206
|
} else if (ev.type==='event'){
|
|
1046
|
-
const r = runs.get(ev.runId); if(!r)
|
|
1207
|
+
const r = runs.get(ev.runId); if(!r) return; // 아카이브 run 의 이벤트는 무시(맵에 없으면 resurrect X)
|
|
1047
1208
|
r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
|
|
1048
1209
|
render(); flash(ev.runId); paintModal();
|
|
1049
1210
|
} else if (ev.type==='task'){
|
|
1050
1211
|
const t = tasks.get(ev.taskId);
|
|
1051
|
-
if (t){ t.status = ev.status; render(); paintModal(); } else { hydrate(); }
|
|
1212
|
+
if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; render(); paintModal(); } else { hydrate(); }
|
|
1052
1213
|
} else if (ev.type==='capture'){
|
|
1053
1214
|
captures.push(ev.capture); paintSidebar();
|
|
1054
1215
|
}
|
|
@@ -1139,11 +1300,16 @@ $('mDiffWrap').addEventListener('click', (e)=>{
|
|
|
1139
1300
|
$('steerInput').value = (f ? f+': ' : '') + '"'+line+'" — ';
|
|
1140
1301
|
$('steerInput').focus();
|
|
1141
1302
|
});
|
|
1142
|
-
function openModal(id){
|
|
1303
|
+
async function openModal(id){
|
|
1143
1304
|
openRunId = id;
|
|
1144
1305
|
if (docMode){ docMode=false; $('mDocsTgl').textContent='Rendered'; $('mDiffWrap').innerHTML='<pre class="diff" id="mDiff">loading…</pre>'; }
|
|
1145
1306
|
$('mDocsTgl').hidden = true;
|
|
1146
1307
|
paintModal(); $('overlay').classList.add('open'); loadDiff();
|
|
1308
|
+
// fleet 는 run 당 최근 40 이벤트만 내림 — 모달은 전체 타임라인을 다시 가져온다.
|
|
1309
|
+
try{
|
|
1310
|
+
const j = await fetch('/api/runs/'+id).then(x=>x.json());
|
|
1311
|
+
if (j.run){ const r = runs.get(id); if (r){ r.events = j.events||[]; if (openRunId===id) paintModal(); } }
|
|
1312
|
+
}catch{}
|
|
1147
1313
|
}
|
|
1148
1314
|
function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
|
|
1149
1315
|
/* ── select mode (integrate) ── */
|
|
@@ -1174,6 +1340,51 @@ $('selGo').addEventListener('click', async ()=>{
|
|
|
1174
1340
|
} else toast('integrate: '+(j.error||res.status), 'error');
|
|
1175
1341
|
});
|
|
1176
1342
|
|
|
1343
|
+
/* ── 그룹 밴드 액션 (fold / Select runs / Close group) ── */
|
|
1344
|
+
$('grid').addEventListener('click', async (e)=>{
|
|
1345
|
+
const fold = e.target.closest('[data-gfold]');
|
|
1346
|
+
if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
|
|
1347
|
+
const gsel = e.target.closest('[data-gsel]');
|
|
1348
|
+
if (gsel){
|
|
1349
|
+
const g=Number(gsel.dataset.gsel);
|
|
1350
|
+
if (!selectMode) setSelectMode(true);
|
|
1351
|
+
const tids = new Set([...tasks.values()].filter(t=>t.groupId===g).map(t=>t.id));
|
|
1352
|
+
for (const r of [...runs.values()].sort((a,b)=>a.id-b.id)){
|
|
1353
|
+
if (!tids.has(r.taskId)) continue;
|
|
1354
|
+
const ok = ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
|
|
1355
|
+
if (ok && !selected.has(r.id)){ selected.add(r.id); selOrder.push(r.id); }
|
|
1356
|
+
}
|
|
1357
|
+
$('selCnt').textContent = selected.size + ' selected';
|
|
1358
|
+
render();
|
|
1359
|
+
if (!selected.size) toast('no settled runs with changes in this group yet', 'error');
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const gclose = e.target.closest('[data-gclose]');
|
|
1363
|
+
if (gclose){ await closeGroup(Number(gclose.dataset.gclose)); return; }
|
|
1364
|
+
});
|
|
1365
|
+
async function closeGroup(g){
|
|
1366
|
+
const tids = [...tasks.values()].filter(t=>t.groupId===g && t.status!=='closed').map(t=>t.id);
|
|
1367
|
+
if (!tids.length){ toast('nothing open in this group', 'ok'); return; }
|
|
1368
|
+
const grp = groups.get(g)||{};
|
|
1369
|
+
const yes = await confirmUI('Close this whole group?',
|
|
1370
|
+
{ sub: (grp.title||'group')+' — stops and cleans every worktree/branch of '+tids.length+' task(s).', danger:true, okLabel:'Close group' });
|
|
1371
|
+
if (!yes) return;
|
|
1372
|
+
const closeOne = (id,force)=>fetch('/api/tasks/'+id+'/close',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({force})});
|
|
1373
|
+
const risky = [];
|
|
1374
|
+
for (const id of tids){
|
|
1375
|
+
const res = await closeOne(id,false);
|
|
1376
|
+
if (res.status===409){ const j=await res.json().catch(()=>({})); (j.atRisk||[]).forEach(a=>risky.push(a)); }
|
|
1377
|
+
}
|
|
1378
|
+
if (risky.length){
|
|
1379
|
+
const list = risky.map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
|
|
1380
|
+
const ok = await confirmUI('Close and delete unmerged output?',
|
|
1381
|
+
{ danger:true, sub: list+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
|
|
1382
|
+
if (!ok){ await hydrate(); return; }
|
|
1383
|
+
for (const id of tids) await closeOne(id,true);
|
|
1384
|
+
}
|
|
1385
|
+
toast('closed '+tids.length+' task'+(tids.length>1?'s':'')+' in the group', 'ok');
|
|
1386
|
+
await hydrate();
|
|
1387
|
+
}
|
|
1177
1388
|
$('grid').addEventListener('click',(e)=>{
|
|
1178
1389
|
const card = e.target.closest('.card'); if(!card) return;
|
|
1179
1390
|
const id = Number(card.id.replace('card-',''));
|
package/src/db/index.ts
CHANGED
|
@@ -83,6 +83,12 @@ export async function ensureSchema(): Promise<void> {
|
|
|
83
83
|
content TEXT NOT NULL DEFAULT '',
|
|
84
84
|
created_at INTEGER DEFAULT (unixepoch())
|
|
85
85
|
);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS task_groups (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
kind TEXT NOT NULL DEFAULT 'goal',
|
|
89
|
+
title TEXT NOT NULL,
|
|
90
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
91
|
+
);
|
|
86
92
|
`);
|
|
87
93
|
// 기존 DB 마이그레이션(멱등)
|
|
88
94
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
@@ -90,4 +96,6 @@ export async function ensureSchema(): Promise<void> {
|
|
|
90
96
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
91
97
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
|
|
92
98
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
99
|
+
try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
|
|
100
|
+
try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
|
|
93
101
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -32,6 +32,14 @@ export const designCaptures = sqliteTable('design_captures', {
|
|
|
32
32
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
/** 태스크 그룹 — 한 goal(plan fan-out)·한 swarm(에이전트 서브태스크)에서 난 형제들. */
|
|
36
|
+
export const taskGroups = sqliteTable('task_groups', {
|
|
37
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
38
|
+
kind: text('kind').notNull().default('goal'), // 'goal' | 'swarm'
|
|
39
|
+
title: text('title').notNull(),
|
|
40
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
41
|
+
});
|
|
42
|
+
|
|
35
43
|
/** 하나의 요청. 여러 AgentRun 으로 병렬 시도됨. */
|
|
36
44
|
export const tasks = sqliteTable('tasks', {
|
|
37
45
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
@@ -41,6 +49,8 @@ export const tasks = sqliteTable('tasks', {
|
|
|
41
49
|
status: text('status').notNull().default('open'), // open | done
|
|
42
50
|
designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
|
|
43
51
|
parentRunId: integer('parent_run_id'), // 에이전트 셀프 오케스트레이션 — 이 태스크를 발사한 run
|
|
52
|
+
groupId: integer('group_id'), // task_groups — goal/swarm 형제 묶음(수동 태스크는 NULL)
|
|
53
|
+
closedAt: integer('closed_at', { mode: 'timestamp' }), // 닫힌 시각(아카이브 정렬·표시)
|
|
44
54
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
45
55
|
});
|
|
46
56
|
|
package/src/orchestrator.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ChildProcess } from 'node:child_process';
|
|
|
8
8
|
import { eq } from 'drizzle-orm';
|
|
9
9
|
import { config } from './config';
|
|
10
10
|
import { db } from './db';
|
|
11
|
-
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots } from './db/schema';
|
|
11
|
+
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups } from './db/schema';
|
|
12
12
|
import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
|
|
13
13
|
import { broadcast } from './hub';
|
|
14
14
|
import { getProvider, type Provider } from './providers';
|
|
@@ -134,9 +134,17 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
|
|
|
134
134
|
// 폭주 가드 — 한 부모가 만들 수 있는 하위 태스크 상한
|
|
135
135
|
const siblings = await db.select().from(tasks).where(eq(tasks.parentRunId, parentRunId));
|
|
136
136
|
if (siblings.length >= 8) return { ok: false, detail: 'subtask limit reached (8 per run)' };
|
|
137
|
+
// 그룹 — 부모가 이미 그룹에 속하면 그 그룹, 아니면 swarm 그룹 생성 후 부모까지 백필.
|
|
138
|
+
let groupId = pt.groupId ?? null;
|
|
139
|
+
if (groupId == null) {
|
|
140
|
+
const gIns = await db.insert(taskGroups).values({ kind: 'swarm', title: pt.title.slice(0, 140) }).returning();
|
|
141
|
+
groupId = gIns[0]!.id;
|
|
142
|
+
await db.update(tasks).set({ groupId }).where(eq(tasks.id, pt.id));
|
|
143
|
+
broadcast({ type: 'task', taskId: pt.id, groupId });
|
|
144
|
+
}
|
|
137
145
|
const n = Math.max(1, Math.min(4, count || 1));
|
|
138
146
|
const tIns = await db.insert(tasks).values({
|
|
139
|
-
repoId: pt.repoId, title: title.slice(0, 140), prompt, parentRunId,
|
|
147
|
+
repoId: pt.repoId, title: title.slice(0, 140), prompt, parentRunId, groupId,
|
|
140
148
|
}).returning();
|
|
141
149
|
const task = tIns[0]!;
|
|
142
150
|
const runIds: number[] = [];
|
|
@@ -714,9 +722,13 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
|
|
|
714
722
|
if (plan.length < 1) return { ok: false, detail: 'planner returned no tasks' };
|
|
715
723
|
}
|
|
716
724
|
|
|
725
|
+
// 이 goal 의 형제들을 한 그룹으로 묶는다(보드 밴드). 드라이 리허설도 동일.
|
|
726
|
+
const gIns = await db.insert(taskGroups).values({ kind: 'goal', title: goal.slice(0, 140) }).returning();
|
|
727
|
+
const groupId = gIns[0]!.id;
|
|
728
|
+
|
|
717
729
|
const created: Array<{ id: number; title: string; runId: number }> = [];
|
|
718
730
|
for (const t of plan) {
|
|
719
|
-
const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt }).returning();
|
|
731
|
+
const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt, groupId }).returning();
|
|
720
732
|
const task = tIns[0]!;
|
|
721
733
|
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
|
|
722
734
|
const run = rIns[0]!;
|
package/src/server.ts
CHANGED
|
@@ -6,11 +6,11 @@ import { createRequire } from 'node:module';
|
|
|
6
6
|
import { randomBytes } from 'node:crypto';
|
|
7
7
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
8
8
|
import websocket from '@fastify/websocket';
|
|
9
|
-
import { eq } from 'drizzle-orm';
|
|
9
|
+
import { eq, inArray, and, like, desc } from 'drizzle-orm';
|
|
10
10
|
import { authGate } from './auth';
|
|
11
11
|
import { config } from './config';
|
|
12
12
|
import { db } from './db';
|
|
13
|
-
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks } from './db/schema';
|
|
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
16
|
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
|
|
@@ -170,15 +170,27 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
170
170
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
171
171
|
|
|
172
172
|
// 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
// 기본 view=active: 닫힌 태스크·그 run·이벤트 전량을 내리지 않는다(수백 run 시 페이로드 폭발 방지).
|
|
174
|
+
// 이벤트는 활성 run 당 최근 40개만(카드는 8, 모달은 전량 refetch). view=all 은 구버전 전량.
|
|
175
|
+
const EVENT_CAP = 40;
|
|
176
|
+
app.get('/api/fleet', async (req) => {
|
|
177
|
+
const view = ((req.query ?? {}) as { view?: string }).view === 'all' ? 'all' : 'active';
|
|
178
|
+
const [ms, rs, allTasks, dcs, gs] = await Promise.all([
|
|
175
179
|
db.select().from(machines),
|
|
176
180
|
db.select().from(repos),
|
|
177
181
|
db.select().from(tasks),
|
|
178
|
-
db.select().from(agentRuns),
|
|
179
|
-
db.select().from(agentEvents),
|
|
180
182
|
db.select().from(designCaptures),
|
|
183
|
+
db.select().from(taskGroups),
|
|
181
184
|
]);
|
|
185
|
+
const activeTasks = allTasks.filter((t) => t.status !== 'closed');
|
|
186
|
+
const closedCount = allTasks.length - activeTasks.length;
|
|
187
|
+
const ts = view === 'all' ? allTasks : activeTasks;
|
|
188
|
+
const taskIds = new Set(ts.map((t) => t.id));
|
|
189
|
+
const allRuns = await db.select().from(agentRuns);
|
|
190
|
+
const rns = view === 'all' ? allRuns : allRuns.filter((r) => taskIds.has(r.taskId));
|
|
191
|
+
const runIds = rns.map((r) => r.id);
|
|
192
|
+
// 이벤트는 대상 run 으로 스코프한 뒤 로드(전량 로드 후 슬라이스 = 고치려는 그 버그).
|
|
193
|
+
const evs = runIds.length ? await db.select().from(agentEvents).where(inArray(agentEvents.runId, runIds)) : [];
|
|
182
194
|
const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
|
|
183
195
|
for (const e of evs) {
|
|
184
196
|
const arr = byRun.get(e.runId) ?? [];
|
|
@@ -186,14 +198,45 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
186
198
|
byRun.set(e.runId, arr);
|
|
187
199
|
}
|
|
188
200
|
return {
|
|
189
|
-
machines: ms, repos: rs, tasks: ts, captures: dcs,
|
|
190
|
-
runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
|
|
201
|
+
machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
|
|
202
|
+
runs: rns.map((r) => ({ ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP) })),
|
|
203
|
+
counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
|
|
191
204
|
// 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
|
|
192
205
|
daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },
|
|
193
206
|
providers: listProviders(),
|
|
194
207
|
};
|
|
195
208
|
});
|
|
196
209
|
|
|
210
|
+
// 아카이브 — 닫힌 태스크 목록(최신순, 페이지네이션·필터). 카드가 아니라 한 줄 행.
|
|
211
|
+
app.get('/api/archive', async (req) => {
|
|
212
|
+
const q = (req.query ?? {}) as { offset?: string; limit?: string; q?: string; repo?: string; status?: string };
|
|
213
|
+
const offset = Math.max(0, Number(q.offset) || 0);
|
|
214
|
+
const limit = Math.min(100, Math.max(1, Number(q.limit) || 50));
|
|
215
|
+
const conds = [eq(tasks.status, 'closed')];
|
|
216
|
+
if (q.q) conds.push(like(tasks.title, `%${q.q}%`));
|
|
217
|
+
if (q.repo) conds.push(eq(tasks.repoId, Number(q.repo)));
|
|
218
|
+
const where = conds.length === 1 ? conds[0] : and(...conds);
|
|
219
|
+
let closed = await db.select().from(tasks).where(where).orderBy(desc(tasks.id));
|
|
220
|
+
// status 필터 = 태스크의 run 중 그 상태를 가진 게 있어야 함(런 로드 후 필터)
|
|
221
|
+
const repoName = new Map((await db.select().from(repos)).map((r) => [r.id, r.name]));
|
|
222
|
+
const grpTitle = new Map((await db.select().from(taskGroups)).map((g) => [g.id, g.title]));
|
|
223
|
+
const total0 = closed.length;
|
|
224
|
+
const page = closed.slice(offset, offset + limit);
|
|
225
|
+
const rows = [];
|
|
226
|
+
for (const t of page) {
|
|
227
|
+
const rs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
|
|
228
|
+
if (q.status && !rs.some((r) => r.status === q.status)) continue;
|
|
229
|
+
rows.push({
|
|
230
|
+
taskId: t.id, title: t.title, repoName: repoName.get(t.repoId) ?? '?',
|
|
231
|
+
groupTitle: t.groupId != null ? grpTitle.get(t.groupId) ?? null : null,
|
|
232
|
+
closedAt: t.closedAt ? Math.floor(t.closedAt.getTime() / 1000) : (t.createdAt ? Math.floor(t.createdAt.getTime() / 1000) : 0),
|
|
233
|
+
runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model })),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// status 필터가 있으면 total 은 근사(페이지 내 필터) — UI 는 rows 로만 판단하니 total0 유지.
|
|
237
|
+
return { total: total0, rows };
|
|
238
|
+
});
|
|
239
|
+
|
|
197
240
|
// ─── 머신 레지스트리 ────────────────────────────────────────────
|
|
198
241
|
app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
|
|
199
242
|
|
|
@@ -539,7 +582,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
539
582
|
const cleanups = [];
|
|
540
583
|
for (const r of trs) cleanups.push({ runId: r.id, ...(await cleanupRun(r.id)) });
|
|
541
584
|
|
|
542
|
-
await db.update(tasks).set({ status: 'closed' }).where(eq(tasks.id, id));
|
|
585
|
+
await db.update(tasks).set({ status: 'closed', closedAt: new Date() }).where(eq(tasks.id, id));
|
|
543
586
|
broadcast({ type: 'task', taskId: id, status: 'closed' });
|
|
544
587
|
return { ok: true, taskId: id, cleanups };
|
|
545
588
|
});
|