coxpit 6.2.0 → 6.3.2

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.2",
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",
@@ -0,0 +1,29 @@
1
+ // "지금 뭐 하고 있나" — 코크핏과 HUD 가 **같은 한 벌**을 쓴다(사본 금지, humanize.ts 전례).
2
+ // 코크핏 활동 페인(v5.28 E)의 Now 줄과 HUD 상세(v5.28 K5)가 같은 규칙으로 같은 말을 해야 하므로,
3
+ // 클라이언트 JS 를 여기 한 곳에 두고 두 페이지의 <script> 에 그대로 끼워 넣는다.
4
+ // 전제: 끼워 넣는 쪽이 runById(맵)를 들고 있다 — 이 함수는 그것만 읽는다.
5
+ // 문자열 안의 이스케이프는 **클라이언트 기준**이다: \\n 은 클라이언트의 \n 이 된다.
6
+ export const ACTIVITY_JS = `/* 라이브 상태 — run 의 최신 이벤트에서 "지금 뭐 하는지"(도구명/사고)를 뽑는다. 실행 중일 때만. */
7
+ function latestActivity(runId){
8
+ var r=runById[runId]; if(!r) return '';
9
+ if(r.status!=='running' && r.status!=='pending') return '';
10
+ var evs=r.events||[];
11
+ for(var i=evs.length-1;i>=0;i--){
12
+ var e=evs[i], k=e.kind;
13
+ if(k==='steer') return 'steer'; if(k==='ask') return 'asking';
14
+ if(k!=='assistant') continue;
15
+ try{ var o=JSON.parse(e.payload);
16
+ var c=o&&o.message&&o.message.content;
17
+ if(c&&c.length){ for(var j=c.length-1;j>=0;j--){ if(c[j].type==='tool_use') return c[j].name||'tool'; if(c[j].type==='text'&&(c[j].text||'').trim()) return 'thinking'; } }
18
+ else if(o&&o.text) return 'thinking';
19
+ }catch(_){}
20
+ }
21
+ return r.status==='pending' ? 'starting' : 'working';
22
+ }
23
+ /* "지금" 한 줄 — 파싱된 스트림에서 온 것만. 아직 아무 이벤트도 없으면 지어내지 않고 starting… 이라 말한다. */
24
+ function actNowText(runId){
25
+ var r=runById[runId]; if(!r) return '';
26
+ var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
27
+ if(!(r.events||[]).length) return live ? 'starting…' : String(r.status||'');
28
+ return latestActivity(runId) || String(r.status||'');
29
+ }`;
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
@@ -2,6 +2,7 @@
2
2
  // 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
3
3
  // Phase 2 = 워크스페이스 트리(/api/fleet 라이브) + 페인 그리드 터미널(오토타일=창분할, 각 페인 /ws/term attach).
4
4
  import { HUMANIZE_JS } from './humanize';
5
+ import { ACTIVITY_JS } from './activity';
5
6
 
