coxpit 4.0.0 → 4.2.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 +3 -2
- package/package.json +1 -1
- package/src/board.ts +210 -12
- package/src/db/index.ts +16 -0
- package/src/db/schema.ts +20 -0
- package/src/orchestrator.ts +65 -8
- package/src/providers.ts +15 -11
- package/src/server.ts +79 -9
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ Your machines. Your auth. Your code never leaves your network.
|
|
|
22
22
|
- **Design Mode** — drag the `⌖ coxpit inspect` bookmarklet to your bar, click it on your running app, click any element: its selector, HTML and computed styles are captured and injected into the agents' prompt as design context.
|
|
23
23
|
- **Self-orchestrating agents** — every local run can spawn its own sub-agents by writing `.coxpit/spawn.json` in its worktree (works under default permissions — no network, no escalation). The daemon launches each subtask as an isolated sub-run and maintains `.coxpit/subtasks.json` with live status. Orchestration moves inside the agent's own reasoning loop.
|
|
24
24
|
- **Start from GitHub** — paste an issue/PR URL and the task form drafts itself from its title and body (gh CLI for private repos, public API otherwise). You review, pick a provider, Run fleet.
|
|
25
|
-
- **Share a run** — one click mints a read-only snapshot link (timeline + diff, no auth, no actions). Show your fleet's work without opening your cockpit.
|
|
25
|
+
- **Share a run** — one click mints a read-only snapshot link (timeline + diff, and the rendered docs, no auth, no actions). Show your fleet's work without opening your cockpit.
|
|
26
|
+
- **The library** — a run's changed documents (Markdown/HTML) are snapshotted when it settles, so the Rendered view survives merge and Close task. Pick a model per launch (any name your CLI accepts), and a close guard warns before it deletes unmerged, unexported output.
|
|
26
27
|
|
|
27
28
|
External tools are spawned, never vendored: `git`, `tmux`, your agent CLI. No editor bundled — terminal-first.
|
|
28
29
|
|
|
@@ -109,7 +110,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
|
|
|
109
110
|
|
|
110
111
|
## Status
|
|
111
112
|
|
|
112
|
-
`v4.
|
|
113
|
+
`v4.2` — everything in v4.1 plus **run grouping**: goal fan-outs and agent swarms cluster into bands on the board (title · N tasks · M settled, with fold, group Select runs, and Close group), and multi-run tasks get `run i/n` attempt counters so same-title cards read as attempts, not duplicates. All shipped and e2e-tested (36 checks). Roadmap: ROADMAP.md.
|
|
113
114
|
|
|
114
115
|
## License
|
|
115
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.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,17 @@ 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}
|
|
144
155
|
.card{border:1px solid var(--line);border-radius:var(--r-card);background:var(--surface);
|
|
145
156
|
overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
|
|
146
157
|
transition:border-color .18s,transform .18s,box-shadow .18s}
|
|
@@ -164,6 +175,15 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
164
175
|
.ev{display:flex;gap:9px;align-items:baseline;min-width:0}
|
|
165
176
|
.ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
|
|
166
177
|
.ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
|
|
178
|
+
/* ── closed card — 죽은 카드는 확실히 죽어 보이게(빗금 + CLOSED 스탬프) ── */
|
|
179
|
+
.card.closed{opacity:.6;filter:saturate(.45)}
|
|
180
|
+
.card.closed .log{position:relative}
|
|
181
|
+
.card.closed .log::after{content:'';position:absolute;inset:0;pointer-events:none;
|
|
182
|
+
background:repeating-linear-gradient(135deg,transparent 0 9px,rgba(255,255,255,.035) 9px 11px)}
|
|
183
|
+
.card.closed .log::before{content:'CLOSED';position:absolute;top:50%;left:50%;z-index:1;
|
|
184
|
+
transform:translate(-50%,-50%) rotate(-7deg);font-family:var(--mono);font-size:15px;
|
|
185
|
+
letter-spacing:.34em;color:var(--faint);border:1px solid var(--line-hi);
|
|
186
|
+
border-radius:6px;padding:4px 14px;background:rgba(11,13,18,.72)}
|
|
167
187
|
/* ── select mode (integrate) ── */
|
|
168
188
|
.toolbar{display:flex;justify-content:flex-end;gap:8px;margin-bottom:12px}
|
|
169
189
|
.card.selmode{cursor:copy}
|
|
@@ -291,6 +311,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
291
311
|
.cmp-review p{margin:0 0 8px}
|
|
292
312
|
|
|
293
313
|
/* ── doc mode (rendered output) ─────────── */
|
|
314
|
+
.doc-src{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-bottom:10px}
|
|
294
315
|
.doc-h{font-family:var(--mono);font-size:10.5px;color:var(--brand);padding:8px 0 4px;
|
|
295
316
|
border-bottom:1px solid var(--line);margin-bottom:8px;word-break:break-all}
|
|
296
317
|
.doc-md{font-size:13px;line-height:1.65;color:var(--muted);margin-bottom:16px;font-family:var(--sans)}
|
|
@@ -378,6 +399,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
378
399
|
<div class="row">
|
|
379
400
|
<button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
|
|
380
401
|
<button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
|
|
402
|
+
<button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it">⎇</button>
|
|
381
403
|
<button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
|
|
382
404
|
</div>
|
|
383
405
|
<form id="repoForm" hidden>
|
|
@@ -404,6 +426,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
404
426
|
<button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
|
|
405
427
|
<button type="button" class="seg-opt" data-agent="codex">Codex</button>
|
|
406
428
|
</div>
|
|
429
|
+
<p class="flabel">model · optional</p>
|
|
430
|
+
<input id="taskModel" placeholder="CLI default" list="modelHist" autocomplete="off" />
|
|
431
|
+
<datalist id="modelHist"></datalist>
|
|
407
432
|
<p class="flabel">design capture · optional</p>
|
|
408
433
|
<select id="taskCapture"><option value="">no design capture</option></select>
|
|
409
434
|
</div>
|
|
@@ -565,6 +590,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
565
590
|
</div>
|
|
566
591
|
</div>
|
|
567
592
|
|
|
593
|
+
<div class="overlay" id="brOverlay">
|
|
594
|
+
<div class="cfm">
|
|
595
|
+
<div class="cfm-b">
|
|
596
|
+
<div class="m">Base branch for this repository</div>
|
|
597
|
+
<div class="s">Merge, Sync base and PR mode all target this branch. Set it to match your repo's flow (e.g. <span style="color:var(--brand);font-family:var(--mono)">develop</span>). Must already exist in the repo.</div>
|
|
598
|
+
<p class="flabel" style="margin-top:12px">branch name</p>
|
|
599
|
+
<input id="brInput" placeholder="main" />
|
|
600
|
+
</div>
|
|
601
|
+
<div class="cfm-f">
|
|
602
|
+
<button class="btn-ghost sm" id="brCancel">Cancel</button>
|
|
603
|
+
<button class="btn sm" id="brOk">Save</button>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
</div>
|
|
607
|
+
|
|
568
608
|
<div class="overlay" id="expOverlay">
|
|
569
609
|
<div class="cfm">
|
|
570
610
|
<div class="cfm-b">
|
|
@@ -596,6 +636,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
596
636
|
<script>
|
|
597
637
|
const runs = new Map(); // runId -> run object
|
|
598
638
|
const tasks = new Map(); // taskId -> task
|
|
639
|
+
const groups = new Map(); // groupId -> {id, kind, title}
|
|
599
640
|
let repos = [], machines = [], captures = [];
|
|
600
641
|
|
|
601
642
|
const $ = (id) => document.getElementById(id);
|
|
@@ -799,12 +840,56 @@ function chipHTML(status){
|
|
|
799
840
|
return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
|
|
800
841
|
+ '<i></i>'+esc(s)+'</span>';
|
|
801
842
|
}
|
|
843
|
+
/* 같은 태스크에 run 이 여럿이면 "run i/n" — 같은 제목 카드가 중복이 아니라 시도로 읽히게 */
|
|
844
|
+
function attemptHTML(r){
|
|
845
|
+
const sib = [...runs.values()].filter(x=>x.taskId===r.taskId).sort((a,b)=>a.id-b.id);
|
|
846
|
+
if (sib.length<2) return '';
|
|
847
|
+
const i = sib.findIndex(x=>x.id===r.id)+1;
|
|
848
|
+
return '<span class="attempt" title="attempt within this task">run '+i+'/'+sib.length+'</span>';
|
|
849
|
+
}
|
|
802
850
|
|
|
851
|
+
/* ── group fold 상태(기기별, WS 재렌더에도 유지) ── */
|
|
852
|
+
let gfold = new Set();
|
|
853
|
+
try{ gfold = new Set(JSON.parse(localStorage.getItem('coxpit.gfold')||'[]')); }catch{}
|
|
854
|
+
function saveGfold(){ try{ localStorage.setItem('coxpit.gfold', JSON.stringify([...gfold])); }catch{} }
|
|
855
|
+
/* 태스크의 모든 run 이 정착했는가 */
|
|
856
|
+
function taskSettled(taskId){
|
|
857
|
+
const rs = [...runs.values()].filter(r=>r.taskId===taskId);
|
|
858
|
+
return rs.length>0 && rs.every(r=>['done','failed','stopped','merged'].includes(r.status));
|
|
859
|
+
}
|
|
860
|
+
function bandHTML(g, grpRuns){
|
|
861
|
+
const glyph = g.kind==='swarm' ? '↳' : '⌁';
|
|
862
|
+
const title = g.kind==='swarm' ? 'swarm of: '+esc(g.title) : esc(g.title);
|
|
863
|
+
const taskIds = [...new Set(grpRuns.map(r=>r.taskId))];
|
|
864
|
+
const settled = taskIds.filter(taskSettled).length;
|
|
865
|
+
const folded = gfold.has(g.id);
|
|
866
|
+
const cards = grpRuns.slice().sort((a,b)=>a.id-b.id).map(cardHTML).join('');
|
|
867
|
+
return '<div class="gband'+(folded?' folded':'')+'" data-g="'+g.id+'">'
|
|
868
|
+
+ '<div class="gband-h"><span class="gband-glyph">'+glyph+'</span>'
|
|
869
|
+
+ '<span class="gband-t">'+title+'</span>'
|
|
870
|
+
+ '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
|
|
871
|
+
+ '<span class="gband-sp"></span>'
|
|
872
|
+
+ '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
|
|
873
|
+
+ '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
|
|
874
|
+
+ '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
|
|
875
|
+
+ '<div class="gband-grid">'+cards+'</div></div>';
|
|
876
|
+
}
|
|
803
877
|
function render(){
|
|
804
878
|
const list = [...runs.values()].sort((a,b)=>b.id-a.id);
|
|
805
879
|
$('empty').style.display = list.length ? 'none' : 'flex';
|
|
806
|
-
if (!list.length) paintOnboarding();
|
|
807
|
-
|
|
880
|
+
if (!list.length){ paintOnboarding(); $('grid').innerHTML=''; return; }
|
|
881
|
+
// 그룹 파티션 — grouped run 은 밴드로 클러스터, ungrouped 는 뒤에 flat.
|
|
882
|
+
const byGroup = new Map(); const flat = [];
|
|
883
|
+
for (const r of list){
|
|
884
|
+
const t = tasks.get(r.taskId);
|
|
885
|
+
const gid = t && t.groupId!=null && groups.has(t.groupId) ? t.groupId : null;
|
|
886
|
+
if (gid==null) flat.push(r);
|
|
887
|
+
else { if(!byGroup.has(gid)) byGroup.set(gid, []); byGroup.get(gid).push(r); }
|
|
888
|
+
}
|
|
889
|
+
let html = '';
|
|
890
|
+
for (const gid of [...byGroup.keys()].sort((a,b)=>b-a)) html += bandHTML(groups.get(gid), byGroup.get(gid));
|
|
891
|
+
html += flat.map(cardHTML).join('');
|
|
892
|
+
$('grid').innerHTML = html;
|
|
808
893
|
if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
|
|
809
894
|
}
|
|
810
895
|
|
|
@@ -862,13 +947,15 @@ function cardHTML(r){
|
|
|
862
947
|
const evs = humanLines(r.events).slice(-8).map(h =>
|
|
863
948
|
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t).slice(0,140)+'</span></div>'
|
|
864
949
|
).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
|
|
865
|
-
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
|
|
950
|
+
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
|
|
866
951
|
return '<div class="card'+selCls+'" id="card-'+r.id+'">'
|
|
867
952
|
+ '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
|
|
868
953
|
+ '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
|
|
869
954
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
870
955
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
871
956
|
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
957
|
+
+ attemptHTML(r)
|
|
958
|
+
+ (r.model ? '<span title="model">⚙ '+esc(r.model)+'</span>' : '')
|
|
872
959
|
+ (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
|
|
873
960
|
+ (r.sessionId && ['done','failed','stopped'].includes(r.status)
|
|
874
961
|
? '<span class="resumable" title="agent session preserved — open the run and Send a next instruction to continue">↻ resumable</span>' : '')
|
|
@@ -890,6 +977,8 @@ async function hydrate(){
|
|
|
890
977
|
machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
|
|
891
978
|
tasks.clear();
|
|
892
979
|
(r.tasks||[]).forEach(t => tasks.set(t.id, t));
|
|
980
|
+
groups.clear();
|
|
981
|
+
(r.groups||[]).forEach(g => groups.set(g.id, g));
|
|
893
982
|
runs.clear();
|
|
894
983
|
(r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
|
|
895
984
|
if (r.daemon) {
|
|
@@ -942,6 +1031,26 @@ $('repoRemove').addEventListener('click', async ()=>{
|
|
|
942
1031
|
if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
|
|
943
1032
|
else toast('remove: '+(j.detail||res.status), 'error');
|
|
944
1033
|
});
|
|
1034
|
+
/* ── 기본 브랜치 변경 ── */
|
|
1035
|
+
$('repoBranch').addEventListener('click', ()=>{
|
|
1036
|
+
const rid = $('taskRepo').value;
|
|
1037
|
+
if (!rid){ toast('no repository selected', 'error'); return; }
|
|
1038
|
+
const repo = repos.find(r=>String(r.id)===String(rid));
|
|
1039
|
+
$('brInput').value = repo ? repo.defaultBranch : '';
|
|
1040
|
+
$('brOverlay').classList.add('open'); $('brInput').focus();
|
|
1041
|
+
});
|
|
1042
|
+
$('brCancel').addEventListener('click', ()=>$('brOverlay').classList.remove('open'));
|
|
1043
|
+
$('brOverlay').addEventListener('click',(e)=>{ if(e.target===$('brOverlay')) $('brOverlay').classList.remove('open'); });
|
|
1044
|
+
async function brSave(){
|
|
1045
|
+
const rid = $('taskRepo').value; const branch = $('brInput').value.trim();
|
|
1046
|
+
if (!rid || !branch) return;
|
|
1047
|
+
const res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({defaultBranch:branch})});
|
|
1048
|
+
const j = await res.json().catch(()=>({}));
|
|
1049
|
+
if (res.ok){ $('brOverlay').classList.remove('open'); toast('base branch → '+j.defaultBranch, 'ok'); hydrate(); }
|
|
1050
|
+
else toast('branch: '+(j.error||res.status), 'error');
|
|
1051
|
+
}
|
|
1052
|
+
$('brOk').addEventListener('click', brSave);
|
|
1053
|
+
$('brInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') brSave(); });
|
|
945
1054
|
$('repoManual').addEventListener('click', ()=>{
|
|
946
1055
|
const f = $('repoForm'); f.hidden = !f.hidden;
|
|
947
1056
|
if (!f.hidden) $('repoPath').focus();
|
|
@@ -998,7 +1107,7 @@ function connectWS(){
|
|
|
998
1107
|
render(); flash(ev.runId); paintModal();
|
|
999
1108
|
} else if (ev.type==='task'){
|
|
1000
1109
|
const t = tasks.get(ev.taskId);
|
|
1001
|
-
if (t){ t.status = ev.status; render(); paintModal(); } else { hydrate(); }
|
|
1110
|
+
if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; render(); paintModal(); } else { hydrate(); }
|
|
1002
1111
|
} else if (ev.type==='capture'){
|
|
1003
1112
|
captures.push(ev.capture); paintSidebar();
|
|
1004
1113
|
}
|
|
@@ -1028,17 +1137,31 @@ function paintModal(){
|
|
|
1028
1137
|
async function loadDiff(){
|
|
1029
1138
|
if (openRunId==null || docMode) return;
|
|
1030
1139
|
const pre = $('mDiff'); if (!pre) return;
|
|
1140
|
+
const rid = openRunId;
|
|
1031
1141
|
pre.textContent = 'loading…'; $('mStat').textContent='';
|
|
1032
1142
|
try{
|
|
1033
|
-
const d = await fetch('/api/runs/'+
|
|
1034
|
-
if (!d.ok){
|
|
1143
|
+
const d = await fetch('/api/runs/'+rid+'/diff').then(x=>x.json());
|
|
1144
|
+
if (!d.ok){
|
|
1145
|
+
pre.textContent = d.stat||'no worktree';
|
|
1146
|
+
// worktree 는 없지만 스냅샷 문서가 있으면 Rendered 토글 노출(머지·Close 후 뷰어)
|
|
1147
|
+
maybeShowDocsToggle(rid);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1035
1150
|
const files = d.stat ? d.stat.split('\\n').filter(Boolean).length : 0;
|
|
1036
1151
|
$('mStat').textContent = files ? '· '+files+' file'+(files>1?'s':'') : '· clean';
|
|
1037
|
-
// 변경분에 문서(md/html)가 있으면 Rendered 토글 노출
|
|
1038
|
-
$('mDocsTgl').hidden = !/\\.(md|markdown|html?|htm)$/im.test(d.stat||'');
|
|
1039
1152
|
pre.innerHTML = diffHTML(d.diff||'');
|
|
1153
|
+
// 변경분에 문서(md/html)가 있으면 즉시 노출, 없으면 스냅샷 확인
|
|
1154
|
+
if (/\\.(md|markdown|html?|htm)$/im.test(d.stat||'')) $('mDocsTgl').hidden = false;
|
|
1155
|
+
else maybeShowDocsToggle(rid);
|
|
1040
1156
|
}catch{ pre.textContent = 'diff failed'; }
|
|
1041
1157
|
}
|
|
1158
|
+
/* worktree 에 라이브 문서가 없을 때만 — 스냅샷이라도 있으면 토글을 켠다 */
|
|
1159
|
+
async function maybeShowDocsToggle(rid){
|
|
1160
|
+
try{
|
|
1161
|
+
const j = await fetch('/api/runs/'+rid+'/docs').then(x=>x.json());
|
|
1162
|
+
if (rid===openRunId && (j.docs||[]).length) $('mDocsTgl').hidden = false;
|
|
1163
|
+
}catch{}
|
|
1164
|
+
}
|
|
1042
1165
|
/* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
|
|
1043
1166
|
let docMode = false;
|
|
1044
1167
|
function docsHTML(docs){
|
|
@@ -1053,7 +1176,9 @@ async function paintDocs(){
|
|
|
1053
1176
|
$('mDiffWrap').innerHTML = '<span style="color:var(--faint)">rendering…</span>';
|
|
1054
1177
|
try{
|
|
1055
1178
|
const d = await fetch('/api/runs/'+openRunId+'/docs').then(x=>x.json());
|
|
1056
|
-
|
|
1179
|
+
const src = d.source==='snapshot'
|
|
1180
|
+
? '<div class="doc-src">worktree gone — snapshot taken at settle</div>' : '';
|
|
1181
|
+
$('mDiffWrap').innerHTML = src + docsHTML(d.docs||[]);
|
|
1057
1182
|
}catch{ $('mDiffWrap').textContent = 'docs failed'; }
|
|
1058
1183
|
}
|
|
1059
1184
|
function setDocMode(on){
|
|
@@ -1108,6 +1233,51 @@ $('selGo').addEventListener('click', async ()=>{
|
|
|
1108
1233
|
} else toast('integrate: '+(j.error||res.status), 'error');
|
|
1109
1234
|
});
|
|
1110
1235
|
|
|
1236
|
+
/* ── 그룹 밴드 액션 (fold / Select runs / Close group) ── */
|
|
1237
|
+
$('grid').addEventListener('click', async (e)=>{
|
|
1238
|
+
const fold = e.target.closest('[data-gfold]');
|
|
1239
|
+
if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
|
|
1240
|
+
const gsel = e.target.closest('[data-gsel]');
|
|
1241
|
+
if (gsel){
|
|
1242
|
+
const g=Number(gsel.dataset.gsel);
|
|
1243
|
+
if (!selectMode) setSelectMode(true);
|
|
1244
|
+
const tids = new Set([...tasks.values()].filter(t=>t.groupId===g).map(t=>t.id));
|
|
1245
|
+
for (const r of [...runs.values()].sort((a,b)=>a.id-b.id)){
|
|
1246
|
+
if (!tids.has(r.taskId)) continue;
|
|
1247
|
+
const ok = ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
|
|
1248
|
+
if (ok && !selected.has(r.id)){ selected.add(r.id); selOrder.push(r.id); }
|
|
1249
|
+
}
|
|
1250
|
+
$('selCnt').textContent = selected.size + ' selected';
|
|
1251
|
+
render();
|
|
1252
|
+
if (!selected.size) toast('no settled runs with changes in this group yet', 'error');
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const gclose = e.target.closest('[data-gclose]');
|
|
1256
|
+
if (gclose){ await closeGroup(Number(gclose.dataset.gclose)); return; }
|
|
1257
|
+
});
|
|
1258
|
+
async function closeGroup(g){
|
|
1259
|
+
const tids = [...tasks.values()].filter(t=>t.groupId===g && t.status!=='closed').map(t=>t.id);
|
|
1260
|
+
if (!tids.length){ toast('nothing open in this group', 'ok'); return; }
|
|
1261
|
+
const grp = groups.get(g)||{};
|
|
1262
|
+
const yes = await confirmUI('Close this whole group?',
|
|
1263
|
+
{ sub: (grp.title||'group')+' — stops and cleans every worktree/branch of '+tids.length+' task(s).', danger:true, okLabel:'Close group' });
|
|
1264
|
+
if (!yes) return;
|
|
1265
|
+
const closeOne = (id,force)=>fetch('/api/tasks/'+id+'/close',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({force})});
|
|
1266
|
+
const risky = [];
|
|
1267
|
+
for (const id of tids){
|
|
1268
|
+
const res = await closeOne(id,false);
|
|
1269
|
+
if (res.status===409){ const j=await res.json().catch(()=>({})); (j.atRisk||[]).forEach(a=>risky.push(a)); }
|
|
1270
|
+
}
|
|
1271
|
+
if (risky.length){
|
|
1272
|
+
const list = risky.map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
|
|
1273
|
+
const ok = await confirmUI('Close and delete unmerged output?',
|
|
1274
|
+
{ danger:true, sub: list+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
|
|
1275
|
+
if (!ok){ await hydrate(); return; }
|
|
1276
|
+
for (const id of tids) await closeOne(id,true);
|
|
1277
|
+
}
|
|
1278
|
+
toast('closed '+tids.length+' task'+(tids.length>1?'s':'')+' in the group', 'ok');
|
|
1279
|
+
await hydrate();
|
|
1280
|
+
}
|
|
1111
1281
|
$('grid').addEventListener('click',(e)=>{
|
|
1112
1282
|
const card = e.target.closest('.card'); if(!card) return;
|
|
1113
1283
|
const id = Number(card.id.replace('card-',''));
|
|
@@ -1125,7 +1295,7 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
1125
1295
|
});
|
|
1126
1296
|
$('mClose').addEventListener('click', closeModal);
|
|
1127
1297
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
1128
|
-
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1298
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1129
1299
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
1130
1300
|
$('mExport').addEventListener('click', ()=>{
|
|
1131
1301
|
if (openRunId==null) return;
|
|
@@ -1195,7 +1365,18 @@ $('mCloseTask').addEventListener('click', async ()=>{
|
|
|
1195
1365
|
const yes = await confirmUI('Close this task?',
|
|
1196
1366
|
{ sub: 'Stops any live runs and removes every worktree and branch of the task.', danger: true, okLabel: 'Close task' });
|
|
1197
1367
|
if (!yes) return;
|
|
1198
|
-
|
|
1368
|
+
const close = (force)=>fetch('/api/tasks/'+r.taskId+'/close',{method:'POST',
|
|
1369
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({force})});
|
|
1370
|
+
let res = await close(false);
|
|
1371
|
+
if (res.status===409){
|
|
1372
|
+
const j = await res.json().catch(()=>({}));
|
|
1373
|
+
const risk = (j.atRisk||[]).map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
|
|
1374
|
+
const ok = await confirmUI('Close and delete unmerged output?',
|
|
1375
|
+
{ danger:true, sub: risk+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
|
|
1376
|
+
if (!ok) return;
|
|
1377
|
+
res = await close(true);
|
|
1378
|
+
}
|
|
1379
|
+
if (!res.ok){ toast('close failed ('+res.status+')', 'error'); return; }
|
|
1199
1380
|
toast('task closed — all runs cleaned', 'ok');
|
|
1200
1381
|
closeModal(); hydrate();
|
|
1201
1382
|
});
|
|
@@ -1511,11 +1692,28 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
1511
1692
|
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
|
|
1512
1693
|
if (!t.ok){ toast('task create failed', 'error'); return; }
|
|
1513
1694
|
tasks.set(t.task.id, t.task);
|
|
1695
|
+
const model = $('taskModel').value.trim();
|
|
1514
1696
|
await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
1515
|
-
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent})});
|
|
1697
|
+
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
|
|
1698
|
+
if (model) rememberModel(model);
|
|
1516
1699
|
$('taskTitle').value=''; $('taskPrompt').value='';
|
|
1517
1700
|
});
|
|
1518
1701
|
|
|
1702
|
+
/* ── model 최근값 기억(기기별, 최대 5) ── */
|
|
1703
|
+
function rememberModel(m){
|
|
1704
|
+
try{
|
|
1705
|
+
const h = JSON.parse(localStorage.getItem('coxpit.models')||'[]');
|
|
1706
|
+
localStorage.setItem('coxpit.models', JSON.stringify([m, ...h.filter(x=>x!==m)].slice(0,5)));
|
|
1707
|
+
}catch{}
|
|
1708
|
+
paintModelHist();
|
|
1709
|
+
}
|
|
1710
|
+
function paintModelHist(){
|
|
1711
|
+
let h = [];
|
|
1712
|
+
try{ h = JSON.parse(localStorage.getItem('coxpit.models')||'[]'); }catch{}
|
|
1713
|
+
$('modelHist').innerHTML = h.map(m=>'<option value="'+escA(m)+'"></option>').join('');
|
|
1714
|
+
}
|
|
1715
|
+
paintModelHist();
|
|
1716
|
+
|
|
1519
1717
|
/* ── agent mode segmented control (mirrors hidden #taskReal) ── */
|
|
1520
1718
|
const segOpts = Array.from(document.querySelectorAll('#modeSeg .seg-opt'));
|
|
1521
1719
|
function setMode(real, persist){
|
package/src/db/index.ts
CHANGED
|
@@ -75,10 +75,26 @@ export async function ensureSchema(): Promise<void> {
|
|
|
75
75
|
token TEXT NOT NULL UNIQUE,
|
|
76
76
|
created_at INTEGER DEFAULT (unixepoch())
|
|
77
77
|
);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS doc_snapshots (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
run_id INTEGER NOT NULL,
|
|
81
|
+
path TEXT NOT NULL,
|
|
82
|
+
kind TEXT NOT NULL,
|
|
83
|
+
content TEXT NOT NULL DEFAULT '',
|
|
84
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
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
|
+
);
|
|
78
92
|
`);
|
|
79
93
|
// 기존 DB 마이그레이션(멱등)
|
|
80
94
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
81
95
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
82
96
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
83
97
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
|
|
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 */ }
|
|
84
100
|
}
|
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,17 @@ 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
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** 정착·정리 시점에 회수한 문서(md/html) 스냅샷 — worktree 소멸 후에도 렌더 뷰 유지. */
|
|
57
|
+
export const docSnapshots = sqliteTable('doc_snapshots', {
|
|
58
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
59
|
+
runId: integer('run_id').notNull(),
|
|
60
|
+
path: text('path').notNull(),
|
|
61
|
+
kind: text('kind').notNull(), // 'md' | 'html'
|
|
62
|
+
content: text('content').notNull().default(''),
|
|
44
63
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
45
64
|
});
|
|
46
65
|
|
|
@@ -64,6 +83,7 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
64
83
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
65
84
|
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
|
66
85
|
prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
|
|
86
|
+
model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
|
|
67
87
|
filesChanged: integer('files_changed').notNull().default(0),
|
|
68
88
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
69
89
|
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
package/src/orchestrator.ts
CHANGED
|
@@ -8,14 +8,14 @@ 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 } 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';
|
|
15
15
|
|
|
16
16
|
/** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
|
|
17
|
-
function agentCommand(provider: Provider, prompt: string, real: boolean): string {
|
|
18
|
-
if (real) return provider.launchCmd(prompt);
|
|
17
|
+
function agentCommand(provider: Provider, prompt: string, real: boolean, model = ''): string {
|
|
18
|
+
if (real) return provider.launchCmd(prompt, model || undefined);
|
|
19
19
|
// 모의: init → assistant → (파일 변경) → result. claude stream-json 라인 형태
|
|
20
20
|
// (드라이런은 프로바이더 불문 배관 리허설 — claude 파서가 처리한다).
|
|
21
21
|
return [
|
|
@@ -134,15 +134,23 @@ 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[] = [];
|
|
143
151
|
for (let i = 0; i < n; i++) {
|
|
144
152
|
const rIns = await db.insert(agentRuns).values({
|
|
145
|
-
taskId: task.id, machineId: pr.machineId, agent: pr.agent, status: 'pending',
|
|
153
|
+
taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending',
|
|
146
154
|
}).returning();
|
|
147
155
|
const run = rIns[0]!;
|
|
148
156
|
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
|
|
@@ -198,6 +206,7 @@ interface RunContext {
|
|
|
198
206
|
prompt: string;
|
|
199
207
|
real: boolean;
|
|
200
208
|
agent: string;
|
|
209
|
+
model: string;
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
async function loadContext(runId: number): Promise<RunContext | null> {
|
|
@@ -237,6 +246,7 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
237
246
|
prompt,
|
|
238
247
|
real: config.agent.real,
|
|
239
248
|
agent: run.agent,
|
|
249
|
+
model: run.model,
|
|
240
250
|
};
|
|
241
251
|
}
|
|
242
252
|
|
|
@@ -293,7 +303,7 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
293
303
|
envPrefix = `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(tok)}; `;
|
|
294
304
|
prompt += orchestrationNote();
|
|
295
305
|
}
|
|
296
|
-
const cmd = `cd ${shq(wtPath)} && ${envPrefix}${pidPrefix}{ ${agentCommand(provider, prompt, useReal)}; }`;
|
|
306
|
+
const cmd = `cd ${shq(wtPath)} && ${envPrefix}${pidPrefix}{ ${agentCommand(provider, prompt, useReal, ctx.model)}; }`;
|
|
297
307
|
// 파일 오케스트레이션 — 로컬 run 이 사는 동안 .coxpit/spawn.json 감시.
|
|
298
308
|
// .coxpit/ 는 repo exclude 에 넣어 diff/머지를 오염시키지 않는다(멱등).
|
|
299
309
|
let orchTimer: NodeJS.Timeout | null = null;
|
|
@@ -353,9 +363,33 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
353
363
|
const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
|
|
354
364
|
const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
|
|
355
365
|
await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
|
|
366
|
+
// 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
|
|
367
|
+
if (filesChanged > 0) void snapshotRunDocs(runId);
|
|
356
368
|
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
357
369
|
}
|
|
358
370
|
|
|
371
|
+
/**
|
|
372
|
+
* 변경 문서(md/html)를 DB 에 스냅샷. 최신 우선(기존 행 삭제 후 재삽입).
|
|
373
|
+
* 빈 읽기(worktree 이미 소멸 등)는 기존 스냅샷을 지우지 않는다.
|
|
374
|
+
*/
|
|
375
|
+
export async function snapshotRunDocs(runId: number): Promise<void> {
|
|
376
|
+
const d = await getRunDocs(runId).catch(() => null);
|
|
377
|
+
if (!d?.ok || d.docs.length === 0) return;
|
|
378
|
+
await db.delete(docSnapshots).where(eq(docSnapshots.runId, runId));
|
|
379
|
+
for (const doc of d.docs) await db.insert(docSnapshots).values({ runId, path: doc.path, kind: doc.kind, content: doc.content });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** worktree(라이브) → 스냅샷 폴백 공용 로더. server 의 /api/runs/:id/docs·/share 가 사용. */
|
|
383
|
+
export async function loadRunDocs(runId: number): Promise<{
|
|
384
|
+
docs: Array<{ path: string; kind: string; content: string }>; source: 'worktree' | 'snapshot';
|
|
385
|
+
}> {
|
|
386
|
+
const live = await getRunDocs(runId).catch(() => null);
|
|
387
|
+
if (live?.ok && live.docs.length > 0) return { docs: live.docs, source: 'worktree' };
|
|
388
|
+
const snap = await db.select().from(docSnapshots).where(eq(docSnapshots.runId, runId));
|
|
389
|
+
if (snap.length > 0) return { docs: snap.map((s) => ({ path: s.path, kind: s.kind, content: s.content })), source: 'snapshot' };
|
|
390
|
+
return { docs: [], source: 'worktree' };
|
|
391
|
+
}
|
|
392
|
+
|
|
359
393
|
/** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
|
|
360
394
|
async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
|
|
361
395
|
if (!config.webhookUrl) return;
|
|
@@ -409,7 +443,7 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
|
|
|
409
443
|
const envPrefix = (!isRemote && config.agentOrch)
|
|
410
444
|
? `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(issueAgentToken(runId))}; `
|
|
411
445
|
: '';
|
|
412
|
-
const resume = provider.resumeCmd(run.sessionId, finalMessage);
|
|
446
|
+
const resume = provider.resumeCmd(run.sessionId, finalMessage, run.model || undefined);
|
|
413
447
|
const cmd = `cd ${shq(wt)} && ${envPrefix}${pidPrefix}{ ${resume}; }`;
|
|
414
448
|
if (!isRemote && config.agentOrch) {
|
|
415
449
|
const orchTimer = startOrchWatch(runId, wt, true);
|
|
@@ -688,9 +722,13 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
|
|
|
688
722
|
if (plan.length < 1) return { ok: false, detail: 'planner returned no tasks' };
|
|
689
723
|
}
|
|
690
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
|
+
|
|
691
729
|
const created: Array<{ id: number; title: string; runId: number }> = [];
|
|
692
730
|
for (const t of plan) {
|
|
693
|
-
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();
|
|
694
732
|
const task = tIns[0]!;
|
|
695
733
|
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
|
|
696
734
|
const run = rIns[0]!;
|
|
@@ -907,11 +945,30 @@ export async function reconcileOrphanRuns(): Promise<number> {
|
|
|
907
945
|
return stale.length;
|
|
908
946
|
}
|
|
909
947
|
|
|
948
|
+
/**
|
|
949
|
+
* Close 가드 — 태스크 닫으면 worktree 가 삭제되므로, 아직 살릴 곳 없는 산출물을 경고.
|
|
950
|
+
* 위험 = 정착(done/failed/stopped) ∧ 변경있음 ∧ 미머지 ∧ export·PR 이벤트 없음.
|
|
951
|
+
*/
|
|
952
|
+
export async function taskCloseRisk(taskId: number): Promise<Array<{ runId: number; filesChanged: number }>> {
|
|
953
|
+
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, taskId));
|
|
954
|
+
const atRisk: Array<{ runId: number; filesChanged: number }> = [];
|
|
955
|
+
for (const r of trs) {
|
|
956
|
+
if (!['done', 'failed', 'stopped'].includes(r.status)) continue;
|
|
957
|
+
if (r.filesChanged <= 0) continue;
|
|
958
|
+
const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, r.id));
|
|
959
|
+
if (evs.some((e) => e.kind === 'export' || e.kind === 'pr')) continue; // 산출물이 이미 탈출함
|
|
960
|
+
atRisk.push({ runId: r.id, filesChanged: r.filesChanged });
|
|
961
|
+
}
|
|
962
|
+
return atRisk;
|
|
963
|
+
}
|
|
964
|
+
|
|
910
965
|
export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
911
966
|
const ctx = await loadContext(runId);
|
|
912
967
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
913
968
|
const run = rr[0];
|
|
914
969
|
if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
|
|
970
|
+
// worktree 를 지우기 전에 문서 스냅샷(정착 안 하는 워크벤치·수정편집도 포착). best-effort.
|
|
971
|
+
await snapshotRunDocs(runId).catch(() => { /* 스냅샷 실패는 정리를 막지 않음 */ });
|
|
915
972
|
// 원격에 잔존 에이전트가 있으면 worktree 제거 전에 죽인다(파일 잠금·좀비 방지).
|
|
916
973
|
if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
|
|
917
974
|
await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
|
package/src/providers.ts
CHANGED
|
@@ -24,8 +24,9 @@ export interface Provider {
|
|
|
24
24
|
id: string;
|
|
25
25
|
label: string;
|
|
26
26
|
bin: string;
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** model 비었으면 CLI 기본값 사용(플래그 미첨부). */
|
|
28
|
+
launchCmd(prompt: string, model?: string): string;
|
|
29
|
+
resumeCmd(sessionId: string, message: string, model?: string): string;
|
|
29
30
|
/** null = 저장하지 않는 라인(스트림 잡음) */
|
|
30
31
|
parseLine(raw: string): ParsedEvent | null;
|
|
31
32
|
}
|
|
@@ -52,13 +53,14 @@ const claudeProvider: Provider = {
|
|
|
52
53
|
id: 'claude-code',
|
|
53
54
|
label: 'Claude Code',
|
|
54
55
|
get bin() { return config.agent.bin; },
|
|
55
|
-
launchCmd(prompt: string): string {
|
|
56
|
+
launchCmd(prompt: string, model?: string): string {
|
|
56
57
|
return `${config.agent.bin} -p ${shq(prompt)} --output-format stream-json --verbose` +
|
|
57
|
-
` --permission-mode ${config.agent.perm}
|
|
58
|
+
` --permission-mode ${config.agent.perm}` + (model ? ` --model ${shq(model)}` : '');
|
|
58
59
|
},
|
|
59
|
-
resumeCmd(sessionId: string, message: string): string {
|
|
60
|
+
resumeCmd(sessionId: string, message: string, model?: string): string {
|
|
60
61
|
return `${config.agent.bin} -p --resume ${shq(sessionId)} ${shq(message)}` +
|
|
61
|
-
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}
|
|
62
|
+
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}` +
|
|
63
|
+
(model ? ` --model ${shq(model)}` : '');
|
|
62
64
|
},
|
|
63
65
|
parseLine(raw: string): ParsedEvent | null {
|
|
64
66
|
const s = raw.trim();
|
|
@@ -116,12 +118,14 @@ const codexProvider: Provider = {
|
|
|
116
118
|
id: 'codex',
|
|
117
119
|
label: 'Codex',
|
|
118
120
|
get bin() { return config.codex.bin; },
|
|
119
|
-
launchCmd(prompt: string): string {
|
|
120
|
-
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}
|
|
121
|
+
launchCmd(prompt: string, model?: string): string {
|
|
122
|
+
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}` +
|
|
123
|
+
(model ? ` -m ${shq(model)}` : '') + ` ${shq(prompt)}`;
|
|
121
124
|
},
|
|
122
|
-
resumeCmd(sessionId: string, message: string): string {
|
|
123
|
-
// --sandbox 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
|
|
124
|
-
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}
|
|
125
|
+
resumeCmd(sessionId: string, message: string, model?: string): string {
|
|
126
|
+
// --sandbox·-m 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
|
|
127
|
+
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}` +
|
|
128
|
+
(model ? ` -m ${shq(model)}` : '') + ` resume ${shq(sessionId)} ${shq(message)}`;
|
|
125
129
|
},
|
|
126
130
|
parseLine(raw: string): ParsedEvent | null {
|
|
127
131
|
const s = raw.trim();
|
package/src/server.ts
CHANGED
|
@@ -10,10 +10,10 @@ import { eq } 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
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff,
|
|
16
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
|
|
17
17
|
import { openTerm } from './term';
|
|
18
18
|
import { addSink, removeSink, broadcast } from './hub';
|
|
19
19
|
import { getProvider, listProviders } from './providers';
|
|
@@ -33,6 +33,31 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
|
33
33
|
const escH = (x: unknown): string =>
|
|
34
34
|
String(x ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
|
35
35
|
|
|
36
|
+
/** 경량 마크다운 → HTML (보드 mdLite 의 서버측 판, 동일 문법). 입력은 먼저 escH. */
|
|
37
|
+
function mdLiteHTML(src: string): string {
|
|
38
|
+
let s = escH(src);
|
|
39
|
+
s = s.replace(/```[a-z]*\n([\s\S]*?)```/g, (_m, c: string) =>
|
|
40
|
+
'<pre style="background:#0e1118;border:1px solid #222835;border-radius:7px;padding:8px 10px;overflow-x:auto">' + c + '</pre>');
|
|
41
|
+
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
42
|
+
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
43
|
+
s = s.replace(/^# (.+)$/gm, '<h2>$1</h2>');
|
|
44
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
45
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
46
|
+
s = s.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
|
|
47
|
+
s = s.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, '<ul>$1</ul>');
|
|
48
|
+
s = s.split(/\n{2,}/).map((b) => /^<(h2|h3|ul|pre)/.test(b.trim()) ? b : (b.trim() ? '<p>' + b.replace(/\n/g, '<br>') + '</p>' : '')).join('');
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 공유 페이지 Documents 섹션 — md 는 mdLiteHTML, html 은 sandbox iframe. */
|
|
53
|
+
function shareDocsHTML(docs: Array<{ path: string; kind: string; content: string }>): string {
|
|
54
|
+
if (!docs.length) return '';
|
|
55
|
+
const body = docs.map((d) => d.kind === 'md'
|
|
56
|
+
? `<div class="doc"><div class="doc-h">${escH(d.path)}</div><div class="doc-b">${mdLiteHTML(d.content)}</div></div>`
|
|
57
|
+
: `<div class="doc"><div class="doc-h">${escH(d.path)}</div><iframe sandbox="" class="doc-frame" srcdoc="${escH(d.content)}"></iframe></div>`).join('');
|
|
58
|
+
return `<div class="sec">Documents</div>${body}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
/** 보드 humanize 의 서버측 축약판 — 이벤트 한 줄을 {k, t} 로. null = 잡음. */
|
|
37
62
|
function shareLine(kind: string, payload: string): { k: string; t: string } | null {
|
|
38
63
|
if (kind === 'steer') return { k: 'steer', t: '→ ' + payload };
|
|
@@ -82,6 +107,7 @@ function sharePageHTML(
|
|
|
82
107
|
taskTitle: string,
|
|
83
108
|
events: Array<{ kind: string; payload: string }>,
|
|
84
109
|
diff: string,
|
|
110
|
+
docs: Array<{ path: string; kind: string; content: string }> = [],
|
|
85
111
|
): string {
|
|
86
112
|
const lines = events.map((e) => shareLine(e.kind, e.payload)).filter((x): x is { k: string; t: string } => !!x);
|
|
87
113
|
const sc: Record<string, string> = { done: '#3fb970', merged: '#4ec9b0', failed: '#e5534b', error: '#e5534b', stopped: '#a371f7', running: '#4184e4', open: '#4ec9b0' };
|
|
@@ -107,6 +133,15 @@ function sharePageHTML(
|
|
|
107
133
|
font-family:ui-monospace,monospace;font-size:11.5px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
|
108
134
|
.f{color:#4ec9b0;font-weight:600}.h{color:#4184e4}.a{color:#3fb970}.d{color:#e5534b}
|
|
109
135
|
.sum{background:#12151c;border:1px solid #222835;border-radius:10px;padding:12px 14px;color:#8792a2;font-size:13px}
|
|
136
|
+
.doc{margin-bottom:18px}
|
|
137
|
+
.doc-h{font-family:ui-monospace,monospace;font-size:10.5px;color:#4ec9b0;border-bottom:1px solid #222835;padding-bottom:5px;margin-bottom:8px;word-break:break-all}
|
|
138
|
+
.doc-b{font-size:13.5px;line-height:1.65;color:#8792a2}
|
|
139
|
+
.doc-b h1,.doc-b h2{font-size:15px;color:#dee4ec;margin:12px 0 6px}
|
|
140
|
+
.doc-b h3{font-size:13px;color:#dee4ec;margin:10px 0 4px}
|
|
141
|
+
.doc-b ul{margin:4px 0 8px;padding-left:18px}.doc-b li{margin-bottom:3px}
|
|
142
|
+
.doc-b strong{color:#dee4ec}.doc-b p{margin:0 0 8px}
|
|
143
|
+
.doc-b code{font-family:ui-monospace,monospace;font-size:.9em;background:#0e1118;padding:1px 5px;border-radius:4px;color:#4ec9b0}
|
|
144
|
+
.doc-frame{width:100%;height:420px;border:1px solid #222835;border-radius:8px;background:#fff}
|
|
110
145
|
.ft{margin-top:40px;color:#3d4657;font-size:12px;font-family:ui-monospace,monospace}
|
|
111
146
|
.ft a{color:#4ec9b0;text-decoration:none}
|
|
112
147
|
</style></head><body><div class="wrap">
|
|
@@ -114,6 +149,7 @@ function sharePageHTML(
|
|
|
114
149
|
<h1>${escH(taskTitle)}</h1>
|
|
115
150
|
<div class="meta">branch ${escH(run.branch || '—')} · ${run.filesChanged} file(s) changed · agent ${escH(run.agent)}</div>
|
|
116
151
|
${run.exitSummary ? `<div class="sum">${escH(run.exitSummary)}</div>` : ''}
|
|
152
|
+
${shareDocsHTML(docs)}
|
|
117
153
|
<div class="sec">Timeline</div>
|
|
118
154
|
<div class="tl">${lines.map((l) => `<div><span class="k">${escH(l.k)}</span><span class="t">${escH(l.t.slice(0, 220))}</span></div>`).join('') || '<span style="color:#5c6675">no events</span>'}</div>
|
|
119
155
|
<div class="sec">Diff</div>
|
|
@@ -135,13 +171,14 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
135
171
|
|
|
136
172
|
// 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
|
|
137
173
|
app.get('/api/fleet', async () => {
|
|
138
|
-
const [ms, rs, ts, rns, evs, dcs] = await Promise.all([
|
|
174
|
+
const [ms, rs, ts, rns, evs, dcs, gs] = await Promise.all([
|
|
139
175
|
db.select().from(machines),
|
|
140
176
|
db.select().from(repos),
|
|
141
177
|
db.select().from(tasks),
|
|
142
178
|
db.select().from(agentRuns),
|
|
143
179
|
db.select().from(agentEvents),
|
|
144
180
|
db.select().from(designCaptures),
|
|
181
|
+
db.select().from(taskGroups),
|
|
145
182
|
]);
|
|
146
183
|
const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
|
|
147
184
|
for (const e of evs) {
|
|
@@ -150,7 +187,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
150
187
|
byRun.set(e.runId, arr);
|
|
151
188
|
}
|
|
152
189
|
return {
|
|
153
|
-
machines: ms, repos: rs, tasks: ts, captures: dcs,
|
|
190
|
+
machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
|
|
154
191
|
runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
|
|
155
192
|
// 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
|
|
156
193
|
daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },
|
|
@@ -304,6 +341,26 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
304
341
|
return { ok: true };
|
|
305
342
|
});
|
|
306
343
|
|
|
344
|
+
// 기본 브랜치 변경 — merge·Sync base·PR 이 향할 대상. develop-flow repo 대응.
|
|
345
|
+
app.patch('/api/repos/:id', async (req, reply) => {
|
|
346
|
+
const id = Number((req.params as { id: string }).id);
|
|
347
|
+
const b = (req.body ?? {}) as { defaultBranch?: string };
|
|
348
|
+
const branch = (b.defaultBranch ?? '').trim();
|
|
349
|
+
if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
|
|
350
|
+
const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
|
|
351
|
+
const repo = rp[0];
|
|
352
|
+
if (!repo) return reply.code(404).send({ error: 'not found' });
|
|
353
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
354
|
+
const m = mr[0];
|
|
355
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
356
|
+
// branch 는 charset 가드 통과(셸 메타문자 없음). 전체 ref 를 인용해 전달.
|
|
357
|
+
const check = await runShellOn(m,
|
|
358
|
+
`git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
|
|
359
|
+
if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
|
|
360
|
+
await db.update(repos).set({ defaultBranch: branch }).where(eq(repos.id, id));
|
|
361
|
+
return { ok: true, defaultBranch: branch };
|
|
362
|
+
});
|
|
363
|
+
|
|
307
364
|
// 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
|
|
308
365
|
app.get('/api/browse', async (req) => {
|
|
309
366
|
const q = (req.query ?? {}) as { path?: string };
|
|
@@ -405,7 +462,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
405
462
|
// N개의 에이전트 run 을 만들고 각자 오케스트레이션 시작(fire-and-forget).
|
|
406
463
|
app.post('/api/tasks/:id/run', async (req, reply) => {
|
|
407
464
|
const id = Number((req.params as { id: string }).id);
|
|
408
|
-
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean };
|
|
465
|
+
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string };
|
|
409
466
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
410
467
|
const task = tr[0];
|
|
411
468
|
if (!task) return reply.code(404).send({ error: 'task not found' });
|
|
@@ -415,10 +472,15 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
415
472
|
const count = Math.max(1, Math.min(8, Number(b.count) || 1));
|
|
416
473
|
// 미지의 값은 기본 프로바이더로 정규화(런처 조작·API 오타 방어)
|
|
417
474
|
const agent = getProvider(b.agent).id;
|
|
475
|
+
// 모델 지정(선택) — 셸 안전 문자만, 빈값 = CLI 기본
|
|
476
|
+
const model = (b.model ?? '').trim();
|
|
477
|
+
if (model && (model.length > 64 || !/^[\w.\-:/]*$/.test(model))) {
|
|
478
|
+
return reply.code(400).send({ error: 'invalid model name' });
|
|
479
|
+
}
|
|
418
480
|
const created: Array<typeof agentRuns.$inferSelect> = [];
|
|
419
481
|
for (let i = 0; i < count; i++) {
|
|
420
482
|
const ins = await db.insert(agentRuns)
|
|
421
|
-
.values({ taskId: id, machineId: rp[0].machineId, agent, status: 'pending' })
|
|
483
|
+
.values({ taskId: id, machineId: rp[0].machineId, agent, model, status: 'pending' })
|
|
422
484
|
.returning();
|
|
423
485
|
created.push(ins[0]!);
|
|
424
486
|
}
|
|
@@ -458,8 +520,14 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
458
520
|
// 태스크 닫기 — 살아있는 run 중지 후 소속 run 전체 worktree/브랜치 정리.
|
|
459
521
|
app.post('/api/tasks/:id/close', async (req, reply) => {
|
|
460
522
|
const id = Number((req.params as { id: string }).id);
|
|
523
|
+
const b = (req.body ?? {}) as { force?: boolean };
|
|
461
524
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
462
525
|
if (!tr[0]) return reply.code(404).send({ error: 'task not found' });
|
|
526
|
+
// Close 가드 — 아직 살릴 곳 없는 산출물(미머지·미export·무PR)이 있으면 확인 요구.
|
|
527
|
+
if (!b.force) {
|
|
528
|
+
const atRisk = await taskCloseRisk(id);
|
|
529
|
+
if (atRisk.length) return reply.code(409).send({ error: 'unmerged output', atRisk });
|
|
530
|
+
}
|
|
463
531
|
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
|
|
464
532
|
|
|
465
533
|
let anyStopped = false;
|
|
@@ -603,12 +671,13 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
603
671
|
return getRunDiff(id);
|
|
604
672
|
});
|
|
605
673
|
|
|
606
|
-
// Doc 모드 — 변경된 문서(md/html)
|
|
674
|
+
// Doc 모드 — 변경된 문서(md/html) 내용째 (렌더 뷰). worktree 라이브 → 스냅샷 폴백.
|
|
607
675
|
app.get('/api/runs/:id/docs', async (req, reply) => {
|
|
608
676
|
const id = Number((req.params as { id: string }).id);
|
|
609
677
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
610
678
|
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
611
|
-
|
|
679
|
+
const { docs, source } = await loadRunDocs(id);
|
|
680
|
+
return { ok: true, docs, source };
|
|
612
681
|
});
|
|
613
682
|
|
|
614
683
|
// ─── 에이전트 셀프 오케스트레이션 (run 별 Bearer 토큰 — authGate 예외, 여기서 자체 검증) ──
|
|
@@ -693,7 +762,8 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
693
762
|
const task = (await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1))[0];
|
|
694
763
|
const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
|
|
695
764
|
const d = await getRunDiff(run.id).catch(() => ({ ok: false, diff: '', stat: '' }));
|
|
696
|
-
|
|
765
|
+
const { docs } = await loadRunDocs(run.id);
|
|
766
|
+
return reply.type('text/html').send(sharePageHTML(run, task?.title ?? `task ${run.taskId}`, evs, d.ok ? d.diff : '', docs));
|
|
697
767
|
});
|
|
698
768
|
|
|
699
769
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|