coxpit 5.5.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cockpit.ts +300 -16
- package/src/db/index.ts +3 -0
- package/src/db/schema.ts +3 -0
- package/src/orchestrator.ts +29 -0
- package/src/server.ts +35 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cockpit.ts
CHANGED
|
@@ -97,6 +97,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
97
97
|
.pane-h .title{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint)}
|
|
98
98
|
.pane-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;padding:0 3px}
|
|
99
99
|
.pane-h .x:hover{color:var(--failed)}
|
|
100
|
+
.vbadge{font-size:9px;font-weight:600;letter-spacing:.03em;padding:1px 6px;border-radius:999px;white-space:nowrap}
|
|
101
|
+
.vbadge.pass{color:var(--done);background:rgba(88,179,104,.16)}
|
|
102
|
+
.vbadge.fail{color:var(--failed);background:rgba(226,91,103,.16)}
|
|
103
|
+
.vbadge.running{color:var(--blocked);background:rgba(214,162,73,.16)}
|
|
104
|
+
.vbadge.error{color:var(--failed);background:rgba(226,91,103,.12)}
|
|
100
105
|
.pane-term{flex:1;min-height:0;padding:4px 2px 2px 8px}
|
|
101
106
|
.pane-term .xterm{height:100%}
|
|
102
107
|
|
|
@@ -106,11 +111,54 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
106
111
|
.empty h1{font-family:var(--mono);font-size:16px;margin:0 0 8px;color:var(--ink)}
|
|
107
112
|
.empty p{color:var(--muted);font-size:13px;margin:0}
|
|
108
113
|
|
|
109
|
-
.reqbar{display:flex;align-items:center;gap:
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
.
|
|
113
|
-
.reqbar .
|
|
114
|
+
.reqbar{display:flex;align-items:center;gap:9px;border-top:1px solid var(--line);background:var(--surface);padding:9px 12px;font-family:var(--mono)}
|
|
115
|
+
.modes{display:inline-flex;gap:2px;border:1px solid var(--line);border-radius:8px;padding:2px;background:var(--panel)}
|
|
116
|
+
.mode{font-size:11px;color:var(--muted);padding:4px 9px;border-radius:6px;cursor:pointer;background:none;border:none;font-family:var(--mono);white-space:nowrap}
|
|
117
|
+
.mode.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.3)}
|
|
118
|
+
.reqbar select{font-family:var(--mono);font-size:11.5px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:5px 7px;max-width:150px}
|
|
119
|
+
.reqbar select:disabled{opacity:.35}
|
|
120
|
+
.reqbar .tgt{font-size:11px;color:var(--brand);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
|
|
121
|
+
.rlbl{display:inline-flex;align-items:center;gap:5px}
|
|
122
|
+
.rchk{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);cursor:pointer;white-space:nowrap}
|
|
123
|
+
.reqinput{flex:1;min-width:80px;font-family:var(--mono);font-size:13px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 11px}
|
|
124
|
+
.reqinput:focus{outline:none;border-color:var(--line-hi);box-shadow:0 0 0 2px rgba(78,201,176,.18)}
|
|
125
|
+
.reqinput::placeholder{color:var(--faint)}
|
|
126
|
+
.reqgo{font-family:var(--mono);font-size:12px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:8px 13px;cursor:pointer}
|
|
127
|
+
.reqgo:disabled{opacity:.4;cursor:default}
|
|
128
|
+
.reqgo.bcast{background:var(--open);color:#0b0d12}
|
|
129
|
+
.reqgo.steer{background:var(--running);color:#04121e}
|
|
130
|
+
.toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
|
|
131
|
+
font-family:var(--mono);font-size:12px;padding:8px 14px;border-radius:9px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:40;max-width:80vw}
|
|
132
|
+
.toast.show{opacity:1}
|
|
133
|
+
|
|
134
|
+
/* ── Review (compare/merge) ── */
|
|
135
|
+
.review{position:absolute;inset:46px 0 0 0;background:var(--bg);display:none;flex-direction:column;overflow:hidden;z-index:20}
|
|
136
|
+
.review.on{display:flex}
|
|
137
|
+
.rv-head{display:flex;align-items:center;gap:10px;height:40px;padding:0 14px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:12px;color:var(--muted)}
|
|
138
|
+
.rv-head select{font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:5px 8px;max-width:340px}
|
|
139
|
+
.rv-cols{flex:1;display:flex;gap:1px;background:var(--line);overflow-x:auto;min-height:0}
|
|
140
|
+
.rv-col{flex:1 0 340px;min-width:300px;display:flex;flex-direction:column;background:var(--bg)}
|
|
141
|
+
.rv-col-h{display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--line);background:var(--surface);font-family:var(--mono);font-size:12px}
|
|
142
|
+
.rv-col-h .rid{color:var(--ink);font-weight:600}
|
|
143
|
+
.rv-col-h .stat{color:var(--faint);font-size:11px;margin-left:auto}
|
|
144
|
+
.rv-merge{font-family:var(--mono);font-size:10.5px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:6px;padding:4px 9px;cursor:pointer}
|
|
145
|
+
.rv-merge:disabled{opacity:.35;cursor:default;background:var(--line-hi);color:var(--faint)}
|
|
146
|
+
.rv-merge.caution{background:var(--blocked);color:#211803}
|
|
147
|
+
.rv-verify{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:11px;color:var(--muted)}
|
|
148
|
+
.rv-verify .vbadge{flex:none}
|
|
149
|
+
.rv-verify .vout{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint);cursor:pointer}
|
|
150
|
+
.rv-reverify{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:10px;padding:2px 7px;cursor:pointer;font-family:var(--mono)}
|
|
151
|
+
.rv-reverify:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
152
|
+
.rv-vcmd{display:flex;align-items:center;gap:7px;margin-left:14px}
|
|
153
|
+
.rv-vcmd input{font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:5px 8px;width:230px}
|
|
154
|
+
.rv-vcmd input::placeholder{color:var(--faint)}
|
|
155
|
+
.rv-vcmd button{font-family:var(--mono);font-size:10.5px;color:var(--muted);background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;cursor:pointer}
|
|
156
|
+
.rv-vcmd button:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
157
|
+
.rv-diff{flex:1;overflow:auto;padding:8px 10px;font-family:var(--mono);font-size:11.5px;line-height:1.5;white-space:pre;color:var(--muted)}
|
|
158
|
+
.rv-diff > span{display:block;min-height:1.2em}
|
|
159
|
+
.dl-file{color:var(--brand)} .dl-hunk{color:var(--open)} .dl-ctx{color:var(--muted)}
|
|
160
|
+
.dl-add{color:#7fdca0;background:rgba(88,179,104,.08)} .dl-del{color:#e58a92;background:rgba(226,91,103,.08)}
|
|
161
|
+
.rv-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--faint);font-family:var(--mono);font-size:13px;text-align:center;padding:24px}
|
|
114
162
|
</style>
|
|
115
163
|
</head>
|
|
116
164
|
<body>
|
|
@@ -118,13 +166,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
118
166
|
<a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
|
|
119
167
|
<span class="mach"><span class="dot"></span><span id="mach">local</span></span>
|
|
120
168
|
<div class="vtabs">
|
|
121
|
-
<button type="button" class="vtab on"><span class="g">⌗</span>Terminal</button>
|
|
122
|
-
<button type="button" class="vtab"
|
|
123
|
-
<button type="button" class="vtab" disabled title="
|
|
169
|
+
<button type="button" class="vtab on" id="vtTerm"><span class="g">⌗</span>Terminal</button>
|
|
170
|
+
<button type="button" class="vtab" id="vtReview"><span class="g">⧉</span>Review</button>
|
|
171
|
+
<button type="button" class="vtab" disabled title="Docs = 보드"><span class="g">▤</span>Docs</button>
|
|
124
172
|
</div>
|
|
125
173
|
<div class="right">
|
|
126
174
|
<span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
|
|
127
|
-
<span class="wip">preview · Phase
|
|
175
|
+
<span class="wip">preview · Phase 4</span>
|
|
128
176
|
<a class="toggle" href="/">← Board</a>
|
|
129
177
|
</div>
|
|
130
178
|
</header>
|
|
@@ -152,13 +200,43 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
152
200
|
</div>
|
|
153
201
|
</div>
|
|
154
202
|
<div class="reqbar">
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
203
|
+
<div class="modes">
|
|
204
|
+
<button type="button" class="mode on" data-mode="new" title="새 태스크를 만들고 N개 에이전트로 팬아웃">⌗ New</button>
|
|
205
|
+
<button type="button" class="mode" data-mode="steer" title="포커스한 run 에 후속 지시">➤ Steer</button>
|
|
206
|
+
<button type="button" class="mode" data-mode="bcast" title="열린 모든 페인 터미널에 그대로 입력">⊞ Broadcast</button>
|
|
207
|
+
</div>
|
|
208
|
+
<span class="rlbl" id="newCtl">
|
|
209
|
+
<select id="reqRepo" title="대상 repo"></select>
|
|
210
|
+
<select id="reqAgent" title="에이전트"></select>
|
|
211
|
+
<select id="reqCount" title="팬아웃 개수">
|
|
212
|
+
<option value="1">×1</option><option value="2">×2</option><option value="3" selected>×3</option><option value="4">×4</option>
|
|
213
|
+
</select>
|
|
214
|
+
<label class="rchk" title="실제 CLI 실행 (기본=드라이런)"><input type="checkbox" id="reqReal" /> real</label>
|
|
215
|
+
</span>
|
|
216
|
+
<span class="tgt" id="reqTgt" style="display:none"></span>
|
|
217
|
+
<input class="reqinput" id="reqInput" placeholder="무엇을 만들까요? — 요청을 적고 ⏎ 로 3개 에이전트에 팬아웃" autocomplete="off" />
|
|
218
|
+
<button type="button" class="reqgo" id="reqGo">Run ⏎</button>
|
|
158
219
|
</div>
|
|
159
220
|
</main>
|
|
160
221
|
</div>
|
|
161
222
|
|
|
223
|
+
<section class="review" id="review">
|
|
224
|
+
<div class="rv-head">
|
|
225
|
+
<span style="color:var(--brand)">⧉ Review</span>
|
|
226
|
+
<span>·</span>
|
|
227
|
+
<select id="rvTask" title="비교할 태스크"></select>
|
|
228
|
+
<span class="rv-vcmd">
|
|
229
|
+
<span style="color:var(--faint)">verify:</span>
|
|
230
|
+
<input id="rvVcmd" placeholder="예: npm test — 정착 시 자동 실행" autocomplete="off" />
|
|
231
|
+
<button type="button" id="rvVsave">save</button>
|
|
232
|
+
</span>
|
|
233
|
+
<span id="rvHint" style="margin-left:auto;color:var(--faint)">정착하면 자동 검증 · 승자를 base 에 merge</span>
|
|
234
|
+
</div>
|
|
235
|
+
<div class="rv-cols" id="rvCols"></div>
|
|
236
|
+
</section>
|
|
237
|
+
|
|
238
|
+
<div class="toast" id="toast"></div>
|
|
239
|
+
|
|
162
240
|
<script src="/vendor/xterm.js"></script>
|
|
163
241
|
<script src="/vendor/addon-fit.js"></script>
|
|
164
242
|
<script src="/vendor/addon-unicode11.js"></script>
|
|
@@ -168,23 +246,48 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
168
246
|
var esc = function(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return ({'&':'&','<':'<','>':'>','"':'"'})[c]; }); };
|
|
169
247
|
var $ = function(id){ return document.getElementById(id); };
|
|
170
248
|
|
|
249
|
+
function toast(msg){ var t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toast._h); toast._h=setTimeout(function(){ t.classList.remove('show'); }, 2600); }
|
|
250
|
+
var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'⚠ verify' };
|
|
251
|
+
function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
|
|
252
|
+
|
|
171
253
|
// ── fleet 상태 ──
|
|
172
|
-
var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[] };
|
|
173
|
-
var runById = {};
|
|
254
|
+
var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
|
|
255
|
+
var runById = {}, taskById = {}, repoById = {};
|
|
174
256
|
var fold = {}; // 접힘 상태(repo/goal/task 노드)
|
|
175
257
|
function isFold(k){ return fold[k]===true; }
|
|
258
|
+
function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
|
|
176
259
|
|
|
177
260
|
async function hydrate(){
|
|
178
261
|
try{
|
|
179
262
|
var d = await (await fetch('/api/fleet?view=all')).json();
|
|
180
|
-
fleet = d; runById = {};
|
|
263
|
+
fleet = d; runById = {}; taskById = {}; repoById = {};
|
|
181
264
|
(d.runs||[]).forEach(function(r){ runById[r.id]=r; });
|
|
265
|
+
(d.tasks||[]).forEach(function(t){ taskById[t.id]=t; });
|
|
266
|
+
(d.repos||[]).forEach(function(r){ repoById[r.id]=r; });
|
|
182
267
|
if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
|
|
183
268
|
renderTree();
|
|
184
269
|
syncPanes();
|
|
270
|
+
populateReq();
|
|
271
|
+
if (reviewOn) renderReviewPicker();
|
|
185
272
|
}catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
|
|
186
273
|
}
|
|
187
274
|
|
|
275
|
+
// ── 요청바 셀렉트 채우기(선택 유지) ──
|
|
276
|
+
function fillSelect(sel, items, val, label, keep){
|
|
277
|
+
var cur = keep && sel.value; sel.innerHTML='';
|
|
278
|
+
items.forEach(function(it){ var o=document.createElement('option'); o.value=val(it); o.textContent=label(it); sel.appendChild(o); });
|
|
279
|
+
if (cur){ for (var i=0;i<sel.options.length;i++){ if (sel.options[i].value===cur){ sel.value=cur; break; } } }
|
|
280
|
+
}
|
|
281
|
+
function populateReq(){
|
|
282
|
+
var repos = fleet.repos||[];
|
|
283
|
+
fillSelect($('reqRepo'), repos, function(r){return String(r.id);}, function(r){return r.name;}, true);
|
|
284
|
+
// repo 미선택 상태면 포커스 페인의 repo 로 기본
|
|
285
|
+
if (focusId){ var rp = repoOfRun((panes.find(function(p){return p.id===focusId;})||{}).runId); if (rp!=null) $('reqRepo').value=String(rp); }
|
|
286
|
+
var provs = fleet.providers||[];
|
|
287
|
+
if (provs.length) fillSelect($('reqAgent'), provs, function(p){return p.id;}, function(p){return p.label||p.id;}, true);
|
|
288
|
+
else if (!$('reqAgent').options.length){ var o=document.createElement('option'); o.value='claude-code'; o.textContent='claude-code'; $('reqAgent').appendChild(o); }
|
|
289
|
+
}
|
|
290
|
+
|
|
188
291
|
// ── 트리 렌더: Machine ▸ Repo ▸ Goal ▸ Task ▸ Run ──
|
|
189
292
|
function renderTree(){
|
|
190
293
|
var el = $('tree');
|
|
@@ -246,6 +349,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
246
349
|
host.style.display = n ? 'grid' : 'none';
|
|
247
350
|
$('paneCount').textContent = n + ' pane' + (n===1?'':'s');
|
|
248
351
|
$('closeBtn').disabled = !focusId;
|
|
352
|
+
if (typeof reqMode!=='undefined' && reqMode==='bcast') setMode('bcast');
|
|
249
353
|
if (!n) return;
|
|
250
354
|
var cols = Math.ceil(Math.sqrt(n));
|
|
251
355
|
var rows = Math.ceil(n / cols);
|
|
@@ -264,6 +368,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
264
368
|
$('closeBtn').disabled = !focusId;
|
|
265
369
|
var p = panes.find(function(x){return x.id===id;});
|
|
266
370
|
if (p && p.term) try{ p.term.focus(); }catch(e){}
|
|
371
|
+
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
267
372
|
}
|
|
268
373
|
function openRunPane(runId){
|
|
269
374
|
var existing = paneByRun[runId];
|
|
@@ -274,6 +379,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
274
379
|
var el = document.createElement('div'); el.className = 'pane'; p.el = el;
|
|
275
380
|
el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
|
|
276
381
|
+ '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
|
|
382
|
+
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
277
383
|
+ '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
|
|
278
384
|
+ '<button class="x" title="close">×</button></div>'
|
|
279
385
|
+ '<div class="pane-term"></div>';
|
|
@@ -340,11 +446,188 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
340
446
|
panes.forEach(function(p){
|
|
341
447
|
var r = runById[p.runId];
|
|
342
448
|
var chip = p.el.querySelector('[data-role=chip]'); var title = p.el.querySelector('[data-role=title]');
|
|
343
|
-
|
|
449
|
+
var vslot = p.el.querySelector('[data-role=vslot]');
|
|
450
|
+
if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal';
|
|
451
|
+
if(vslot) vslot.innerHTML = vbadge(r.verifyStatus); }
|
|
344
452
|
});
|
|
345
453
|
}
|
|
346
454
|
$('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
|
|
347
455
|
|
|
456
|
+
// ── 요청바: New(팬아웃) / Steer / Broadcast ──
|
|
457
|
+
var reqMode = 'new';
|
|
458
|
+
function focusRun(){ if (!focusId) return null; var p = panes.find(function(x){return x.id===focusId;}); return p?p.runId:null; }
|
|
459
|
+
function setMode(m){
|
|
460
|
+
reqMode = m;
|
|
461
|
+
Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
|
|
462
|
+
var go=$('reqGo'), inp=$('reqInput');
|
|
463
|
+
$('newCtl').style.display = m==='new'?'inline-flex':'none';
|
|
464
|
+
$('reqTgt').style.display = m==='new'?'none':'inline';
|
|
465
|
+
go.className = 'reqgo' + (m==='bcast'?' bcast':m==='steer'?' steer':'');
|
|
466
|
+
if (m==='new'){ go.textContent='Run ⏎'; inp.placeholder='무엇을 만들까요? — 요청을 적고 ⏎ 로 팬아웃'; }
|
|
467
|
+
else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
|
|
468
|
+
$('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
|
|
469
|
+
inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
|
|
470
|
+
else { go.textContent='Send ⏎'; var n=panes.length;
|
|
471
|
+
$('reqTgt').textContent = '⊞ 열린 페인 '+n+'개에 브로드캐스트';
|
|
472
|
+
inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 페인이 없습니다'; }
|
|
473
|
+
}
|
|
474
|
+
Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
|
|
475
|
+
|
|
476
|
+
var submitting = false;
|
|
477
|
+
async function submitReq(){
|
|
478
|
+
var inp = $('reqInput'); var text = inp.value.trim();
|
|
479
|
+
if (reqMode==='new'){
|
|
480
|
+
if (!text || submitting) return;
|
|
481
|
+
var repoId = Number($('reqRepo').value); if (!repoId){ toast('repo 를 먼저 등록/선택하세요'); return; }
|
|
482
|
+
submitting=true; $('reqGo').disabled=true;
|
|
483
|
+
try{
|
|
484
|
+
var title = text.length>72 ? text.slice(0,69)+'…' : text;
|
|
485
|
+
var tk = await (await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
|
|
486
|
+
body:JSON.stringify({repoId:repoId, title:title, prompt:text})})).json();
|
|
487
|
+
if (!tk || !tk.task){ toast('태스크 생성 실패'); return; }
|
|
488
|
+
var count = Number($('reqCount').value)||1;
|
|
489
|
+
var rr = await (await fetch('/api/tasks/'+tk.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
490
|
+
body:JSON.stringify({agent:$('reqAgent').value, count:count, real:$('reqReal').checked})})).json();
|
|
491
|
+
var ids = (rr&&rr.runs||[]).map(function(x){return x.id;});
|
|
492
|
+
inp.value='';
|
|
493
|
+
await hydrate();
|
|
494
|
+
// 팬아웃 자동 타일: 생성된 run 을 전부 페인으로
|
|
495
|
+
ids.forEach(function(id){ openRunPane(id); });
|
|
496
|
+
toast(ids.length+'개 에이전트 팬아웃 · r'+ids.join(' r'));
|
|
497
|
+
}catch(e){ toast('요청 실패: '+e); }
|
|
498
|
+
finally{ submitting=false; $('reqGo').disabled=false; }
|
|
499
|
+
} else if (reqMode==='steer'){
|
|
500
|
+
var rid = focusRun(); if (!rid){ toast('포커스한 페인이 없습니다'); return; }
|
|
501
|
+
if (!text || submitting) return;
|
|
502
|
+
submitting=true; $('reqGo').disabled=true;
|
|
503
|
+
try{
|
|
504
|
+
var res = await fetch('/api/runs/'+rid+'/steer',{method:'POST',headers:{'content-type':'application/json'},
|
|
505
|
+
body:JSON.stringify({message:text})});
|
|
506
|
+
if (res.ok){ inp.value=''; toast('r'+rid+' 에 후속 지시 전송'); }
|
|
507
|
+
else { var j = await res.json().catch(function(){return{};}); toast('steer 불가: '+(j.error||res.status)); }
|
|
508
|
+
}catch(e){ toast('steer 실패: '+e); }
|
|
509
|
+
finally{ submitting=false; $('reqGo').disabled=false; }
|
|
510
|
+
} else { // broadcast
|
|
511
|
+
if (!panes.length){ toast('열린 페인이 없습니다'); return; }
|
|
512
|
+
var payload = text + '\\r';
|
|
513
|
+
var sent=0;
|
|
514
|
+
panes.forEach(function(p){ if (p.ws && p.ws.readyState===1){ p.ws.send(JSON.stringify({t:'i',d:payload})); sent++; } });
|
|
515
|
+
inp.value='';
|
|
516
|
+
toast('브로드캐스트 → '+sent+'개 페인');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
$('reqGo').addEventListener('click', submitReq);
|
|
520
|
+
$('reqInput').addEventListener('keydown', function(e){ if (e.key==='Enter' && !e.isComposing){ e.preventDefault(); submitReq(); } });
|
|
521
|
+
|
|
522
|
+
// ── Review 탭: compare(diff 나란히) + merge 승자 ──
|
|
523
|
+
var reviewOn = false, rvTaskId = null;
|
|
524
|
+
function showTerminal(){ reviewOn=false; $('review').classList.remove('on'); $('vtReview').classList.remove('on'); $('vtTerm').classList.add('on'); }
|
|
525
|
+
function showReview(){ reviewOn=true; $('review').classList.add('on'); $('vtReview').classList.add('on'); $('vtTerm').classList.remove('on'); renderReviewPicker(); }
|
|
526
|
+
$('vtTerm').addEventListener('click', showTerminal);
|
|
527
|
+
$('vtReview').addEventListener('click', showReview);
|
|
528
|
+
function reviewableTasks(){
|
|
529
|
+
var byTask = {}; (fleet.runs||[]).forEach(function(r){ byTask[r.taskId]=(byTask[r.taskId]||0)+1; });
|
|
530
|
+
return (fleet.tasks||[]).filter(function(t){ return (byTask[t.id]||0)>=1; })
|
|
531
|
+
.sort(function(a,b){ return b.id-a.id; });
|
|
532
|
+
}
|
|
533
|
+
function renderReviewPicker(){
|
|
534
|
+
var tasks = reviewableTasks(); var sel=$('rvTask');
|
|
535
|
+
var cur = rvTaskId!=null ? String(rvTaskId) : (focusRun()!=null && runById[focusRun()] ? String(runById[focusRun()].taskId) : (tasks[0]?String(tasks[0].id):''));
|
|
536
|
+
sel.innerHTML='';
|
|
537
|
+
tasks.forEach(function(t){ var rp=repoById[t.repoId]; var o=document.createElement('option'); o.value=String(t.id);
|
|
538
|
+
o.textContent = (rp?rp.name+' · ':'')+t.title; sel.appendChild(o); });
|
|
539
|
+
if (!tasks.length){ $('rvCols').innerHTML='<div class="rv-empty">비교할 run 이 있는 태스크가 아직 없습니다. Terminal 에서 팬아웃해 보세요.</div>'; rvTaskId=null; return; }
|
|
540
|
+
if (cur){ sel.value=cur; } rvTaskId = Number(sel.value);
|
|
541
|
+
syncVcmd();
|
|
542
|
+
loadCompare(rvTaskId);
|
|
543
|
+
}
|
|
544
|
+
$('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); syncVcmd(); loadCompare(rvTaskId); });
|
|
545
|
+
function diffHTML(text){
|
|
546
|
+
if (!text || !text.trim()) return '<span style="color:var(--faint)">no changes</span>';
|
|
547
|
+
return text.split('\\n').map(function(l){
|
|
548
|
+
var e = esc(l) || ' ';
|
|
549
|
+
if (l.indexOf('diff --git')===0 || l.indexOf('+++')===0 || l.indexOf('---')===0) return '<span class="dl-file">'+e+'</span>';
|
|
550
|
+
if (l.indexOf('@@')===0) return '<span class="dl-hunk">'+e+'</span>';
|
|
551
|
+
if (l.charAt(0)==='+') return '<span class="dl-add">'+e+'</span>';
|
|
552
|
+
if (l.charAt(0)==='-') return '<span class="dl-del">'+e+'</span>';
|
|
553
|
+
return '<span class="dl-ctx">'+e+'</span>';
|
|
554
|
+
}).join('');
|
|
555
|
+
}
|
|
556
|
+
async function loadCompare(taskId){
|
|
557
|
+
var host=$('rvCols'); host.innerHTML='<div class="rv-empty">불러오는 중…</div>';
|
|
558
|
+
var task = taskById[taskId]; var repo = task && repoById[task.repoId];
|
|
559
|
+
var hasVerify = !!(repo && repo.verifyCmd && repo.verifyCmd.trim());
|
|
560
|
+
try{
|
|
561
|
+
var d = await (await fetch('/api/tasks/'+taskId+'/compare')).json();
|
|
562
|
+
var rns = (d.runs||[]).sort(function(a,b){return a.id-b.id;});
|
|
563
|
+
if (!rns.length){ host.innerHTML='<div class="rv-empty">run 이 없습니다.</div>'; return; }
|
|
564
|
+
host.innerHTML='';
|
|
565
|
+
rns.forEach(function(r){
|
|
566
|
+
var col=document.createElement('div'); col.className='rv-col';
|
|
567
|
+
var st = typeof r.stat==='string' ? r.stat.split('\\n').pop().trim() : '';
|
|
568
|
+
var mergeable = (r.status==='done'||r.status==='open'||r.status==='merged') && r.filesChanged>0;
|
|
569
|
+
// green-gate: verifyCmd 있는데 pass 아니면 "merge anyway"(주의색) — 막지 않고 경고
|
|
570
|
+
var gated = hasVerify && r.verifyStatus!=='pass';
|
|
571
|
+
var mlabel = r.status==='merged' ? 'merged' : (gated ? 'merge anyway' : 'merge ▸');
|
|
572
|
+
var vline = '';
|
|
573
|
+
if (hasVerify){
|
|
574
|
+
var vs = r.verifyStatus||''; var out = (r.verifyOutput||'').split('\\n').pop();
|
|
575
|
+
var txt = vs==='pass'?'통과' : vs==='fail'?'실패' : vs==='running'?'검증 중…' : vs==='error'?'검증 오류' : '미검증';
|
|
576
|
+
vline = '<div class="rv-verify">'+(vbadge(vs)||'<span class="vbadge error">· verify</span>')
|
|
577
|
+
+ '<span class="vout" data-vout="'+r.id+'" title="클릭=전체 출력">'+esc(txt+(out?' — '+out:''))+'</span>'
|
|
578
|
+
+ '<button class="rv-reverify" data-reverify="'+r.id+'">re-verify</button></div>';
|
|
579
|
+
}
|
|
580
|
+
col.innerHTML = '<div class="rv-col-h"><span class="st '+esc(r.status)+'" style="width:7px;height:7px;border-radius:50%;display:inline-block"></span>'
|
|
581
|
+
+ '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
|
|
582
|
+
+ '<span class="stat">'+esc(st)+'</span>'
|
|
583
|
+
+ '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
|
|
584
|
+
+ (gated?' title="검증 미통과 — 그래도 머지"':'')+'>'+mlabel+'</button></div>'
|
|
585
|
+
+ vline
|
|
586
|
+
+ '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
|
|
587
|
+
col._vout = { text: r.verifyOutput||'' };
|
|
588
|
+
host.appendChild(col);
|
|
589
|
+
});
|
|
590
|
+
// 전체 verify 출력 저장(툴팁·토스트용)
|
|
591
|
+
voutById = {}; rns.forEach(function(r){ voutById[r.id]=r.verifyOutput||''; });
|
|
592
|
+
}catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
|
|
593
|
+
}
|
|
594
|
+
var voutById = {};
|
|
595
|
+
$('rvCols').addEventListener('click', async function(e){
|
|
596
|
+
var vo = e.target.closest('[data-vout]');
|
|
597
|
+
if (vo){ var t = voutById[vo.dataset.vout]||''; toast(t? t.slice(-600) : '검증 출력 없음'); return; }
|
|
598
|
+
var rv = e.target.closest('[data-reverify]');
|
|
599
|
+
if (rv){ var id=rv.dataset.reverify; rv.disabled=true; rv.textContent='…';
|
|
600
|
+
try{ await fetch('/api/runs/'+id+'/verify',{method:'POST'}); toast('r'+id+' 재검증 시작'); }
|
|
601
|
+
catch(err){ toast('재검증 실패: '+err); rv.disabled=false; rv.textContent='re-verify'; }
|
|
602
|
+
return; }
|
|
603
|
+
var b = e.target.closest('[data-merge]'); if (!b) return;
|
|
604
|
+
var rid = b.dataset.merge; var prev=b.textContent; b.disabled=true; b.textContent='merging…';
|
|
605
|
+
try{
|
|
606
|
+
var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
607
|
+
var j = await res.json().catch(function(){return{};});
|
|
608
|
+
if (res.ok){ toast('r'+rid+' merge 완료'); await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId); }
|
|
609
|
+
else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent=prev; }
|
|
610
|
+
}catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent=prev; }
|
|
611
|
+
});
|
|
612
|
+
// verify 명령 저장(선택 태스크의 repo 에)
|
|
613
|
+
function syncVcmd(){
|
|
614
|
+
var t = rvTaskId!=null?taskById[rvTaskId]:null; var rp = t&&repoById[t.repoId];
|
|
615
|
+
$('rvVcmd').value = rp ? (rp.verifyCmd||'') : '';
|
|
616
|
+
$('rvVcmd').dataset.repo = rp ? String(rp.id) : '';
|
|
617
|
+
}
|
|
618
|
+
async function saveVcmd(){
|
|
619
|
+
var rid = $('rvVcmd').dataset.repo; if (!rid){ toast('repo 를 알 수 없습니다'); return; }
|
|
620
|
+
var cmd = $('rvVcmd').value.trim();
|
|
621
|
+
try{
|
|
622
|
+
var res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({verifyCmd:cmd})});
|
|
623
|
+
var j = await res.json().catch(function(){return{};});
|
|
624
|
+
if (res.ok){ if (repoById[rid]) repoById[rid].verifyCmd=cmd; toast(cmd?'verify 명령 저장':'verify 끔'); await hydrate(); if (rvTaskId!=null){ syncVcmd(); loadCompare(rvTaskId); } }
|
|
625
|
+
else toast('저장 실패: '+(j.error||res.status));
|
|
626
|
+
}catch(err){ toast('저장 실패: '+err); }
|
|
627
|
+
}
|
|
628
|
+
$('rvVsave').addEventListener('click', saveVcmd);
|
|
629
|
+
$('rvVcmd').addEventListener('keydown', function(e){ if (e.key==='Enter'){ e.preventDefault(); saveVcmd(); } });
|
|
630
|
+
|
|
348
631
|
// ── /ws 라이브 구독 → 트리·페인 갱신 ──
|
|
349
632
|
function wsConnect(){
|
|
350
633
|
var proto = location.protocol==='https:'?'wss':'ws';
|
|
@@ -356,6 +639,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
356
639
|
var hydT=null;
|
|
357
640
|
function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
|
|
358
641
|
|
|
642
|
+
setMode('new');
|
|
359
643
|
hydrate();
|
|
360
644
|
wsConnect();
|
|
361
645
|
window.addEventListener('resize', function(){ panes.forEach(fitPane); });
|
package/src/db/index.ts
CHANGED
|
@@ -103,4 +103,7 @@ export async function ensureSchema(): Promise<void> {
|
|
|
103
103
|
try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
|
|
104
104
|
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN agent_pid INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
105
105
|
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN log_offset INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
106
|
+
try { await client.execute("ALTER TABLE repos ADD COLUMN verify_cmd TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
107
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_status TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
108
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_output TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
106
109
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const repos = sqliteTable('repos', {
|
|
|
19
19
|
path: text('path').notNull(),
|
|
20
20
|
name: text('name').notNull(),
|
|
21
21
|
defaultBranch: text('default_branch').notNull().default('main'),
|
|
22
|
+
verifyCmd: text('verify_cmd').notNull().default(''), // 정착한 run 을 검증하는 명령(테스트·빌드). 빈값 = 검증 없음
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
/** Design Mode 캡처 — 북마클릿 인스펙터가 보낸 UI 요소 컨텍스트. */
|
|
@@ -90,6 +91,8 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
90
91
|
filesChanged: integer('files_changed').notNull().default(0),
|
|
91
92
|
agentPid: integer('agent_pid').notNull().default(0), // detached sh pgid — 재시작 후 생존 판정/stop
|
|
92
93
|
logOffset: integer('log_offset').notNull().default(0), // 내구 로그에서 tail 이 소비한 바이트(재-adopt 시 여기부터)
|
|
94
|
+
verifyStatus: text('verify_status').notNull().default(''), // '' none | running | pass | fail | error — 정착 후 repo.verifyCmd 결과
|
|
95
|
+
verifyOutput: text('verify_output').notNull().default(''), // 검증 출력 tail(실패 진단용)
|
|
93
96
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
94
97
|
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
|
95
98
|
exitSummary: text('exit_summary').notNull().default(''),
|
package/src/orchestrator.ts
CHANGED
|
@@ -534,9 +534,38 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
534
534
|
await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
|
|
535
535
|
// 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
|
|
536
536
|
if (filesChanged > 0) void snapshotRunDocs(runId);
|
|
537
|
+
// verify in-loop — repo.verifyCmd 가 있으면 정착한(변경 있는) run 을 자동 검증. best-effort.
|
|
538
|
+
if (status === 'done' && filesChanged > 0) void verifyRun(runId);
|
|
537
539
|
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
538
540
|
}
|
|
539
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Verify in-loop — repo.verifyCmd 를 run 의 worktree 에서 실행해 pass/fail 을 기록.
|
|
544
|
+
* verifyCmd 미설정이면 상태를 비우고 no-op. 정착 훅이 자동 호출(done+변경), 수동 재검증도 지원.
|
|
545
|
+
*/
|
|
546
|
+
export async function verifyRun(runId: number): Promise<{ ok: boolean; status: string; detail?: string }> {
|
|
547
|
+
const ctx = await loadContext(runId);
|
|
548
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
549
|
+
const run = rr[0];
|
|
550
|
+
if (!ctx || !run) return { ok: false, status: '', detail: 'run not found' };
|
|
551
|
+
const rp = await db.select().from(repos).where(eq(repos.id, ctx.repoId)).limit(1);
|
|
552
|
+
const cmd = (rp[0]?.verifyCmd ?? '').trim();
|
|
553
|
+
if (!cmd) { await setRun(runId, { verifyStatus: '', verifyOutput: '' }); return { ok: true, status: '' }; }
|
|
554
|
+
if (!run.worktreePath) return { ok: false, status: 'error', detail: 'worktree gone' };
|
|
555
|
+
const exists = await runShellOn(ctx.machine, `test -d ${shq(run.worktreePath)} && echo yes`, 8000);
|
|
556
|
+
if (!/yes/.test(exists.stdout)) {
|
|
557
|
+
await setRun(runId, { verifyStatus: 'error', verifyOutput: 'worktree missing' });
|
|
558
|
+
return { ok: false, status: 'error', detail: 'worktree missing' };
|
|
559
|
+
}
|
|
560
|
+
await setRun(runId, { verifyStatus: 'running', verifyOutput: '' });
|
|
561
|
+
const r = await runShellOn(ctx.machine, `cd ${shq(run.worktreePath)} && ( ${cmd} )`, 180000);
|
|
562
|
+
const merged = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
|
|
563
|
+
const tail = merged.length > 6000 ? '…' + merged.slice(-6000) : merged;
|
|
564
|
+
const status = r.ok ? 'pass' : r.code === -1 ? 'error' : 'fail';
|
|
565
|
+
await setRun(runId, { verifyStatus: status, verifyOutput: tail });
|
|
566
|
+
return { ok: true, status };
|
|
567
|
+
}
|
|
568
|
+
|
|
540
569
|
/**
|
|
541
570
|
* 변경 문서(md/html)를 DB 에 스냅샷. 최신 우선(기존 행 삭제 후 재삽입).
|
|
542
571
|
* 빈 읽기(worktree 이미 소멸 등)는 기존 스냅샷을 지우지 않는다.
|
package/src/server.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { db } from './db';
|
|
|
19
19
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
|
|
20
20
|
import { BOOKMARKLET_JS } from './design';
|
|
21
21
|
import { runShellOn, shq } from './exec';
|
|
22
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments } from './orchestrator';
|
|
22
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun } from './orchestrator';
|
|
23
23
|
import { openTerm } from './term';
|
|
24
24
|
import { addSink, removeSink, broadcast } from './hub';
|
|
25
25
|
import { getProvider, listProviders } from './providers';
|
|
@@ -685,21 +685,33 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
685
685
|
// 기본 브랜치 변경 — merge·Sync base·PR 이 향할 대상. develop-flow repo 대응.
|
|
686
686
|
app.patch('/api/repos/:id', async (req, reply) => {
|
|
687
687
|
const id = Number((req.params as { id: string }).id);
|
|
688
|
-
const b = (req.body ?? {}) as { defaultBranch?: string };
|
|
689
|
-
const branch = (b.defaultBranch ?? '').trim();
|
|
690
|
-
if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
|
|
688
|
+
const b = (req.body ?? {}) as { defaultBranch?: string; verifyCmd?: string };
|
|
691
689
|
const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
|
|
692
690
|
const repo = rp[0];
|
|
693
691
|
if (!repo) return reply.code(404).send({ error: 'not found' });
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
692
|
+
const patch: { defaultBranch?: string; verifyCmd?: string } = {};
|
|
693
|
+
// defaultBranch(선택) — repo 에 존재하는 ref 로 검증
|
|
694
|
+
if (b.defaultBranch !== undefined) {
|
|
695
|
+
const branch = (b.defaultBranch ?? '').trim();
|
|
696
|
+
if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
|
|
697
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
698
|
+
const m = mr[0];
|
|
699
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
700
|
+
const check = await runShellOn(m,
|
|
701
|
+
`git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
|
|
702
|
+
if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
|
|
703
|
+
patch.defaultBranch = branch;
|
|
704
|
+
}
|
|
705
|
+
// verifyCmd(선택) — 정착 run 검증 명령. 빈문자 = 검증 끔. 제어문자만 금지, 길이 캡.
|
|
706
|
+
if (b.verifyCmd !== undefined) {
|
|
707
|
+
const vc = String(b.verifyCmd ?? '').trim();
|
|
708
|
+
if (vc.length > 500) return reply.code(400).send({ error: 'verify command too long (max 500)' });
|
|
709
|
+
if (/[\x00-\x1f]/.test(vc)) return reply.code(400).send({ error: "verify command has control characters" });
|
|
710
|
+
patch.verifyCmd = vc;
|
|
711
|
+
}
|
|
712
|
+
if (!Object.keys(patch).length) return reply.code(400).send({ error: 'nothing to update' });
|
|
713
|
+
await db.update(repos).set(patch).where(eq(repos.id, id));
|
|
714
|
+
return { ok: true, ...patch };
|
|
703
715
|
});
|
|
704
716
|
|
|
705
717
|
// 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
|
|
@@ -978,6 +990,16 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
978
990
|
return reply.code(202).send(res);
|
|
979
991
|
});
|
|
980
992
|
|
|
993
|
+
// 수동 재검증 — repo.verifyCmd 를 이 run 의 worktree 에서 다시 실행(정착 자동검증과 동일 경로).
|
|
994
|
+
app.post('/api/runs/:id/verify', async (req, reply) => {
|
|
995
|
+
const id = Number((req.params as { id: string }).id);
|
|
996
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
997
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
998
|
+
const res = await verifyRun(id);
|
|
999
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
1000
|
+
return reply.code(202).send(res);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
981
1003
|
// base 동기화 — 오래 사는 세션 worktree 에 base 최신 머지.
|
|
982
1004
|
app.post('/api/runs/:id/sync', async (req, reply) => {
|
|
983
1005
|
const id = Number((req.params as { id: string }).id);
|