coxpit 4.1.0 → 4.2.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 +1 -1
- package/package.json +1 -1
- package/src/board.ts +107 -3
- package/src/db/index.ts +7 -0
- package/src/db/schema.ts +9 -0
- package/src/orchestrator.ts +15 -3
- package/src/server.ts +4 -3
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
|
|
|
110
110
|
|
|
111
111
|
## Status
|
|
112
112
|
|
|
113
|
-
`v4.
|
|
113
|
+
`v4.2` — everything in v4.1 plus **run grouping**: goal fan-outs and agent swarms cluster into bands on the board (title · N tasks · M settled, with fold, group Select runs, and Close group), and multi-run tasks get `run i/n` attempt counters so same-title cards read as attempts, not duplicates. All shipped and e2e-tested (36 checks). Roadmap: ROADMAP.md.
|
|
114
114
|
|
|
115
115
|
## License
|
|
116
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.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
|
@@ -141,6 +141,17 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
141
141
|
|
|
142
142
|
/* ── run cards ──────────────────────────── */
|
|
143
143
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
|
|
144
|
+
/* ── group bands (goal/swarm 형제 묶음) — 점선 클러스터, 좌측 액센트 바·채움 금지 ── */
|
|
145
|
+
.gband{grid-column:1/-1;border:1px dashed var(--line-hi);border-radius:14px;padding:12px;margin-bottom:0}
|
|
146
|
+
.gband-h{display:flex;align-items:center;gap:10px;margin-bottom:10px;font-family:var(--mono)}
|
|
147
|
+
.gband-glyph{color:var(--brand);font-size:13px}
|
|
148
|
+
.gband-t{color:var(--ink);font-size:12.5px;font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:40%}
|
|
149
|
+
.gband-n{color:var(--faint);font-size:11px}
|
|
150
|
+
.gband-sp{flex:1}
|
|
151
|
+
.gband-fold{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px}
|
|
152
|
+
.gband.folded .gband-grid{display:none}
|
|
153
|
+
.gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
|
|
154
|
+
.attempt{color:var(--brand);opacity:.8}
|
|
144
155
|
.card{border:1px solid var(--line);border-radius:var(--r-card);background:var(--surface);
|
|
145
156
|
overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
|
|
146
157
|
transition:border-color .18s,transform .18s,box-shadow .18s}
|
|
@@ -625,6 +636,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
625
636
|
<script>
|
|
626
637
|
const runs = new Map(); // runId -> run object
|
|
627
638
|
const tasks = new Map(); // taskId -> task
|
|
639
|
+
const groups = new Map(); // groupId -> {id, kind, title}
|
|
628
640
|
let repos = [], machines = [], captures = [];
|
|
629
641
|
|
|
630
642
|
const $ = (id) => document.getElementById(id);
|
|
@@ -828,12 +840,56 @@ function chipHTML(status){
|
|
|
828
840
|
return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
|
|
829
841
|
+ '<i></i>'+esc(s)+'</span>';
|
|
830
842
|
}
|
|
843
|
+
/* 같은 태스크에 run 이 여럿이면 "run i/n" — 같은 제목 카드가 중복이 아니라 시도로 읽히게 */
|
|
844
|
+
function attemptHTML(r){
|
|
845
|
+
const sib = [...runs.values()].filter(x=>x.taskId===r.taskId).sort((a,b)=>a.id-b.id);
|
|
846
|
+
if (sib.length<2) return '';
|
|
847
|
+
const i = sib.findIndex(x=>x.id===r.id)+1;
|
|
848
|
+
return '<span class="attempt" title="attempt within this task">run '+i+'/'+sib.length+'</span>';
|
|
849
|
+
}
|
|
831
850
|
|
|
851
|
+
/* ── group fold 상태(기기별, WS 재렌더에도 유지) ── */
|
|
852
|
+
let gfold = new Set();
|
|
853
|
+
try{ gfold = new Set(JSON.parse(localStorage.getItem('coxpit.gfold')||'[]')); }catch{}
|
|
854
|
+
function saveGfold(){ try{ localStorage.setItem('coxpit.gfold', JSON.stringify([...gfold])); }catch{} }
|
|
855
|
+
/* 태스크의 모든 run 이 정착했는가 */
|
|
856
|
+
function taskSettled(taskId){
|
|
857
|
+
const rs = [...runs.values()].filter(r=>r.taskId===taskId);
|
|
858
|
+
return rs.length>0 && rs.every(r=>['done','failed','stopped','merged'].includes(r.status));
|
|
859
|
+
}
|
|
860
|
+
function bandHTML(g, grpRuns){
|
|
861
|
+
const glyph = g.kind==='swarm' ? '↳' : '⌁';
|
|
862
|
+
const title = g.kind==='swarm' ? 'swarm of: '+esc(g.title) : esc(g.title);
|
|
863
|
+
const taskIds = [...new Set(grpRuns.map(r=>r.taskId))];
|
|
864
|
+
const settled = taskIds.filter(taskSettled).length;
|
|
865
|
+
const folded = gfold.has(g.id);
|
|
866
|
+
const cards = grpRuns.slice().sort((a,b)=>a.id-b.id).map(cardHTML).join('');
|
|
867
|
+
return '<div class="gband'+(folded?' folded':'')+'" data-g="'+g.id+'">'
|
|
868
|
+
+ '<div class="gband-h"><span class="gband-glyph">'+glyph+'</span>'
|
|
869
|
+
+ '<span class="gband-t">'+title+'</span>'
|
|
870
|
+
+ '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
|
|
871
|
+
+ '<span class="gband-sp"></span>'
|
|
872
|
+
+ '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
|
|
873
|
+
+ '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
|
|
874
|
+
+ '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
|
|
875
|
+
+ '<div class="gband-grid">'+cards+'</div></div>';
|
|
876
|
+
}
|
|
832
877
|
function render(){
|
|
833
878
|
const list = [...runs.values()].sort((a,b)=>b.id-a.id);
|
|
834
879
|
$('empty').style.display = list.length ? 'none' : 'flex';
|
|
835
|
-
if (!list.length) paintOnboarding();
|
|
836
|
-
|
|
880
|
+
if (!list.length){ paintOnboarding(); $('grid').innerHTML=''; return; }
|
|
881
|
+
// 그룹 파티션 — grouped run 은 밴드로 클러스터, ungrouped 는 뒤에 flat.
|
|
882
|
+
const byGroup = new Map(); const flat = [];
|
|
883
|
+
for (const r of list){
|
|
884
|
+
const t = tasks.get(r.taskId);
|
|
885
|
+
const gid = t && t.groupId!=null && groups.has(t.groupId) ? t.groupId : null;
|
|
886
|
+
if (gid==null) flat.push(r);
|
|
887
|
+
else { if(!byGroup.has(gid)) byGroup.set(gid, []); byGroup.get(gid).push(r); }
|
|
888
|
+
}
|
|
889
|
+
let html = '';
|
|
890
|
+
for (const gid of [...byGroup.keys()].sort((a,b)=>b-a)) html += bandHTML(groups.get(gid), byGroup.get(gid));
|
|
891
|
+
html += flat.map(cardHTML).join('');
|
|
892
|
+
$('grid').innerHTML = html;
|
|
837
893
|
if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
|
|
838
894
|
}
|
|
839
895
|
|
|
@@ -898,6 +954,7 @@ function cardHTML(r){
|
|
|
898
954
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
899
955
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
900
956
|
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
957
|
+
+ attemptHTML(r)
|
|
901
958
|
+ (r.model ? '<span title="model">⚙ '+esc(r.model)+'</span>' : '')
|
|
902
959
|
+ (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
|
|
903
960
|
+ (r.sessionId && ['done','failed','stopped'].includes(r.status)
|
|
@@ -920,6 +977,8 @@ async function hydrate(){
|
|
|
920
977
|
machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
|
|
921
978
|
tasks.clear();
|
|
922
979
|
(r.tasks||[]).forEach(t => tasks.set(t.id, t));
|
|
980
|
+
groups.clear();
|
|
981
|
+
(r.groups||[]).forEach(g => groups.set(g.id, g));
|
|
923
982
|
runs.clear();
|
|
924
983
|
(r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
|
|
925
984
|
if (r.daemon) {
|
|
@@ -1048,7 +1107,7 @@ function connectWS(){
|
|
|
1048
1107
|
render(); flash(ev.runId); paintModal();
|
|
1049
1108
|
} else if (ev.type==='task'){
|
|
1050
1109
|
const t = tasks.get(ev.taskId);
|
|
1051
|
-
if (t){ t.status = ev.status; render(); paintModal(); } else { hydrate(); }
|
|
1110
|
+
if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; render(); paintModal(); } else { hydrate(); }
|
|
1052
1111
|
} else if (ev.type==='capture'){
|
|
1053
1112
|
captures.push(ev.capture); paintSidebar();
|
|
1054
1113
|
}
|
|
@@ -1174,6 +1233,51 @@ $('selGo').addEventListener('click', async ()=>{
|
|
|
1174
1233
|
} else toast('integrate: '+(j.error||res.status), 'error');
|
|
1175
1234
|
});
|
|
1176
1235
|
|
|
1236
|
+
/* ── 그룹 밴드 액션 (fold / Select runs / Close group) ── */
|
|
1237
|
+
$('grid').addEventListener('click', async (e)=>{
|
|
1238
|
+
const fold = e.target.closest('[data-gfold]');
|
|
1239
|
+
if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
|
|
1240
|
+
const gsel = e.target.closest('[data-gsel]');
|
|
1241
|
+
if (gsel){
|
|
1242
|
+
const g=Number(gsel.dataset.gsel);
|
|
1243
|
+
if (!selectMode) setSelectMode(true);
|
|
1244
|
+
const tids = new Set([...tasks.values()].filter(t=>t.groupId===g).map(t=>t.id));
|
|
1245
|
+
for (const r of [...runs.values()].sort((a,b)=>a.id-b.id)){
|
|
1246
|
+
if (!tids.has(r.taskId)) continue;
|
|
1247
|
+
const ok = ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
|
|
1248
|
+
if (ok && !selected.has(r.id)){ selected.add(r.id); selOrder.push(r.id); }
|
|
1249
|
+
}
|
|
1250
|
+
$('selCnt').textContent = selected.size + ' selected';
|
|
1251
|
+
render();
|
|
1252
|
+
if (!selected.size) toast('no settled runs with changes in this group yet', 'error');
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const gclose = e.target.closest('[data-gclose]');
|
|
1256
|
+
if (gclose){ await closeGroup(Number(gclose.dataset.gclose)); return; }
|
|
1257
|
+
});
|
|
1258
|
+
async function closeGroup(g){
|
|
1259
|
+
const tids = [...tasks.values()].filter(t=>t.groupId===g && t.status!=='closed').map(t=>t.id);
|
|
1260
|
+
if (!tids.length){ toast('nothing open in this group', 'ok'); return; }
|
|
1261
|
+
const grp = groups.get(g)||{};
|
|
1262
|
+
const yes = await confirmUI('Close this whole group?',
|
|
1263
|
+
{ sub: (grp.title||'group')+' — stops and cleans every worktree/branch of '+tids.length+' task(s).', danger:true, okLabel:'Close group' });
|
|
1264
|
+
if (!yes) return;
|
|
1265
|
+
const closeOne = (id,force)=>fetch('/api/tasks/'+id+'/close',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({force})});
|
|
1266
|
+
const risky = [];
|
|
1267
|
+
for (const id of tids){
|
|
1268
|
+
const res = await closeOne(id,false);
|
|
1269
|
+
if (res.status===409){ const j=await res.json().catch(()=>({})); (j.atRisk||[]).forEach(a=>risky.push(a)); }
|
|
1270
|
+
}
|
|
1271
|
+
if (risky.length){
|
|
1272
|
+
const list = risky.map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
|
|
1273
|
+
const ok = await confirmUI('Close and delete unmerged output?',
|
|
1274
|
+
{ danger:true, sub: list+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
|
|
1275
|
+
if (!ok){ await hydrate(); return; }
|
|
1276
|
+
for (const id of tids) await closeOne(id,true);
|
|
1277
|
+
}
|
|
1278
|
+
toast('closed '+tids.length+' task'+(tids.length>1?'s':'')+' in the group', 'ok');
|
|
1279
|
+
await hydrate();
|
|
1280
|
+
}
|
|
1177
1281
|
$('grid').addEventListener('click',(e)=>{
|
|
1178
1282
|
const card = e.target.closest('.card'); if(!card) return;
|
|
1179
1283
|
const id = Number(card.id.replace('card-',''));
|
package/src/db/index.ts
CHANGED
|
@@ -83,6 +83,12 @@ export async function ensureSchema(): Promise<void> {
|
|
|
83
83
|
content TEXT NOT NULL DEFAULT '',
|
|
84
84
|
created_at INTEGER DEFAULT (unixepoch())
|
|
85
85
|
);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS task_groups (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
kind TEXT NOT NULL DEFAULT 'goal',
|
|
89
|
+
title TEXT NOT NULL,
|
|
90
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
91
|
+
);
|
|
86
92
|
`);
|
|
87
93
|
// 기존 DB 마이그레이션(멱등)
|
|
88
94
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
@@ -90,4 +96,5 @@ export async function ensureSchema(): Promise<void> {
|
|
|
90
96
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
91
97
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
|
|
92
98
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
99
|
+
try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
|
|
93
100
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -32,6 +32,14 @@ export const designCaptures = sqliteTable('design_captures', {
|
|
|
32
32
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
/** 태스크 그룹 — 한 goal(plan fan-out)·한 swarm(에이전트 서브태스크)에서 난 형제들. */
|
|
36
|
+
export const taskGroups = sqliteTable('task_groups', {
|
|
37
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
38
|
+
kind: text('kind').notNull().default('goal'), // 'goal' | 'swarm'
|
|
39
|
+
title: text('title').notNull(),
|
|
40
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
41
|
+
});
|
|
42
|
+
|
|
35
43
|
/** 하나의 요청. 여러 AgentRun 으로 병렬 시도됨. */
|
|
36
44
|
export const tasks = sqliteTable('tasks', {
|
|
37
45
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
@@ -41,6 +49,7 @@ export const tasks = sqliteTable('tasks', {
|
|
|
41
49
|
status: text('status').notNull().default('open'), // open | done
|
|
42
50
|
designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
|
|
43
51
|
parentRunId: integer('parent_run_id'), // 에이전트 셀프 오케스트레이션 — 이 태스크를 발사한 run
|
|
52
|
+
groupId: integer('group_id'), // task_groups — goal/swarm 형제 묶음(수동 태스크는 NULL)
|
|
44
53
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
45
54
|
});
|
|
46
55
|
|
package/src/orchestrator.ts
CHANGED
|
@@ -8,7 +8,7 @@ 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, docSnapshots } from './db/schema';
|
|
11
|
+
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups } 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';
|
|
@@ -134,9 +134,17 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
|
|
|
134
134
|
// 폭주 가드 — 한 부모가 만들 수 있는 하위 태스크 상한
|
|
135
135
|
const siblings = await db.select().from(tasks).where(eq(tasks.parentRunId, parentRunId));
|
|
136
136
|
if (siblings.length >= 8) return { ok: false, detail: 'subtask limit reached (8 per run)' };
|
|
137
|
+
// 그룹 — 부모가 이미 그룹에 속하면 그 그룹, 아니면 swarm 그룹 생성 후 부모까지 백필.
|
|
138
|
+
let groupId = pt.groupId ?? null;
|
|
139
|
+
if (groupId == null) {
|
|
140
|
+
const gIns = await db.insert(taskGroups).values({ kind: 'swarm', title: pt.title.slice(0, 140) }).returning();
|
|
141
|
+
groupId = gIns[0]!.id;
|
|
142
|
+
await db.update(tasks).set({ groupId }).where(eq(tasks.id, pt.id));
|
|
143
|
+
broadcast({ type: 'task', taskId: pt.id, groupId });
|
|
144
|
+
}
|
|
137
145
|
const n = Math.max(1, Math.min(4, count || 1));
|
|
138
146
|
const tIns = await db.insert(tasks).values({
|
|
139
|
-
repoId: pt.repoId, title: title.slice(0, 140), prompt, parentRunId,
|
|
147
|
+
repoId: pt.repoId, title: title.slice(0, 140), prompt, parentRunId, groupId,
|
|
140
148
|
}).returning();
|
|
141
149
|
const task = tIns[0]!;
|
|
142
150
|
const runIds: number[] = [];
|
|
@@ -714,9 +722,13 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
|
|
|
714
722
|
if (plan.length < 1) return { ok: false, detail: 'planner returned no tasks' };
|
|
715
723
|
}
|
|
716
724
|
|
|
725
|
+
// 이 goal 의 형제들을 한 그룹으로 묶는다(보드 밴드). 드라이 리허설도 동일.
|
|
726
|
+
const gIns = await db.insert(taskGroups).values({ kind: 'goal', title: goal.slice(0, 140) }).returning();
|
|
727
|
+
const groupId = gIns[0]!.id;
|
|
728
|
+
|
|
717
729
|
const created: Array<{ id: number; title: string; runId: number }> = [];
|
|
718
730
|
for (const t of plan) {
|
|
719
|
-
const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt }).returning();
|
|
731
|
+
const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt, groupId }).returning();
|
|
720
732
|
const task = tIns[0]!;
|
|
721
733
|
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
|
|
722
734
|
const run = rIns[0]!;
|
package/src/server.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { eq } from 'drizzle-orm';
|
|
|
10
10
|
import { authGate } from './auth';
|
|
11
11
|
import { config } from './config';
|
|
12
12
|
import { db } from './db';
|
|
13
|
-
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks } from './db/schema';
|
|
13
|
+
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
|
|
14
14
|
import { BOOKMARKLET_JS } from './design';
|
|
15
15
|
import { runShellOn, shq } from './exec';
|
|
16
16
|
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
|
|
@@ -171,13 +171,14 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
171
171
|
|
|
172
172
|
// 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
|
|
173
173
|
app.get('/api/fleet', async () => {
|
|
174
|
-
const [ms, rs, ts, rns, evs, dcs] = await Promise.all([
|
|
174
|
+
const [ms, rs, ts, rns, evs, dcs, gs] = await Promise.all([
|
|
175
175
|
db.select().from(machines),
|
|
176
176
|
db.select().from(repos),
|
|
177
177
|
db.select().from(tasks),
|
|
178
178
|
db.select().from(agentRuns),
|
|
179
179
|
db.select().from(agentEvents),
|
|
180
180
|
db.select().from(designCaptures),
|
|
181
|
+
db.select().from(taskGroups),
|
|
181
182
|
]);
|
|
182
183
|
const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
|
|
183
184
|
for (const e of evs) {
|
|
@@ -186,7 +187,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
186
187
|
byRun.set(e.runId, arr);
|
|
187
188
|
}
|
|
188
189
|
return {
|
|
189
|
-
machines: ms, repos: rs, tasks: ts, captures: dcs,
|
|
190
|
+
machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
|
|
190
191
|
runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
|
|
191
192
|
// 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
|
|
192
193
|
daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },
|