coxpit 2.6.0 → 2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "2.6.0",
3
+ "version": "2.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/board.ts CHANGED
@@ -159,6 +159,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
159
159
  .ev{display:flex;gap:9px;align-items:baseline;min-width:0}
160
160
  .ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
161
161
  .ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
162
+ /* ── select mode (integrate) ── */
163
+ .toolbar{display:flex;justify-content:flex-end;gap:8px;margin-bottom:12px}
164
+ .card.selmode{cursor:copy}
165
+ .card .selbox{display:none;width:20px;height:20px;border-radius:50%;border:1px solid var(--line-hi);
166
+ align-items:center;justify-content:center;font-size:11px;color:transparent;flex:none}
167
+ .card.selmode .selbox{display:flex}
168
+ .card.selected{border-color:var(--brand)}
169
+ .card.selected .selbox{background:var(--brand);border-color:var(--brand);color:var(--brand-ink)}
170
+ .selbar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:45;display:none;
171
+ align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line-hi);border-radius:12px;
172
+ padding:10px 16px;box-shadow:var(--shadow)}
173
+ .selbar.on{display:flex}
174
+ .selbar .cnt{font-family:var(--mono);font-size:12px;color:var(--brand)}
175
+ .selbar .note{font-family:var(--mono);font-size:11px;color:var(--faint)}
176
+
162
177
  .empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
163
178
  display:flex;flex-direction:column;gap:10px;align-items:center}
164
179
  .empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
@@ -300,7 +315,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
300
315
  </div>
301
316
  <input type="checkbox" id="taskReal" hidden />
302
317
  <div class="row">
303
- <input id="taskCount" class="narrow" type="number" min="1" max="8" value="2" title="number of agents" />
318
+ <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="number of agents — 1 for a job, N to explore variants" />
304
319
  <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
305
320
  </div>
306
321
  </form>
@@ -315,6 +330,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
315
330
  </div>
316
331
  </aside>
317
332
  <main>
333
+ <div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
318
334
  <div class="grid" id="grid"></div>
319
335
  <div class="empty" id="empty">
320
336
  <span class="glyph">▚▞▚</span>
