coxpit 3.0.1 → 3.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/package.json +1 -1
- package/src/board.ts +96 -62
- package/src/orchestrator.ts +44 -1
- package/src/server.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.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
|
@@ -13,12 +13,13 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
13
13
|
--ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
|
|
14
14
|
--brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
|
|
15
15
|
--s-pending:#8792a2; --s-preparing:#d6a249; --s-running:#55a7e0;
|
|
16
|
-
--s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0;
|
|
16
|
+
--s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0; --s-open:#7f9cf5;
|
|
17
17
|
--mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
|
|
18
18
|
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
|
|
19
19
|
--r-card:10px; --r-ctl:8px; --shadow:0 8px 28px rgba(0,0,0,.35);
|
|
20
20
|
}
|
|
21
21
|
*{box-sizing:border-box}
|
|
22
|
+
[hidden]{display:none !important}
|
|
22
23
|
html,body{height:100%}
|
|
23
24
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
|
|
24
25
|
-webkit-font-smoothing:antialiased}
|
|
@@ -298,60 +299,65 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
298
299
|
<div class="layout">
|
|
299
300
|
<aside>
|
|
300
301
|
<div class="sect">
|
|
301
|
-
<p class="sect-label">
|
|
302
|
-
<
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
<button class="btn sm"
|
|
302
|
+
<p class="sect-label">Context</p>
|
|
303
|
+
<p class="flabel">machine</p>
|
|
304
|
+
<select id="repoMachine"></select>
|
|
305
|
+
<p class="flabel">repository</p>
|
|
306
|
+
<select id="taskRepo"></select>
|
|
307
|
+
<div class="row">
|
|
308
|
+
<button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
|
|
309
|
+
<button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
|
|
310
|
+
<button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
|
|
311
|
+
</div>
|
|
312
|
+
<form id="repoForm" hidden>
|
|
308
313
|
<div class="row">
|
|
309
|
-
<input id="repoPath" placeholder="
|
|
314
|
+
<input id="repoPath" placeholder="/abs/path/to/repo" />
|
|
310
315
|
<button class="btn-ghost sm" type="submit" style="flex:0 0 auto">Register</button>
|
|
311
316
|
</div>
|
|
312
317
|
</form>
|
|
313
318
|
</div>
|
|
319
|
+
|
|
314
320
|
<div class="sect">
|
|
315
|
-
<p class="sect-label">
|
|
321
|
+
<p class="sect-label">Start</p>
|
|
322
|
+
<div class="seg" id="launchTabs">
|
|
323
|
+
<button type="button" class="seg-opt on" data-tab="task">Task</button>
|
|
324
|
+
<button type="button" class="seg-opt" data-tab="goal">Goal</button>
|
|
325
|
+
<button type="button" class="seg-opt" data-tab="bench">Workbench</button>
|
|
326
|
+
</div>
|
|
316
327
|
<form id="taskForm">
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
<
|
|
324
|
-
|
|
328
|
+
<div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
|
|
329
|
+
<input id="taskTitle" placeholder="Task title" />
|
|
330
|
+
<textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
|
|
331
|
+
<p class="flabel">design capture · optional</p>
|
|
332
|
+
<select id="taskCapture"><option value="">no design capture</option></select>
|
|
333
|
+
</div>
|
|
334
|
+
<div id="panelGoal" hidden style="flex-direction:column;gap:8px">
|
|
335
|
+
<textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge with Select runs → Integrate."></textarea>
|
|
336
|
+
</div>
|
|
337
|
+
<div id="panelBench" hidden style="flex-direction:column;gap:8px">
|
|
338
|
+
<input id="benchTitle" placeholder="Workbench name · optional" />
|
|
339
|
+
<p style="font-size:11.5px;color:var(--faint);margin:0;line-height:1.55">Isolated worktree + terminal. Work interactively — run <span style="color:var(--brand);font-family:var(--mono)">claude</span> inside, take hours — then decide the merge from the card.</p>
|
|
340
|
+
</div>
|
|
325
341
|
<div class="seg" id="modeSeg" role="group" aria-label="agent mode">
|
|
326
342
|
<button type="button" class="seg-opt" data-real="0">Dry run</button>
|
|
327
343
|
<button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
|
|
328
344
|
</div>
|
|
329
345
|
<input type="checkbox" id="taskReal" hidden />
|
|
330
346
|
<div class="row">
|
|
331
|
-
<input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="
|
|
347
|
+
<input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="agents — 1 for a job, N to explore variants" />
|
|
332
348
|
<button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
|
|
333
349
|
</div>
|
|
334
350
|
</form>
|
|
335
351
|
</div>
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
<p class="flabel">goal</p>
|
|
342
|
-
<textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge later with Select runs → Integrate."></textarea>
|
|
343
|
-
<button class="btn" type="submit" id="planGo">Plan & fan out</button>
|
|
344
|
-
<span style="font-size:11px;color:var(--faint);font-family:var(--mono)">follows the Dry/Real mode above · planner reads only</span>
|
|
345
|
-
</form>
|
|
346
|
-
</div>
|
|
347
|
-
<div class="sect">
|
|
348
|
-
<p class="sect-label">Design captures</p>
|
|
349
|
-
<div id="captures" style="display:flex;flex-direction:column;gap:6px"></div>
|
|
350
|
-
<a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px"
|
|
352
|
+
|
|
353
|
+
<details class="sect" id="libraryBox">
|
|
354
|
+
<summary class="sect-label" style="cursor:pointer;list-style:none">Library · design captures ▾</summary>
|
|
355
|
+
<div id="captures" style="display:flex;flex-direction:column;gap:6px;margin-top:10px"></div>
|
|
356
|
+
<a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px;margin-top:6px"
|
|
351
357
|
title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
|
|
352
358
|
<span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
|
|
353
359
|
With auth on, append ?k=<pass> to the script URL.</span>
|
|
354
|
-
</
|
|
360
|
+
</details>
|
|
355
361
|
</aside>
|
|
356
362
|
<main>
|
|
357
363
|
<div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
|
|
@@ -767,23 +773,19 @@ async function hydrate(){
|
|
|
767
773
|
function paintSidebar(){
|
|
768
774
|
$('machines').innerHTML = machines.map(m =>
|
|
769
775
|
'<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
|
|
770
|
-
$('repos').innerHTML = repos.map(r =>
|
|
771
|
-
'<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
|
|
772
|
-
+ '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
|
|
773
|
-
+ '<div class="path">'+esc(r.path)+'</div></div>').join('')
|
|
774
|
-
|| '<div class="repo" style="color:var(--faint)">none registered</div>';
|
|
775
776
|
$('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
|
|
777
|
+
const prevRepo = $('taskRepo').value;
|
|
776
778
|
$('taskRepo').innerHTML = repos.length
|
|
777
|
-
? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('')
|
|
778
|
-
: '<option value="">register a repo
|
|
779
|
+
? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+' · '+esc(r.defaultBranch)+'</option>').join('')
|
|
780
|
+
: '<option value="">register a repo — Browse… ↓</option>';
|
|
781
|
+
if (prevRepo && repos.some(r=>String(r.id)===prevRepo)) $('taskRepo').value = prevRepo;
|
|
779
782
|
$('runFleetBtn').disabled = !repos.length;
|
|
780
783
|
const capSel = $('taskCapture');
|
|
781
784
|
const cur = capSel.value;
|
|
782
785
|
capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
|
|
783
786
|
'<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
|
|
784
787
|
capSel.value = cur;
|
|
785
|
-
|
|
786
|
-
['repoMachine','taskRepo','taskCapture','planRepo'].forEach(id => { dressSelect(id); syncSelect(id); });
|
|
788
|
+
['repoMachine','taskRepo','taskCapture'].forEach(id => { dressSelect(id); syncSelect(id); });
|
|
787
789
|
$('captures').innerHTML = captures.map(c=>
|
|
788
790
|
'<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
|
|
789
791
|
+ '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
|
|
@@ -797,16 +799,21 @@ $('captures').addEventListener('click', async (e)=>{
|
|
|
797
799
|
await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
|
|
798
800
|
hydrate();
|
|
799
801
|
});
|
|
800
|
-
$('
|
|
801
|
-
const
|
|
802
|
-
|
|
802
|
+
$('repoRemove').addEventListener('click', async ()=>{
|
|
803
|
+
const rid = $('taskRepo').value;
|
|
804
|
+
if (!rid){ toast('no repository selected', 'error'); return; }
|
|
805
|
+
const yes = await confirmUI('Remove the selected repository from coxpit?',
|
|
803
806
|
{ sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
|
|
804
807
|
if (!yes) return;
|
|
805
|
-
const res = await fetch('/api/repos/'+
|
|
808
|
+
const res = await fetch('/api/repos/'+rid,{method:'DELETE'});
|
|
806
809
|
const j = await res.json().catch(()=>({}));
|
|
807
810
|
if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
|
|
808
811
|
else toast('remove: '+(j.detail||res.status), 'error');
|
|
809
812
|
});
|
|
813
|
+
$('repoManual').addEventListener('click', ()=>{
|
|
814
|
+
const f = $('repoForm'); f.hidden = !f.hidden;
|
|
815
|
+
if (!f.hidden) $('repoPath').focus();
|
|
816
|
+
});
|
|
810
817
|
|
|
811
818
|
/* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
|
|
812
819
|
let notifyOn = false;
|
|
@@ -1172,28 +1179,53 @@ $('mTerm').addEventListener('click', ()=>{
|
|
|
1172
1179
|
});
|
|
1173
1180
|
|
|
1174
1181
|
/* ── forms ── */
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1182
|
+
/* ── 런처 탭 — 하나의 시작점: Task | Goal | Workbench ── */
|
|
1183
|
+
let lTab = 'task';
|
|
1184
|
+
const L_LABEL = { task: 'Run fleet', goal: 'Plan & fan out', bench: 'Open workbench' };
|
|
1185
|
+
document.querySelectorAll('#launchTabs .seg-opt').forEach(b=>{
|
|
1186
|
+
b.addEventListener('click', ()=>{
|
|
1187
|
+
lTab = b.dataset.tab;
|
|
1188
|
+
document.querySelectorAll('#launchTabs .seg-opt').forEach(x=>x.classList.toggle('on', x===b));
|
|
1189
|
+
$('panelTask').style.display = lTab==='task' ? 'flex' : 'none';
|
|
1190
|
+
$('panelGoal').hidden = lTab!=='goal';
|
|
1191
|
+
$('panelGoal').style.display = lTab==='goal' ? 'flex' : 'none';
|
|
1192
|
+
$('panelBench').hidden = lTab!=='bench';
|
|
1193
|
+
$('panelBench').style.display = lTab==='bench' ? 'flex' : 'none';
|
|
1194
|
+
$('modeSeg').style.display = lTab==='bench' ? 'none' : 'flex'; // workbench 는 에이전트 없음
|
|
1195
|
+
$('taskCount').style.display = lTab==='task' ? '' : 'none';
|
|
1196
|
+
$('runFleetBtn').textContent = L_LABEL[lTab];
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
async function submitGoal(){
|
|
1201
|
+
const repoId = Number($('taskRepo').value);
|
|
1178
1202
|
const goal = $('planGoal').value.trim();
|
|
1179
1203
|
if (!repoId){ toast('register a repo first', 'error'); return; }
|
|
1180
1204
|
if (!goal){ toast('write a goal first', 'error'); return; }
|
|
1181
1205
|
const real = $('taskReal').checked;
|
|
1182
|
-
const btn = $('
|
|
1206
|
+
const btn = $('runFleetBtn');
|
|
1183
1207
|
btn.disabled = true; btn.textContent = real ? 'Planning… (1–3 min)' : 'Planning…';
|
|
1184
1208
|
try{
|
|
1185
1209
|
const res = await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},
|
|
1186
1210
|
body:JSON.stringify({repoId, goal, real})});
|
|
1187
1211
|
const j = await res.json().catch(()=>({}));
|
|
1188
|
-
if (res.ok){
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1212
|
+
if (res.ok){ toast(j.tasks.length+' task(s) planned & launched', 'ok'); $('planGoal').value=''; hydrate(); }
|
|
1213
|
+
else toast('plan: '+(j.detail||j.error||res.status), 'error');
|
|
1214
|
+
} finally { btn.disabled = false; btn.textContent = L_LABEL[lTab]; }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
async function submitBench(){
|
|
1218
|
+
const repoId = Number($('taskRepo').value);
|
|
1219
|
+
if (!repoId){ toast('register a repo first', 'error'); return; }
|
|
1220
|
+
const res = await fetch('/api/workbench',{method:'POST',headers:{'content-type':'application/json'},
|
|
1221
|
+
body:JSON.stringify({repoId, title: $('benchTitle').value.trim()})});
|
|
1222
|
+
const j = await res.json().catch(()=>({}));
|
|
1223
|
+
if (!res.ok){ toast('workbench: '+(j.detail||j.error||res.status), 'error'); return; }
|
|
1224
|
+
toast('workbench open — terminal attached', 'ok');
|
|
1225
|
+
$('benchTitle').value='';
|
|
1226
|
+
await hydrate();
|
|
1227
|
+
openTerm(j.runId); // 바로 터미널로
|
|
1228
|
+
}
|
|
1197
1229
|
|
|
1198
1230
|
$('repoForm').addEventListener('submit', async (e)=>{
|
|
1199
1231
|
e.preventDefault();
|
|
@@ -1205,9 +1237,11 @@ $('repoForm').addEventListener('submit', async (e)=>{
|
|
|
1205
1237
|
});
|
|
1206
1238
|
$('taskForm').addEventListener('submit', async (e)=>{
|
|
1207
1239
|
e.preventDefault();
|
|
1240
|
+
if (lTab === 'goal') return submitGoal();
|
|
1241
|
+
if (lTab === 'bench') return submitBench();
|
|
1208
1242
|
const repoId = Number($('taskRepo').value);
|
|
1209
1243
|
const title = $('taskTitle').value.trim();
|
|
1210
|
-
if (!repoId || !title) return;
|
|
1244
|
+
if (!repoId || !title){ toast(!repoId?'register a repo first':'task title required', 'error'); return; }
|
|
1211
1245
|
const capId = Number($('taskCapture').value) || undefined;
|
|
1212
1246
|
const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
|
|
1213
1247
|
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
|
package/src/orchestrator.ts
CHANGED
|
@@ -413,6 +413,49 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
|
|
|
413
413
|
return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Workbench — 인터랙티브 작업방: 격리 worktree + tmux 만 만들고 에이전트는
|
|
418
|
+
* 띄우지 않는다. 사람이 터미널로 들어가(원하면 claude TUI 로) 오래 작업하고,
|
|
419
|
+
* coxpit 은 diff·merge·PR·export 레일만 제공한다. status='open'.
|
|
420
|
+
*/
|
|
421
|
+
export async function openWorkbench(repoId: number, title: string): Promise<{
|
|
422
|
+
ok: boolean; detail: string; taskId?: number; runId?: number;
|
|
423
|
+
}> {
|
|
424
|
+
const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
|
|
425
|
+
const repo = rp[0];
|
|
426
|
+
if (!repo) return { ok: false, detail: 'repo not found' };
|
|
427
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
428
|
+
const m = mr[0];
|
|
429
|
+
if (!m) return { ok: false, detail: 'machine not found' };
|
|
430
|
+
const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
431
|
+
|
|
432
|
+
const tIns = await db.insert(tasks).values({ repoId, title: title || 'Workbench', prompt: '(interactive workbench)' }).returning();
|
|
433
|
+
const task = tIns[0]!;
|
|
434
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'workbench', status: 'pending' }).returning();
|
|
435
|
+
const run = rIns[0]!;
|
|
436
|
+
const runId = run.id;
|
|
437
|
+
broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'workbench', branch: '', filesChanged: 0 });
|
|
438
|
+
|
|
439
|
+
const branch = `coxpit/r${runId}`;
|
|
440
|
+
const wtParent = ppath.join(ppath.dirname(repo.path), '.coxpit-worktrees');
|
|
441
|
+
const wtPath = ppath.join(wtParent, `r${runId}`);
|
|
442
|
+
const session = `coxpit-r${runId}`;
|
|
443
|
+
|
|
444
|
+
const prep = await runShellOn(
|
|
445
|
+
machine,
|
|
446
|
+
`mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
|
|
447
|
+
` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
|
|
448
|
+
20000,
|
|
449
|
+
);
|
|
450
|
+
if (!prep.ok) {
|
|
451
|
+
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'workbench prep failed' });
|
|
452
|
+
return { ok: false, detail: (prep.stderr || prep.stdout).trim().slice(0, 300) };
|
|
453
|
+
}
|
|
454
|
+
await setRun(runId, { status: 'open', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
|
|
455
|
+
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, workbench: true }));
|
|
456
|
+
return { ok: true, detail: 'workbench open', taskId: task.id, runId };
|
|
457
|
+
}
|
|
458
|
+
|
|
416
459
|
/**
|
|
417
460
|
* Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
|
|
418
461
|
* 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
|
|
@@ -635,7 +678,7 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
|
|
|
635
678
|
const run = rr[0];
|
|
636
679
|
if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
|
|
637
680
|
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
|
|
638
|
-
if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
|
|
681
|
+
if (!['done', 'failed', 'stopped', 'open'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
|
|
639
682
|
const wt = shq(run.worktreePath);
|
|
640
683
|
|
|
641
684
|
// 0) 사전 조건: origin 리모트 + gh CLI
|
package/src/server.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { db } from './db';
|
|
|
12
12
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
|
|
13
13
|
import { BOOKMARKLET_JS } from './design';
|
|
14
14
|
import { runShellOn, shq } from './exec';
|
|
15
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun } from './orchestrator';
|
|
15
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench } from './orchestrator';
|
|
16
16
|
import { openTerm } from './term';
|
|
17
17
|
import { addSink, removeSink, broadcast } from './hub';
|
|
18
18
|
import { BOARD_HTML } from './board';
|
|
@@ -32,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
32
32
|
app.addHook('onRequest', authGate);
|
|
33
33
|
|
|
34
34
|
// 무인증 헬스(외부 감시용)
|
|
35
|
-
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.0
|
|
35
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.1.0' }));
|
|
36
36
|
|
|
37
37
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨.
|
|
38
38
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
@@ -422,6 +422,16 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
422
422
|
return res;
|
|
423
423
|
});
|
|
424
424
|
|
|
425
|
+
// Workbench — 인터랙티브 작업방(worktree+tmux, 에이전트 없음).
|
|
426
|
+
app.post('/api/workbench', async (req, reply) => {
|
|
427
|
+
const b = (req.body ?? {}) as { repoId?: number; title?: string };
|
|
428
|
+
const repoId = Number(b.repoId);
|
|
429
|
+
if (!repoId) return reply.code(400).send({ error: 'repoId required' });
|
|
430
|
+
const res = await openWorkbench(repoId, (b.title ?? '').trim());
|
|
431
|
+
if (!res.ok) return reply.code(422).send(res);
|
|
432
|
+
return reply.code(201).send(res);
|
|
433
|
+
});
|
|
434
|
+
|
|
425
435
|
// Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
|
|
426
436
|
// (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
|
|
427
437
|
app.post('/api/plan', async (req, reply) => {
|
|
@@ -491,7 +501,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
491
501
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
492
502
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
493
503
|
addSink(socket);
|
|
494
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.0
|
|
504
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.1.0' }));
|
|
495
505
|
socket.on('close', () => removeSink(socket));
|
|
496
506
|
});
|
|
497
507
|
|