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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.6.0",
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="터미널 우선 셸 (preview)">◨ Cockpit</a>
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
- <span class="wip">preview · Phase 3</span>
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 id="rvHint" style="margin-left:auto;color:var(--faint)">run diff 를 나란히 · 승자를 base 에 merge</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
- if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal'; }
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
- + (r.status==='merged'?'merged':'merge ▸')+'</button></div>'
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='merge ▸'; }
558
- }catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent='merge ▸'; }
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(''),
@@ -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 mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
695
- const m = mr[0];
696
- if (!m) return reply.code(404).send({ error: 'machine not found' });
697
- // branch charset 가드 통과( 메타문자 없음). 전체 ref 를 인용해 전달.
698
- const check = await runShellOn(m,
699
- `git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
700
- if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
701
- await db.update(repos).set({ defaultBranch: branch }).where(eq(repos.id, id));
702
- return { ok: true, defaultBranch: branch };
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);