@@ -400,6 +416,13 @@ export const BOARD_HTML = /* html */ `<!doctype html>
400
416
 
401
417
  <div class="toasts" id="toasts"></div>
402
418
 
419
+ <div class="selbar" id="selbar">
420
+ <span class="cnt" id="selCnt">0 selected</span>
421
+ <span class="note">merges in selection order · conflicts spawn an integration agent</span>
422
+ <button class="btn sm" id="selGo">Integrate → base</button>
423
+ <button class="btn-ghost sm" id="selCancel">Cancel</button>
424
+ </div>
425
+
403
426
  <div class="overlay" id="expOverlay">
404
427
  <div class="cfm">
405
428
  <div class="cfm-b">
@@ -636,8 +659,10 @@ function cardHTML(r){
636
659
  const evs = (r.events||[]).slice(-8).map(e =>
637
660
  '<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload)).slice(0,140)+'</span></div>'
638
661
  ).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
639
- return '<div class="card" id="card-'+r.id+'">'
640
- + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'+chipHTML(r.status)+'</div>'
662
+ const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
663
+ return '<div class="card'+selCls+'" id="card-'+r.id+'">'
664
+ + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
665
+ + '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
641
666
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
642
667
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
643
668
  + '<span>'+esc(r.agent||'')+'</span>'
@@ -717,6 +742,7 @@ function connectWS(){
717
742
  const known = runs.has(ev.runId ?? ev.id);
718
743
  upsertRun(ev);
719
744
  if (!known && ev.taskId==null){ hydrate(); return; }
745
+ if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
720
746
  render(); flash(ev.runId ?? ev.id); paintModal();
721
747
  if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
722
748
  if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
@@ -766,9 +792,48 @@ async function loadDiff(){
766
792
  }
767
793
  function openModal(id){ openRunId = id; paintModal(); $('overlay').classList.add('open'); loadDiff(); }
768
794
  function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
795
+ /* ── select mode (integrate) ── */
796
+ let selectMode = false;
797
+ const selected = new Set();
798
+ const selOrder = [];
799
+ function setSelectMode(on){
800
+ selectMode = on;
801
+ if (!on){ selected.clear(); selOrder.length = 0; }
802
+ $('selToggle').textContent = on ? 'Exit select' : 'Select runs';
803
+ $('selbar').classList.toggle('on', on);
804
+ $('selCnt').textContent = selected.size + ' selected';
805
+ render();
806
+ }
807
+ $('selToggle').addEventListener('click', ()=>setSelectMode(!selectMode));
808
+ $('selCancel').addEventListener('click', ()=>setSelectMode(false));
809
+ $('selGo').addEventListener('click', async ()=>{
810
+ if (!selOrder.length){ toast('select settled runs with changes first', 'error'); return; }
811
+ const yes = await confirmUI('Integrate '+selOrder.length+' run(s) into the base branch?',
812
+ { sub: 'Merged one by one in selection order. A run that conflicts spawns an integration agent (real, spends credits) to resolve it.', okLabel: 'Integrate' });
813
+ if (!yes) return;
814
+ const res = await fetch('/api/integrate',{method:'POST',headers:{'content-type':'application/json'},
815
+ body:JSON.stringify({runIds:selOrder})});
816
+ const j = await res.json().catch(()=>({}));
817
+ if (res.ok){
818
+ toast('integrate: '+j.merged+' merged · '+j.conflicts+' conflict→agent · '+j.skipped+' skipped', j.conflicts||j.skipped ? undefined : 'ok');
819
+ setSelectMode(false); hydrate();
820
+ } else toast('integrate: '+(j.error||res.status), 'error');
821
+ });
822
+
769
823
  $('grid').addEventListener('click',(e)=>{
770
824
  const card = e.target.closest('.card'); if(!card) return;
771
- openModal(Number(card.id.replace('card-','')));
825
+ const id = Number(card.id.replace('card-',''));
826
+ if (selectMode){
827
+ const r = runs.get(id);
828
+ const eligible = r && ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0) > 0 && r.status!=='merged';
829
+ if (!eligible){ toast('r'+id+': only settled runs with changes can be integrated', 'error'); return; }
830
+ if (selected.has(id)){ selected.delete(id); selOrder.splice(selOrder.indexOf(id),1); }
831
+ else { selected.add(id); selOrder.push(id); }
832
+ $('selCnt').textContent = selected.size + ' selected';
833
+ render();
834
+ return;
835
+ }
836
+ openModal(id);
772
837
  });
773
838
  $('mClose').addEventListener('click', closeModal);
774
839
  $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
@@ -45,6 +45,8 @@ const stoppedRuns = new Set<number>();
45
45
  interface RunContext {
46
46
  runId: number;
47
47
  machine: MachineTarget;
48
+ machineId: number;
49
+ repoId: number;
48
50
  repoPath: string;
49
51
  baseBranch: string;
50
52
  prompt: string;
@@ -81,6 +83,8 @@ async function loadContext(runId: number): Promise<RunContext | null> {
81
83
  return {
82
84
  runId,
83
85
  machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
86
+ machineId: m.id,
87
+ repoId: repo.id,
84
88
  repoPath: repo.path,
85
89
  baseBranch: repo.defaultBranch,
86
90
  prompt,
@@ -285,7 +289,7 @@ export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: st
285
289
  * 승자 run 머지 — worktree 미커밋 변경을 자동 커밋 후 run 브랜치를
286
290
  * repo 기본 브랜치에 merge. 본 repo 가 기본 브랜치+클린일 때만, 충돌 시 abort.
287
291
  */
288
- export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string }> {
292
+ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: string; conflict?: boolean }> {
289
293
  const ctx = await loadContext(runId);
290
294
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
291
295
  const run = rr[0];
@@ -323,12 +327,59 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
323
327
  30000,
324
328
  );
325
329
  if (mg.stdout.includes('COXPIT_MERGE_FAILED')) {
326
- return { ok: false, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
330
+ return { ok: false, conflict: true, detail: 'merge conflict — aborted: ' + mg.stdout.replace('COXPIT_MERGE_FAILED', '').trim().slice(0, 300) };
327
331
  }
328
332
  await setRun(runId, { status: 'merged' });
329
333
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
330
334
  }
331
335
 
336
+ export interface IntegrateResult {
337
+ runId: number;
338
+ status: 'merged' | 'conflict' | 'skipped';
339
+ detail?: string;
340
+ integrationTaskId?: number;
341
+ integrationRunId?: number;
342
+ }
343
+
344
+ /**
345
+ * 통합(Integrate) — 여러 run(태스크 무관)을 base 에 순차 머지한다.
346
+ * 충돌 나는 run 은 멈추지 않고 "통합 태스크"를 자동 발사 — 에이전트가
347
+ * base 에서 분기한 새 worktree 에서 해당 브랜치를 머지·충돌 해소한다.
348
+ * (스웜의 수렴 단계: 분업한 결과를 다시 하나로.)
349
+ */
350
+ export async function integrateRuns(runIds: number[], real?: boolean): Promise<IntegrateResult[]> {
351
+ const results: IntegrateResult[] = [];
352
+ for (const id of runIds) {
353
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
354
+ const run = rr[0];
355
+ if (!run) { results.push({ runId: id, status: 'skipped', detail: 'not found' }); continue; }
356
+ if (run.status === 'merged') { results.push({ runId: id, status: 'skipped', detail: 'already merged' }); continue; }
357
+
358
+ const m = await mergeRun(id);
359
+ if (m.ok) { results.push({ runId: id, status: 'merged' }); continue; }
360
+ if (!m.conflict) { results.push({ runId: id, status: 'skipped', detail: m.detail }); continue; }
361
+
362
+ // 충돌 → 통합 태스크 자동 발사 (에이전트가 머지를 대신 푼다)
363
+ const ctx = await loadContext(id);
364
+ if (!ctx) { results.push({ runId: id, status: 'skipped', detail: 'context missing for integration' }); continue; }
365
+ const title = `Integrate r${id} into ${ctx.baseBranch}`;
366
+ const prompt =
367
+ `This worktree is branched from '${ctx.baseBranch}'. Merge the branch '${run.branch}' into it:\n` +
368
+ `1. Run: git merge ${run.branch}\n` +
369
+ `2. Resolve every conflict by preserving the intent of BOTH sides — do not simply pick one side.\n` +
370
+ `3. Make sure the result is consistent (typecheck/tests if available), then commit the merge.\n` +
371
+ `Do not modify files unrelated to the conflicts.`;
372
+ const tIns = await db.insert(tasks).values({ repoId: ctx.repoId, title, prompt }).returning();
373
+ const newTask = tIns[0]!;
374
+ const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending' }).returning();
375
+ const newRun = rIns[0]!;
376
+ broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, branch: '', filesChanged: 0 });
377
+ void launchRun(newRun.id, real ?? true);
378
+ results.push({ runId: id, status: 'conflict', detail: m.detail, integrationTaskId: newTask.id, integrationRunId: newRun.id });
379
+ }
380
+ return results;
381
+ }
382
+
332
383
  /**
333
384
  * 결과 파일 회수(export) — 조회성 태스크용: worktree 의 변경·신규 파일을
334
385
  * 지정 폴더로 복사한다. 머지 없이 산출물만 가져오는 길. (v1: 로컬 머신 전용)
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 } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns } 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: '2.6.0' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.7.0' }));
36
36
 
37
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -401,6 +401,22 @@ export async function buildServer(): Promise<FastifyInstance> {
401
401
  return reply.code(202).send(res);
402
402
  });
403
403
 
404
+ // 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
405
+ app.post('/api/integrate', async (req, reply) => {
406
+ const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };
407
+ const ids = (b.runIds ?? []).map(Number).filter((n) => Number.isInteger(n) && n > 0);
408
+ if (!ids.length) return reply.code(400).send({ error: 'runIds required' });
409
+ if (ids.length > 20) return reply.code(400).send({ error: 'too many runs (max 20)' });
410
+ const results = await integrateRuns(ids, b.real);
411
+ return {
412
+ ok: true,
413
+ merged: results.filter((r) => r.status === 'merged').length,
414
+ conflicts: results.filter((r) => r.status === 'conflict').length,
415
+ skipped: results.filter((r) => r.status === 'skipped').length,
416
+ results,
417
+ };
418
+ });
419
+
404
420
  // 결과 파일 회수 — 머지 없이 worktree 산출물만 지정 폴더로 복사(조회성 태스크).
405
421
  app.post('/api/runs/:id/export', async (req, reply) => {
406
422
  const id = Number((req.params as { id: string }).id);
@@ -441,7 +457,7 @@ export async function buildServer(): Promise<FastifyInstance> {
441
457
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
442
458
  app.get('/ws', { websocket: true }, (socket) => {
443
459
  addSink(socket);
444
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.6.0' }));
460
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.7.0' }));
445
461
  socket.on('close', () => removeSink(socket));
446
462
  });
447
463