coxpit 6.1.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 +1 -1
- package/src/board.ts +118 -71
- package/src/cockpit.ts +548 -52
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +4 -0
- package/src/humanize.ts +62 -0
- package/src/orchestrator.ts +57 -14
- package/src/server.ts +18 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "6.
|
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 데몬이 서빙하는 단일 페이지 플릿 콘솔(빌드 스텝 0, 자가완결).
|
|
2
2
|
// /api/fleet 로 하이드레이트 → /ws 구독 델타 → run 상세(타임라인·diff·터미널)·비교/머지.
|
|
3
3
|
import { ICON_SPRITE, ICON_CSS, ICON_JS_HELPER } from './icons.js';
|
|
4
|
+
import { HUMANIZE_JS } from './humanize';
|
|
4
5
|
|
|
5
6
|
export const BOARD_HTML = /* html */ `<!doctype html>
|
|
6
7
|
<html lang="en">
|
|
@@ -553,6 +554,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
553
554
|
.chip.noop{color:#c9922e;border-color:rgba(201,146,46,.45)}
|
|
554
555
|
.chip.noop.blk{color:#e0955a;border-color:rgba(224,149,90,.6)}
|
|
555
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}
|
|
556
562
|
.meta{display:flex;gap:16px;padding:0 14px 10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
557
563
|
.meta b{color:var(--muted);font-weight:500;font-variant-numeric:tabular-nums}
|
|
558
564
|
.meta .resumable{color:var(--brand);opacity:.75}
|
|
@@ -727,6 +733,12 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
727
733
|
.cmp-h .files{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
728
734
|
.cmp-meta{font-family:var(--mono);font-size:11px;color:var(--faint);padding:8px 14px;border-bottom:1px solid var(--line);
|
|
729
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)}
|
|
730
742
|
.cmp-diff{overflow:auto;padding:11px 14px;flex:1;min-height:0}
|
|
731
743
|
.cmp-f{padding:10px 14px;border-top:1px solid var(--line);display:flex;gap:8px;align-items:center}
|
|
732
744
|
.cmp-f .msg{font-family:var(--mono);font-size:11px;color:var(--muted);flex:1;
|
|
@@ -1327,6 +1339,18 @@ const runs = new Map(); // runId -> run object
|
|
|
1327
1339
|
const tasks = new Map(); // taskId -> task
|
|
1328
1340
|
const groups = new Map(); // groupId -> {id, kind, title}
|
|
1329
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));
|
|
1330
1354
|
// v5.0 rail — 선택된 repo 로 보드 스코프(client-side). null = All repositories.
|
|
1331
1355
|
let selectedRepo = null;
|
|
1332
1356
|
try { const s = localStorage.getItem('coxpit.repo'); selectedRepo = s ? Number(s) : null; } catch {}
|
|
@@ -1479,64 +1503,7 @@ $('cfmOk').addEventListener('click', ()=>cfmClose(true));
|
|
|
1479
1503
|
$('cfmCancel').addEventListener('click', ()=>cfmClose(false));
|
|
1480
1504
|
$('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
|
|
1481
1505
|
|
|
1482
|
-
|
|
1483
|
-
function humanize(e){
|
|
1484
|
-
const kind = e.kind, payload = e.payload;
|
|
1485
|
-
if (kind === 'rate_limit_event') return null;
|
|
1486
|
-
if (kind === 'steer') return { k:'steer', t:'→ '+payload };
|
|
1487
|
-
if (kind === 'ask') return { k:'ask', t:'? '+payload };
|
|
1488
|
-
if (kind === 'sync') return { k:'sync', t:payload };
|
|
1489
|
-
if (kind === 'export'){ try{ const o=JSON.parse(payload); return { k:'export', t:o.copied+' file(s) → '+o.dest }; }catch{ return { k:'export', t:payload }; } }
|
|
1490
|
-
if (kind === 'pr') return { k:'pr', t:payload };
|
|
1491
|
-
if (kind === 'stderr') return { k:'stderr', t:payload };
|
|
1492
|
-
try{
|
|
1493
|
-
const o = JSON.parse(payload);
|
|
1494
|
-
if (o.type === 'system'){
|
|
1495
|
-
if (o.subtype === 'init' || !o.subtype) return { k:'session',
|
|
1496
|
-
t:'started'+(o.model?' · '+String(o.model).replace(/\\u001b\\[[0-9;]*m/g,'')
|
|
1497
|
-
.replace(/\\x1b\\[[0-9;]*m/g,'') : '') };
|
|
1498
|
-
if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
|
|
1499
|
-
return null; // thinking_tokens 등 스트림 잡음
|
|
1500
|
-
}
|
|
1501
|
-
if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
|
|
1502
|
-
if (o.type === 'assistant' && o.message){
|
|
1503
|
-
const parts = [];
|
|
1504
|
-
for (const x of (o.message.content||[])){
|
|
1505
|
-
if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
|
|
1506
|
-
else if (x.type === 'tool_use'){
|
|
1507
|
-
const i = x.input || {};
|
|
1508
|
-
const arg = i.file_path || i.command || i.path || i.pattern || '';
|
|
1509
|
-
parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
return parts.length ? parts : null;
|
|
1513
|
-
}
|
|
1514
|
-
if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
|
|
1515
|
-
if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
|
|
1516
|
-
if (kind === 'meta' && o.subtask) return { k:'swarm', t:'↳ spawned task #'+o.subtask+' — '+String(o.title||'').slice(0,60)+' ('+((o.runs||[]).map(x=>'r'+x).join(' '))+')' };
|
|
1517
|
-
if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
|
|
1518
|
-
return { k:kind, t:payload.slice(0,140) };
|
|
1519
|
-
}catch{
|
|
1520
|
-
// 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
|
|
1521
|
-
// text 조각만 구제하고, 없으면 생략.
|
|
1522
|
-
if (payload.trim().startsWith('{')){
|
|
1523
|
-
const texts = [];
|
|
1524
|
-
const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
|
|
1525
|
-
while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
|
|
1526
|
-
return texts.length ? { k:'said', t:texts.join(' · ') } : null;
|
|
1527
|
-
}
|
|
1528
|
-
return { k:kind, t:payload };
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
function humanLines(events){
|
|
1532
|
-
const out = [];
|
|
1533
|
-
for (const e of (events||[])){
|
|
1534
|
-
const h = humanize(e);
|
|
1535
|
-
if (!h) continue;
|
|
1536
|
-
if (Array.isArray(h)) out.push(...h); else out.push(h);
|
|
1537
|
-
}
|
|
1538
|
-
return out;
|
|
1539
|
-
}
|
|
1506
|
+
${HUMANIZE_JS}
|
|
1540
1507
|
function summarize(kind, payload){
|
|
1541
1508
|
const h = humanize({kind, payload});
|
|
1542
1509
|
if (!h) return '';
|
|
@@ -1569,6 +1536,42 @@ function chipHTML(status){
|
|
|
1569
1536
|
return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
|
|
1570
1537
|
+ '<i></i>'+esc(s)+'</span>';
|
|
1571
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
|
+
}
|
|
1572
1575
|
/* 같은 태스크에 run 이 여럿이면 "run i/n" — 같은 제목 카드가 중복이 아니라 시도로 읽히게 */
|
|
1573
1576
|
function attemptHTML(r){
|
|
1574
1577
|
const sib = [...runs.values()].filter(x=>x.taskId===r.taskId).sort((a,b)=>a.id-b.id);
|
|
@@ -1748,7 +1751,7 @@ $('dbList').addEventListener('click', (e)=>{
|
|
|
1748
1751
|
// documents include closed/archived runs not in the live map — seed a minimal run+task so the modal renders
|
|
1749
1752
|
if (!runs.has(id)){
|
|
1750
1753
|
const dr = (dbData||[]).find(r=>r.runId===id);
|
|
1751
|
-
if (dr){
|
|
1754
|
+
if (dr && !isSessionTask(dr.taskId)){ // v5.28 I
|
|
1752
1755
|
runs.set(id, { id, taskId: dr.taskId, status: dr.status, branch:'', filesChanged:0, events:[], prUrl: dr.prUrl });
|
|
1753
1756
|
if (!tasks.has(dr.taskId)) tasks.set(dr.taskId, { id: dr.taskId, title: dr.title, status:'closed', repoId:0 });
|
|
1754
1757
|
}
|
|
@@ -1905,7 +1908,9 @@ async function archFetch(reset){
|
|
|
1905
1908
|
try{
|
|
1906
1909
|
const j = await fetch(url).then(x=>x.json());
|
|
1907
1910
|
archTotal = j.total||0;
|
|
1908
|
-
|
|
1911
|
+
// v5.28 I — 닫힌 세션도 보드의 아카이브엔 서지 않는다. 행엔 repoId 가 없어 이름으로 거른다
|
|
1912
|
+
// (페이지 진행은 서버가 준 행 수 그대로 — 거른 것 때문에 다음 장을 놓치지 않게).
|
|
1913
|
+
const rows = (j.rows||[]).filter(row => !sessionRepoNames.has(row.repoName)).map(archRowHTML).join('');
|
|
1909
1914
|
if (reset) $('archList').innerHTML = rows || '<div class="arch-empty">no closed tasks match</div>';
|
|
1910
1915
|
else $('archList').insertAdjacentHTML('beforeend', rows);
|
|
1911
1916
|
archOffset += (j.rows||[]).length;
|
|
@@ -1922,6 +1927,7 @@ $('archList').addEventListener('click', async (e)=>{
|
|
|
1922
1927
|
try{
|
|
1923
1928
|
const j = await fetch('/api/tasks/'+tid).then(x=>x.json());
|
|
1924
1929
|
if (!j.task || !(j.runs||[]).length){ toast('task has no runs', 'error'); return; }
|
|
1930
|
+
if (isSessionRepo(j.task.repoId)) return; // v5.28 I — 세션은 보드가 열지 않는다
|
|
1925
1931
|
tasks.set(j.task.id, j.task);
|
|
1926
1932
|
j.runs.forEach(rn => { if(!runs.has(rn.id)) runs.set(rn.id, { ...rn, events: [] }); });
|
|
1927
1933
|
openModal(j.runs.map(r=>r.id).sort((a,b)=>a-b)[0]);
|
|
@@ -2029,7 +2035,7 @@ function cardHTML(r){
|
|
|
2029
2035
|
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
|
|
2030
2036
|
return '<div class="card'+selCls+'" id="card-'+r.id+'">'
|
|
2031
2037
|
+ '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
|
|
2032
|
-
+ '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)
|
|
2038
|
+
+ '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)+dryChip(r)
|
|
2033
2039
|
+ (r.noop ? '<span class="chip noop'+(r.noopReason==='blocked'?' blk':'')+'" title="'
|
|
2034
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?')
|
|
2035
2041
|
+'">'+(r.noopReason==='blocked'?'blocked':'no changes')+'</span>' : '')
|
|
@@ -2049,6 +2055,7 @@ function cardHTML(r){
|
|
|
2049
2055
|
function flash(id){ const el=$('card-'+id); if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); } }
|
|
2050
2056
|
|
|
2051
2057
|
function upsertRun(patch){
|
|
2058
|
+
if (isSessionRun(patch)) return; // v5.28 I — 세션은 보드 상태에 들어오지 않는다(마지막 문)
|
|
2052
2059
|
const cur = runs.get(patch.runId ?? patch.id) || { id: patch.runId ?? patch.id, events: [] };
|
|
2053
2060
|
Object.assign(cur, patch, { id: cur.id, events: cur.events });
|
|
2054
2061
|
if (patch.runId) cur.id = patch.runId;
|
|
@@ -2057,14 +2064,21 @@ function upsertRun(patch){
|
|
|
2057
2064
|
|
|
2058
2065
|
async function hydrate(){
|
|
2059
2066
|
const r = await fetch('/api/fleet?view=active').then(x=>x.json());
|
|
2060
|
-
|
|
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||[];
|
|
2061
2075
|
if (r.counts){ const n = r.counts.closedTasks||0; $('navArchiveN').textContent = n ? String(n) : ''; }
|
|
2062
2076
|
tasks.clear();
|
|
2063
|
-
(r.tasks||[]).forEach(t => tasks.set(t.id, t));
|
|
2077
|
+
(r.tasks||[]).forEach(t => { if (!isSessionTask(t.id)) tasks.set(t.id, t); });
|
|
2064
2078
|
groups.clear();
|
|
2065
2079
|
(r.groups||[]).forEach(g => groups.set(g.id, g));
|
|
2066
2080
|
runs.clear();
|
|
2067
|
-
(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||[] }); });
|
|
2068
2082
|
if (r.daemon) {
|
|
2069
2083
|
const d = r.daemon;
|
|
2070
2084
|
if (d.port) daemonPort = d.port;
|
|
@@ -2137,6 +2151,7 @@ function railCounts(){
|
|
|
2137
2151
|
// repoId → { active, attn } — /api/fleet active runs 를 client-side 로 그룹
|
|
2138
2152
|
const m = new Map();
|
|
2139
2153
|
for (const r of runs.values()){
|
|
2154
|
+
if (isSessionRun(r)) continue; // v5.28 I — 세션은 세지 않는다(배지도 스코프도)
|
|
2140
2155
|
const t = tasks.get(r.taskId); if (!t) continue;
|
|
2141
2156
|
const rec = m.get(t.repoId) || { active:0, attn:false };
|
|
2142
2157
|
rec.active++;
|
|
@@ -2292,6 +2307,11 @@ function connectWS(){
|
|
|
2292
2307
|
const known = runs.has(rid);
|
|
2293
2308
|
// 아카이브된(맵에 없는) run 의 뒤늦은 echo 는 무시 — 신규는 항상 pending 으로 먼저 온다.
|
|
2294
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; }
|
|
2295
2315
|
upsertRun(ev);
|
|
2296
2316
|
if (!known && ev.taskId==null){ hydrate(); return; }
|
|
2297
2317
|
if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
|
|
@@ -2304,6 +2324,7 @@ function connectWS(){
|
|
|
2304
2324
|
r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
|
|
2305
2325
|
render(); flash(ev.runId); paintModal();
|
|
2306
2326
|
} else if (ev.type==='task'){
|
|
2327
|
+
if (isSessionTask(ev.taskId) || isSessionRepo(ev.repoId)) return; // v5.28 I
|
|
2307
2328
|
const t = tasks.get(ev.taskId);
|
|
2308
2329
|
// repoId 는 v6.0 S2 승격(재부모화)으로 바뀐다 — 안 받으면 repo 스코프가 다음 하이드레이트까지 어긋난다.
|
|
2309
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(); }
|
|
@@ -2542,6 +2563,9 @@ $('selToggle').addEventListener('click', ()=>setSelectMode(!selectMode));
|
|
|
2542
2563
|
$('selCancel').addEventListener('click', ()=>setSelectMode(false));
|
|
2543
2564
|
$('selGo').addEventListener('click', async ()=>{
|
|
2544
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;
|
|
2545
2569
|
const yes = await confirmUI('Integrate '+selOrder.length+' run(s) into the base branch?',
|
|
2546
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' });
|
|
2547
2571
|
if (!yes) return;
|
|
@@ -2736,9 +2760,11 @@ async function paintCompare(){
|
|
|
2736
2760
|
const mergeable = ['done','failed','stopped'].includes(r.status) && files>0;
|
|
2737
2761
|
const merged = r.status==='merged';
|
|
2738
2762
|
return '<div class="cmp-col" data-run="'+r.id+'">'
|
|
2739
|
-
+ '<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)
|
|
2740
2764
|
+ '<span class="files">'+files+' file'+(files===1?'':'s')+'</span></div>'
|
|
2741
2765
|
+ '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
|
|
2766
|
+
// 머지한 열은 그 판정을 계속 달고 있다 — 다시 그려도 지워지지 않게 여기서 그린다(J2).
|
|
2767
|
+
+ mergeVerifyHTML(r.id)
|
|
2742
2768
|
+ '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
|
|
2743
2769
|
+ '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
|
|
2744
2770
|
+ (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR '+ic('external-link')+' '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
|
|
@@ -2746,7 +2772,7 @@ async function paintCompare(){
|
|
|
2746
2772
|
+ (merged
|
|
2747
2773
|
? chipHTML('merged')
|
|
2748
2774
|
: (r.prUrl ? '' : '<button class="btn-ghost sm" data-pr="'+r.id+'"'+(mergeable?'':' disabled')+'>Open PR</button>')
|
|
2749
|
-
+ '<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>')
|
|
2750
2776
|
+ '</div></div>';
|
|
2751
2777
|
}).join('');
|
|
2752
2778
|
// doc 모드 — 각 열의 diff 를 렌더된 문서로 치환 (열별 비동기, 도착 순)
|
|
@@ -2776,6 +2802,13 @@ async function driftNote(rid){
|
|
|
2776
2802
|
}catch(e){ return ''; }
|
|
2777
2803
|
}
|
|
2778
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
|
+
}
|
|
2779
2812
|
const prBtn = e.target.closest('button[data-pr]');
|
|
2780
2813
|
if (prBtn){
|
|
2781
2814
|
const rid = Number(prBtn.dataset.pr);
|
|
@@ -2803,6 +2836,8 @@ $('cmpBody').addEventListener('click', async (e)=>{
|
|
|
2803
2836
|
}
|
|
2804
2837
|
const btn = e.target.closest('button[data-merge]'); if(!btn) return;
|
|
2805
2838
|
const rid = Number(btn.dataset.merge);
|
|
2839
|
+
// dry 열은 여기서 한 번 더 붙잡는다 — 나란히 놓고 고르는 자리가 실수가 나는 자리였다(H3).
|
|
2840
|
+
if (btn.dataset.dry==='1' && !(await confirmDryMerge(1))) return;
|
|
2806
2841
|
const yes = await confirmUI('Merge r'+rid+' into the base branch?',
|
|
2807
2842
|
{ sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.'+(await driftNote(rid)), okLabel: 'Merge' });
|
|
2808
2843
|
if (!yes) return;
|
|
@@ -2811,7 +2846,13 @@ $('cmpBody').addEventListener('click', async (e)=>{
|
|
|
2811
2846
|
const j = await res.json().catch(()=>({detail:'merge failed'}));
|
|
2812
2847
|
const msg = $('cmpMsg-'+rid);
|
|
2813
2848
|
if (msg) msg.textContent = j.detail || (res.ok?'merged':'failed');
|
|
2814
|
-
if (res.ok){
|
|
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
|
+
}
|
|
2815
2856
|
else { toast('merge: '+(j.detail||res.status), 'error'); btn.disabled = false; }
|
|
2816
2857
|
});
|
|
2817
2858
|
$('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
|
|
@@ -3074,7 +3115,7 @@ $('roomChips').addEventListener('click',(e)=>{
|
|
|
3074
3115
|
closeRoom();
|
|
3075
3116
|
if (runs.has(rid)) openModal(rid);
|
|
3076
3117
|
else { // 아카이브 등 맵에 없는 run — 태스크로 하이드레이트 후 연다
|
|
3077
|
-
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
|
|
3078
3119
|
}
|
|
3079
3120
|
});
|
|
3080
3121
|
$('roomClose').addEventListener('click', closeRoom);
|
|
@@ -3117,7 +3158,7 @@ async function roomRunAction(act, rid){
|
|
|
3117
3158
|
if (act==='open'){
|
|
3118
3159
|
closeRoom();
|
|
3119
3160
|
if (runs.has(rid)) openModal(rid);
|
|
3120
|
-
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
|
|
3121
3162
|
return;
|
|
3122
3163
|
}
|
|
3123
3164
|
if (act==='fix'){ // 펼치고 steer 입력에 포커스
|
|
@@ -3140,12 +3181,18 @@ async function roomRunAction(act, rid){
|
|
|
3140
3181
|
return;
|
|
3141
3182
|
}
|
|
3142
3183
|
if (act==='merge'){
|
|
3184
|
+
if (isDryRun(r) && !(await confirmDryMerge(1))) return;
|
|
3143
3185
|
const yes = await confirmUI('Merge r'+rid+' into the base branch?',
|
|
3144
3186
|
{ sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
|
|
3145
3187
|
if (!yes) return;
|
|
3146
3188
|
const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
3147
3189
|
const j = await res.json().catch(()=>({detail:'merge failed'}));
|
|
3148
|
-
if (res.ok){
|
|
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
|
+
}
|
|
3149
3196
|
else toast('merge: '+(j.detail||res.status), 'error');
|
|
3150
3197
|
return;
|
|
3151
3198
|
}
|
|
@@ -3545,7 +3592,7 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
3545
3592
|
const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
|
|
3546
3593
|
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId,outputs})}).then(x=>x.json());
|
|
3547
3594
|
if (!t.ok){ toast('task create failed', 'error'); return; }
|
|
3548
|
-
tasks.set(t.task.id, t.task);
|
|
3595
|
+
if (!isSessionRepo(t.task.repoId)) tasks.set(t.task.id, t.task); // v5.28 I
|
|
3549
3596
|
const model = $('taskModel').value.trim();
|
|
3550
3597
|
await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
3551
3598
|
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
|