6
7
  export const COCKPIT_HTML = /* html */ `<!doctype html>
7
8
  <html lang="en">
@@ -329,6 +330,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
329
330
  .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
331
  .leaf-h .pane-act:empty{display:none}
331
332
  .tnode.run .ract{margin-left:6px;font-size:10px;color:var(--brand);opacity:.85}
333
+ /* v5.28 H2 — dry(모의) run 표식. 낱말 하나("dry")를 기존 작은 칩 모양에 얹을 뿐,
334
+ 색은 하나도 새로 만들지 않는다(--faint 글자 + --line-hi 테두리). 진짜 run 에는 아무것도 안 붙는다. */
335
+ .dryc{flex:none;font-family:var(--mono);font-size:9px;line-height:1.7;letter-spacing:.04em;
336
+ color:var(--faint);border:1px solid var(--line-hi);border-radius:4px;padding:0 4px;white-space:nowrap}
332
337
  .leaf-h .zoom{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
333
338
  .leaf-h .zoom:hover{color:var(--brand)}
334
339
  .leaf-h .pact{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
@@ -928,6 +933,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
928
933
  </div>
929
934
  </div>
930
935
 
936
+ <div class="modal" id="cfmModal">
937
+ <div class="pick" style="width:min(460px,92vw)">
938
+ <div class="pick-h"><span class="t" id="cfmTitle">확인</span><button class="x" id="cfmX" title="닫기">×</button></div>
939
+ <div class="sheet-note" id="cfmMsg"></div>
940
+ <div class="sheet-body"><div class="sheet-tradeoff" id="cfmSub"></div></div>
941
+ <div class="pick-f">
942
+ <button type="button" class="home" id="cfmCancel">취소</button>
943
+ <button type="button" class="go" id="cfmOk">확인</button>
944
+ </div>
945
+ </div>
946
+ </div>
947
+
931
948
  <div class="modal" id="palette">
932
949
  <div class="pal">
933
950
  <input id="palInput" placeholder="이동·명령 검색 (⌘K) · 세션·run·프로젝트·명령" autocomplete="off" spellcheck="false" />
@@ -965,25 +982,41 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
965
982
  t.classList.add('show'); clearTimeout(toast._h);
966
983
  toast._h=setTimeout(function(){ t.classList.remove('show'); }, act?7000:2600);
967
984
  }
985
+ // ── 확인 대화상자 (보드의 confirmUI 와 같은 계약: promise 로 true/false) ──
986
+ // 결정은 여기서, 결과는 토스트에서. 네이티브 confirm 은 쓰지 않는다(DESIGN.md) — 뼈대는
987
+ // 이미 있는 .pick 시트 그대로라 새 컴포넌트도, 새 색도 없다.
988
+ var cfmResolve = null;
989
+ function confirmUI(message, opts){
990
+ opts = opts || {};
991
+ $('cfmTitle').textContent = opts.title || '확인';
992
+ $('cfmMsg').textContent = message;
993
+ $('cfmSub').textContent = opts.sub || '';
994
+ $('cfmOk').textContent = opts.okLabel || '확인';
995
+ $('cfmModal').classList.add('on');
996
+ setTimeout(function(){ try{ $('cfmOk').focus(); }catch(e){} }, 30);
997
+ return new Promise(function(resolve){ cfmResolve = resolve; });
998
+ }
999
+ function cfmClose(v){
1000
+ $('cfmModal').classList.remove('on');
1001
+ if (cfmResolve){ var r=cfmResolve; cfmResolve=null; r(v); }
1002
+ }
1003
+ $('cfmOk').addEventListener('click', function(){ cfmClose(true); });
1004
+ $('cfmCancel').addEventListener('click', function(){ cfmClose(false); });
1005
+ $('cfmX').addEventListener('click', function(){ cfmClose(false); });
1006
+ $('cfmModal').addEventListener('click', function(e){ if(e.target===$('cfmModal')) cfmClose(false); });
1007
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape' && $('cfmModal').classList.contains('on')){ e.preventDefault(); cfmClose(false); } });
1008
+ // dry 머지 게이트 (v5.28 H3) — 모의 한 번은 말없이 지나가지 않는다.
1009
+ function confirmDryMerge(n){
1010
+ return confirmUI('이 run 은 dry (모의)입니다 — 정말 머지할까요?', {
1011
+ title: 'dry run 머지',
1012
+ sub: (n>1 ? '고른 '+n+'개가 dry 입니다. ' : '') + 'dry run 의 변경은 모의 스트림이 만든 것입니다 — 진짜 작업이 아닙니다.',
1013
+ okLabel: '그래도 머지',
1014
+ });
1015
+ }
968
1016
  var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
969
1017
  function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
970
- // 라이브 상태 run 최신 이벤트에서 "지금 하는지"(도구명/사고) 뽑는다. 실행 중일 때만.
971
- function latestActivity(runId){
972
- var r=runById[runId]; if(!r) return '';
973
- if(r.status!=='running' && r.status!=='pending') return '';
974
- var evs=r.events||[];
975
- for(var i=evs.length-1;i>=0;i--){
976
- var e=evs[i], k=e.kind;
977
- if(k==='steer') return 'steer'; if(k==='ask') return 'asking';
978
- if(k!=='assistant') continue;
979
- try{ var o=JSON.parse(e.payload);
980
- var c=o&&o.message&&o.message.content;
981
- if(c&&c.length){ for(var j=c.length-1;j>=0;j--){ if(c[j].type==='tool_use') return c[j].name||'tool'; if(c[j].type==='text'&&(c[j].text||'').trim()) return 'thinking'; } }
982
- else if(o&&o.text) return 'thinking';
983
- }catch(_){}
984
- }
985
- return r.status==='pending' ? 'starting' : 'working';
986
- }
1018
+ // 라이브 상태 + "지금" HUD(v5.28 K)와 **같은 한 벌**(src/activity.ts). 사본을 만들지 않는다.
1019
+ ${ACTIVITY_JS}
987
1020
 
988
1021
  // ── fleet 상태 ──
989
1022
  var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
@@ -993,6 +1026,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
993
1026
  function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
994
1027
  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
1028
 
1029
+ // ── dry(모의) run 표식 (v5.28 H2) ──
1030
+ // 판정은 한 줄뿐이다: **real===false 인 것만** dry 다. undefined(마이그레이션 이전·아직 안 온
1031
+ // 델타)는 모르는 것이지 dry 가 아니다 — 모르는 것을 모의라고 부르면 그게 거짓 경보다.
1032
+ function isDryRun(r){ return !!r && r.real===false; }
1033
+ function dryChip(r){ return isDryRun(r) ? '<span class="dryc" title="dry run — 모의 스트림이 만든 변경입니다(진짜 에이전트가 아닙니다)">dry</span>' : ''; }
996
1034
  // ── 에이전트 상태 (v5.28 A4) ──
997
1035
  // 서버가 터미널 출력에서 읽어 보내준 거친 상태만 담는다. 여기 없는 run 은 점이 없다 —
998
1036
  // 클라이언트는 상태를 짐작하지도, 전이를 지어내지도 않는다(spec A6). 'unknown'(첫 출력 전)도 담지 않는다.
@@ -1288,6 +1326,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1288
1326
  var tip = 'r'+r.id+' · '+r.status+(r.inPlace?' · repo 체크아웃에서 직접 작업(격리 없음)':'');
1289
1327
  // 점 하나에 두 층 — 에이전트 상태가 살아 있으면 그게 이기고, 없으면 지금까지의 run 상태 그대로.
1290
1328
  s += '<div class="tnode run'+open+'" data-run="'+r.id+'" title="'+esc(tip)+'"><span class="st '+esc(r.status)+' '+asClass(agentStateOf(r.id))+'"></span>'
1329
+ + dryChip(r)
1291
1330
  + '<span class="n">'+esc(runTreeName(r.id))+'</span>'+(act?'<span class="ract">'+esc(act)+'</span>':'')
1292
1331
  + '<span class="meta">r'+r.id+esc(ip)+'</span></div>';
1293
1332
  });
@@ -3489,13 +3528,7 @@ ${HUMANIZE_JS}
3489
3528
  render();
3490
3529
  }
3491
3530
 
3492
- // "지금" 한 줄 파싱된 스트림에서 것만. 아직 아무 이벤트도 없으면 지어내지 않고 starting… 이라 말한다.
3493
- function actNowText(runId){
3494
- var r=runById[runId]; if(!r) return '';
3495
- var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
3496
- if(!(r.events||[]).length) return live ? 'starting…' : String(r.status||'');
3497
- return latestActivity(runId) || String(r.status||'');
3498
- }
3531
+ // "지금" 한 줄(actNowText)은 ACTIVITY_JS 산다 HUD 같은 벌.
3499
3532
  function actPath(runId){
3500
3533
  var r=runById[runId]; if(!r) return '';
3501
3534
  var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
@@ -3516,6 +3549,7 @@ ${HUMANIZE_JS}
3516
3549
  + '<span class="anm">'+esc(runLabel(runId))+'</span>'
3517
3550
  + '<span class="apath">'+esc(actPath(runId))+'</span>'
3518
3551
  + '<span class="act-chip '+asClass(s)+'" data-role="actchip">'+esc(s)+'</span>'
3552
+ + dryChip(runById[runId])
3519
3553
  + '<span class="ameta">'+esc(actMeta(runId))+'</span>'
3520
3554
  + '</div>'
3521
3555
  + '<div class="act-now" data-role="actnow">'+esc(actNowText(runId))+'</div>'
@@ -3765,12 +3799,26 @@ ${HUMANIZE_JS}
3765
3799
  + '<span class="vout" data-vout="'+r.id+'" title="클릭=전체 출력">'+esc(txt+(out?' — '+out:''))+'</span>'
3766
3800
  + '<button class="rv-reverify" data-reverify="'+r.id+'">re-verify</button></div>';
3767
3801
  }
3802
+ // 머지된 승자의 base 검증(J2) — 내려앉은 그 호흡의 판정을 머지를 누른 그 자리에 남긴다.
3803
+ // 서버가 머지 응답에 실어 보낸 것만 그린다. 실패는 보고일 뿐, 머지는 그대로 서 있다.
3804
+ var mv = mergeVerifyById[r.id]; var mvline = '';
3805
+ if (mv && mv.status){
3806
+ mvline = '<div class="rv-verify" data-role="mverify">'+(vbadge(mv.status)||'<span class="vbadge error">· verify</span>')
3807
+ + '<span class="vout" data-mvout="'+r.id+'" title="클릭=전체 출력">'
3808
+ + esc('merged · verify: '+mv.status+(mv.status==='pass'?'':' — 머지는 됐지만 검증 실패 — 로그 확인'))+'</span></div>';
3809
+ } else if (mv){
3810
+ mvline = '<div class="rv-verify" data-role="mverify"><span class="vout" data-vnudge="1" title="위 verify 칸으로">'
3811
+ + esc('merged · 검증 명령이 없어요 — repo 설정에서 verifyCmd 를 지정하면 승자를 자동 검증합니다')+'</span></div>';
3812
+ }
3768
3813
  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
3814
  + '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
3815
+ // dry 후보는 열에서부터 표가 난다 — 나란히 놓고 고르는 자리가 실수가 나는 자리였다(H3).
3816
+ + dryChip(r)
3770
3817
  + '<span class="stat">'+esc(st)+'</span>'
3771
- + '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
3818
+ + '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+(isDryRun(r)?' data-dry="1"':'')+((r.status==='merged'||!mergeable)?' disabled':'')
3772
3819
  + (noIso?' title="체크아웃에서 직접 작업 — 머지할 것이 없습니다(아래는 커밋 안 한 변경)"':(gated?' title="검증 미통과 — 그래도 머지"':''))+'>'+mlabel+'</button></div>'
3773
3820
  + vline
3821
+ + mvline
3774
3822
  + '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
3775
3823
  col._vout = { text: r.verifyOutput||'' };
3776
3824
  host.appendChild(col);
@@ -3780,7 +3828,15 @@ ${HUMANIZE_JS}
3780
3828
  }catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
3781
3829
  }
3782
3830
  var voutById = {};
3831
+ /* 머지 응답이 실어 온 base 검증 결과(v5.28 J2). 다시 그려도 그 열에 남아 있게 여기 둔다. */
3832
+ var mergeVerifyById = {};
3783
3833
  $('rvCols').addEventListener('click', async function(e){
3834
+ var mo = e.target.closest('[data-mvout]');
3835
+ if (mo){ var mvv = mergeVerifyById[mo.dataset.mvout]; var mt = (mvv&&mvv.output)||'';
3836
+ toast(mt? mt.slice(-600) : '검증 출력 없음'); return; }
3837
+ var nd = e.target.closest('[data-vnudge]');
3838
+ if (nd){ $('rvVcmd').focus(); $('rvVcmd').select();
3839
+ toast('verify 명령을 적고 save — 다음 머지부터 승자를 자동 검증합니다'); return; }
3784
3840
  var vo = e.target.closest('[data-vout]');
3785
3841
  if (vo){ var t = voutById[vo.dataset.vout]||''; toast(t? t.slice(-600) : '검증 출력 없음'); return; }
3786
3842
  var rv = e.target.closest('[data-reverify]');
@@ -3789,11 +3845,20 @@ ${HUMANIZE_JS}
3789
3845
  catch(err){ toast('재검증 실패: '+err); rv.disabled=false; rv.textContent='re-verify'; }
3790
3846
  return; }
3791
3847
  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…';
3848
+ var rid = b.dataset.merge;
3849
+ // 모의가 진짜인 척 base 로 넘어가지 않게 — 아는 dry 에만 한 번 묻는다(H3).
3850
+ if (b.getAttribute('data-dry')==='1' && !(await confirmDryMerge(1))) return;
3851
+ var prev=b.textContent; b.disabled=true; b.textContent='merging…';
3793
3852
  try{
3794
3853
  var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
3795
3854
  var j = await res.json().catch(function(){return{};});
3796
- if (res.ok){ toast('r'+rid+' merge 완료'); await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId); }
3855
+ if (res.ok){
3856
+ // 검증은 머지 다음의 한 호흡이지 머지의 조건이 아니다 — 실패해도 되돌리지 않고 말만 한다(J1/J3).
3857
+ mergeVerifyById[rid] = (j && j.verify) || { status:'', output:'' };
3858
+ var mvs = mergeVerifyById[rid].status;
3859
+ toast('r'+rid+' merge 완료'+(mvs ? ' · verify: '+mvs : ' · 검증 명령 없음'));
3860
+ await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId);
3861
+ }
3797
3862
  else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent=prev; }
3798
3863
  }catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent=prev; }
3799
3864
  });
package/src/db/index.ts CHANGED
@@ -8,8 +8,20 @@ const client = createClient({ url: `file:${config.dbPath}` });
8
8
 
9
9
  export const db = drizzle(client, { schema });
10
10
 
11
+ /**
12
+ * 저널 모드 — 로컬 파일 DB 에서만 의미가 있다. WAL 이면 보드 하이드레이션(읽기)이
13
+ * 이벤트 적재(쓰기)를 막지 않고, synchronous=NORMAL 이면 이벤트 INSERT 마다 fsync 를
14
+ * 기다리지 않는다(크래시 시 최근 몇 이벤트 손실 가능 — 감사 로그라 감수한다).
15
+ * 원격 libSQL URL 은 PRAGMA 를 받지 않을 수 있어 **최선노력**이다: 실패해도 부트는 계속된다.
16
+ */
17
+ async function applyPragmas(): Promise<void> {
18
+ try { await client.execute('PRAGMA journal_mode=WAL'); } catch { /* 원격/미지원 */ }
19
+ try { await client.execute('PRAGMA synchronous=NORMAL'); } catch { /* 원격/미지원 */ }
20
+ }
21
+
11
22
  /** 스키마 부트스트랩(멱등). 정식 마이그레이션은 drizzle-kit(추후). */
12
23
  export async function ensureSchema(): Promise<void> {
24
+ await applyPragmas();
13
25
  await client.executeMultiple(`
