coxpit 5.6.0 → 5.8.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 +1 -1
- package/src/cockpit.ts +79 -10
- package/src/db/index.ts +3 -0
- package/src/db/schema.ts +3 -0
- package/src/orchestrator.ts +29 -0
- package/src/server.ts +35 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.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
|
@@ -882,7 +882,7 @@ ${ICON_SPRITE}
|
|
|
882
882
|
<div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
|
|
883
883
|
<button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
|
|
884
884
|
<button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)"><svg class="ic"><use href="#i-external-link"/></svg></button>
|
|
885
|
-
<a class="cockpit-link" href="/cockpit" title="터미널 우선 셸
|
|
885
|
+
<a class="cockpit-link" href="/cockpit" title="터미널 우선 셸 — 데스크톱 앱 기본 뷰">◨ Cockpit</a>
|
|
886
886
|
<div class="machines" id="machines"></div>
|
|
887
887
|
</header>
|
|
888
888
|
<div class="scrim" id="scrim"></div>
|
package/src/cockpit.ts
CHANGED
|
@@ -97,6 +97,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
97
97
|
.pane-h .title{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint)}
|
|
98
98
|
.pane-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;padding:0 3px}
|
|
99
99
|
.pane-h .x:hover{color:var(--failed)}
|
|
100
|
+
.vbadge{font-size:9px;font-weight:600;letter-spacing:.03em;padding:1px 6px;border-radius:999px;white-space:nowrap}
|
|
101
|
+
.vbadge.pass{color:var(--done);background:rgba(88,179,104,.16)}
|
|
102
|
+
.vbadge.fail{color:var(--failed);background:rgba(226,91,103,.16)}
|
|
103
|
+
.vbadge.running{color:var(--blocked);background:rgba(214,162,73,.16)}
|
|
104
|
+
.vbadge.error{color:var(--failed);background:rgba(226,91,103,.12)}
|
|
100
105
|
.pane-term{flex:1;min-height:0;padding:4px 2px 2px 8px}
|
|
101
106
|
.pane-term .xterm{height:100%}
|
|
102
107
|
|
|
@@ -138,6 +143,17 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
138
143
|
.rv-col-h .stat{color:var(--faint);font-size:11px;margin-left:auto}
|
|
139
144
|
.rv-merge{font-family:var(--mono);font-size:10.5px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:6px;padding:4px 9px;cursor:pointer}
|
|
140
145
|
.rv-merge:disabled{opacity:.35;cursor:default;background:var(--line-hi);color:var(--faint)}
|
|
146
|
+
.rv-merge.caution{background:var(--blocked);color:#211803}
|
|
147
|
+
.rv-verify{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:11px;color:var(--muted)}
|
|
148
|
+
.rv-verify .vbadge{flex:none}
|
|
149
|
+
.rv-verify .vout{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint);cursor:pointer}
|
|
150
|
+
.rv-reverify{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:10px;padding:2px 7px;cursor:pointer;font-family:var(--mono)}
|
|
151
|
+
.rv-reverify:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
152
|
+
.rv-vcmd{display:flex;align-items:center;gap:7px;margin-left:14px}
|
|
153
|
+
.rv-vcmd input{font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:5px 8px;width:230px}
|
|
154
|
+
.rv-vcmd input::placeholder{color:var(--faint)}
|
|
155
|
+
.rv-vcmd button{font-family:var(--mono);font-size:10.5px;color:var(--muted);background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;cursor:pointer}
|
|
156
|
+
.rv-vcmd button:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
141
157
|
.rv-diff{flex:1;overflow:auto;padding:8px 10px;font-family:var(--mono);font-size:11.5px;line-height:1.5;white-space:pre;color:var(--muted)}
|
|
142
158
|
.rv-diff > span{display:block;min-height:1.2em}
|
|
143
159
|
.dl-file{color:var(--brand)} .dl-hunk{color:var(--open)} .dl-ctx{color:var(--muted)}
|
|
@@ -156,8 +172,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
156
172
|
</div>
|
|
157
173
|
<div class="right">
|
|
158
174
|
<span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
|
|
159
|
-
<
|
|
160
|
-
<a class="toggle" href="/">← Board</a>
|
|
175
|
+
<a class="toggle" href="/" title="보드(모니터) 뷰로">← Board</a>
|
|
161
176
|
</div>
|
|
162
177
|
</header>
|
|
163
178
|
|
|
@@ -209,7 +224,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
209
224
|
<span style="color:var(--brand)">⧉ Review</span>
|
|
210
225
|
<span>·</span>
|
|
211
226
|
<select id="rvTask" title="비교할 태스크"></select>
|
|
212
|
-
<span
|
|
227
|
+
<span class="rv-vcmd">
|
|
228
|
+
<span style="color:var(--faint)">verify:</span>
|
|
229
|
+
<input id="rvVcmd" placeholder="예: npm test — 정착 시 자동 실행" autocomplete="off" />
|
|
230
|
+
<button type="button" id="rvVsave">save</button>
|
|
231
|
+
</span>
|
|
232
|
+
<span id="rvHint" style="margin-left:auto;color:var(--faint)">정착하면 자동 검증 · 승자를 base 에 merge</span>
|
|
213
233
|
</div>
|
|
214
234
|
<div class="rv-cols" id="rvCols"></div>
|
|
215
235
|
</section>
|
|
@@ -226,6 +246,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
226
246
|
var $ = function(id){ return document.getElementById(id); };
|
|
227
247
|
|
|
228
248
|
function toast(msg){ var t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toast._h); toast._h=setTimeout(function(){ t.classList.remove('show'); }, 2600); }
|
|
249
|
+
var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'⚠ verify' };
|
|
250
|
+
function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
|
|
229
251
|
|
|
230
252
|
// ── fleet 상태 ──
|
|
231
253
|
var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
|
|
@@ -356,6 +378,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
356
378
|
var el = document.createElement('div'); el.className = 'pane'; p.el = el;
|
|
357
379
|
el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
|
|
358
380
|
+ '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
|
|
381
|
+
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
359
382
|
+ '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
|
|
360
383
|
+ '<button class="x" title="close">×</button></div>'
|
|
361
384
|
+ '<div class="pane-term"></div>';
|
|
@@ -422,7 +445,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
422
445
|
panes.forEach(function(p){
|
|
423
446
|
var r = runById[p.runId];
|
|
424
447
|
var chip = p.el.querySelector('[data-role=chip]'); var title = p.el.querySelector('[data-role=title]');
|
|
425
|
-
|
|
448
|
+
var vslot = p.el.querySelector('[data-role=vslot]');
|
|
449
|
+
if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal';
|
|
450
|
+
if(vslot) vslot.innerHTML = vbadge(r.verifyStatus); }
|
|
426
451
|
});
|
|
427
452
|
}
|
|
428
453
|
$('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
|
|
@@ -512,9 +537,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
512
537
|
o.textContent = (rp?rp.name+' · ':'')+t.title; sel.appendChild(o); });
|
|
513
538
|
if (!tasks.length){ $('rvCols').innerHTML='<div class="rv-empty">비교할 run 이 있는 태스크가 아직 없습니다. Terminal 에서 팬아웃해 보세요.</div>'; rvTaskId=null; return; }
|
|
514
539
|
if (cur){ sel.value=cur; } rvTaskId = Number(sel.value);
|
|
540
|
+
syncVcmd();
|
|
515
541
|
loadCompare(rvTaskId);
|
|
516
542
|
}
|
|
517
|
-
$('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); loadCompare(rvTaskId); });
|
|
543
|
+
$('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); syncVcmd(); loadCompare(rvTaskId); });
|
|
518
544
|
function diffHTML(text){
|
|
519
545
|
if (!text || !text.trim()) return '<span style="color:var(--faint)">no changes</span>';
|
|
520
546
|
return text.split('\\n').map(function(l){
|
|
@@ -528,6 +554,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
528
554
|
}
|
|
529
555
|
async function loadCompare(taskId){
|
|
530
556
|
var host=$('rvCols'); host.innerHTML='<div class="rv-empty">불러오는 중…</div>';
|
|
557
|
+
var task = taskById[taskId]; var repo = task && repoById[task.repoId];
|
|
558
|
+
var hasVerify = !!(repo && repo.verifyCmd && repo.verifyCmd.trim());
|
|
531
559
|
try{
|
|
532
560
|
var d = await (await fetch('/api/tasks/'+taskId+'/compare')).json();
|
|
533
561
|
var rns = (d.runs||[]).sort(function(a,b){return a.id-b.id;});
|
|
@@ -537,26 +565,67 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
537
565
|
var col=document.createElement('div'); col.className='rv-col';
|
|
538
566
|
var st = typeof r.stat==='string' ? r.stat.split('\\n').pop().trim() : '';
|
|
539
567
|
var mergeable = (r.status==='done'||r.status==='open'||r.status==='merged') && r.filesChanged>0;
|
|
568
|
+
// green-gate: verifyCmd 있는데 pass 아니면 "merge anyway"(주의색) — 막지 않고 경고
|
|
569
|
+
var gated = hasVerify && r.verifyStatus!=='pass';
|
|
570
|
+
var mlabel = r.status==='merged' ? 'merged' : (gated ? 'merge anyway' : 'merge ▸');
|
|
571
|
+
var vline = '';
|
|
572
|
+
if (hasVerify){
|
|
573
|
+
var vs = r.verifyStatus||''; var out = (r.verifyOutput||'').split('\\n').pop();
|
|
574
|
+
var txt = vs==='pass'?'통과' : vs==='fail'?'실패' : vs==='running'?'검증 중…' : vs==='error'?'검증 오류' : '미검증';
|
|
575
|
+
vline = '<div class="rv-verify">'+(vbadge(vs)||'<span class="vbadge error">· verify</span>')
|
|
576
|
+
+ '<span class="vout" data-vout="'+r.id+'" title="클릭=전체 출력">'+esc(txt+(out?' — '+out:''))+'</span>'
|
|
577
|
+
+ '<button class="rv-reverify" data-reverify="'+r.id+'">re-verify</button></div>';
|
|
578
|
+
}
|
|
540
579
|
col.innerHTML = '<div class="rv-col-h"><span class="st '+esc(r.status)+'" style="width:7px;height:7px;border-radius:50%;display:inline-block"></span>'
|
|
541
580
|
+ '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
|
|
542
581
|
+ '<span class="stat">'+esc(st)+'</span>'
|
|
543
|
-
+ '<button class="rv-merge" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
|
|
544
|
-
+ (
|
|
582
|
+
+ '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
|
|
583
|
+
+ (gated?' title="검증 미통과 — 그래도 머지"':'')+'>'+mlabel+'</button></div>'
|
|
584
|
+
+ vline
|
|
545
585
|
+ '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
|
|
586
|
+
col._vout = { text: r.verifyOutput||'' };
|
|
546
587
|
host.appendChild(col);
|
|
547
588
|
});
|
|
589
|
+
// 전체 verify 출력 저장(툴팁·토스트용)
|
|
590
|
+
voutById = {}; rns.forEach(function(r){ voutById[r.id]=r.verifyOutput||''; });
|
|
548
591
|
}catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
|
|
549
592
|
}
|
|
593
|
+
var voutById = {};
|
|
550
594
|
$('rvCols').addEventListener('click', async function(e){
|
|
595
|
+
var vo = e.target.closest('[data-vout]');
|
|
596
|
+
if (vo){ var t = voutById[vo.dataset.vout]||''; toast(t? t.slice(-600) : '검증 출력 없음'); return; }
|
|
597
|
+
var rv = e.target.closest('[data-reverify]');
|
|
598
|
+
if (rv){ var id=rv.dataset.reverify; rv.disabled=true; rv.textContent='…';
|
|
599
|
+
try{ await fetch('/api/runs/'+id+'/verify',{method:'POST'}); toast('r'+id+' 재검증 시작'); }
|
|
600
|
+
catch(err){ toast('재검증 실패: '+err); rv.disabled=false; rv.textContent='re-verify'; }
|
|
601
|
+
return; }
|
|
551
602
|
var b = e.target.closest('[data-merge]'); if (!b) return;
|
|
552
|
-
var rid = b.dataset.merge; b.disabled=true; b.textContent='merging…';
|
|
603
|
+
var rid = b.dataset.merge; var prev=b.textContent; b.disabled=true; b.textContent='merging…';
|
|
553
604
|
try{
|
|
554
605
|
var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
555
606
|
var j = await res.json().catch(function(){return{};});
|
|
556
607
|
if (res.ok){ toast('r'+rid+' merge 완료'); await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId); }
|
|
557
|
-
else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent=
|
|
558
|
-
}catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent=
|
|
608
|
+
else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent=prev; }
|
|
609
|
+
}catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent=prev; }
|
|
559
610
|
});
|
|
611
|
+
// verify 명령 저장(선택 태스크의 repo 에)
|
|
612
|
+
function syncVcmd(){
|
|
613
|
+
var t = rvTaskId!=null?taskById[rvTaskId]:null; var rp = t&&repoById[t.repoId];
|
|
614
|
+
$('rvVcmd').value = rp ? (rp.verifyCmd||'') : '';
|
|
615
|
+
$('rvVcmd').dataset.repo = rp ? String(rp.id) : '';
|
|
616
|
+
}
|
|
617
|
+
async function saveVcmd(){
|
|
618
|
+
var rid = $('rvVcmd').dataset.repo; if (!rid){ toast('repo 를 알 수 없습니다'); return; }
|
|
619
|
+
var cmd = $('rvVcmd').value.trim();
|
|
620
|
+
try{
|
|
621
|
+
var res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({verifyCmd:cmd})});
|
|
622
|
+
var j = await res.json().catch(function(){return{};});
|
|
623
|
+
if (res.ok){ if (repoById[rid]) repoById[rid].verifyCmd=cmd; toast(cmd?'verify 명령 저장':'verify 끔'); await hydrate(); if (rvTaskId!=null){ syncVcmd(); loadCompare(rvTaskId); } }
|
|
624
|
+
else toast('저장 실패: '+(j.error||res.status));
|
|
625
|
+
}catch(err){ toast('저장 실패: '+err); }
|
|
626
|
+
}
|
|
627
|
+
$('rvVsave').addEventListener('click', saveVcmd);
|
|
628
|
+
$('rvVcmd').addEventListener('keydown', function(e){ if (e.key==='Enter'){ e.preventDefault(); saveVcmd(); } });
|
|
560
629
|
|
|
561
630
|
// ── /ws 라이브 구독 → 트리·페인 갱신 ──
|
|
562
631
|
function wsConnect(){
|
package/src/db/index.ts
CHANGED
|
@@ -103,4 +103,7 @@ export async function ensureSchema(): Promise<void> {
|
|
|
103
103
|
try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
|
|
104
104
|
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN agent_pid INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
105
105
|
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN log_offset INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
106
|
+
try { await client.execute("ALTER TABLE repos ADD COLUMN verify_cmd TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
107
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_status TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
108
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_output TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
106
109
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const repos = sqliteTable('repos', {
|
|
|
19
19
|
path: text('path').notNull(),
|
|
20
20
|
name: text('name').notNull(),
|
|
21
21
|
defaultBranch: text('default_branch').notNull().default('main'),
|
|
22
|
+
verifyCmd: text('verify_cmd').notNull().default(''), // 정착한 run 을 검증하는 명령(테스트·빌드). 빈값 = 검증 없음
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
/** Design Mode 캡처 — 북마클릿 인스펙터가 보낸 UI 요소 컨텍스트. */
|
|
@@ -90,6 +91,8 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
90
91
|
filesChanged: integer('files_changed').notNull().default(0),
|
|
91
92
|
agentPid: integer('agent_pid').notNull().default(0), // detached sh pgid — 재시작 후 생존 판정/stop
|
|
92
93
|
logOffset: integer('log_offset').notNull().default(0), // 내구 로그에서 tail 이 소비한 바이트(재-adopt 시 여기부터)
|
|
94
|
+
verifyStatus: text('verify_status').notNull().default(''), // '' none | running | pass | fail | error — 정착 후 repo.verifyCmd 결과
|
|
95
|
+
verifyOutput: text('verify_output').notNull().default(''), // 검증 출력 tail(실패 진단용)
|
|
93
96
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
94
97
|
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
|
95
98
|
exitSummary: text('exit_summary').notNull().default(''),
|
package/src/orchestrator.ts
CHANGED
|
@@ -534,9 +534,38 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
534
534
|
await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
|
|
535
535
|
// 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
|
|
536
536
|
if (filesChanged > 0) void snapshotRunDocs(runId);
|
|
537
|
+
// verify in-loop — repo.verifyCmd 가 있으면 정착한(변경 있는) run 을 자동 검증. best-effort.
|
|
538
|
+
if (status === 'done' && filesChanged > 0) void verifyRun(runId);
|
|
537
539
|
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
538
540
|
}
|
|
539
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Verify in-loop — repo.verifyCmd 를 run 의 worktree 에서 실행해 pass/fail 을 기록.
|
|
544
|
+
* verifyCmd 미설정이면 상태를 비우고 no-op. 정착 훅이 자동 호출(done+변경), 수동 재검증도 지원.
|
|
545
|
+
*/
|
|
546
|
+
export async function verifyRun(runId: number): Promise<{ ok: boolean; status: string; detail?: string }> {
|
|
547
|
+
const ctx = await loadContext(runId);
|
|
548
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
549
|
+
const run = rr[0];
|
|
550
|
+
if (!ctx || !run) return { ok: false, status: '', detail: 'run not found' };
|
|
551
|
+
const rp = await db.select().from(repos).where(eq(repos.id, ctx.repoId)).limit(1);
|
|
552
|
+
const cmd = (rp[0]?.verifyCmd ?? '').trim();
|
|
553
|
+
if (!cmd) { await setRun(runId, { verifyStatus: '', verifyOutput: '' }); return { ok: true, status: '' }; }
|
|
554
|
+
if (!run.worktreePath) return { ok: false, status: 'error', detail: 'worktree gone' };
|
|
555
|
+
const exists = await runShellOn(ctx.machine, `test -d ${shq(run.worktreePath)} && echo yes`, 8000);
|
|
556
|
+
if (!/yes/.test(exists.stdout)) {
|
|
557
|
+
await setRun(runId, { verifyStatus: 'error', verifyOutput: 'worktree missing' });
|
|
558
|
+
return { ok: false, status: 'error', detail: 'worktree missing' };
|
|
559
|
+
}
|
|
560
|
+
await setRun(runId, { verifyStatus: 'running', verifyOutput: '' });
|
|
561
|
+
const r = await runShellOn(ctx.machine, `cd ${shq(run.worktreePath)} && ( ${cmd} )`, 180000);
|
|
562
|
+
const merged = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
|
|
563
|
+
const tail = merged.length > 6000 ? '…' + merged.slice(-6000) : merged;
|
|
564
|
+
const status = r.ok ? 'pass' : r.code === -1 ? 'error' : 'fail';
|
|
565
|
+
await setRun(runId, { verifyStatus: status, verifyOutput: tail });
|
|
566
|
+
return { ok: true, status };
|
|
567
|
+
}
|
|
568
|
+
|
|
540
569
|
/**
|
|
541
570
|
* 변경 문서(md/html)를 DB 에 스냅샷. 최신 우선(기존 행 삭제 후 재삽입).
|
|
542
571
|
* 빈 읽기(worktree 이미 소멸 등)는 기존 스냅샷을 지우지 않는다.
|
package/src/server.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { db } from './db';
|
|
|
19
19
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
|
|
20
20
|
import { BOOKMARKLET_JS } from './design';
|
|
21
21
|
import { runShellOn, shq } from './exec';
|
|
22
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments } from './orchestrator';
|
|
22
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun } from './orchestrator';
|
|
23
23
|
import { openTerm } from './term';
|
|
24
24
|
import { addSink, removeSink, broadcast } from './hub';
|
|
25
25
|
import { getProvider, listProviders } from './providers';
|
|
@@ -685,21 +685,33 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
685
685
|
// 기본 브랜치 변경 — merge·Sync base·PR 이 향할 대상. develop-flow repo 대응.
|
|
686
686
|
app.patch('/api/repos/:id', async (req, reply) => {
|
|
687
687
|
const id = Number((req.params as { id: string }).id);
|
|
688
|
-
const b = (req.body ?? {}) as { defaultBranch?: string };
|
|
689
|
-
const branch = (b.defaultBranch ?? '').trim();
|
|
690
|
-
if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
|
|
688
|
+
const b = (req.body ?? {}) as { defaultBranch?: string; verifyCmd?: string };
|
|
691
689
|
const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
|
|
692
690
|
const repo = rp[0];
|
|
693
691
|
if (!repo) return reply.code(404).send({ error: 'not found' });
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
692
|
+
const patch: { defaultBranch?: string; verifyCmd?: string } = {};
|
|
693
|
+
// defaultBranch(선택) — repo 에 존재하는 ref 로 검증
|
|
694
|
+
if (b.defaultBranch !== undefined) {
|
|
695
|
+
const branch = (b.defaultBranch ?? '').trim();
|
|
696
|
+
if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
|
|
697
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
698
|
+
const m = mr[0];
|
|
699
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
700
|
+
const check = await runShellOn(m,
|
|
701
|
+
`git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
|
|
702
|
+
if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
|
|
703
|
+
patch.defaultBranch = branch;
|
|
704
|
+
}
|
|
705
|
+
// verifyCmd(선택) — 정착 run 검증 명령. 빈문자 = 검증 끔. 제어문자만 금지, 길이 캡.
|
|
706
|
+
if (b.verifyCmd !== undefined) {
|
|
707
|
+
const vc = String(b.verifyCmd ?? '').trim();
|
|
708
|
+
if (vc.length > 500) return reply.code(400).send({ error: 'verify command too long (max 500)' });
|
|
709
|
+
if (/[\x00-\x1f]/.test(vc)) return reply.code(400).send({ error: "verify command has control characters" });
|
|
710
|
+
patch.verifyCmd = vc;
|
|
711
|
+
}
|
|
712
|
+
if (!Object.keys(patch).length) return reply.code(400).send({ error: 'nothing to update' });
|
|
713
|
+
await db.update(repos).set(patch).where(eq(repos.id, id));
|
|
714
|
+
return { ok: true, ...patch };
|
|
703
715
|
});
|
|
704
716
|
|
|
705
717
|
// 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
|
|
@@ -978,6 +990,16 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
978
990
|
return reply.code(202).send(res);
|
|
979
991
|
});
|
|
980
992
|
|
|
993
|
+
// 수동 재검증 — repo.verifyCmd 를 이 run 의 worktree 에서 다시 실행(정착 자동검증과 동일 경로).
|
|
994
|
+
app.post('/api/runs/:id/verify', async (req, reply) => {
|
|
995
|
+
const id = Number((req.params as { id: string }).id);
|
|
996
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
997
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
998
|
+
const res = await verifyRun(id);
|
|
999
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
1000
|
+
return reply.code(202).send(res);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
981
1003
|
// base 동기화 — 오래 사는 세션 worktree 에 base 최신 머지.
|
|
982
1004
|
app.post('/api/runs/:id/sync', async (req, reply) => {
|
|
983
1005
|
const id = Number((req.params as { id: string }).id);
|