coxpit 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/board.ts +122 -15
- package/src/orchestrator.ts +123 -0
- package/src/server.ts +27 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.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
|
@@ -263,6 +263,18 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
263
263
|
.cmp-f .msg{font-family:var(--mono);font-size:11px;color:var(--muted);flex:1;
|
|
264
264
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
265
265
|
|
|
266
|
+
/* ── AI review panel (compare) ── */
|
|
267
|
+
.cmp-review{border-bottom:1px solid var(--line);background:var(--surface2);padding:14px 20px;
|
|
268
|
+
max-height:42vh;overflow:auto;font-size:13px;line-height:1.65;color:var(--muted)}
|
|
269
|
+
.cmp-review[hidden]{display:none}
|
|
270
|
+
.cmp-review h2{font-size:13px;color:var(--brand);margin:14px 0 6px;letter-spacing:.02em}
|
|
271
|
+
.cmp-review h3{font-size:12.5px;color:var(--ink);margin:12px 0 4px}
|
|
272
|
+
.cmp-review ul{margin:4px 0 8px;padding-left:18px}
|
|
273
|
+
.cmp-review li{margin-bottom:3px}
|
|
274
|
+
.cmp-review strong{color:var(--ink)}
|
|
275
|
+
.cmp-review code{font-family:var(--mono);font-size:.9em;background:#0e1118;padding:1px 5px;border-radius:4px;color:var(--brand)}
|
|
276
|
+
.cmp-review p{margin:0 0 8px}
|
|
277
|
+
|
|
266
278
|
/* ── terminal ───────────────────────────── */
|
|
267
279
|
.term-body{flex:1;min-height:0;background:#0b0d12;padding:8px 4px 4px 10px}
|
|
268
280
|
#xterm{width:100%;height:100%}
|
|
@@ -320,6 +332,17 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
320
332
|
</div>
|
|
321
333
|
</form>
|
|
322
334
|
</div>
|
|
335
|
+
<div class="sect">
|
|
336
|
+
<p class="sect-label">Plan a goal · swarm</p>
|
|
337
|
+
<form id="planForm">
|
|
338
|
+
<p class="flabel">repository</p>
|
|
339
|
+
<select id="planRepo"></select>
|
|
340
|
+
<p class="flabel">goal</p>
|
|
341
|
+
<textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge later with Select runs → Integrate."></textarea>
|
|
342
|
+
<button class="btn" type="submit" id="planGo">Plan & fan out</button>
|
|
343
|
+
<span style="font-size:11px;color:var(--faint);font-family:var(--mono)">follows the Dry/Real mode above · planner reads only</span>
|
|
344
|
+
</form>
|
|
345
|
+
</div>
|
|
323
346
|
<div class="sect">
|
|
324
347
|
<p class="sect-label">Design captures</p>
|
|
325
348
|
<div id="captures" style="display:flex;flex-direction:column;gap:6px"></div>
|
|
@@ -379,9 +402,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
379
402
|
<div class="modal wide">
|
|
380
403
|
<div class="modal-h">
|
|
381
404
|
<span class="title" id="cmpTitle">Compare</span>
|
|
405
|
+
<button class="btn sm" id="cmpAI">AI review</button>
|
|
382
406
|
<button class="btn-ghost sm" id="cmpRefresh">Refresh</button>
|
|
383
407
|
<button class="x" id="cmpClose" aria-label="close">×</button>
|
|
384
408
|
</div>
|
|
409
|
+
<div class="cmp-review" id="cmpReview" hidden></div>
|
|
385
410
|
<div class="cmp" id="cmpBody"></div>
|
|
386
411
|
</div>
|
|
387
412
|
</div>
|
|
@@ -567,20 +592,49 @@ $('cfmOk').addEventListener('click', ()=>cfmClose(true));
|
|
|
567
592
|
$('cfmCancel').addEventListener('click', ()=>cfmClose(false));
|
|
568
593
|
$('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
|
|
569
594
|
|
|
570
|
-
|
|
595
|
+
/* 이벤트 인간화 — JSON 원문 대신 사람이 읽는 한 줄로. null = 표시 생략(노이즈). */
|
|
596
|
+
function humanize(e){
|
|
597
|
+
const kind = e.kind, payload = e.payload;
|
|
598
|
+
if (kind === 'rate_limit_event') return null;
|
|
599
|
+
if (kind === 'steer') return { k:'steer', t:'→ '+payload };
|
|
600
|
+
if (kind === 'export'){ try{ const o=JSON.parse(payload); return { k:'export', t:o.copied+' file(s) → '+o.dest }; }catch{ return { k:'export', t:payload }; } }
|
|
601
|
+
if (kind === 'pr') return { k:'pr', t:payload };
|
|
602
|
+
if (kind === 'stderr') return { k:'stderr', t:payload };
|
|
571
603
|
try{
|
|
572
604
|
const o = JSON.parse(payload);
|
|
605
|
+
if (o.type === 'system') return { k:'session', t:'started · '+(o.model||o.subtype||'') };
|
|
606
|
+
if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
|
|
573
607
|
if (o.type === 'assistant' && o.message){
|
|
574
|
-
const
|
|
575
|
-
|
|
608
|
+
const parts = [];
|
|
609
|
+
for (const x of (o.message.content||[])){
|
|
610
|
+
if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
|
|
611
|
+
else if (x.type === 'tool_use'){
|
|
612
|
+
const i = x.input || {};
|
|
613
|
+
const arg = i.file_path || i.command || i.path || i.pattern || '';
|
|
614
|
+
parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return parts.length ? parts : null;
|
|
576
618
|
}
|
|
577
|
-
if (o.type === 'assistant' && o.text) return o.text;
|
|
578
|
-
if (o.type === 'result') return o.result || '
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
619
|
+
if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
|
|
620
|
+
if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
|
|
621
|
+
if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
|
|
622
|
+
return { k:kind, t:payload.slice(0,140) };
|
|
623
|
+
}catch{ return { k:kind, t:payload }; }
|
|
624
|
+
}
|
|
625
|
+
function humanLines(events){
|
|
626
|
+
const out = [];
|
|
627
|
+
for (const e of (events||[])){
|
|
628
|
+
const h = humanize(e);
|
|
629
|
+
if (!h) continue;
|
|
630
|
+
if (Array.isArray(h)) out.push(...h); else out.push(h);
|
|
631
|
+
}
|
|
632
|
+
return out;
|
|
633
|
+
}
|
|
634
|
+
function summarize(kind, payload){
|
|
635
|
+
const h = humanize({kind, payload});
|
|
636
|
+
if (!h) return '';
|
|
637
|
+
return Array.isArray(h) ? h.map(x=>x.t).join(' · ') : h.t;
|
|
584
638
|
}
|
|
585
639
|
function diffHTML(text){
|
|
586
640
|
if (!text.trim()) return '<span style="color:var(--faint)">no changes</span>';
|
|
@@ -656,8 +710,8 @@ function cardHTML(r){
|
|
|
656
710
|
const closed = task && task.status==='closed';
|
|
657
711
|
const title = (task ? esc(task.title) : ('task ' + (r.taskId ?? '?')))
|
|
658
712
|
+ (closed ? ' <span class="closed">· closed</span>' : '');
|
|
659
|
-
const evs = (r.events
|
|
660
|
-
'<div class="ev"><span class="k">'+esc(
|
|
713
|
+
const evs = humanLines(r.events).slice(-8).map(h =>
|
|
714
|
+
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t).slice(0,140)+'</span></div>'
|
|
661
715
|
).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
|
|
662
716
|
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
|
|
663
717
|
return '<div class="card'+selCls+'" id="card-'+r.id+'">'
|
|
@@ -706,7 +760,8 @@ function paintSidebar(){
|
|
|
706
760
|
capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
|
|
707
761
|
'<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
|
|
708
762
|
capSel.value = cur;
|
|
709
|
-
|
|
763
|
+
$('planRepo').innerHTML = $('taskRepo').innerHTML;
|
|
764
|
+
['repoMachine','taskRepo','taskCapture','planRepo'].forEach(id => { dressSelect(id); syncSelect(id); });
|
|
710
765
|
$('captures').innerHTML = captures.map(c=>
|
|
711
766
|
'<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
|
|
712
767
|
+ '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
|
|
@@ -775,8 +830,8 @@ function paintModal(){
|
|
|
775
830
|
$('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
|
|
776
831
|
// steer 는 정착한 real run 에서만 의미(드라이런은 세션 없음 — 서버가 사유와 함께 거절)
|
|
777
832
|
$('steerRow').style.display = ['done','failed','stopped'].includes(r.status) ? '' : 'none';
|
|
778
|
-
$('mTimeline').innerHTML = (r.events
|
|
779
|
-
'<div class="ev"><span class="k">'+esc(
|
|
833
|
+
$('mTimeline').innerHTML = humanLines(r.events).map(h =>
|
|
834
|
+
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t)+'</span></div>'
|
|
780
835
|
).join('') || '<span style="color:var(--faint)">no events yet</span>';
|
|
781
836
|
}
|
|
782
837
|
async function loadDiff(){
|
|
@@ -896,6 +951,7 @@ $('mCloseTask').addEventListener('click', async ()=>{
|
|
|
896
951
|
let cmpTaskId = null;
|
|
897
952
|
async function openCompare(taskId){
|
|
898
953
|
cmpTaskId = taskId;
|
|
954
|
+
$('cmpReview').hidden = true; $('cmpReview').innerHTML = '';
|
|
899
955
|
$('cmpOverlay').classList.add('open');
|
|
900
956
|
$('cmpBody').innerHTML = '<div class="empty" style="flex:1">loading…</div>';
|
|
901
957
|
await paintCompare();
|
|
@@ -957,6 +1013,34 @@ $('cmpBody').addEventListener('click', async (e)=>{
|
|
|
957
1013
|
$('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
|
|
958
1014
|
$('cmpOverlay').addEventListener('click',(e)=>{ if(e.target===$('cmpOverlay')){ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
959
1015
|
$('cmpRefresh').addEventListener('click', paintCompare);
|
|
1016
|
+
/* 초경량 md 렌더 (리뷰 표시용) */
|
|
1017
|
+
function mdLite(src){
|
|
1018
|
+
let s = esc(src);
|
|
1019
|
+
s = s.replace(/\`\`\`[a-z]*\\n([\\s\\S]*?)\`\`\`/g, (m,c)=>'<pre style="background:#0e1118;border:1px solid var(--line);border-radius:7px;padding:8px 10px;overflow-x:auto">'+c+'</pre>');
|
|
1020
|
+
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
1021
|
+
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
1022
|
+
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
1023
|
+
s = s.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
|
|
1024
|
+
s = s.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
|
|
1025
|
+
s = s.replace(/(<li>[\\s\\S]*?<\\/li>)(?!\\s*<li>)/g, '<ul>$1</ul>');
|
|
1026
|
+
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('');
|
|
1027
|
+
return s;
|
|
1028
|
+
}
|
|
1029
|
+
$('cmpAI').addEventListener('click', async ()=>{
|
|
1030
|
+
if (cmpTaskId==null) return;
|
|
1031
|
+
const yes = await confirmUI('Run an AI review of these implementations?',
|
|
1032
|
+
{ sub: 'A reviewer agent reads every diff and summarizes each approach, pros/cons, and a recommendation — so you judge instead of reading all the code. Real agent, spends credits (~1–2 min).', okLabel: 'Review' });
|
|
1033
|
+
if (!yes) return;
|
|
1034
|
+
const btn = $('cmpAI');
|
|
1035
|
+
btn.disabled = true; btn.textContent = 'Reviewing…';
|
|
1036
|
+
try{
|
|
1037
|
+
const res = await fetch('/api/tasks/'+cmpTaskId+'/review',{method:'POST',
|
|
1038
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({real:true})});
|
|
1039
|
+
const j = await res.json().catch(()=>({}));
|
|
1040
|
+
if (res.ok){ $('cmpReview').innerHTML = mdLite(j.review||''); $('cmpReview').hidden = false; }
|
|
1041
|
+
else toast('review: '+(j.detail||res.status), 'error');
|
|
1042
|
+
} finally { btn.disabled = false; btn.textContent = 'AI review'; }
|
|
1043
|
+
});
|
|
960
1044
|
$('mCompare').addEventListener('click', ()=>{
|
|
961
1045
|
if (openRunId==null) return;
|
|
962
1046
|
const r = runs.get(openRunId); if(!r) return;
|
|
@@ -1018,6 +1102,29 @@ $('mTerm').addEventListener('click', ()=>{
|
|
|
1018
1102
|
});
|
|
1019
1103
|
|
|
1020
1104
|
/* ── forms ── */
|
|
1105
|
+
$('planForm').addEventListener('submit', async (e)=>{
|
|
1106
|
+
e.preventDefault();
|
|
1107
|
+
const repoId = Number($('planRepo').value);
|
|
1108
|
+
const goal = $('planGoal').value.trim();
|
|
1109
|
+
if (!repoId){ toast('register a repo first', 'error'); return; }
|
|
1110
|
+
if (!goal){ toast('write a goal first', 'error'); return; }
|
|
1111
|
+
const real = $('taskReal').checked;
|
|
1112
|
+
const btn = $('planGo');
|
|
1113
|
+
btn.disabled = true; btn.textContent = real ? 'Planning… (1–3 min)' : 'Planning…';
|
|
1114
|
+
try{
|
|
1115
|
+
const res = await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},
|
|
1116
|
+
body:JSON.stringify({repoId, goal, real})});
|
|
1117
|
+
const j = await res.json().catch(()=>({}));
|
|
1118
|
+
if (res.ok){
|
|
1119
|
+
toast(j.tasks.length+' task(s) planned & launched', 'ok');
|
|
1120
|
+
$('planGoal').value='';
|
|
1121
|
+
hydrate();
|
|
1122
|
+
} else toast('plan: '+(j.detail||j.error||res.status), 'error');
|
|
1123
|
+
} finally {
|
|
1124
|
+
btn.disabled = false; btn.textContent = 'Plan & fan out';
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1021
1128
|
$('repoForm').addEventListener('submit', async (e)=>{
|
|
1022
1129
|
e.preventDefault();
|
|
1023
1130
|
const body = { machineSlug: $('repoMachine').value, path: $('repoPath').value.trim() };
|
package/src/orchestrator.ts
CHANGED
|
@@ -333,6 +333,129 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
|
|
|
333
333
|
return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
|
|
338
|
+
* 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
|
|
339
|
+
* (수렴은 Integrate 가 담당. real=false 는 배관 리허설용 모의 2분할.)
|
|
340
|
+
*/
|
|
341
|
+
export async function planFanout(repoId: number, goal: string, real: boolean): Promise<{
|
|
342
|
+
ok: boolean; detail: string; tasks?: Array<{ id: number; title: string; runId: number }>;
|
|
343
|
+
}> {
|
|
344
|
+
const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
|
|
345
|
+
const repo = rp[0];
|
|
346
|
+
if (!repo) return { ok: false, detail: 'repo not found' };
|
|
347
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
348
|
+
const m = mr[0];
|
|
349
|
+
if (!m) return { ok: false, detail: 'machine not found' };
|
|
350
|
+
const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
351
|
+
|
|
352
|
+
let plan: Array<{ title: string; prompt: string }>;
|
|
353
|
+
if (!real) {
|
|
354
|
+
// 드라이런: 파이프라인 리허설용 고정 2분할
|
|
355
|
+
plan = [
|
|
356
|
+
{ title: `[plan] ${goal.slice(0, 40)} — part 1`, prompt: `${goal}\n(rehearsal plan, part 1)` },
|
|
357
|
+
{ title: `[plan] ${goal.slice(0, 40)} — part 2`, prompt: `${goal}\n(rehearsal plan, part 2)` },
|
|
358
|
+
];
|
|
359
|
+
} else {
|
|
360
|
+
const plannerPrompt =
|
|
361
|
+
`You are planning work for this repository. Goal:\n${goal}\n\n` +
|
|
362
|
+
`Read the repository as needed, then respond with ONLY a JSON object (no prose, no code fences):\n` +
|
|
363
|
+
`{"tasks":[{"title":"short imperative title","prompt":"full agent prompt"}]}\n` +
|
|
364
|
+
`Rules: 2-6 tasks. Each must be independently executable in an isolated git worktree by a coding agent ` +
|
|
365
|
+
`that knows nothing about the other tasks. Each prompt must name the target files, the constraints, and how to verify. ` +
|
|
366
|
+
`Minimize file overlap between tasks to reduce merge conflicts. Do not include setup/integration tasks.`;
|
|
367
|
+
// 플래너는 읽기만 하면 되므로 repo 본체에서 default 권한(편집 자동거부)으로 실행
|
|
368
|
+
const cmd = `cd ${shq(repo.path)} && ${config.agent.bin} -p ${shq(plannerPrompt)} --output-format json`;
|
|
369
|
+
const r = await runShellOn(machine, cmd, 300000);
|
|
370
|
+
if (!r.ok) return { ok: false, detail: 'planner failed: ' + (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
371
|
+
try {
|
|
372
|
+
const envelope = JSON.parse(r.stdout.trim()) as { result?: string };
|
|
373
|
+
let body = (envelope.result ?? '').trim();
|
|
374
|
+
const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
375
|
+
if (fence?.[1] != null) body = fence[1].trim();
|
|
376
|
+
const first = body.indexOf('{');
|
|
377
|
+
const last = body.lastIndexOf('}');
|
|
378
|
+
if (first === -1 || last === -1) throw new Error('no JSON in planner output');
|
|
379
|
+
const parsed = JSON.parse(body.slice(first, last + 1)) as { tasks?: Array<{ title?: string; prompt?: string }> };
|
|
380
|
+
plan = (parsed.tasks ?? [])
|
|
381
|
+
.filter((t) => typeof t.title === 'string' && typeof t.prompt === 'string' && t.title && t.prompt)
|
|
382
|
+
.slice(0, 8)
|
|
383
|
+
.map((t) => ({ title: t.title as string, prompt: t.prompt as string }));
|
|
384
|
+
} catch (e) {
|
|
385
|
+
return { ok: false, detail: 'could not parse the plan: ' + String(e).slice(0, 200) };
|
|
386
|
+
}
|
|
387
|
+
if (plan.length < 1) return { ok: false, detail: 'planner returned no tasks' };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const created: Array<{ id: number; title: string; runId: number }> = [];
|
|
391
|
+
for (const t of plan) {
|
|
392
|
+
const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt }).returning();
|
|
393
|
+
const task = tIns[0]!;
|
|
394
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
|
|
395
|
+
const run = rIns[0]!;
|
|
396
|
+
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
|
|
397
|
+
void launchRun(run.id, real);
|
|
398
|
+
created.push({ id: task.id, title: t.title, runId: run.id });
|
|
399
|
+
}
|
|
400
|
+
return { ok: true, detail: `${created.length} task(s) launched`, tasks: created };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* AI 리뷰(심판) — 태스크의 정착 run diff 들을 리뷰 에이전트가 읽고
|
|
405
|
+
* 접근 방식·장단점·리스크·추천을 요약한다. 사람은 코드 전수가 아니라
|
|
406
|
+
* 판단만 하면 되도록. (read-only, 워크트리 불필요)
|
|
407
|
+
*/
|
|
408
|
+
export async function reviewTask(taskId: number, real: boolean): Promise<{ ok: boolean; detail: string; review?: string }> {
|
|
409
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1);
|
|
410
|
+
const task = tr[0];
|
|
411
|
+
if (!task) return { ok: false, detail: 'task not found' };
|
|
412
|
+
const rp = await db.select().from(repos).where(eq(repos.id, task.repoId)).limit(1);
|
|
413
|
+
const repo = rp[0];
|
|
414
|
+
if (!repo) return { ok: false, detail: 'repo not found' };
|
|
415
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
416
|
+
const m = mr[0];
|
|
417
|
+
if (!m) return { ok: false, detail: 'machine not found' };
|
|
418
|
+
const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
419
|
+
|
|
420
|
+
const trs = (await db.select().from(agentRuns).where(eq(agentRuns.taskId, taskId)))
|
|
421
|
+
.filter((r) => ['done', 'failed', 'stopped', 'merged'].includes(r.status));
|
|
422
|
+
if (trs.length < 2) return { ok: false, detail: 'need at least 2 settled runs to review' };
|
|
423
|
+
|
|
424
|
+
const sections: string[] = [];
|
|
425
|
+
for (const r of trs) {
|
|
426
|
+
const d = await getRunDiff(r.id);
|
|
427
|
+
const diff = (d.ok ? d.diff : '(worktree gone — diff unavailable)').slice(0, 15000);
|
|
428
|
+
sections.push(`### run r${r.id} (status: ${r.status})\nAgent's own summary: ${(r.exitSummary || '-').slice(0, 300)}\n\nDiff:\n\`\`\`diff\n${diff}\n\`\`\``);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!real) {
|
|
432
|
+
return {
|
|
433
|
+
ok: true, detail: 'rehearsal review',
|
|
434
|
+
review: `## AI Review (rehearsal)\n\n${trs.map((r) => `**r${r.id}** — approach: (dry-run placeholder)\n- pros: n/a\n- cons: n/a`).join('\n\n')}\n\n**Recommendation**: run with Real agent for an actual review.`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const prompt =
|
|
439
|
+
`You are reviewing ${trs.length} competing implementations of the same task.\n` +
|
|
440
|
+
`Task: ${task.title}\nOriginal prompt: ${task.prompt.slice(0, 800)}\n\n` +
|
|
441
|
+
sections.join('\n\n') +
|
|
442
|
+
`\n\nWrite a review in markdown, in the language of the task prompt (Korean if the prompt is Korean):\n` +
|
|
443
|
+
`1. For EACH run: one-line approach summary, then pros (max 3) and cons (max 3) as bullets.\n` +
|
|
444
|
+
`2. '## 추천' section: which run to merge and WHY, in 2-3 sentences. If combining both is better, say exactly what to steer.\n` +
|
|
445
|
+
`Judge correctness, simplicity, consistency with the existing codebase, and risk. Be decisive. Respond with ONLY the markdown.`;
|
|
446
|
+
const cmd = `cd ${shq(repo.path)} && ${config.agent.bin} -p ${shq(prompt)} --output-format json`;
|
|
447
|
+
const r = await runShellOn(machine, cmd, 300000);
|
|
448
|
+
if (!r.ok) return { ok: false, detail: 'reviewer failed: ' + (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
449
|
+
try {
|
|
450
|
+
const envelope = JSON.parse(r.stdout.trim()) as { result?: string };
|
|
451
|
+
const review = (envelope.result ?? '').trim();
|
|
452
|
+
if (!review) throw new Error('empty review');
|
|
453
|
+
return { ok: true, detail: 'reviewed', review };
|
|
454
|
+
} catch (e) {
|
|
455
|
+
return { ok: false, detail: 'could not parse review: ' + String(e).slice(0, 200) };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
336
459
|
export interface IntegrateResult {
|
|
337
460
|
runId: number;
|
|
338
461
|
status: 'merged' | 'conflict' | 'skipped';
|
package/src/server.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { db } from './db';
|
|
|
12
12
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
|
|
13
13
|
import { BOOKMARKLET_JS } from './design';
|
|
14
14
|
import { runShellOn, shq } from './exec';
|
|
15
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns } from './orchestrator';
|
|
15
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask } from './orchestrator';
|
|
16
16
|
import { openTerm } from './term';
|
|
17
17
|
import { addSink, removeSink, broadcast } from './hub';
|
|
18
18
|
import { BOARD_HTML } from './board';
|
|
@@ -32,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
32
32
|
app.addHook('onRequest', authGate);
|
|
33
33
|
|
|
34
34
|
// 무인증 헬스(외부 감시용)
|
|
35
|
-
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.
|
|
35
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.9.0' }));
|
|
36
36
|
|
|
37
37
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨.
|
|
38
38
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
@@ -325,6 +325,17 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
325
325
|
return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
+
// AI 리뷰 — 심판 에이전트가 run diff 들을 읽고 접근/장단점/추천을 요약.
|
|
329
|
+
app.post('/api/tasks/:id/review', async (req, reply) => {
|
|
330
|
+
const id = Number((req.params as { id: string }).id);
|
|
331
|
+
const b = (req.body ?? {}) as { real?: boolean };
|
|
332
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
333
|
+
if (!tr[0]) return reply.code(404).send({ error: 'task not found' });
|
|
334
|
+
const res = await reviewTask(id, b.real === true);
|
|
335
|
+
if (!res.ok) return reply.code(422).send(res);
|
|
336
|
+
return res;
|
|
337
|
+
});
|
|
338
|
+
|
|
328
339
|
// 비교 뷰 — 태스크의 모든 run + 각 diff 를 한 방에 (승자 고르기용).
|
|
329
340
|
app.get('/api/tasks/:id/compare', async (req, reply) => {
|
|
330
341
|
const id = Number((req.params as { id: string }).id);
|
|
@@ -401,6 +412,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
401
412
|
return reply.code(202).send(res);
|
|
402
413
|
});
|
|
403
414
|
|
|
415
|
+
// Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
|
|
416
|
+
// (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
|
|
417
|
+
app.post('/api/plan', async (req, reply) => {
|
|
418
|
+
const b = (req.body ?? {}) as { repoId?: number; goal?: string; real?: boolean };
|
|
419
|
+
const repoId = Number(b.repoId);
|
|
420
|
+
const goal = (b.goal ?? '').trim();
|
|
421
|
+
if (!repoId || !goal) return reply.code(400).send({ error: 'repoId and goal required' });
|
|
422
|
+
if (goal.length > 4000) return reply.code(400).send({ error: 'goal too long' });
|
|
423
|
+
const res = await planFanout(repoId, goal, b.real === true);
|
|
424
|
+
if (!res.ok) return reply.code(422).send(res);
|
|
425
|
+
return reply.code(202).send(res);
|
|
426
|
+
});
|
|
427
|
+
|
|
404
428
|
// 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
|
|
405
429
|
app.post('/api/integrate', async (req, reply) => {
|
|
406
430
|
const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };
|
|
@@ -457,7 +481,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
457
481
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
458
482
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
459
483
|
addSink(socket);
|
|
460
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.
|
|
484
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.9.0' }));
|
|
461
485
|
socket.on('close', () => removeSink(socket));
|
|
462
486
|
});
|
|
463
487
|
|