coxpit 6.2.0 → 6.3.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": "6.2.0",
3
+ "version": "6.3.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": "SEE LICENSE IN LICENSE.md",
package/src/board.ts CHANGED
@@ -554,6 +554,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
554
554
  .chip.noop{color:#c9922e;border-color:rgba(201,146,46,.45)}
555
555
  .chip.noop.blk{color:#e0955a;border-color:rgba(224,149,90,.6)}
556
556
  .card-h .chip.noop::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:1px}
557
+ /* v5.28 H2 — dry(모의) run 표식. 낱말 하나를 조용한 톤으로: 색은 새로 만들지 않고
558
+ --faint 글자 + --line-hi 테두리만 쓴다. 진짜 run 에는 아무 칩도 붙지 않는다. */
559
+ .dryc{display:inline-flex;align-items:center;font-family:var(--mono);font-size:9.5px;line-height:1.6;
560
+ letter-spacing:.06em;padding:2px 6px;border-radius:999px;color:var(--faint);border:1px solid var(--line-hi);
561
+ background:transparent;flex:none}
557
562
  .meta{display:flex;gap:16px;padding:0 14px 10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
558
563
  .meta b{color:var(--muted);font-weight:500;font-variant-numeric:tabular-nums}
559
564
  .meta .resumable{color:var(--brand);opacity:.75}
@@ -728,6 +733,12 @@ export const BOARD_HTML = /* html */ `<!doctype html>
728
733
  .cmp-h .files{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
729
734
  .cmp-meta{font-family:var(--mono);font-size:11px;color:var(--faint);padding:8px 14px;border-bottom:1px solid var(--line);
730
735
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:var(--surface2)}
736
+ /* 머지 직후의 base 검증 한 줄 (v5.28 J2). 판정보다 안내가 길어서 여기만 줄바꿈한다 —
737
+ 잘린 안내는 안내가 아니다. 색은 기존 상태 토큰만 쓰고 새 색은 없다. */
738
+ .cmp-mv{font-family:var(--mono);font-size:11px;color:var(--muted);padding:7px 14px;
739
+ border-bottom:1px solid var(--line);background:var(--surface2);line-height:1.55}
740
+ .cmp-mv .mvout{cursor:pointer}
741
+ .cmp-mv a{color:var(--brand)}
731
742
  .cmp-diff{overflow:auto;padding:11px 14px;flex:1;min-height:0}
732
743
  .cmp-f{padding:10px 14px;border-top:1px solid var(--line);display:flex;gap:8px;align-items:center}
733
744
  .cmp-f .msg{font-family:var(--mono);font-size:11px;color:var(--muted);flex:1;
@@ -1328,6 +1339,18 @@ const runs = new Map(); // runId -> run object
1328
1339
  const tasks = new Map(); // taskId -> task
1329
1340
  const groups = new Map(); // groupId -> {id, kind, title}
1330
1341
  let repos = [], machines = [], captures = [];
1342
+ /* v5.28 I — 보드는 에이전트 작업의 열람실이다. 자유 터미널 세션은 run 이 아니므로
1343
+ 여기 서지 않는다(세션의 집은 코크핏이고, 거기선 그대로 산다). sessions 버킷
1344
+ (repo.kind==='sessions') 에 속한 repo·task·run 은 보드 상태에 아예 들이지 않는다 —
1345
+ 하이드레이트·WS 델타·단건 fetch 전부 같은 문을 지난다. */
1346
+ let sessionRepoIds = new Set(); // kind==='sessions' 인 repo id (머신마다 하나)
1347
+ let sessionRepoNames = new Set(); // 아카이브 행엔 repoId 가 없다 — 거기선 이름이 유일한 열쇠
1348
+ let sessionTaskIds = new Set(); // 그 버킷에 담긴 task id
1349
+ let sessionRunIds = new Set(); // 그 task 들의 run id (taskId 없는 상태 패치 델타용)
1350
+ const isSessionRepo = (repoId) => sessionRepoIds.has(repoId);
1351
+ const isSessionTask = (taskId) => sessionTaskIds.has(taskId);
1352
+ // run 객체와 WS 델타를 모두 받는다(델타는 runId, 객체는 id).
1353
+ const isSessionRun = (r) => !!r && (sessionRunIds.has(r.runId ?? r.id) || isSessionTask(r.taskId));
1331
1354
  // v5.0 rail — 선택된 repo 로 보드 스코프(client-side). null = All repositories.
1332
1355
  let selectedRepo = null;
1333
1356
  try { const s = localStorage.getItem('coxpit.repo'); selectedRepo = s ? Number(s) : null; } catch {}
@@ -1513,6 +1536,42 @@ function chipHTML(status){
1513
1536
  return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
1514
1537
  + '<i></i>'+esc(s)+'</span>';
1515
1538
  }
1539
+ /* ── dry(모의) run (v5.28 H) ──
1540
+ 판정은 real===false 하나뿐이다. undefined(이 컬럼 이전의 run, 아직 안 온 델타)는 **모르는 것**이지
1541
+ dry 가 아니다 — 모르는 것을 모의라 부르면 거짓 경보가 된다. dry 를 배지하되, dry 를 추측하지 않는다. */
1542
+ function isDryRun(r){ return !!r && r.real===false; }
1543
+ function dryChip(r){
1544
+ return isDryRun(r)
1545
+ ? '<span class="dryc" title="dry run — a mock stream made these changes (not a real agent)">dry</span>' : '';
1546
+ }
1547
+ /* ── 머지 직후의 base 검증 (v5.28 J2) ──
1548
+ 승자가 내려앉은 그 호흡의 판정을, 머지를 누른 그 자리에 남긴다. 서버가 머지 응답에
1549
+ 실어 보낸 것(verify:{status,output})만 그린다 — 클라이언트는 아무것도 추측하지 않는다.
1550
+ 색은 기존 상태 토큰 그대로(pass=done · fail/error=failed), 새 색 없음. */
1551
+ const mergeVerifyByRun = {};
1552
+ function mergeVerifyHTML(rid){
1553
+ const v = mergeVerifyByRun[rid]; if (!v) return '';
1554
+ if (!v.status){
1555
+ return '<div class="cmp-mv">merged · 검증 명령이 없어요 — repo 설정에서 verifyCmd 를 지정하면 승자를 자동 검증합니다 '
1556
+ + '<a href="/cockpit" target="_blank" rel="noopener">verifyCmd 설정</a></div>';
1557
+ }
1558
+ const col = statusColor(v.status==='pass' ? 'done' : 'failed');
1559
+ return '<div class="cmp-mv"><span class="mvout" data-mvout="'+rid+'" style="color:'+col+'"'
1560
+ + ' title="click to see the verify output">merged · verify: '+esc(v.status)+'</span>'
1561
+ + (v.status==='pass' ? '' : ' 머지는 됐지만 검증 실패 — 로그 확인') + '</div>';
1562
+ }
1563
+ /* 검증은 보고일 뿐 — 실패해도 머지는 서 있다. 토스트의 어조만 달라진다. */
1564
+ function mergeVerifyToast(v){
1565
+ if (!v || !v.status) return { suffix: ' · 검증 명령 없음 — repo 의 verifyCmd 를 지정하면 자동 검증', level: 'ok' };
1566
+ return { suffix: ' · verify: '+v.status, level: v.status==='pass' ? 'ok' : 'error' };
1567
+ }
1568
+ /* 모의를 진짜처럼 base 로 넘기지 않게 — 아는 dry 에만 한 번 묻는다. */
1569
+ function confirmDryMerge(n){
1570
+ return confirmUI('이 run 은 dry (모의)입니다 — 정말 머지할까요?', {
1571
+ sub: (n>1 ? '고른 '+n+'개가 dry 입니다. ' : '') + 'dry run 의 변경은 모의 스트림이 만든 것입니다 — 진짜 작업이 아닙니다.',
1572
+ danger: true, okLabel: '그래도 머지',
1573
+ });
1574
+ }
1516
1575
  /* 같은 태스크에 run 이 여럿이면 "run i/n" — 같은 제목 카드가 중복이 아니라 시도로 읽히게 */
1517
1576
  function attemptHTML(r){
1518
1577
  const sib = [...runs.values()].filter(x=>x.taskId===r.taskId).sort((a,b)=>a.id-b.id);
@@ -1692,7 +1751,7 @@ $('dbList').addEventListener('click', (e)=>{
1692
1751
  // documents include closed/archived runs not in the live map — seed a minimal run+task so the modal renders
1693
1752
  if (!runs.has(id)){
1694
1753
  const dr = (dbData||[]).find(r=>r.runId===id);
1695
- if (dr){
1754
+ if (dr && !isSessionTask(dr.taskId)){ // v5.28 I
1696
1755
  runs.set(id, { id, taskId: dr.taskId, status: dr.status, branch:'', filesChanged:0, events:[], prUrl: dr.prUrl });
1697
1756
  if (!tasks.has(dr.taskId)) tasks.set(dr.taskId, { id: dr.taskId, title: dr.title, status:'closed', repoId:0 });
1698
1757
  }
@@ -1849,7 +1908,9 @@ async function archFetch(reset){
1849
1908
  try{
1850
1909
  const j = await fetch(url).then(x=>x.json());
1851
1910
  archTotal = j.total||0;
1852
- const rows = (j.rows||[]).map(archRowHTML).join('');
1911
+ // v5.28 I — 닫힌 세션도 보드의 아카이브엔 서지 않는다. 행엔 repoId 가 없어 이름으로 거른다
1912
+ // (페이지 진행은 서버가 준 행 수 그대로 — 거른 것 때문에 다음 장을 놓치지 않게).
1913
+ const rows = (j.rows||[]).filter(row => !sessionRepoNames.has(row.repoName)).map(archRowHTML).join('');
1853
1914
  if (reset) $('archList').innerHTML = rows || '<div class="arch-empty">no closed tasks match</div>';
1854
1915
  else $('archList').insertAdjacentHTML('beforeend', rows);
1855
1916
  archOffset += (j.rows||[]).length;
@@ -1866,6 +1927,7 @@ $('archList').addEventListener('click', async (e)=>{
1866
1927
  try{
1867
1928
  const j = await fetch('/api/tasks/'+tid).then(x=>x.json());
1868
1929
  if (!j.task || !(j.runs||[]).length){ toast('task has no runs', 'error'); return; }
1930
+ if (isSessionRepo(j.task.repoId)) return; // v5.28 I — 세션은 보드가 열지 않는다
1869
1931
  tasks.set(j.task.id, j.task);
1870
1932
  j.runs.forEach(rn => { if(!runs.has(rn.id)) runs.set(rn.id, { ...rn, events: [] }); });
1871
1933
  openModal(j.runs.map(r=>r.id).sort((a,b)=>a-b)[0]);
@@ -1973,7 +2035,7 @@ function cardHTML(r){
1973
2035
  const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
1974
2036
  return '<div class="card'+selCls+'" id="card-'+r.id+'">'
1975
2037
  + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
1976
- + '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)
2038
+ + '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)+dryChip(r)
1977
2039
  + (r.noop ? '<span class="chip noop'+(r.noopReason==='blocked'?' blk':'')+'" title="'
1978
2040
  +(r.noopReason==='blocked'?'settled without making the change it was asked for — likely stalled on approval':'this run changed no files — did it actually do the work?')
1979
2041
  +'">'+(r.noopReason==='blocked'?'blocked':'no changes')+'</span>' : '')
@@ -1993,6 +2055,7 @@ function cardHTML(r){
1993
2055
  function flash(id){ const el=$('card-'+id); if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); } }
1994
2056
 
1995
2057
  function upsertRun(patch){
2058
+ if (isSessionRun(patch)) return; // v5.28 I — 세션은 보드 상태에 들어오지 않는다(마지막 문)
1996
2059
  const cur = runs.get(patch.runId ?? patch.id) || { id: patch.runId ?? patch.id, events: [] };
1997
2060
  Object.assign(cur, patch, { id: cur.id, events: cur.events });
1998
2061
  if (patch.runId) cur.id = patch.runId;
@@ -2001,14 +2064,21 @@ function upsertRun(patch){
2001
2064
 
2002
2065
  async function hydrate(){
2003
2066
  const r = await fetch('/api/fleet?view=active').then(x=>x.json());
2004
- machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
2067
+ // v5.28 I 거르는 기준부터 다시 세운다(repo 목록이 바뀌면 세션 집합도 같이 바뀐다).
2068
+ const allRepos = r.repos||[];
2069
+ const sessionRepos = allRepos.filter(x => x.kind === 'sessions');
2070
+ sessionRepoIds = new Set(sessionRepos.map(x => x.id));
2071
+ sessionRepoNames = new Set(sessionRepos.map(x => x.name));
2072
+ sessionTaskIds = new Set((r.tasks||[]).filter(t => isSessionRepo(t.repoId)).map(t => t.id));
2073
+ sessionRunIds = new Set((r.runs||[]).filter(rn => isSessionTask(rn.taskId)).map(rn => rn.id));
2074
+ machines = r.machines||[]; repos = allRepos.filter(x => !isSessionRepo(x.id)); captures = r.captures||[];
2005
2075
  if (r.counts){ const n = r.counts.closedTasks||0; $('navArchiveN').textContent = n ? String(n) : ''; }
2006
2076
  tasks.clear();
2007
- (r.tasks||[]).forEach(t => tasks.set(t.id, t));
2077
+ (r.tasks||[]).forEach(t => { if (!isSessionTask(t.id)) tasks.set(t.id, t); });
2008
2078
  groups.clear();
2009
2079
  (r.groups||[]).forEach(g => groups.set(g.id, g));
2010
2080
  runs.clear();
2011
- (r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
2081
+ (r.runs||[]).forEach(rn => { if (!isSessionRun(rn)) runs.set(rn.id, { ...rn, events: rn.events||[] }); });
2012
2082
  if (r.daemon) {
2013
2083
  const d = r.daemon;
2014
2084
  if (d.port) daemonPort = d.port;
@@ -2081,6 +2151,7 @@ function railCounts(){
2081
2151
  // repoId → { active, attn } — /api/fleet active runs 를 client-side 로 그룹
2082
2152
  const m = new Map();
2083
2153
  for (const r of runs.values()){
2154
+ if (isSessionRun(r)) continue; // v5.28 I — 세션은 세지 않는다(배지도 스코프도)
2084
2155
  const t = tasks.get(r.taskId); if (!t) continue;
2085
2156
  const rec = m.get(t.repoId) || { active:0, attn:false };
2086
2157
  rec.active++;
@@ -2236,6 +2307,11 @@ function connectWS(){
2236
2307
  const known = runs.has(rid);
2237
2308
  // 아카이브된(맵에 없는) run 의 뒤늦은 echo 는 무시 — 신규는 항상 pending 으로 먼저 온다.
2238
2309
  if (!known && ev.status && ev.status!=='pending') return;
2310
+ // v5.28 I — 보드가 열려 있는 사이 시작된 Scratch 세션은 여기서 멈춘다.
2311
+ if (isSessionRun(ev)) return;
2312
+ // 정체를 모르는 새 task 의 첫 델타는 넣지 않고 하이드레이트에 맡긴다 — 세션이면 그대로
2313
+ // 걸러지고, 에이전트 run 이면 같은 하이드레이트가 곧 세운다(잠깐 떴다 사라지지 않게).
2314
+ if (!known && ev.taskId!=null && !tasks.has(ev.taskId)){ hydrate(); return; }
2239
2315
  upsertRun(ev);
2240
2316
  if (!known && ev.taskId==null){ hydrate(); return; }
2241
2317
  if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
@@ -2248,6 +2324,7 @@ function connectWS(){
2248
2324
  r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
2249
2325
  render(); flash(ev.runId); paintModal();
2250
2326
  } else if (ev.type==='task'){
2327
+ if (isSessionTask(ev.taskId) || isSessionRepo(ev.repoId)) return; // v5.28 I
2251
2328
  const t = tasks.get(ev.taskId);
2252
2329
  // repoId 는 v6.0 S2 승격(재부모화)으로 바뀐다 — 안 받으면 repo 스코프가 다음 하이드레이트까지 어긋난다.
2253
2330
  if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; if (ev.repoId!=null) t.repoId = ev.repoId; render(); paintModal(); } else { hydrate(); }
@@ -2486,6 +2563,9 @@ $('selToggle').addEventListener('click', ()=>setSelectMode(!selectMode));
2486
2563
  $('selCancel').addEventListener('click', ()=>setSelectMode(false));
2487
2564
  $('selGo').addEventListener('click', async ()=>{
2488
2565
  if (!selOrder.length){ toast('select settled runs with changes first', 'error'); return; }
2566
+ // 묶어서 넘기는 길이 가장 조용하다 — 고른 것 중 아는 dry 가 하나라도 있으면 먼저 묻는다(H3).
2567
+ const dryN = selOrder.filter(id=>isDryRun(runs.get(id))).length;
2568
+ if (dryN && !(await confirmDryMerge(dryN))) return;
2489
2569
  const yes = await confirmUI('Integrate '+selOrder.length+' run(s) into the base branch?',
2490
2570
  { sub: 'Merged one by one in selection order. A run that conflicts spawns an integration agent (real, spends credits) to resolve it.', okLabel: 'Integrate' });
2491
2571
  if (!yes) return;
@@ -2680,9 +2760,11 @@ async function paintCompare(){
2680
2760
  const mergeable = ['done','failed','stopped'].includes(r.status) && files>0;
2681
2761
  const merged = r.status==='merged';
2682
2762
  return '<div class="cmp-col" data-run="'+r.id+'">'
2683
- + '<div class="cmp-h"><span class="rid">r'+r.id+'</span>'+chipHTML(r.status)
2763
+ + '<div class="cmp-h"><span class="rid">r'+r.id+'</span>'+chipHTML(r.status)+dryChip(r)
2684
2764
  + '<span class="files">'+files+' file'+(files===1?'':'s')+'</span></div>'
2685
2765
  + '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
2766
+ // 머지한 열은 그 판정을 계속 달고 있다 — 다시 그려도 지워지지 않게 여기서 그린다(J2).
2767
+ + mergeVerifyHTML(r.id)
2686
2768
  + '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
2687
2769
  + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
2688
2770
  + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR '+ic('external-link')+' '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
@@ -2690,7 +2772,7 @@ async function paintCompare(){
2690
2772
  + (merged
2691
2773
  ? chipHTML('merged')
2692
2774
  : (r.prUrl ? '' : '<button class="btn-ghost sm" data-pr="'+r.id+'"'+(mergeable?'':' disabled')+'>Open PR</button>')
2693
- + '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
2775
+ + '<button class="btn sm" data-merge="'+r.id+'"'+(isDryRun(r)?' data-dry="1"':'')+(mergeable?'':' disabled')+'>Merge this</button>')
2694
2776
  + '</div></div>';
2695
2777
  }).join('');
2696
2778
  // doc 모드 — 각 열의 diff 를 렌더된 문서로 치환 (열별 비동기, 도착 순)
@@ -2720,6 +2802,13 @@ async function driftNote(rid){
2720
2802
  }catch(e){ return ''; }
2721
2803
  }
2722
2804
  $('cmpBody').addEventListener('click', async (e)=>{
2805
+ // 머지 검증 꼬리 — 기존 verify-output 보기와 같은 방식(전문은 토스트로)
2806
+ const mvo = e.target.closest('[data-mvout]');
2807
+ if (mvo){
2808
+ const v = mergeVerifyByRun[mvo.dataset.mvout];
2809
+ toast((v && v.output) ? v.output.slice(-600) : 'no verify output');
2810
+ return;
2811
+ }
2723
2812
  const prBtn = e.target.closest('button[data-pr]');
2724
2813
  if (prBtn){
2725
2814
  const rid = Number(prBtn.dataset.pr);
@@ -2747,6 +2836,8 @@ $('cmpBody').addEventListener('click', async (e)=>{
2747
2836
  }
2748
2837
  const btn = e.target.closest('button[data-merge]'); if(!btn) return;
2749
2838
  const rid = Number(btn.dataset.merge);
2839
+ // dry 열은 여기서 한 번 더 붙잡는다 — 나란히 놓고 고르는 자리가 실수가 나는 자리였다(H3).
2840
+ if (btn.dataset.dry==='1' && !(await confirmDryMerge(1))) return;
2750
2841
  const yes = await confirmUI('Merge r'+rid+' into the base branch?',
2751
2842
  { sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.'+(await driftNote(rid)), okLabel: 'Merge' });
2752
2843
  if (!yes) return;
@@ -2755,7 +2846,13 @@ $('cmpBody').addEventListener('click', async (e)=>{
2755
2846
  const j = await res.json().catch(()=>({detail:'merge failed'}));
2756
2847
  const msg = $('cmpMsg-'+rid);
2757
2848
  if (msg) msg.textContent = j.detail || (res.ok?'merged':'failed');
2758
- if (res.ok){ toast('r'+rid+' merged to base', 'ok'); await paintCompare(); hydrate(); }
2849
+ if (res.ok){
2850
+ // 머지는 이미 섰다 — verify 는 그 위에 얹는 판정이지 되돌리는 신호가 아니다(J1/J3).
2851
+ mergeVerifyByRun[rid] = j.verify || { status:'', output:'' };
2852
+ const vt = mergeVerifyToast(j.verify);
2853
+ toast('r'+rid+' merged to base'+vt.suffix, vt.level);
2854
+ await paintCompare(); hydrate();
2855
+ }
2759
2856
  else { toast('merge: '+(j.detail||res.status), 'error'); btn.disabled = false; }
2760
2857
  });
2761
2858
  $('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
@@ -3018,7 +3115,7 @@ $('roomChips').addEventListener('click',(e)=>{
3018
3115
  closeRoom();
3019
3116
  if (runs.has(rid)) openModal(rid);
3020
3117
  else { // 아카이브 등 맵에 없는 run — 태스크로 하이드레이트 후 연다
3021
- fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run){ runs.set(rid, {...d.run, events:d.events||[]}); openModal(rid); } }).catch(()=>{});
3118
+ fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run && !isSessionRun(d.run)){ runs.set(rid, {...d.run, events:d.events||[]}); openModal(rid); } }).catch(()=>{}); // v5.28 I
3022
3119
  }
3023
3120
  });
3024
3121
  $('roomClose').addEventListener('click', closeRoom);
@@ -3061,7 +3158,7 @@ async function roomRunAction(act, rid){
3061
3158
  if (act==='open'){
3062
3159
  closeRoom();
3063
3160
  if (runs.has(rid)) openModal(rid);
3064
- else fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run){ runs.set(rid,{...d.run,events:d.events||[]}); openModal(rid); } }).catch(()=>{});
3161
+ else fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run && !isSessionRun(d.run)){ runs.set(rid,{...d.run,events:d.events||[]}); openModal(rid); } }).catch(()=>{}); // v5.28 I
3065
3162
  return;
3066
3163
  }
3067
3164
  if (act==='fix'){ // 펼치고 steer 입력에 포커스
@@ -3084,12 +3181,18 @@ async function roomRunAction(act, rid){
3084
3181
  return;
3085
3182
  }
3086
3183
  if (act==='merge'){
3184
+ if (isDryRun(r) && !(await confirmDryMerge(1))) return;
3087
3185
  const yes = await confirmUI('Merge r'+rid+' into the base branch?',
3088
3186
  { sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
3089
3187
  if (!yes) return;
3090
3188
  const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
3091
3189
  const j = await res.json().catch(()=>({detail:'merge failed'}));
3092
- if (res.ok){ toast('r'+rid+' merged to base', 'ok'); await roomLoad(); hydrate(); }
3190
+ if (res.ok){
3191
+ mergeVerifyByRun[rid] = j.verify || { status:'', output:'' };
3192
+ const vt = mergeVerifyToast(j.verify);
3193
+ toast('r'+rid+' merged to base'+vt.suffix, vt.level);
3194
+ await roomLoad(); hydrate();
3195
+ }
3093
3196
  else toast('merge: '+(j.detail||res.status), 'error');
3094
3197
  return;
3095
3198
  }
@@ -3489,7 +3592,7 @@ $('taskForm').addEventListener('submit', async (e)=>{
3489
3592
  const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
3490
3593
  body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId,outputs})}).then(x=>x.json());
3491
3594
  if (!t.ok){ toast('task create failed', 'error'); return; }
3492
- tasks.set(t.task.id, t.task);
3595
+ if (!isSessionRepo(t.task.repoId)) tasks.set(t.task.id, t.task); // v5.28 I
3493
3596
  const model = $('taskModel').value.trim();
3494
3597
  await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
3495
3598
  body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
package/src/cockpit.ts CHANGED
@@ -329,6 +329,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
329
329
  .leaf-h .pane-act{font-family:var(--mono);font-size:10px;color:var(--brand);opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px}
330
330
  .leaf-h .pane-act:empty{display:none}
331
331
  .tnode.run .ract{margin-left:6px;font-size:10px;color:var(--brand);opacity:.85}
332
+ /* v5.28 H2 — dry(모의) run 표식. 낱말 하나("dry")를 기존 작은 칩 모양에 얹을 뿐,
333
+ 색은 하나도 새로 만들지 않는다(--faint 글자 + --line-hi 테두리). 진짜 run 에는 아무것도 안 붙는다. */
334
+ .dryc{flex:none;font-family:var(--mono);font-size:9px;line-height:1.7;letter-spacing:.04em;
335
+ color:var(--faint);border:1px solid var(--line-hi);border-radius:4px;padding:0 4px;white-space:nowrap}
332
336
  .leaf-h .zoom{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
333
337
  .leaf-h .zoom:hover{color:var(--brand)}
334
338
  .leaf-h .pact{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
@@ -928,6 +932,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
928
932
  </div>
929
933
  </div>
930
934
 
935
+ <div class="modal" id="cfmModal">
936
+ <div class="pick" style="width:min(460px,92vw)">
937
+ <div class="pick-h"><span class="t" id="cfmTitle">확인</span><button class="x" id="cfmX" title="닫기">×</button></div>
938
+ <div class="sheet-note" id="cfmMsg"></div>
939
+ <div class="sheet-body"><div class="sheet-tradeoff" id="cfmSub"></div></div>
940
+ <div class="pick-f">
941
+ <button type="button" class="home" id="cfmCancel">취소</button>
942
+ <button type="button" class="go" id="cfmOk">확인</button>
943
+ </div>
944
+ </div>
945
+ </div>
946
+
931
947
  <div class="modal" id="palette">
932
948
  <div class="pal">
933
949
  <input id="palInput" placeholder="이동·명령 검색 (⌘K) · 세션·run·프로젝트·명령" autocomplete="off" spellcheck="false" />
@@ -965,6 +981,37 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
965
981
  t.classList.add('show'); clearTimeout(toast._h);
966
982
  toast._h=setTimeout(function(){ t.classList.remove('show'); }, act?7000:2600);
967
983
  }
984
+ // ── 확인 대화상자 (보드의 confirmUI 와 같은 계약: promise 로 true/false) ──
985
+ // 결정은 여기서, 결과는 토스트에서. 네이티브 confirm 은 쓰지 않는다(DESIGN.md) — 뼈대는
986
+ // 이미 있는 .pick 시트 그대로라 새 컴포넌트도, 새 색도 없다.
987
+ var cfmResolve = null;
988
+ function confirmUI(message, opts){
989
+ opts = opts || {};
990
+ $('cfmTitle').textContent = opts.title || '확인';
991
+ $('cfmMsg').textContent = message;
992
+ $('cfmSub').textContent = opts.sub || '';
993
+ $('cfmOk').textContent = opts.okLabel || '확인';
994
+ $('cfmModal').classList.add('on');
995
+ setTimeout(function(){ try{ $('cfmOk').focus(); }catch(e){} }, 30);
996
+ return new Promise(function(resolve){ cfmResolve = resolve; });
997
+ }
998
+ function cfmClose(v){
999
+ $('cfmModal').classList.remove('on');
1000
+ if (cfmResolve){ var r=cfmResolve; cfmResolve=null; r(v); }
1001
+ }
1002
+ $('cfmOk').addEventListener('click', function(){ cfmClose(true); });
1003
+ $('cfmCancel').addEventListener('click', function(){ cfmClose(false); });
1004
+ $('cfmX').addEventListener('click', function(){ cfmClose(false); });
1005
+ $('cfmModal').addEventListener('click', function(e){ if(e.target===$('cfmModal')) cfmClose(false); });
1006
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape' && $('cfmModal').classList.contains('on')){ e.preventDefault(); cfmClose(false); } });
1007
+ // dry 머지 게이트 (v5.28 H3) — 모의 한 번은 말없이 지나가지 않는다.
1008
+ function confirmDryMerge(n){
1009
+ return confirmUI('이 run 은 dry (모의)입니다 — 정말 머지할까요?', {
1010
+ title: 'dry run 머지',
1011
+ sub: (n>1 ? '고른 '+n+'개가 dry 입니다. ' : '') + 'dry run 의 변경은 모의 스트림이 만든 것입니다 — 진짜 작업이 아닙니다.',
1012
+ okLabel: '그래도 머지',
1013
+ });
1014
+ }
968
1015
  var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
969
1016
  function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
970
1017
  // 라이브 상태 — run 의 최신 이벤트에서 "지금 뭐 하는지"(도구명/사고)를 뽑는다. 실행 중일 때만.
@@ -993,6 +1040,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
993
1040
  function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
994
1041
  function runIsSession(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; var rp=t&&repoById[t.repoId]; return !!(rp&&rp.kind==='sessions'); }
995
1042
 
1043
+ // ── dry(모의) run 표식 (v5.28 H2) ──
1044
+ // 판정은 한 줄뿐이다: **real===false 인 것만** dry 다. undefined(마이그레이션 이전·아직 안 온
1045
+ // 델타)는 모르는 것이지 dry 가 아니다 — 모르는 것을 모의라고 부르면 그게 거짓 경보다.
1046
+ function isDryRun(r){ return !!r && r.real===false; }
1047
+ function dryChip(r){ return isDryRun(r) ? '<span class="dryc" title="dry run — 모의 스트림이 만든 변경입니다(진짜 에이전트가 아닙니다)">dry</span>' : ''; }
996
1048
  // ── 에이전트 상태 (v5.28 A4) ──
997
1049
  // 서버가 터미널 출력에서 읽어 보내준 거친 상태만 담는다. 여기 없는 run 은 점이 없다 —
998
1050
  // 클라이언트는 상태를 짐작하지도, 전이를 지어내지도 않는다(spec A6). 'unknown'(첫 출력 전)도 담지 않는다.
@@ -1288,6 +1340,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1288
1340
  var tip = 'r'+r.id+' · '+r.status+(r.inPlace?' · repo 체크아웃에서 직접 작업(격리 없음)':'');
1289
1341
  // 점 하나에 두 층 — 에이전트 상태가 살아 있으면 그게 이기고, 없으면 지금까지의 run 상태 그대로.
1290
1342
  s += '<div class="tnode run'+open+'" data-run="'+r.id+'" title="'+esc(tip)+'"><span class="st '+esc(r.status)+' '+asClass(agentStateOf(r.id))+'"></span>'
1343
+ + dryChip(r)
1291
1344
  + '<span class="n">'+esc(runTreeName(r.id))+'</span>'+(act?'<span class="ract">'+esc(act)+'</span>':'')
1292
1345
  + '<span class="meta">r'+r.id+esc(ip)+'</span></div>';
1293
1346
  });
@@ -3516,6 +3569,7 @@ ${HUMANIZE_JS}
3516
3569
  + '<span class="anm">'+esc(runLabel(runId))+'</span>'
3517
3570
  + '<span class="apath">'+esc(actPath(runId))+'</span>'
3518
3571
  + '<span class="act-chip '+asClass(s)+'" data-role="actchip">'+esc(s)+'</span>'
3572
+ + dryChip(runById[runId])
3519
3573
  + '<span class="ameta">'+esc(actMeta(runId))+'</span>'
3520
3574
  + '</div>'
3521
3575
  + '<div class="act-now" data-role="actnow">'+esc(actNowText(runId))+'</div>'
@@ -3765,12 +3819,26 @@ ${HUMANIZE_JS}
3765
3819
  + '<span class="vout" data-vout="'+r.id+'" title="클릭=전체 출력">'+esc(txt+(out?' — '+out:''))+'</span>'
3766
3820
  + '<button class="rv-reverify" data-reverify="'+r.id+'">re-verify</button></div>';
3767
3821
  }
3822
+ // 머지된 승자의 base 검증(J2) — 내려앉은 그 호흡의 판정을 머지를 누른 그 자리에 남긴다.
3823
+ // 서버가 머지 응답에 실어 보낸 것만 그린다. 실패는 보고일 뿐, 머지는 그대로 서 있다.
3824
+ var mv = mergeVerifyById[r.id]; var mvline = '';
3825
+ if (mv && mv.status){
3826
+ mvline = '<div class="rv-verify" data-role="mverify">'+(vbadge(mv.status)||'<span class="vbadge error">· verify</span>')
3827
+ + '<span class="vout" data-mvout="'+r.id+'" title="클릭=전체 출력">'
3828
+ + esc('merged · verify: '+mv.status+(mv.status==='pass'?'':' — 머지는 됐지만 검증 실패 — 로그 확인'))+'</span></div>';
3829
+ } else if (mv){
3830
+ mvline = '<div class="rv-verify" data-role="mverify"><span class="vout" data-vnudge="1" title="위 verify 칸으로">'
3831
+ + esc('merged · 검증 명령이 없어요 — repo 설정에서 verifyCmd 를 지정하면 승자를 자동 검증합니다')+'</span></div>';
3832
+ }
3768
3833
  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>'
3769
3834
  + '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
3835
+ // dry 후보는 열에서부터 표가 난다 — 나란히 놓고 고르는 자리가 실수가 나는 자리였다(H3).
3836
+ + dryChip(r)
3770
3837
  + '<span class="stat">'+esc(st)+'</span>'
3771
- + '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
3838
+ + '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+(isDryRun(r)?' data-dry="1"':'')+((r.status==='merged'||!mergeable)?' disabled':'')
3772
3839
  + (noIso?' title="체크아웃에서 직접 작업 — 머지할 것이 없습니다(아래는 커밋 안 한 변경)"':(gated?' title="검증 미통과 — 그래도 머지"':''))+'>'+mlabel+'</button></div>'
3773
3840
  + vline
3841
+ + mvline
3774
3842
  + '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
3775
3843
  col._vout = { text: r.verifyOutput||'' };
3776
3844
  host.appendChild(col);
@@ -3780,7 +3848,15 @@ ${HUMANIZE_JS}
3780
3848
  }catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
3781
3849
  }
3782
3850
  var voutById = {};
3851
+ /* 머지 응답이 실어 온 base 검증 결과(v5.28 J2). 다시 그려도 그 열에 남아 있게 여기 둔다. */
3852
+ var mergeVerifyById = {};
3783
3853
  $('rvCols').addEventListener('click', async function(e){
3854
+ var mo = e.target.closest('[data-mvout]');
3855
+ if (mo){ var mvv = mergeVerifyById[mo.dataset.mvout]; var mt = (mvv&&mvv.output)||'';
3856
+ toast(mt? mt.slice(-600) : '검증 출력 없음'); return; }
3857
+ var nd = e.target.closest('[data-vnudge]');
3858
+ if (nd){ $('rvVcmd').focus(); $('rvVcmd').select();
3859
+ toast('verify 명령을 적고 save — 다음 머지부터 승자를 자동 검증합니다'); return; }
3784
3860
  var vo = e.target.closest('[data-vout]');
3785
3861
  if (vo){ var t = voutById[vo.dataset.vout]||''; toast(t? t.slice(-600) : '검증 출력 없음'); return; }
3786
3862
  var rv = e.target.closest('[data-reverify]');
@@ -3789,11 +3865,20 @@ ${HUMANIZE_JS}
3789
3865
  catch(err){ toast('재검증 실패: '+err); rv.disabled=false; rv.textContent='re-verify'; }
3790
3866
  return; }
3791
3867
  var b = e.target.closest('[data-merge]'); if (!b) return;
3792
- var rid = b.dataset.merge; var prev=b.textContent; b.disabled=true; b.textContent='merging…';
3868
+ var rid = b.dataset.merge;
3869
+ // 모의가 진짜인 척 base 로 넘어가지 않게 — 아는 dry 에만 한 번 묻는다(H3).
3870
+ if (b.getAttribute('data-dry')==='1' && !(await confirmDryMerge(1))) return;
3871
+ var prev=b.textContent; b.disabled=true; b.textContent='merging…';
3793
3872
  try{
3794
3873
  var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
3795
3874
  var j = await res.json().catch(function(){return{};});
3796
- if (res.ok){ toast('r'+rid+' merge 완료'); await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId); }
3875
+ if (res.ok){
3876
+ // 검증은 머지 다음의 한 호흡이지 머지의 조건이 아니다 — 실패해도 되돌리지 않고 말만 한다(J1/J3).
3877
+ mergeVerifyById[rid] = (j && j.verify) || { status:'', output:'' };
3878
+ var mvs = mergeVerifyById[rid].status;
3879
+ toast('r'+rid+' merge 완료'+(mvs ? ' · verify: '+mvs : ' · 검증 명령 없음'));
3880
+ await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId);
3881
+ }
3797
3882
  else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent=prev; }
3798
3883
  }catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent=prev; }
3799
3884
  });
package/src/db/index.ts CHANGED
@@ -115,4 +115,6 @@ export async function ensureSchema(): Promise<void> {
115
115
  try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
116
116
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN title TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
117
117
  try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
118
+ // DEFAULT 1 = 기존 run 은 real 로 남는다. 모르는 과거를 dry 로 칠하지 않기 위한 기본값이다(v5.28 H1).
119
+ try { await client.execute('ALTER TABLE agent_runs ADD COLUMN real INTEGER NOT NULL DEFAULT 1'); } catch { /* exists */ }
118
120
  }
package/src/db/schema.ts CHANGED
@@ -93,6 +93,10 @@ export const agentRuns = sqliteTable('agent_runs', {
93
93
  sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
94
94
  prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
95
95
  model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
96
+ // v5.28 Part H — 이 run 이 진짜 에이전트로 돌았나(real), 모의 스트림이었나(dry).
97
+ // 기본 true(real)는 **의도**다: 이 컬럼이 생기기 전 run 은 알 수 없고, 모르는 것을 dry 라
98
+ // 부르면 거짓 경보가 된다. 아는 dry 만 표시한다 — dry 를 배지하되, dry 를 추측하지 않는다.
99
+ real: integer('real', { mode: 'boolean' }).notNull().default(true),
96
100
  filesChanged: integer('files_changed').notNull().default(0),
97
101
  agentPid: integer('agent_pid').notNull().default(0), // detached sh pgid — 재시작 후 생존 판정/stop
98
102
  logOffset: integer('log_offset').notNull().default(0), // 내구 로그에서 tail 이 소비한 바이트(재-adopt 시 여기부터)
@@ -344,10 +344,10 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
344
344
  const runIds: number[] = [];
345
345
  for (let i = 0; i < n; i++) {
346
346
  const rIns = await db.insert(agentRuns).values({
347
- taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending',
347
+ taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending', real,
348
348
  }).returning();
349
349
  const run = rIns[0]!;
350
- broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
350
+ broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, real, branch: '', filesChanged: 0 });
351
351
  void launchRun(run.id, real);
352
352
  runIds.push(run.id);
353
353
  }
@@ -484,7 +484,9 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
484
484
  const session = `coxpit-r${runId}`;
485
485
 
486
486
  try {
487
- await setRun(runId, { status: 'preparing', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
487
+ // real 여기서 각인한다 명령을 고르는 값이 run 의 사실이다(v5.28 H1).
488
+ // 'preparing' 에 실어 두면 worktree 단계에서 넘어져도 "이 run 은 모의였다"가 남는다.
489
+ await setRun(runId, { status: 'preparing', real: !!useReal, branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
488
490
 
489
491
  // 1) worktree 생성(격리 브랜치) — in-place 는 건너뛴다(격리가 없는 것이 요점).
490
492
  if (!inPlace) {
@@ -607,6 +609,17 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
607
609
  void notifySettle(runId, status, filesChanged, exitSummary);
608
610
  }
609
611
 
612
+ /**
613
+ * verify 러너 — 명령 한 번, 꼬리 한 줌. 어디서 돌리든 판정 규칙은 하나여야 해서
614
+ * verifyRun(run 의 worktree)·verifyBase(머지된 base)가 이 함수를 같이 쓴다.
615
+ */
616
+ async function runVerifyCmd(cmd: string, cwd: string, machine: MachineTarget): Promise<{ status: string; output: string }> {
617
+ const r = await runShellOn(machine, `cd ${shq(cwd)} && ( ${cmd} )`, 180000);
618
+ const merged = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
619
+ const output = merged.length > 6000 ? '…' + merged.slice(-6000) : merged;
620
+ return { status: r.ok ? 'pass' : r.code === -1 ? 'error' : 'fail', output };
621
+ }
622
+
610
623
  /**
611
624
  * Verify in-loop — repo.verifyCmd 를 run 의 worktree 에서 실행해 pass/fail 을 기록.
612
625
  * verifyCmd 미설정이면 상태를 비우고 no-op. 정착 훅이 자동 호출(done+변경), 수동 재검증도 지원.
@@ -626,12 +639,34 @@ export async function verifyRun(runId: number): Promise<{ ok: boolean; status: s
626
639
  return { ok: false, status: 'error', detail: 'worktree missing' };
627
640
  }
628
641
  await setRun(runId, { verifyStatus: 'running', verifyOutput: '' });
629
- const r = await runShellOn(ctx.machine, `cd ${shq(run.worktreePath)} && ( ${cmd} )`, 180000);
630
- const merged = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
631
- const tail = merged.length > 6000 ? '…' + merged.slice(-6000) : merged;
632
- const status = r.ok ? 'pass' : r.code === -1 ? 'error' : 'fail';
633
- await setRun(runId, { verifyStatus: status, verifyOutput: tail });
634
- return { ok: true, status };
642
+ const v = await runVerifyCmd(cmd, run.worktreePath, ctx.machine);
643
+ await setRun(runId, { verifyStatus: v.status, verifyOutput: v.output });
644
+ return { ok: true, status: v.status };
645
+ }
646
+
647
+ /**
648
+ * 머지된 base 검증(v5.28 J1) — repo.verifyCmd 를 **repo.path**(머지가 막 내려앉은 기본 브랜치)에서
649
+ * 돌린다. 승자가 내려앉은 그 호흡에 같은 명령을 돌려 pass/fail 을 그 자리에서 말하기 위한 것이라,
650
+ * 지나간 worktree 가 아니라 base 를 본다.
651
+ * - verifyCmd 가 비어 있으면 아무 명령도 추측하지 않고 no-op(status '').
652
+ * - best-effort — 실패는 보고일 뿐, 머지를 되돌리지 않는다.
653
+ */
654
+ export async function verifyBase(repoId: number): Promise<{ status: string; output: string }> {
655
+ const none = { status: '', output: '' };
656
+ const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
657
+ const repo = rp[0];
658
+ if (!repo) return none;
659
+ const cmd = (repo.verifyCmd ?? '').trim();
660
+ if (!cmd) return none;
661
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
662
+ const m = mr[0];
663
+ if (!m) return none;
664
+ const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
665
+ try {
666
+ return await runVerifyCmd(cmd, repo.path, machine);
667
+ } catch (e) {
668
+ return { status: 'error', output: String(e).slice(0, 300) };
669
+ }
635
670
  }
636
671
 
637
672
  /**
@@ -1267,9 +1302,9 @@ export async function launchGroupTask(
1267
1302
  const machineId = rp[0]!.machineId;
1268
1303
  const tIns = await db.insert(tasks).values({ repoId, title: title.slice(0, 140), prompt, groupId }).returning();
1269
1304
  const task = tIns[0]!;
1270
- const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending' }).returning();
1305
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending', real }).returning();
1271
1306
  const run = rIns[0]!;
1272
- broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
1307
+ broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, real, branch: '', filesChanged: 0 });
1273
1308
  void launchRun(run.id, real);
1274
1309
  return { id: task.id, title: task.title, runId: run.id };
1275
1310
  }
@@ -1512,6 +1547,8 @@ export interface IntegrateResult {
1512
1547
  detail?: string;
1513
1548
  integrationTaskId?: number;
1514
1549
  integrationRunId?: number;
1550
+ /** 머지된 건에 한해 base 검증 결과(v5.28 J1). verifyCmd 가 없으면 status ''. */
1551
+ verify?: { status: string; output: string };
1515
1552
  }
1516
1553
 
1517
1554
  /**
@@ -1529,7 +1566,13 @@ export async function integrateRuns(runIds: number[], real?: boolean): Promise<I
1529
1566
  if (run.status === 'merged') { results.push({ runId: id, status: 'skipped', detail: 'already merged' }); continue; }
1530
1567
 
1531
1568
  const m = await mergeRun(id);
1532
- if (m.ok) { results.push({ runId: id, status: 'merged' }); continue; }
1569
+ if (m.ok) {
1570
+ // 내려앉은 그 호흡에 base 를 검증한다(J1) — 실패해도 머지는 그대로 서 있고, 보고만 된다.
1571
+ const mc = await loadContext(id);
1572
+ const verify = mc ? await verifyBase(mc.repoId) : { status: '', output: '' };
1573
+ results.push({ runId: id, status: 'merged', verify });
1574
+ continue;
1575
+ }
1533
1576
  if (!m.conflict) { results.push({ runId: id, status: 'skipped', detail: m.detail }); continue; }
1534
1577
 
1535
1578
  // 충돌 → 통합 태스크 자동 발사 (에이전트가 머지를 대신 푼다)
@@ -1544,9 +1587,9 @@ export async function integrateRuns(runIds: number[], real?: boolean): Promise<I
1544
1587
  `Do not modify files unrelated to the conflicts.`;
1545
1588
  const tIns = await db.insert(tasks).values({ repoId: ctx.repoId, title, prompt }).returning();
1546
1589
  const newTask = tIns[0]!;
1547
- const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending' }).returning();
1590
+ const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending', real: real ?? true }).returning();
1548
1591
  const newRun = rIns[0]!;
1549
- broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, branch: '', filesChanged: 0 });
1592
+ broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, real: real ?? true, branch: '', filesChanged: 0 });
1550
1593
  void launchRun(newRun.id, real ?? true);
1551
1594
  results.push({ runId: id, status: 'conflict', detail: m.detail, integrationTaskId: newTask.id, integrationRunId: newRun.id });
1552
1595
  }
package/src/server.ts CHANGED
@@ -21,7 +21,7 @@ import { db } from './db';
21
21
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
22
22
  import { BOOKMARKLET_JS } from './design';
23
23
  import { runShellOn, shq } from './exec';
24
- import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getRunPwd, getSessionChat } from './orchestrator';
24
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, verifyBase, openSessionAt, deleteSession, getScrollback, getRunPwd, getSessionChat } from './orchestrator';
25
25
  import { openTerm } from './term';
26
26
  import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates, spottedPorts } from './agentstate';
27
27
  import { scanListeners, scanPort, killPid, dropCache as dropListenerCache } from './procscan';
@@ -383,7 +383,8 @@ export async function buildServer(): Promise<FastifyInstance> {
383
383
  machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
384
384
  runs: rns.map((r) => {
385
385
  const sig = noopSignal(r.status, r.filesChanged, r.exitSummary, taskOut.get(r.taskId) ?? '[]');
386
- return { ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
386
+ // real 언제나 불리언으로 나간다 클라이언트는 real===false 하나만 보고 dry 칩을 그린다.
387
+ return { ...r, real: !!r.real, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
387
388
  }),
388
389
  counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
389
390
  // 지금 터미널이 붙어 있는 run 의 에이전트 상태(runId → {state,detail,ts}).
@@ -500,7 +501,7 @@ export async function buildServer(): Promise<FastifyInstance> {
500
501
  taskId: t.id, title: t.title, repoName: repoName.get(t.repoId) ?? '?',
501
502
  groupTitle: t.groupId != null ? grpTitle.get(t.groupId) ?? null : null,
502
503
  closedAt: t.closedAt ? Math.floor(t.closedAt.getTime() / 1000) : (t.createdAt ? Math.floor(t.createdAt.getTime() / 1000) : 0),
503
- runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model })),
504
+ runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model, real: !!r.real })),
504
505
  });
505
506
  }
506
507
  // status 필터가 있으면 total 은 근사(페이지 내 필터) — UI 는 rows 로만 판단하니 total0 유지.
@@ -958,7 +959,7 @@ export async function buildServer(): Promise<FastifyInstance> {
958
959
  const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
959
960
  if (!tr[0]) return reply.code(404).send({ error: 'not found' });
960
961
  const runs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
961
- return { task: tr[0], runs };
962
+ return { task: tr[0], runs: runs.map((r) => ({ ...r, real: !!r.real })) };
962
963
  });
963
964
 
964
965
  // 태스크 이름 변경(=세션 이름 변경) + v6.0 S2 승격(repoId 재부모화).
@@ -1089,16 +1090,19 @@ export async function buildServer(): Promise<FastifyInstance> {
1089
1090
  });
1090
1091
  }
1091
1092
  }
1093
+ // dry/real 은 run 의 성질이다 — 만들 때부터 각인한다(v5.28 H1). body 가 말이 없으면
1094
+ // launchRun 이 쓰게 될 그 기본값(config.agent.real)을 그대로 쓴다: 행과 명령이 갈리면 안 된다.
1095
+ const useReal = b.real === undefined ? config.agent.real : !!b.real;
1092
1096
  const created: Array<typeof agentRuns.$inferSelect> = [];
1093
1097
  for (let i = 0; i < count; i++) {
1094
1098
  const ins = await db.insert(agentRuns)
1095
- .values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending' })
1099
+ .values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending', real: useReal })
1096
1100
  .returning();
1097
1101
  created.push(ins[0]!);
1098
1102
  }
1099
1103
  // 보드가 taskId 를 알도록 생성 브로드캐스트 후 백그라운드 시작.
1100
1104
  for (const r of created) {
1101
- broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, branch: '', filesChanged: 0 });
1105
+ broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, real: useReal, branch: '', filesChanged: 0 });
1102
1106
  void launchRun(r.id, b.real);
1103
1107
  }
1104
1108
  return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
@@ -1124,7 +1128,7 @@ export async function buildServer(): Promise<FastifyInstance> {
1124
1128
  const runsOut = [];
1125
1129
  for (const r of trs) {
1126
1130
  const d = await getRunDiff(r.id);
1127
- runsOut.push({ ...r, diff: d.ok ? d.diff : '', stat: d.ok ? d.stat : d.stat });
1131
+ runsOut.push({ ...r, real: !!r.real, diff: d.ok ? d.diff : '', stat: d.ok ? d.stat : d.stat });
1128
1132
  }
1129
1133
  return { task: tr[0], runs: runsOut };
1130
1134
  });
@@ -1165,7 +1169,7 @@ export async function buildServer(): Promise<FastifyInstance> {
1165
1169
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1166
1170
  if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1167
1171
  const events = await db.select().from(agentEvents).where(eq(agentEvents.runId, id));
1168
- return { run: rr[0], events };
1172
+ return { run: { ...rr[0], real: !!rr[0].real }, events };
1169
1173
  });
1170
1174
 
1171
1175
  // v6.0 T4 — run 의 역할 이름 변경(탭 더블클릭). title 만 받는다.
@@ -1226,7 +1230,11 @@ export async function buildServer(): Promise<FastifyInstance> {
1226
1230
  if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1227
1231
  const res = await mergeRun(id);
1228
1232
  if (!res.ok) return reply.code(409).send(res);
1229
- return res;
1233
+ // 내려앉은 그 호흡에 머지된 base 를 검증한다(v5.28 J1). verifyCmd 가 없으면 status ''(no-op),
1234
+ // 실패해도 머지는 되돌리지 않는다 — 보고만 한다.
1235
+ const tr = await db.select().from(tasks).where(eq(tasks.id, rr[0].taskId)).limit(1);
1236
+ const verify = tr[0] ? await verifyBase(tr[0].repoId) : { status: '', output: '' };
1237
+ return { ...res, verify };
1230
1238
  });
1231
1239
 
1232
1240
  // 후속 지시(steer) — 정착한 run 을 같은 세션(--resume)·같은 worktree 로 계속.
@@ -1445,6 +1453,7 @@ export async function buildServer(): Promise<FastifyInstance> {
1445
1453
  const runs = g.rows.map(({ run, task }) => ({
1446
1454
  runId: run.id, taskId: task.id, title: task.title, status: run.status,
1447
1455
  agent: run.agent, model: run.model, branch: run.branch, filesChanged: run.filesChanged,
1456
+ real: !!run.real,
1448
1457
  live: isRunLive(run.id), steerable: isSteerable(run),
1449
1458
  // 수렴 콕핏 결정 행용: 태스크 닫힘 여부 + worktree 생존(터미널 가드·머지 가능성 판단).
1450
1459
  taskStatus: task.status, hasWorktree: !!run.worktreePath,