coxpit 4.0.0 → 4.1.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/README.md +3 -2
- package/package.json +1 -1
- package/src/board.ts +103 -9
- package/src/db/index.ts +9 -0
- package/src/db/schema.ts +11 -0
- package/src/orchestrator.ts +51 -6
- package/src/providers.ts +15 -11
- package/src/server.ts +75 -6
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ Your machines. Your auth. Your code never leaves your network.
|
|
|
22
22
|
- **Design Mode** — drag the `⌖ coxpit inspect` bookmarklet to your bar, click it on your running app, click any element: its selector, HTML and computed styles are captured and injected into the agents' prompt as design context.
|
|
23
23
|
- **Self-orchestrating agents** — every local run can spawn its own sub-agents by writing `.coxpit/spawn.json` in its worktree (works under default permissions — no network, no escalation). The daemon launches each subtask as an isolated sub-run and maintains `.coxpit/subtasks.json` with live status. Orchestration moves inside the agent's own reasoning loop.
|
|
24
24
|
- **Start from GitHub** — paste an issue/PR URL and the task form drafts itself from its title and body (gh CLI for private repos, public API otherwise). You review, pick a provider, Run fleet.
|
|
25
|
-
- **Share a run** — one click mints a read-only snapshot link (timeline + diff, no auth, no actions). Show your fleet's work without opening your cockpit.
|
|
25
|
+
- **Share a run** — one click mints a read-only snapshot link (timeline + diff, and the rendered docs, no auth, no actions). Show your fleet's work without opening your cockpit.
|
|
26
|
+
- **The library** — a run's changed documents (Markdown/HTML) are snapshotted when it settles, so the Rendered view survives merge and Close task. Pick a model per launch (any name your CLI accepts), and a close guard warns before it deletes unmerged, unexported output.
|
|
26
27
|
|
|
27
28
|
External tools are spawned, never vendored: `git`, `tmux`, your agent CLI. No editor bundled — terminal-first.
|
|
28
29
|
|
|
@@ -109,7 +110,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
|
|
|
109
110
|
|
|
110
111
|
## Status
|
|
111
112
|
|
|
112
|
-
`v4.
|
|
113
|
+
`v4.1` — fleet, two providers (Claude Code · Codex) with per-launch model choice, compare/merge + AI review + doc mode with settle-time snapshots, terminal (full-screen, session tabs, mobile input bar), swarm (plan fan-out · integrate · agent self-orchestration), sessions (steer/ask · resume), mobile board with deep links, GitHub import, read-only share links that render documents, a close guard, and per-repo base branch override — all shipped and e2e-tested (35 checks). Roadmap: ROADMAP.md.
|
|
113
114
|
|
|
114
115
|
## License
|
|
115
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/board.ts
CHANGED
|
@@ -164,6 +164,15 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
164
164
|
.ev{display:flex;gap:9px;align-items:baseline;min-width:0}
|
|
165
165
|
.ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
|
|
166
166
|
.ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
|
|
167
|
+
/* ── closed card — 죽은 카드는 확실히 죽어 보이게(빗금 + CLOSED 스탬프) ── */
|
|
168
|
+
.card.closed{opacity:.6;filter:saturate(.45)}
|
|
169
|
+
.card.closed .log{position:relative}
|
|
170
|
+
.card.closed .log::after{content:'';position:absolute;inset:0;pointer-events:none;
|
|
171
|
+
background:repeating-linear-gradient(135deg,transparent 0 9px,rgba(255,255,255,.035) 9px 11px)}
|
|
172
|
+
.card.closed .log::before{content:'CLOSED';position:absolute;top:50%;left:50%;z-index:1;
|
|
173
|
+
transform:translate(-50%,-50%) rotate(-7deg);font-family:var(--mono);font-size:15px;
|
|
174
|
+
letter-spacing:.34em;color:var(--faint);border:1px solid var(--line-hi);
|
|
175
|
+
border-radius:6px;padding:4px 14px;background:rgba(11,13,18,.72)}
|
|
167
176
|
/* ── select mode (integrate) ── */
|
|
168
177
|
.toolbar{display:flex;justify-content:flex-end;gap:8px;margin-bottom:12px}
|
|
169
178
|
.card.selmode{cursor:copy}
|
|
@@ -291,6 +300,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
291
300
|
.cmp-review p{margin:0 0 8px}
|
|
292
301
|
|
|
293
302
|
/* ── doc mode (rendered output) ─────────── */
|
|
303
|
+
.doc-src{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-bottom:10px}
|
|
294
304
|
.doc-h{font-family:var(--mono);font-size:10.5px;color:var(--brand);padding:8px 0 4px;
|
|
295
305
|
border-bottom:1px solid var(--line);margin-bottom:8px;word-break:break-all}
|
|
296
306
|
.doc-md{font-size:13px;line-height:1.65;color:var(--muted);margin-bottom:16px;font-family:var(--sans)}
|
|
@@ -378,6 +388,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
378
388
|
<div class="row">
|
|
379
389
|
<button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
|
|
380
390
|
<button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
|
|
391
|
+
<button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it">⎇</button>
|
|
381
392
|
<button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
|
|
382
393
|
</div>
|
|
383
394
|
<form id="repoForm" hidden>
|
|
@@ -404,6 +415,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
404
415
|
<button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
|
|
405
416
|
<button type="button" class="seg-opt" data-agent="codex">Codex</button>
|
|
406
417
|
</div>
|
|
418
|
+
<p class="flabel">model · optional</p>
|
|
419
|
+
<input id="taskModel" placeholder="CLI default" list="modelHist" autocomplete="off" />
|
|
420
|
+
<datalist id="modelHist"></datalist>
|
|
407
421
|
<p class="flabel">design capture · optional</p>
|
|
408
422
|
<select id="taskCapture"><option value="">no design capture</option></select>
|
|
409
423
|
</div>
|
|
@@ -565,6 +579,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
565
579
|
</div>
|
|
566
580
|
</div>
|
|
567
581
|
|
|
582
|
+
<div class="overlay" id="brOverlay">
|
|
583
|
+
<div class="cfm">
|
|
584
|
+
<div class="cfm-b">
|
|
585
|
+
<div class="m">Base branch for this repository</div>
|
|
586
|
+
<div class="s">Merge, Sync base and PR mode all target this branch. Set it to match your repo's flow (e.g. <span style="color:var(--brand);font-family:var(--mono)">develop</span>). Must already exist in the repo.</div>
|
|
587
|
+
<p class="flabel" style="margin-top:12px">branch name</p>
|
|
588
|
+
<input id="brInput" placeholder="main" />
|
|
589
|
+
</div>
|
|
590
|
+
<div class="cfm-f">
|
|
591
|
+
<button class="btn-ghost sm" id="brCancel">Cancel</button>
|
|
592
|
+
<button class="btn sm" id="brOk">Save</button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
|
|
568
597
|
<div class="overlay" id="expOverlay">
|
|
569
598
|
<div class="cfm">
|
|
570
599
|
<div class="cfm-b">
|
|
@@ -862,13 +891,14 @@ function cardHTML(r){
|
|
|
862
891
|
const evs = humanLines(r.events).slice(-8).map(h =>
|
|
863
892
|
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t).slice(0,140)+'</span></div>'
|
|
864
893
|
).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
|
|
865
|
-
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
|
|
894
|
+
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
|
|
866
895
|
return '<div class="card'+selCls+'" id="card-'+r.id+'">'
|
|
867
896
|
+ '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
|
|
868
897
|
+ '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
|
|
869
898
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
870
899
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
871
900
|
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
901
|
+
+ (r.model ? '<span title="model">⚙ '+esc(r.model)+'</span>' : '')
|
|
872
902
|
+ (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
|
|
873
903
|
+ (r.sessionId && ['done','failed','stopped'].includes(r.status)
|
|
874
904
|
? '<span class="resumable" title="agent session preserved — open the run and Send a next instruction to continue">↻ resumable</span>' : '')
|
|
@@ -942,6 +972,26 @@ $('repoRemove').addEventListener('click', async ()=>{
|
|
|
942
972
|
if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
|
|
943
973
|
else toast('remove: '+(j.detail||res.status), 'error');
|
|
944
974
|
});
|
|
975
|
+
/* ── 기본 브랜치 변경 ── */
|
|
976
|
+
$('repoBranch').addEventListener('click', ()=>{
|
|
977
|
+
const rid = $('taskRepo').value;
|
|
978
|
+
if (!rid){ toast('no repository selected', 'error'); return; }
|
|
979
|
+
const repo = repos.find(r=>String(r.id)===String(rid));
|
|
980
|
+
$('brInput').value = repo ? repo.defaultBranch : '';
|
|
981
|
+
$('brOverlay').classList.add('open'); $('brInput').focus();
|
|
982
|
+
});
|
|
983
|
+
$('brCancel').addEventListener('click', ()=>$('brOverlay').classList.remove('open'));
|
|
984
|
+
$('brOverlay').addEventListener('click',(e)=>{ if(e.target===$('brOverlay')) $('brOverlay').classList.remove('open'); });
|
|
985
|
+
async function brSave(){
|
|
986
|
+
const rid = $('taskRepo').value; const branch = $('brInput').value.trim();
|
|
987
|
+
if (!rid || !branch) return;
|
|
988
|
+
const res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({defaultBranch:branch})});
|
|
989
|
+
const j = await res.json().catch(()=>({}));
|
|
990
|
+
if (res.ok){ $('brOverlay').classList.remove('open'); toast('base branch → '+j.defaultBranch, 'ok'); hydrate(); }
|
|
991
|
+
else toast('branch: '+(j.error||res.status), 'error');
|
|
992
|
+
}
|
|
993
|
+
$('brOk').addEventListener('click', brSave);
|
|
994
|
+
$('brInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') brSave(); });
|
|
945
995
|
$('repoManual').addEventListener('click', ()=>{
|
|
946
996
|
const f = $('repoForm'); f.hidden = !f.hidden;
|
|
947
997
|
if (!f.hidden) $('repoPath').focus();
|
|
@@ -1028,17 +1078,31 @@ function paintModal(){
|
|
|
1028
1078
|
async function loadDiff(){
|
|
1029
1079
|
if (openRunId==null || docMode) return;
|
|
1030
1080
|
const pre = $('mDiff'); if (!pre) return;
|
|
1081
|
+
const rid = openRunId;
|
|
1031
1082
|
pre.textContent = 'loading…'; $('mStat').textContent='';
|
|
1032
1083
|
try{
|
|
1033
|
-
const d = await fetch('/api/runs/'+
|
|
1034
|
-
if (!d.ok){
|
|
1084
|
+
const d = await fetch('/api/runs/'+rid+'/diff').then(x=>x.json());
|
|
1085
|
+
if (!d.ok){
|
|
1086
|
+
pre.textContent = d.stat||'no worktree';
|
|
1087
|
+
// worktree 는 없지만 스냅샷 문서가 있으면 Rendered 토글 노출(머지·Close 후 뷰어)
|
|
1088
|
+
maybeShowDocsToggle(rid);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1035
1091
|
const files = d.stat ? d.stat.split('\\n').filter(Boolean).length : 0;
|
|
1036
1092
|
$('mStat').textContent = files ? '· '+files+' file'+(files>1?'s':'') : '· clean';
|
|
1037
|
-
// 변경분에 문서(md/html)가 있으면 Rendered 토글 노출
|
|
1038
|
-
$('mDocsTgl').hidden = !/\\.(md|markdown|html?|htm)$/im.test(d.stat||'');
|
|
1039
1093
|
pre.innerHTML = diffHTML(d.diff||'');
|
|
1094
|
+
// 변경분에 문서(md/html)가 있으면 즉시 노출, 없으면 스냅샷 확인
|
|
1095
|
+
if (/\\.(md|markdown|html?|htm)$/im.test(d.stat||'')) $('mDocsTgl').hidden = false;
|
|
1096
|
+
else maybeShowDocsToggle(rid);
|
|
1040
1097
|
}catch{ pre.textContent = 'diff failed'; }
|
|
1041
1098
|
}
|
|
1099
|
+
/* worktree 에 라이브 문서가 없을 때만 — 스냅샷이라도 있으면 토글을 켠다 */
|
|
1100
|
+
async function maybeShowDocsToggle(rid){
|
|
1101
|
+
try{
|
|
1102
|
+
const j = await fetch('/api/runs/'+rid+'/docs').then(x=>x.json());
|
|
1103
|
+
if (rid===openRunId && (j.docs||[]).length) $('mDocsTgl').hidden = false;
|
|
1104
|
+
}catch{}
|
|
1105
|
+
}
|
|
1042
1106
|
/* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
|
|
1043
1107
|
let docMode = false;
|
|
1044
1108
|
function docsHTML(docs){
|
|
@@ -1053,7 +1117,9 @@ async function paintDocs(){
|
|
|
1053
1117
|
$('mDiffWrap').innerHTML = '<span style="color:var(--faint)">rendering…</span>';
|
|
1054
1118
|
try{
|
|
1055
1119
|
const d = await fetch('/api/runs/'+openRunId+'/docs').then(x=>x.json());
|
|
1056
|
-
|
|
1120
|
+
const src = d.source==='snapshot'
|
|
1121
|
+
? '<div class="doc-src">worktree gone — snapshot taken at settle</div>' : '';
|
|
1122
|
+
$('mDiffWrap').innerHTML = src + docsHTML(d.docs||[]);
|
|
1057
1123
|
}catch{ $('mDiffWrap').textContent = 'docs failed'; }
|
|
1058
1124
|
}
|
|
1059
1125
|
function setDocMode(on){
|
|
@@ -1125,7 +1191,7 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
1125
1191
|
});
|
|
1126
1192
|
$('mClose').addEventListener('click', closeModal);
|
|
1127
1193
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
1128
|
-
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1194
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1129
1195
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
1130
1196
|
$('mExport').addEventListener('click', ()=>{
|
|
1131
1197
|
if (openRunId==null) return;
|
|
@@ -1195,7 +1261,18 @@ $('mCloseTask').addEventListener('click', async ()=>{
|
|
|
1195
1261
|
const yes = await confirmUI('Close this task?',
|
|
1196
1262
|
{ sub: 'Stops any live runs and removes every worktree and branch of the task.', danger: true, okLabel: 'Close task' });
|
|
1197
1263
|
if (!yes) return;
|
|
1198
|
-
|
|
1264
|
+
const close = (force)=>fetch('/api/tasks/'+r.taskId+'/close',{method:'POST',
|
|
1265
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({force})});
|
|
1266
|
+
let res = await close(false);
|
|
1267
|
+
if (res.status===409){
|
|
1268
|
+
const j = await res.json().catch(()=>({}));
|
|
1269
|
+
const risk = (j.atRisk||[]).map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
|
|
1270
|
+
const ok = await confirmUI('Close and delete unmerged output?',
|
|
1271
|
+
{ danger:true, sub: risk+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
|
|
1272
|
+
if (!ok) return;
|
|
1273
|
+
res = await close(true);
|
|
1274
|
+
}
|
|
1275
|
+
if (!res.ok){ toast('close failed ('+res.status+')', 'error'); return; }
|
|
1199
1276
|
toast('task closed — all runs cleaned', 'ok');
|
|
1200
1277
|
closeModal(); hydrate();
|
|
1201
1278
|
});
|
|
@@ -1511,11 +1588,28 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
1511
1588
|
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
|
|
1512
1589
|
if (!t.ok){ toast('task create failed', 'error'); return; }
|
|
1513
1590
|
tasks.set(t.task.id, t.task);
|
|
1591
|
+
const model = $('taskModel').value.trim();
|
|
1514
1592
|
await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
1515
|
-
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent})});
|
|
1593
|
+
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
|
|
1594
|
+
if (model) rememberModel(model);
|
|
1516
1595
|
$('taskTitle').value=''; $('taskPrompt').value='';
|
|
1517
1596
|
});
|
|
1518
1597
|
|
|
1598
|
+
/* ── model 최근값 기억(기기별, 최대 5) ── */
|
|
1599
|
+
function rememberModel(m){
|
|
1600
|
+
try{
|
|
1601
|
+
const h = JSON.parse(localStorage.getItem('coxpit.models')||'[]');
|
|
1602
|
+
localStorage.setItem('coxpit.models', JSON.stringify([m, ...h.filter(x=>x!==m)].slice(0,5)));
|
|
1603
|
+
}catch{}
|
|
1604
|
+
paintModelHist();
|
|
1605
|
+
}
|
|
1606
|
+
function paintModelHist(){
|
|
1607
|
+
let h = [];
|
|
1608
|
+
try{ h = JSON.parse(localStorage.getItem('coxpit.models')||'[]'); }catch{}
|
|
1609
|
+
$('modelHist').innerHTML = h.map(m=>'<option value="'+escA(m)+'"></option>').join('');
|
|
1610
|
+
}
|
|
1611
|
+
paintModelHist();
|
|
1612
|
+
|
|
1519
1613
|
/* ── agent mode segmented control (mirrors hidden #taskReal) ── */
|
|
1520
1614
|
const segOpts = Array.from(document.querySelectorAll('#modeSeg .seg-opt'));
|
|
1521
1615
|
function setMode(real, persist){
|
package/src/db/index.ts
CHANGED
|
@@ -75,10 +75,19 @@ export async function ensureSchema(): Promise<void> {
|
|
|
75
75
|
token TEXT NOT NULL UNIQUE,
|
|
76
76
|
created_at INTEGER DEFAULT (unixepoch())
|
|
77
77
|
);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS doc_snapshots (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
run_id INTEGER NOT NULL,
|
|
81
|
+
path TEXT NOT NULL,
|
|
82
|
+
kind TEXT NOT NULL,
|
|
83
|
+
content TEXT NOT NULL DEFAULT '',
|
|
84
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
85
|
+
);
|
|
78
86
|
`);
|
|
79
87
|
// 기존 DB 마이그레이션(멱등)
|
|
80
88
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
81
89
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
82
90
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
83
91
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
|
|
92
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
84
93
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -44,6 +44,16 @@ export const tasks = sqliteTable('tasks', {
|
|
|
44
44
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
/** 정착·정리 시점에 회수한 문서(md/html) 스냅샷 — worktree 소멸 후에도 렌더 뷰 유지. */
|
|
48
|
+
export const docSnapshots = sqliteTable('doc_snapshots', {
|
|
49
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
50
|
+
runId: integer('run_id').notNull(),
|
|
51
|
+
path: text('path').notNull(),
|
|
52
|
+
kind: text('kind').notNull(), // 'md' | 'html'
|
|
53
|
+
content: text('content').notNull().default(''),
|
|
54
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
55
|
+
});
|
|
56
|
+
|
|
47
57
|
/** 읽기 전용 공유 링크 — run 스냅샷을 무인증으로 보여준다(토큰 = capability). */
|
|
48
58
|
export const shareLinks = sqliteTable('share_links', {
|
|
49
59
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
@@ -64,6 +74,7 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
64
74
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
65
75
|
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
|
66
76
|
prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
|
|
77
|
+
model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
|
|
67
78
|
filesChanged: integer('files_changed').notNull().default(0),
|
|
68
79
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
69
80
|
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
package/src/orchestrator.ts
CHANGED
|
@@ -8,14 +8,14 @@ import type { ChildProcess } from 'node:child_process';
|
|
|
8
8
|
import { eq } from 'drizzle-orm';
|
|
9
9
|
import { config } from './config';
|
|
10
10
|
import { db } from './db';
|
|
11
|
-
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures } from './db/schema';
|
|
11
|
+
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots } from './db/schema';
|
|
12
12
|
import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
|
|
13
13
|
import { broadcast } from './hub';
|
|
14
14
|
import { getProvider, type Provider } from './providers';
|
|
15
15
|
|
|
16
16
|
/** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
|
|
17
|
-
function agentCommand(provider: Provider, prompt: string, real: boolean): string {
|
|
18
|
-
if (real) return provider.launchCmd(prompt);
|
|
17
|
+
function agentCommand(provider: Provider, prompt: string, real: boolean, model = ''): string {
|
|
18
|
+
if (real) return provider.launchCmd(prompt, model || undefined);
|
|
19
19
|
// 모의: init → assistant → (파일 변경) → result. claude stream-json 라인 형태
|
|
20
20
|
// (드라이런은 프로바이더 불문 배관 리허설 — claude 파서가 처리한다).
|
|
21
21
|
return [
|
|
@@ -142,7 +142,7 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
|
|
|
142
142
|
const runIds: number[] = [];
|
|
143
143
|
for (let i = 0; i < n; i++) {
|
|
144
144
|
const rIns = await db.insert(agentRuns).values({
|
|
145
|
-
taskId: task.id, machineId: pr.machineId, agent: pr.agent, status: 'pending',
|
|
145
|
+
taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending',
|
|
146
146
|
}).returning();
|
|
147
147
|
const run = rIns[0]!;
|
|
148
148
|
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
|
|
@@ -198,6 +198,7 @@ interface RunContext {
|
|
|
198
198
|
prompt: string;
|
|
199
199
|
real: boolean;
|
|
200
200
|
agent: string;
|
|
201
|
+
model: string;
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
async function loadContext(runId: number): Promise<RunContext | null> {
|
|
@@ -237,6 +238,7 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
237
238
|
prompt,
|
|
238
239
|
real: config.agent.real,
|
|
239
240
|
agent: run.agent,
|
|
241
|
+
model: run.model,
|
|
240
242
|
};
|
|
241
243
|
}
|
|
242
244
|
|
|
@@ -293,7 +295,7 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
293
295
|
envPrefix = `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(tok)}; `;
|
|
294
296
|
prompt += orchestrationNote();
|
|
295
297
|
}
|
|
296
|
-
const cmd = `cd ${shq(wtPath)} && ${envPrefix}${pidPrefix}{ ${agentCommand(provider, prompt, useReal)}; }`;
|
|
298
|
+
const cmd = `cd ${shq(wtPath)} && ${envPrefix}${pidPrefix}{ ${agentCommand(provider, prompt, useReal, ctx.model)}; }`;
|
|
297
299
|
// 파일 오케스트레이션 — 로컬 run 이 사는 동안 .coxpit/spawn.json 감시.
|
|
298
300
|
// .coxpit/ 는 repo exclude 에 넣어 diff/머지를 오염시키지 않는다(멱등).
|
|
299
301
|
let orchTimer: NodeJS.Timeout | null = null;
|
|
@@ -353,9 +355,33 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
353
355
|
const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
|
|
354
356
|
const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
|
|
355
357
|
await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
|
|
358
|
+
// 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
|
|
359
|
+
if (filesChanged > 0) void snapshotRunDocs(runId);
|
|
356
360
|
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
357
361
|
}
|
|
358
362
|
|
|
363
|
+
/**
|
|
364
|
+
* 변경 문서(md/html)를 DB 에 스냅샷. 최신 우선(기존 행 삭제 후 재삽입).
|
|
365
|
+
* 빈 읽기(worktree 이미 소멸 등)는 기존 스냅샷을 지우지 않는다.
|
|
366
|
+
*/
|
|
367
|
+
export async function snapshotRunDocs(runId: number): Promise<void> {
|
|
368
|
+
const d = await getRunDocs(runId).catch(() => null);
|
|
369
|
+
if (!d?.ok || d.docs.length === 0) return;
|
|
370
|
+
await db.delete(docSnapshots).where(eq(docSnapshots.runId, runId));
|
|
371
|
+
for (const doc of d.docs) await db.insert(docSnapshots).values({ runId, path: doc.path, kind: doc.kind, content: doc.content });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** worktree(라이브) → 스냅샷 폴백 공용 로더. server 의 /api/runs/:id/docs·/share 가 사용. */
|
|
375
|
+
export async function loadRunDocs(runId: number): Promise<{
|
|
376
|
+
docs: Array<{ path: string; kind: string; content: string }>; source: 'worktree' | 'snapshot';
|
|
377
|
+
}> {
|
|
378
|
+
const live = await getRunDocs(runId).catch(() => null);
|
|
379
|
+
if (live?.ok && live.docs.length > 0) return { docs: live.docs, source: 'worktree' };
|
|
380
|
+
const snap = await db.select().from(docSnapshots).where(eq(docSnapshots.runId, runId));
|
|
381
|
+
if (snap.length > 0) return { docs: snap.map((s) => ({ path: s.path, kind: s.kind, content: s.content })), source: 'snapshot' };
|
|
382
|
+
return { docs: [], source: 'worktree' };
|
|
383
|
+
}
|
|
384
|
+
|
|
359
385
|
/** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
|
|
360
386
|
async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
|
|
361
387
|
if (!config.webhookUrl) return;
|
|
@@ -409,7 +435,7 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
|
|
|
409
435
|
const envPrefix = (!isRemote && config.agentOrch)
|
|
410
436
|
? `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(issueAgentToken(runId))}; `
|
|
411
437
|
: '';
|
|
412
|
-
const resume = provider.resumeCmd(run.sessionId, finalMessage);
|
|
438
|
+
const resume = provider.resumeCmd(run.sessionId, finalMessage, run.model || undefined);
|
|
413
439
|
const cmd = `cd ${shq(wt)} && ${envPrefix}${pidPrefix}{ ${resume}; }`;
|
|
414
440
|
if (!isRemote && config.agentOrch) {
|
|
415
441
|
const orchTimer = startOrchWatch(runId, wt, true);
|
|
@@ -907,11 +933,30 @@ export async function reconcileOrphanRuns(): Promise<number> {
|
|
|
907
933
|
return stale.length;
|
|
908
934
|
}
|
|
909
935
|
|
|
936
|
+
/**
|
|
937
|
+
* Close 가드 — 태스크 닫으면 worktree 가 삭제되므로, 아직 살릴 곳 없는 산출물을 경고.
|
|
938
|
+
* 위험 = 정착(done/failed/stopped) ∧ 변경있음 ∧ 미머지 ∧ export·PR 이벤트 없음.
|
|
939
|
+
*/
|
|
940
|
+
export async function taskCloseRisk(taskId: number): Promise<Array<{ runId: number; filesChanged: number }>> {
|
|
941
|
+
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, taskId));
|
|
942
|
+
const atRisk: Array<{ runId: number; filesChanged: number }> = [];
|
|
943
|
+
for (const r of trs) {
|
|
944
|
+
if (!['done', 'failed', 'stopped'].includes(r.status)) continue;
|
|
945
|
+
if (r.filesChanged <= 0) continue;
|
|
946
|
+
const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, r.id));
|
|
947
|
+
if (evs.some((e) => e.kind === 'export' || e.kind === 'pr')) continue; // 산출물이 이미 탈출함
|
|
948
|
+
atRisk.push({ runId: r.id, filesChanged: r.filesChanged });
|
|
949
|
+
}
|
|
950
|
+
return atRisk;
|
|
951
|
+
}
|
|
952
|
+
|
|
910
953
|
export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
911
954
|
const ctx = await loadContext(runId);
|
|
912
955
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
913
956
|
const run = rr[0];
|
|
914
957
|
if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
|
|
958
|
+
// worktree 를 지우기 전에 문서 스냅샷(정착 안 하는 워크벤치·수정편집도 포착). best-effort.
|
|
959
|
+
await snapshotRunDocs(runId).catch(() => { /* 스냅샷 실패는 정리를 막지 않음 */ });
|
|
915
960
|
// 원격에 잔존 에이전트가 있으면 worktree 제거 전에 죽인다(파일 잠금·좀비 방지).
|
|
916
961
|
if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
|
|
917
962
|
await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
|
package/src/providers.ts
CHANGED
|
@@ -24,8 +24,9 @@ export interface Provider {
|
|
|
24
24
|
id: string;
|
|
25
25
|
label: string;
|
|
26
26
|
bin: string;
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** model 비었으면 CLI 기본값 사용(플래그 미첨부). */
|
|
28
|
+
launchCmd(prompt: string, model?: string): string;
|
|
29
|
+
resumeCmd(sessionId: string, message: string, model?: string): string;
|
|
29
30
|
/** null = 저장하지 않는 라인(스트림 잡음) */
|
|
30
31
|
parseLine(raw: string): ParsedEvent | null;
|
|
31
32
|
}
|
|
@@ -52,13 +53,14 @@ const claudeProvider: Provider = {
|
|
|
52
53
|
id: 'claude-code',
|
|
53
54
|
label: 'Claude Code',
|
|
54
55
|
get bin() { return config.agent.bin; },
|
|
55
|
-
launchCmd(prompt: string): string {
|
|
56
|
+
launchCmd(prompt: string, model?: string): string {
|
|
56
57
|
return `${config.agent.bin} -p ${shq(prompt)} --output-format stream-json --verbose` +
|
|
57
|
-
` --permission-mode ${config.agent.perm}
|
|
58
|
+
` --permission-mode ${config.agent.perm}` + (model ? ` --model ${shq(model)}` : '');
|
|
58
59
|
},
|
|
59
|
-
resumeCmd(sessionId: string, message: string): string {
|
|
60
|
+
resumeCmd(sessionId: string, message: string, model?: string): string {
|
|
60
61
|
return `${config.agent.bin} -p --resume ${shq(sessionId)} ${shq(message)}` +
|
|
61
|
-
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}
|
|
62
|
+
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}` +
|
|
63
|
+
(model ? ` --model ${shq(model)}` : '');
|
|
62
64
|
},
|
|
63
65
|
parseLine(raw: string): ParsedEvent | null {
|
|
64
66
|
const s = raw.trim();
|
|
@@ -116,12 +118,14 @@ const codexProvider: Provider = {
|
|
|
116
118
|
id: 'codex',
|
|
117
119
|
label: 'Codex',
|
|
118
120
|
get bin() { return config.codex.bin; },
|
|
119
|
-
launchCmd(prompt: string): string {
|
|
120
|
-
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}
|
|
121
|
+
launchCmd(prompt: string, model?: string): string {
|
|
122
|
+
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}` +
|
|
123
|
+
(model ? ` -m ${shq(model)}` : '') + ` ${shq(prompt)}`;
|
|
121
124
|
},
|
|
122
|
-
resumeCmd(sessionId: string, message: string): string {
|
|
123
|
-
// --sandbox 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
|
|
124
|
-
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}
|
|
125
|
+
resumeCmd(sessionId: string, message: string, model?: string): string {
|
|
126
|
+
// --sandbox·-m 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
|
|
127
|
+
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}` +
|
|
128
|
+
(model ? ` -m ${shq(model)}` : '') + ` resume ${shq(sessionId)} ${shq(message)}`;
|
|
125
129
|
},
|
|
126
130
|
parseLine(raw: string): ParsedEvent | null {
|
|
127
131
|
const s = raw.trim();
|
package/src/server.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { db } from './db';
|
|
|
13
13
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks } from './db/schema';
|
|
14
14
|
import { BOOKMARKLET_JS } from './design';
|
|
15
15
|
import { runShellOn, shq } from './exec';
|
|
16
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff,
|
|
16
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
|
|
17
17
|
import { openTerm } from './term';
|
|
18
18
|
import { addSink, removeSink, broadcast } from './hub';
|
|
19
19
|
import { getProvider, listProviders } from './providers';
|
|
@@ -33,6 +33,31 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
|
33
33
|
const escH = (x: unknown): string =>
|
|
34
34
|
String(x ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
|
35
35
|
|
|
36
|
+
/** 경량 마크다운 → HTML (보드 mdLite 의 서버측 판, 동일 문법). 입력은 먼저 escH. */
|
|
37
|
+
function mdLiteHTML(src: string): string {
|
|
38
|
+
let s = escH(src);
|
|
39
|
+
s = s.replace(/```[a-z]*\n([\s\S]*?)```/g, (_m, c: string) =>
|
|
40
|
+
'<pre style="background:#0e1118;border:1px solid #222835;border-radius:7px;padding:8px 10px;overflow-x:auto">' + c + '</pre>');
|
|
41
|
+
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
42
|
+
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
43
|
+
s = s.replace(/^# (.+)$/gm, '<h2>$1</h2>');
|
|
44
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
45
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
46
|
+
s = s.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
|
|
47
|
+
s = s.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, '<ul>$1</ul>');
|
|
48
|
+
s = s.split(/\n{2,}/).map((b) => /^<(h2|h3|ul|pre)/.test(b.trim()) ? b : (b.trim() ? '<p>' + b.replace(/\n/g, '<br>') + '</p>' : '')).join('');
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 공유 페이지 Documents 섹션 — md 는 mdLiteHTML, html 은 sandbox iframe. */
|
|
53
|
+
function shareDocsHTML(docs: Array<{ path: string; kind: string; content: string }>): string {
|
|
54
|
+
if (!docs.length) return '';
|
|
55
|
+
const body = docs.map((d) => d.kind === 'md'
|
|
56
|
+
? `<div class="doc"><div class="doc-h">${escH(d.path)}</div><div class="doc-b">${mdLiteHTML(d.content)}</div></div>`
|
|
57
|
+
: `<div class="doc"><div class="doc-h">${escH(d.path)}</div><iframe sandbox="" class="doc-frame" srcdoc="${escH(d.content)}"></iframe></div>`).join('');
|
|
58
|
+
return `<div class="sec">Documents</div>${body}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
/** 보드 humanize 의 서버측 축약판 — 이벤트 한 줄을 {k, t} 로. null = 잡음. */
|
|
37
62
|
function shareLine(kind: string, payload: string): { k: string; t: string } | null {
|
|
38
63
|
if (kind === 'steer') return { k: 'steer', t: '→ ' + payload };
|
|
@@ -82,6 +107,7 @@ function sharePageHTML(
|
|
|
82
107
|
taskTitle: string,
|
|
83
108
|
events: Array<{ kind: string; payload: string }>,
|
|
84
109
|
diff: string,
|
|
110
|
+
docs: Array<{ path: string; kind: string; content: string }> = [],
|
|
85
111
|
): string {
|
|
86
112
|
const lines = events.map((e) => shareLine(e.kind, e.payload)).filter((x): x is { k: string; t: string } => !!x);
|
|
87
113
|
const sc: Record<string, string> = { done: '#3fb970', merged: '#4ec9b0', failed: '#e5534b', error: '#e5534b', stopped: '#a371f7', running: '#4184e4', open: '#4ec9b0' };
|
|
@@ -107,6 +133,15 @@ function sharePageHTML(
|
|
|
107
133
|
font-family:ui-monospace,monospace;font-size:11.5px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
|
108
134
|
.f{color:#4ec9b0;font-weight:600}.h{color:#4184e4}.a{color:#3fb970}.d{color:#e5534b}
|
|
109
135
|
.sum{background:#12151c;border:1px solid #222835;border-radius:10px;padding:12px 14px;color:#8792a2;font-size:13px}
|
|
136
|
+
.doc{margin-bottom:18px}
|
|
137
|
+
.doc-h{font-family:ui-monospace,monospace;font-size:10.5px;color:#4ec9b0;border-bottom:1px solid #222835;padding-bottom:5px;margin-bottom:8px;word-break:break-all}
|
|
138
|
+
.doc-b{font-size:13.5px;line-height:1.65;color:#8792a2}
|
|
139
|
+
.doc-b h1,.doc-b h2{font-size:15px;color:#dee4ec;margin:12px 0 6px}
|
|
140
|
+
.doc-b h3{font-size:13px;color:#dee4ec;margin:10px 0 4px}
|
|
141
|
+
.doc-b ul{margin:4px 0 8px;padding-left:18px}.doc-b li{margin-bottom:3px}
|
|
142
|
+
.doc-b strong{color:#dee4ec}.doc-b p{margin:0 0 8px}
|
|
143
|
+
.doc-b code{font-family:ui-monospace,monospace;font-size:.9em;background:#0e1118;padding:1px 5px;border-radius:4px;color:#4ec9b0}
|
|
144
|
+
.doc-frame{width:100%;height:420px;border:1px solid #222835;border-radius:8px;background:#fff}
|
|
110
145
|
.ft{margin-top:40px;color:#3d4657;font-size:12px;font-family:ui-monospace,monospace}
|
|
111
146
|
.ft a{color:#4ec9b0;text-decoration:none}
|
|
112
147
|
</style></head><body><div class="wrap">
|
|
@@ -114,6 +149,7 @@ function sharePageHTML(
|
|
|
114
149
|
<h1>${escH(taskTitle)}</h1>
|
|
115
150
|
<div class="meta">branch ${escH(run.branch || '—')} · ${run.filesChanged} file(s) changed · agent ${escH(run.agent)}</div>
|
|
116
151
|
${run.exitSummary ? `<div class="sum">${escH(run.exitSummary)}</div>` : ''}
|
|
152
|
+
${shareDocsHTML(docs)}
|
|
117
153
|
<div class="sec">Timeline</div>
|
|
118
154
|
<div class="tl">${lines.map((l) => `<div><span class="k">${escH(l.k)}</span><span class="t">${escH(l.t.slice(0, 220))}</span></div>`).join('') || '<span style="color:#5c6675">no events</span>'}</div>
|
|
119
155
|
<div class="sec">Diff</div>
|
|
@@ -304,6 +340,26 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
304
340
|
return { ok: true };
|
|
305
341
|
});
|
|
306
342
|
|
|
343
|
+
// 기본 브랜치 변경 — merge·Sync base·PR 이 향할 대상. develop-flow repo 대응.
|
|
344
|
+
app.patch('/api/repos/:id', async (req, reply) => {
|
|
345
|
+
const id = Number((req.params as { id: string }).id);
|
|
346
|
+
const b = (req.body ?? {}) as { defaultBranch?: string };
|
|
347
|
+
const branch = (b.defaultBranch ?? '').trim();
|
|
348
|
+
if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
|
|
349
|
+
const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
|
|
350
|
+
const repo = rp[0];
|
|
351
|
+
if (!repo) return reply.code(404).send({ error: 'not found' });
|
|
352
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
353
|
+
const m = mr[0];
|
|
354
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
355
|
+
// branch 는 charset 가드 통과(셸 메타문자 없음). 전체 ref 를 인용해 전달.
|
|
356
|
+
const check = await runShellOn(m,
|
|
357
|
+
`git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
|
|
358
|
+
if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
|
|
359
|
+
await db.update(repos).set({ defaultBranch: branch }).where(eq(repos.id, id));
|
|
360
|
+
return { ok: true, defaultBranch: branch };
|
|
361
|
+
});
|
|
362
|
+
|
|
307
363
|
// 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
|
|
308
364
|
app.get('/api/browse', async (req) => {
|
|
309
365
|
const q = (req.query ?? {}) as { path?: string };
|
|
@@ -405,7 +461,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
405
461
|
// N개의 에이전트 run 을 만들고 각자 오케스트레이션 시작(fire-and-forget).
|
|
406
462
|
app.post('/api/tasks/:id/run', async (req, reply) => {
|
|
407
463
|
const id = Number((req.params as { id: string }).id);
|
|
408
|
-
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean };
|
|
464
|
+
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string };
|
|
409
465
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
410
466
|
const task = tr[0];
|
|
411
467
|
if (!task) return reply.code(404).send({ error: 'task not found' });
|
|
@@ -415,10 +471,15 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
415
471
|
const count = Math.max(1, Math.min(8, Number(b.count) || 1));
|
|
416
472
|
// 미지의 값은 기본 프로바이더로 정규화(런처 조작·API 오타 방어)
|
|
417
473
|
const agent = getProvider(b.agent).id;
|
|
474
|
+
// 모델 지정(선택) — 셸 안전 문자만, 빈값 = CLI 기본
|
|
475
|
+
const model = (b.model ?? '').trim();
|
|
476
|
+
if (model && (model.length > 64 || !/^[\w.\-:/]*$/.test(model))) {
|
|
477
|
+
return reply.code(400).send({ error: 'invalid model name' });
|
|
478
|
+
}
|
|
418
479
|
const created: Array<typeof agentRuns.$inferSelect> = [];
|
|
419
480
|
for (let i = 0; i < count; i++) {
|
|
420
481
|
const ins = await db.insert(agentRuns)
|
|
421
|
-
.values({ taskId: id, machineId: rp[0].machineId, agent, status: 'pending' })
|
|
482
|
+
.values({ taskId: id, machineId: rp[0].machineId, agent, model, status: 'pending' })
|
|
422
483
|
.returning();
|
|
423
484
|
created.push(ins[0]!);
|
|
424
485
|
}
|
|
@@ -458,8 +519,14 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
458
519
|
// 태스크 닫기 — 살아있는 run 중지 후 소속 run 전체 worktree/브랜치 정리.
|
|
459
520
|
app.post('/api/tasks/:id/close', async (req, reply) => {
|
|
460
521
|
const id = Number((req.params as { id: string }).id);
|
|
522
|
+
const b = (req.body ?? {}) as { force?: boolean };
|
|
461
523
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
462
524
|
if (!tr[0]) return reply.code(404).send({ error: 'task not found' });
|
|
525
|
+
// Close 가드 — 아직 살릴 곳 없는 산출물(미머지·미export·무PR)이 있으면 확인 요구.
|
|
526
|
+
if (!b.force) {
|
|
527
|
+
const atRisk = await taskCloseRisk(id);
|
|
528
|
+
if (atRisk.length) return reply.code(409).send({ error: 'unmerged output', atRisk });
|
|
529
|
+
}
|
|
463
530
|
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
|
|
464
531
|
|
|
465
532
|
let anyStopped = false;
|
|
@@ -603,12 +670,13 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
603
670
|
return getRunDiff(id);
|
|
604
671
|
});
|
|
605
672
|
|
|
606
|
-
// Doc 모드 — 변경된 문서(md/html)
|
|
673
|
+
// Doc 모드 — 변경된 문서(md/html) 내용째 (렌더 뷰). worktree 라이브 → 스냅샷 폴백.
|
|
607
674
|
app.get('/api/runs/:id/docs', async (req, reply) => {
|
|
608
675
|
const id = Number((req.params as { id: string }).id);
|
|
609
676
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
610
677
|
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
611
|
-
|
|
678
|
+
const { docs, source } = await loadRunDocs(id);
|
|
679
|
+
return { ok: true, docs, source };
|
|
612
680
|
});
|
|
613
681
|
|
|
614
682
|
// ─── 에이전트 셀프 오케스트레이션 (run 별 Bearer 토큰 — authGate 예외, 여기서 자체 검증) ──
|
|
@@ -693,7 +761,8 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
693
761
|
const task = (await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1))[0];
|
|
694
762
|
const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
|
|
695
763
|
const d = await getRunDiff(run.id).catch(() => ({ ok: false, diff: '', stat: '' }));
|
|
696
|
-
|
|
764
|
+
const { docs } = await loadRunDocs(run.id);
|
|
765
|
+
return reply.type('text/html').send(sharePageHTML(run, task?.title ?? `task ${run.taskId}`, evs, d.ok ? d.diff : '', docs));
|
|
697
766
|
});
|
|
698
767
|
|
|
699
768
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|