14
26
  CREATE TABLE IF NOT EXISTS machines (
15
27
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -115,4 +127,14 @@ export async function ensureSchema(): Promise<void> {
115
127
  try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
116
128
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN title TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
117
129
  try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
130
+ // DEFAULT 1 = 기존 run 은 real 로 남는다. 모르는 과거를 dry 로 칠하지 않기 위한 기본값이다(v5.28 H1).
131
+ try { await client.execute('ALTER TABLE agent_runs ADD COLUMN real INTEGER NOT NULL DEFAULT 1'); } catch { /* exists */ }
132
+ // 조인 키 인덱스(멱등) — 없으면 이 여섯 조회가 매번 테이블 전체를 훑는다.
133
+ // ALTER 뒤에 두는 건 의도다: tasks.parent_run_id 는 위에서 붙는 컬럼이다.
134
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_events_run ON agent_events(run_id)'); } catch { /* exists */ }
135
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_runs_task ON agent_runs(task_id)'); } catch { /* exists */ }
136
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_tasks_repo ON tasks(repo_id)'); } catch { /* exists */ }
137
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_run_id)'); } catch { /* exists */ }
138
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_docsnap_run ON doc_snapshots(run_id)'); } catch { /* exists */ }
139
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_sharelinks_run ON share_links(run_id)'); } catch { /* exists */ }
118
140
  }
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 시 여기부터)