coxpit 5.6.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cockpit.ts +79 -9
- 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.7.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/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,7 +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
|
-
<span class="wip">preview · Phase
|
|
175
|
+
<span class="wip">preview · Phase 4</span>
|
|
160
176
|
<a class="toggle" href="/">← Board</a>
|
|
161
177
|
</div>
|
|
162
178
|
</header>
|
|
@@ -209,7 +225,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
209
225
|
<span style="color:var(--brand)">⧉ Review</span>
|
|
210
226
|
<span>·</span>
|
|
211
227
|
<select id="rvTask" title="비교할 태스크"></select>
|
|
212
|
-
<span
|
|
228
|
+
<span class="rv-vcmd">
|
|
229
|
+
<span style="color:var(--faint)">verify:</span>
|
|
230
|
+
<input id="rvVcmd" placeholder="예: npm test — 정착 시 자동 실행" autocomplete="off" />
|
|
231
|
+
<button type="button" id="rvVsave">save</button>
|
|
232
|
+
</span>
|
|
233
|
+
<span id="rvHint" style="margin-left:auto;color:var(--faint)">정착하면 자동 검증 · 승자를 base 에 merge</span>
|
|
213
234
|
</div>
|
|
214
235
|
<div class="rv-cols" id="rvCols"></div>
|
|
215
236
|
</section>
|
|
@@ -226,6 +247,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
226
247
|
var $ = function(id){ return document.getElementById(id); };
|
|
227
248
|
|
|
228
249
|
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); }
|
|
250
|
+
var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'⚠ verify' };
|
|
251
|
+
function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
|
|
229
252
|
|
|
230
253
|
// ── fleet 상태 ──
|
|
231
254
|
var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
|
|
@@ -356,6 +379,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
356
379
|
var el = document.createElement('div'); el.className = 'pane'; p.el = el;
|
|
357
380
|
el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
|
|
358
381
|
+ '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
|
|
382
|
+
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
359
383
|
+ '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
|
|
360
384
|
+ '<button class="x" title="close">×</button></div>'
|
|
361
385
|
+ '<div class="pane-term"></div>';
|
|
@@ -422,7 +446,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
422
446
|
panes.forEach(function(p){
|
|
423
447
|
var r = runById[p.runId];
|
|
424
448
|
var chip = p.el.querySelector('[data-role=chip]'); var title = p.el.querySelector('[data-role=title]');
|
|
425
|
-
|
|
449
|
+
var vslot = p.el.querySelector('[data-role=vslot]');
|
|
450
|
+
if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal';
|
|
451
|
+
if(vslot) vslot.innerHTML = vbadge(r.verifyStatus); }
|
|
426
452
|
});
|
|
427
453
|
}
|
|
428
454
|
$('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
|
|
@@ -512,9 +538,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
512
538
|
o.textContent = (rp?rp.name+' · ':'')+t.title; sel.appendChild(o); });
|
|
513
539
|
if (!tasks.length){ $('rvCols').innerHTML='<div class="rv-empty">비교할 run 이 있는 태스크가 아직 없습니다. Terminal 에서 팬아웃해 보세요.</div>'; rvTaskId=null; return; }
|
|
514
540
|
if (cur){ sel.value=cur; } rvTaskId = Number(sel.value);
|
|
541
|
+
syncVcmd();
|
|
515
542
|
loadCompare(rvTaskId);
|
|
516
543
|
}
|
|
517
|
-
$('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); loadCompare(rvTaskId); });
|
|
544
|
+
$('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); syncVcmd(); loadCompare(rvTaskId); });
|
|
518
545
|
function diffHTML(text){
|
|
519
546
|
if (!text || !text.trim()) return '<span style="color:var(--faint)">no changes</span>';
|
|
520
547
|
return text.split('\\n').map(function(l){
|
|
@@ -528,6 +555,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
528
555
|
}
|
|
529
556
|
async function loadCompare(taskId){
|
|
530
557
|
var host=$('rvCols'); host.innerHTML='<div class="rv-empty">불러오는 중…</div>';
|
|
558
|
+
var task = taskById[taskId]; var repo = task && repoById[task.repoId];
|
|
559
|
+
var hasVerify = !!(repo && repo.verifyCmd && repo.verifyCmd.trim());
|
|
531
560
|
try{
|
|
532
561
|
var d = await (await fetch('/api/tasks/'+taskId+'/compare')).json();
|
|
533
562
|
var rns = (d.runs||[]).sort(function(a,b){return a.id-b.id;});
|
|
@@ -537,26 +566,67 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
537
566
|
var col=document.createElement('div'); col.className='rv-col';
|
|
538
567
|
var st = typeof r.stat==='string' ? r.stat.split('\\n').pop().trim() : '';
|
|
539
568
|
var mergeable = (r.status==='done'||r.status==='open'||r.status==='merged') && r.filesChanged>0;
|
|
569
|
+
// green-gate: verifyCmd 있는데 pass 아니면 "merge anyway"(주의색) — 막지 않고 경고
|
|
570
|
+
var gated = hasVerify && r.verifyStatus!=='pass';
|
|
571
|
+
var mlabel = r.status==='merged' ? 'merged' : (gated ? 'merge anyway' : 'merge ▸');
|
|
572
|
+
var vline = '';
|
|
573
|
+
if (hasVerify){
|
|
574
|
+
var vs = r.verifyStatus||''; var out = (r.verifyOutput||'').split('\\n').pop();
|
|
575
|
+
var txt = vs==='pass'?'통과' : vs==='fail'?'실패' : vs==='running'?'검증 중…' : vs==='error'?'검증 오류' : '미검증';
|
|
576
|
+
vline = '<div class="rv-verify">'+(vbadge(vs)||'<span class="vbadge error">· verify</span>')
|
|
577
|
+
+ '<span class="vout" data-vout="'+r.id+'" title="클릭=전체 출력">'+esc(txt+(out?' — '+out:''))+'</span>'
|
|
578
|
+
+ '<button class="rv-reverify" data-reverify="'+r.id+'">re-verify</button></div>';
|
|
579
|
+
}
|
|
540
580
|
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
581
|
+ '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
|
|
542
582
|
+ '<span class="stat">'+esc(st)+'</span>'
|
|
543
|
-
+ '<button class="rv-merge" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
|
|
544
|
-
+ (
|
|
583
|
+
+ '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
|
|
584
|
+
+ (gated?' title="검증 미통과 — 그래도 머지"':'')+'>'+mlabel+'</button></div>'
|
|
585
|
+
+ vline
|
|
545
586
|
+ '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
|
|
587
|
+
col._vout = { text: r.verifyOutput||'' };
|
|
546
588
|
host.appendChild(col);
|
|
547
589
|
});
|
|
590
|
+
// 전체 verify 출력 저장(툴팁·토스트용)
|
|
591
|
+
voutById = {}; rns.forEach(function(r){ voutById[r.id]=r.verifyOutput||''; });
|
|
548
592
|
}catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
|
|
549
593
|
}
|
|
594
|
+
var voutById = {};
|
|
550
595
|
$('rvCols').addEventListener('click', async function(e){
|
|
596
|
+
var vo = e.target.closest('[data-vout]');
|
|
597
|
+
if (vo){ var t = voutById[vo.dataset.vout]||''; toast(t? t.slice(-600) : '검증 출력 없음'); return; }
|
|
598
|
+
var rv = e.target.closest('[data-reverify]');
|
|
599
|
+
if (rv){ var id=rv.dataset.reverify; rv.disabled=true; rv.textContent='…';
|
|
600
|
+
try{ await fetch('/api/runs/'+id+'/verify',{method:'POST'}); toast('r'+id+' 재검증 시작'); }
|
|
601
|
+
catch(err){ toast('재검증 실패: '+err); rv.disabled=false; rv.textContent='re-verify'; }
|
|
602
|
+
return; }
|
|
551
603
|
var b = e.target.closest('[data-merge]'); if (!b) return;
|
|
552
|
-
var rid = b.dataset.merge; b.disabled=true; b.textContent='merging…';
|
|
604
|
+
var rid = b.dataset.merge; var prev=b.textContent; b.disabled=true; b.textContent='merging…';
|
|
553
605
|
try{
|
|
554
606
|
var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
555
607
|
var j = await res.json().catch(function(){return{};});
|
|
556
608
|
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=
|
|
609
|
+
else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent=prev; }
|
|
610
|
+
}catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent=prev; }
|
|
559
611
|
});
|
|
612
|
+
// verify 명령 저장(선택 태스크의 repo 에)
|
|
613
|
+
function syncVcmd(){
|
|
614
|
+
var t = rvTaskId!=null?taskById[rvTaskId]:null; var rp = t&&repoById[t.repoId];
|
|
615
|
+
$('rvVcmd').value = rp ? (rp.verifyCmd||'') : '';
|
|
616
|
+
$('rvVcmd').dataset.repo = rp ? String(rp.id) : '';
|
|
617
|
+
}
|
|
618
|
+
async function saveVcmd(){
|
|
619
|
+
var rid = $('rvVcmd').dataset.repo; if (!rid){ toast('repo 를 알 수 없습니다'); return; }
|
|
620
|
+
var cmd = $('rvVcmd').value.trim();
|
|
621
|
+
try{
|
|
622
|
+
var res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({verifyCmd:cmd})});
|
|
623
|
+
var j = await res.json().catch(function(){return{};});
|
|
624
|
+
if (res.ok){ if (repoById[rid]) repoById[rid].verifyCmd=cmd; toast(cmd?'verify 명령 저장':'verify 끔'); await hydrate(); if (rvTaskId!=null){ syncVcmd(); loadCompare(rvTaskId); } }
|
|
625
|
+
else toast('저장 실패: '+(j.error||res.status));
|
|
626
|
+
}catch(err){ toast('저장 실패: '+err); }
|
|
627
|
+
}
|
|
628
|
+
$('rvVsave').addEventListener('click', saveVcmd);
|
|
629
|
+
$('rvVcmd').addEventListener('keydown', function(e){ if (e.key==='Enter'){ e.preventDefault(); saveVcmd(); } });
|
|
560
630
|
|
|
561
631
|
// ── /ws 라이브 구독 → 트리·페인 갱신 ──
|
|
562
632
|
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);
|