coxpit 4.2.0 → 4.3.1

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 CHANGED
@@ -70,7 +70,7 @@ Your keys and login never touch coxpit's config or database.
70
70
  |---|---|---|
71
71
  | `COXPIT_HOST` / `COXPIT_PORT` | `127.0.0.1` / `8210` | daemon bind |
72
72
  | `COXPIT_DB` | `~/.coxpit/coxpit.db` | SQLite (libSQL) file (a legacy `./coxpit.db` in the cwd is still honored) |
73
- | `COXPIT_AUTH_PASS` / `COXPIT_AUTH_USER` | — / `admin` | basic auth (empty pass = open; set it) |
73
+ | `COXPIT_AUTH_PASS` / `COXPIT_AUTH_USER` | — / `admin` | basic auth. **Empty pass = all requests rejected** (fail-closed) — set it, or use `COXPIT_AUTH_DISABLED=1` for local dev |
74
74
  | `COXPIT_AUTH_DISABLED` | — | `1` disables auth (local dev only) |
75
75
  | `COXPIT_SSH_KEY` | — | private key for remote machines (else ssh defaults/agent) |
76
76
  | `COXPIT_AGENT_REAL` | — | `1` = real agent CLI by default (credits!) |
@@ -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.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.
113
+ `v4.3` — everything in v4.2 plus **the logbook**: the board is Active-first (closed tasks leave the grid), an Archive view lists them as searchable rows, and `/api/fleet` now scopes to active work and caps events per run so it stays fast at hundreds of runs. Opening an archived task still renders its documents — from the v4.1 snapshot. All shipped and e2e-tested (38 checks). ⚠️ `/api/fleet` defaults to `view=active` now — pass `?view=all` for the previous everything-payload. Roadmap: ROADMAP.md.
114
114
 
115
115
  ## License
116
116
 
package/bin/coxpit.js CHANGED
@@ -29,9 +29,10 @@ Usage:
29
29
  Configuration is env-only (a .env file in the cwd is loaded):
30
30
  COXPIT_HOST bind host (default 127.0.0.1)
31
31
  COXPIT_PORT bind port (default 8210)
32
- COXPIT_DB SQLite (libSQL) file (default ./coxpit.db)
32
+ COXPIT_DB SQLite (libSQL) file (default ~/.coxpit/coxpit.db)
33
33
  COXPIT_AUTH_USER basic auth user (default admin)
34
- COXPIT_AUTH_PASS basic auth password (empty = open; set it)
34
+ COXPIT_AUTH_PASS basic auth password (empty = all requests rejected;
35
+ set it, or COXPIT_AUTH_DISABLED=1 for local dev)
35
36
  COXPIT_AUTH_DISABLED 1 disables auth (local dev only)
36
37
  COXPIT_SSH_KEY private key for remote machines (else ssh defaults/agent)
37
38
  COXPIT_AGENT_REAL 1 = real agent CLI by default (credits!)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "4.2.0",
3
+ "version": "4.3.1",
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
@@ -152,6 +152,22 @@ export const BOARD_HTML = /* html */ `<!doctype html>
152
152
  .gband.folded .gband-grid{display:none}
153
153
  .gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
154
154
  .attempt{color:var(--brand);opacity:.8}
155
+ /* ── active/archive view seg + archive list ── */
156
+ .view-seg{padding:2px;gap:2px}
157
+ .view-seg .seg-opt{padding:4px 12px;font-size:11.5px}
158
+ .view-seg .seg-hint{color:var(--faint);margin-left:4px}
159
+ .arch-bar{display:flex;gap:8px;margin-bottom:12px}
160
+ .arch-bar input{flex:1}
161
+ .arch-bar select{flex:0 0 auto}
162
+ .arch-row{display:flex;align-items:center;gap:14px;padding:10px 14px;border:1px solid var(--line);
163
+ border-radius:9px;background:var(--surface);margin-bottom:7px;cursor:pointer;transition:border-color .15s}
164
+ .arch-row:hover{border-color:var(--line-hi)}
165
+ .ar-title{flex:1;font-weight:600;font-size:13px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
166
+ .ar-runs{font-family:var(--mono);font-size:11px;color:var(--muted);flex:0 1 auto;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:34%}
167
+ .ar-repo{font-family:var(--mono);font-size:11px;color:var(--faint);flex:0 0 auto}
168
+ .ar-date{font-family:var(--mono);font-size:11px;color:var(--faint);flex:0 0 auto}
169
+ .arch-empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:30px 0;text-align:center}
170
+ @media (max-width:860px){.ar-repo{display:none}.ar-runs{max-width:44%}}
155
171
  .card{border:1px solid var(--line);border-radius:var(--r-card);background:var(--surface);
156
172
  overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
157
173
  transition:border-color .18s,transform .18s,box-shadow .18s}
@@ -383,6 +399,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
383
399
  <button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
384
400
  <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
385
401
  <span class="daemon-badge" id="daemonBadge" style="display:none"></span>
402
+ <div class="seg view-seg" id="viewSeg">
403
+ <button type="button" class="seg-opt on" data-view="active">Active</button>
404
+ <button type="button" class="seg-opt" data-view="archive">Archive <span id="archN" class="seg-hint"></span></button>
405
+ </div>
386
406
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
387
407
  <button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
388
408
  <div class="machines" id="machines"></div>
@@ -468,6 +488,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
468
488
  <span>No runs yet</span>
469
489
  <span style="color:#3d4657">register a repo, write a task, hit Run fleet</span>
470
490
  </div>
491
+ <div id="archive" hidden>
492
+ <div class="arch-bar">
493
+ <input id="archQ" placeholder="search title…" autocomplete="off" />
494
+ <select id="archRepo"><option value="">all repos</option></select>
495
+ </div>
496
+ <div id="archList"></div>
497
+ <div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
498
+ </div>
471
499
  </main>
472
500
  </div>
473
501
 
@@ -875,6 +903,7 @@ function bandHTML(g, grpRuns){
875
903
  + '<div class="gband-grid">'+cards+'</div></div>';
876
904
  }
877
905
  function render(){
906
+ if (boardView==='archive') return; // 아카이브 뷰는 자체 리스트 — grid 안 건드림
878
907
  const list = [...runs.values()].sort((a,b)=>b.id-a.id);
879
908
  $('empty').style.display = list.length ? 'none' : 'flex';
880
909
  if (!list.length){ paintOnboarding(); $('grid').innerHTML=''; return; }
@@ -893,6 +922,73 @@ function render(){
893
922
  if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
894
923
  }
895
924
 
925
+ /* ── active / archive view ─────────────────────────────── */
926
+ let boardView = 'active';
927
+ let archOffset = 0, archTotal = 0;
928
+ const ARCH_LIMIT = 50;
929
+ function setView(v){
930
+ boardView = v;
931
+ document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>{
932
+ const on = b.dataset.view===v;
933
+ b.classList.toggle('on', on); b.setAttribute('aria-pressed', on?'true':'false');
934
+ });
935
+ const archive = v==='archive';
936
+ $('archive').hidden = !archive;
937
+ $('grid').style.display = archive ? 'none' : '';
938
+ $('empty').style.display = archive ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
939
+ document.querySelector('.toolbar').style.display = archive ? 'none' : '';
940
+ if (archive){ paintArchRepos(); archFetch(true); }
941
+ else render();
942
+ }
943
+ document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
944
+ function paintArchRepos(){
945
+ const sel = $('archRepo'); const cur = sel.value;
946
+ sel.innerHTML = '<option value="">all repos</option>' + repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('');
947
+ sel.value = cur;
948
+ }
949
+ function archRowHTML(row){
950
+ const runs = row.runs.map(r=>'r'+r.id+' '+r.status).join(' · ');
951
+ const d = row.closedAt ? new Date(row.closedAt*1000) : null;
952
+ const date = d ? (String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')) : '';
953
+ const grp = row.groupTitle ? '<span class="ar-repo" title="group">⌁ '+esc(row.groupTitle.slice(0,24))+'</span>' : '';
954
+ return '<div class="arch-row" data-t="'+row.taskId+'">'
955
+ + '<span class="ar-title">'+esc(row.title)+'</span>'
956
+ + '<span class="ar-runs">'+esc(runs)+'</span>'+grp
957
+ + '<span class="ar-repo">'+esc(row.repoName)+'</span>'
958
+ + '<span class="ar-date">'+date+'</span></div>';
959
+ }
960
+ async function archFetch(reset){
961
+ if (reset){ archOffset = 0; $('archList').innerHTML = '<div class="arch-empty">loading…</div>'; }
962
+ const q = encodeURIComponent($('archQ').value.trim());
963
+ const repo = $('archRepo').value;
964
+ const url = '/api/archive?offset='+archOffset+'&limit='+ARCH_LIMIT
965
+ + (q?'&q='+q:'') + (repo?'&repo='+repo:'');
966
+ try{
967
+ const j = await fetch(url).then(x=>x.json());
968
+ archTotal = j.total||0;
969
+ const rows = (j.rows||[]).map(archRowHTML).join('');
970
+ if (reset) $('archList').innerHTML = rows || '<div class="arch-empty">no closed tasks match</div>';
971
+ else $('archList').insertAdjacentHTML('beforeend', rows);
972
+ archOffset += (j.rows||[]).length;
973
+ $('archMore').hidden = archOffset >= archTotal || !(j.rows||[]).length;
974
+ }catch{ $('archList').innerHTML = '<div class="arch-empty">archive failed</div>'; }
975
+ }
976
+ $('archMore').addEventListener('click', ()=>archFetch(false));
977
+ let archTimer = null;
978
+ $('archQ').addEventListener('input', ()=>{ clearTimeout(archTimer); archTimer = setTimeout(()=>archFetch(true), 300); });
979
+ $('archRepo').addEventListener('change', ()=>archFetch(true));
980
+ $('archList').addEventListener('click', async (e)=>{
981
+ const row = e.target.closest('.arch-row'); if(!row) return;
982
+ const tid = Number(row.dataset.t);
983
+ try{
984
+ const j = await fetch('/api/tasks/'+tid).then(x=>x.json());
985
+ if (!j.task || !(j.runs||[]).length){ toast('task has no runs', 'error'); return; }
986
+ tasks.set(j.task.id, j.task);
987
+ j.runs.forEach(rn => { if(!runs.has(rn.id)) runs.set(rn.id, { ...rn, events: [] }); });
988
+ openModal(j.runs.map(r=>r.id).sort((a,b)=>a-b)[0]);
989
+ }catch{ toast('could not open task', 'error'); }
990
+ });
991
+
896
992
  /* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
897
993
  let readiness = null, probing = false;
898
994
  async function probeFirstMachine(){
@@ -973,8 +1069,9 @@ function upsertRun(patch){
973
1069
  }
974
1070
 
975
1071
  async function hydrate(){
976
- const r = await fetch('/api/fleet').then(x=>x.json());
1072
+ const r = await fetch('/api/fleet?view=active').then(x=>x.json());
977
1073
  machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
1074
+ if (r.counts){ const n = r.counts.closedTasks||0; $('archN').textContent = n ? '· '+n : ''; }
978
1075
  tasks.clear();
979
1076
  (r.tasks||[]).forEach(t => tasks.set(t.id, t));
980
1077
  groups.clear();
@@ -1092,17 +1189,22 @@ function connectWS(){
1092
1189
  ws.onclose = ()=>{ $('wsdot').classList.remove('on'); $('wstext').textContent='reconnecting'; setTimeout(connectWS,1500); };
1093
1190
  ws.onmessage = (m)=>{
1094
1191
  let ev; try{ ev = JSON.parse(m.data); }catch{ return; }
1192
+ // 아카이브 뷰는 정적 스냅샷 — 라이브 갱신으로 뒤엎지 않는다.
1193
+ if (boardView==='archive' && (ev.type==='run'||ev.type==='event'||ev.type==='task')) return;
1095
1194
  if (ev.type==='run'){
1096
- const known = runs.has(ev.runId ?? ev.id);
1195
+ const rid = ev.runId ?? ev.id;
1196
+ const known = runs.has(rid);
1197
+ // 아카이브된(맵에 없는) run 의 뒤늦은 echo 는 무시 — 신규는 항상 pending 으로 먼저 온다.
1198
+ if (!known && ev.status && ev.status!=='pending') return;
1097
1199
  upsertRun(ev);
1098
1200
  if (!known && ev.taskId==null){ hydrate(); return; }
1099
1201
  if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
1100
- render(); flash(ev.runId ?? ev.id); paintModal();
1101
- if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
1202
+ render(); flash(rid); paintModal();
1203
+ if (openRunId===rid && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
1102
1204
  if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
1103
1205
  if (['done','failed','error','stopped'].includes(ev.status)) notifySettleUI(ev);
1104
1206
  } else if (ev.type==='event'){
1105
- const r = runs.get(ev.runId); if(!r){ hydrate(); return; }
1207
+ const r = runs.get(ev.runId); if(!r) return; // 아카이브 run 의 이벤트는 무시(맵에 없으면 resurrect X)
1106
1208
  r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
1107
1209
  render(); flash(ev.runId); paintModal();
1108
1210
  } else if (ev.type==='task'){
@@ -1198,11 +1300,16 @@ $('mDiffWrap').addEventListener('click', (e)=>{
1198
1300
  $('steerInput').value = (f ? f+': ' : '') + '"'+line+'" — ';
1199
1301
  $('steerInput').focus();
1200
1302
  });
1201
- function openModal(id){
1303
+ async function openModal(id){
1202
1304
  openRunId = id;
1203
1305
  if (docMode){ docMode=false; $('mDocsTgl').textContent='Rendered'; $('mDiffWrap').innerHTML='<pre class="diff" id="mDiff">loading…</pre>'; }
1204
1306
  $('mDocsTgl').hidden = true;
1205
1307
  paintModal(); $('overlay').classList.add('open'); loadDiff();
1308
+ // fleet 는 run 당 최근 40 이벤트만 내림 — 모달은 전체 타임라인을 다시 가져온다.
1309
+ try{
1310
+ const j = await fetch('/api/runs/'+id).then(x=>x.json());
1311
+ if (j.run){ const r = runs.get(id); if (r){ r.events = j.events||[]; if (openRunId===id) paintModal(); } }
1312
+ }catch{}
1206
1313
  }
1207
1314
  function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
1208
1315
  /* ── select mode (integrate) ── */
package/src/db/index.ts CHANGED
@@ -97,4 +97,5 @@ export async function ensureSchema(): Promise<void> {
97
97
  try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
98
98
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
99
99
  try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
100
+ try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
100
101
  }
package/src/db/schema.ts CHANGED
@@ -50,6 +50,7 @@ export const tasks = sqliteTable('tasks', {
50
50
  designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
51
51
  parentRunId: integer('parent_run_id'), // 에이전트 셀프 오케스트레이션 — 이 태스크를 발사한 run
52
52
  groupId: integer('group_id'), // task_groups — goal/swarm 형제 묶음(수동 태스크는 NULL)
53
+ closedAt: integer('closed_at', { mode: 'timestamp' }), // 닫힌 시각(아카이브 정렬·표시)
53
54
  createdAt: integer('created_at', { mode: 'timestamp' }),
54
55
  });
55
56
 
package/src/index.ts CHANGED
@@ -39,3 +39,11 @@ try {
39
39
  }
40
40
  throw e;
41
41
  }
42
+
43
+ // 인증 켜졌는데 비번이 비면 fail-closed(전 요청 401) — 첫 실행자가 401 보고 당황하지 않게 명시.
44
+ if (!config.auth.disabled && config.auth.pass === '') {
45
+ console.warn(
46
+ '[coxpit] auth is ON but COXPIT_AUTH_PASS is empty — every request will be rejected (401).\n' +
47
+ '[coxpit] Set COXPIT_AUTH_PASS to a password, or COXPIT_AUTH_DISABLED=1 for local dev.',
48
+ );
49
+ }
package/src/server.ts CHANGED
@@ -6,7 +6,7 @@ import { createRequire } from 'node:module';
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import Fastify, { type FastifyInstance } from 'fastify';
8
8
  import websocket from '@fastify/websocket';
9
- import { eq } from 'drizzle-orm';
9
+ import { eq, inArray, and, like, desc } from 'drizzle-orm';
10
10
  import { authGate } from './auth';
11
11
  import { config } from './config';
12
12
  import { db } from './db';
@@ -170,16 +170,27 @@ export async function buildServer(): Promise<FastifyInstance> {
170
170
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
171
171
 
172
172
  // 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
173
- app.get('/api/fleet', async () => {
174
- const [ms, rs, ts, rns, evs, dcs, gs] = await Promise.all([
173
+ // 기본 view=active: 닫힌 태스크·그 run·이벤트 전량을 내리지 않는다(수백 run 시 페이로드 폭발 방지).
174
+ // 이벤트는 활성 run 최근 40개만(카드는 8, 모달은 전량 refetch). view=all 구버전 전량.
175
+ const EVENT_CAP = 40;
176
+ app.get('/api/fleet', async (req) => {
177
+ const view = ((req.query ?? {}) as { view?: string }).view === 'all' ? 'all' : 'active';
178
+ const [ms, rs, allTasks, dcs, gs] = await Promise.all([
175
179
  db.select().from(machines),
176
180
  db.select().from(repos),
177
181
  db.select().from(tasks),
178
- db.select().from(agentRuns),
179
- db.select().from(agentEvents),
180
182
  db.select().from(designCaptures),
181
183
  db.select().from(taskGroups),
182
184
  ]);
185
+ const activeTasks = allTasks.filter((t) => t.status !== 'closed');
186
+ const closedCount = allTasks.length - activeTasks.length;
187
+ const ts = view === 'all' ? allTasks : activeTasks;
188
+ const taskIds = new Set(ts.map((t) => t.id));
189
+ const allRuns = await db.select().from(agentRuns);
190
+ const rns = view === 'all' ? allRuns : allRuns.filter((r) => taskIds.has(r.taskId));
191
+ const runIds = rns.map((r) => r.id);
192
+ // 이벤트는 대상 run 으로 스코프한 뒤 로드(전량 로드 후 슬라이스 = 고치려는 그 버그).
193
+ const evs = runIds.length ? await db.select().from(agentEvents).where(inArray(agentEvents.runId, runIds)) : [];
183
194
  const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
184
195
  for (const e of evs) {
185
196
  const arr = byRun.get(e.runId) ?? [];
@@ -188,13 +199,44 @@ export async function buildServer(): Promise<FastifyInstance> {
188
199
  }
189
200
  return {
190
201
  machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
191
- runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
202
+ runs: rns.map((r) => ({ ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP) })),
203
+ counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
192
204
  // 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
193
205
  daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },
194
206
  providers: listProviders(),
195
207
  };
196
208
  });
197
209
 
210
+ // 아카이브 — 닫힌 태스크 목록(최신순, 페이지네이션·필터). 카드가 아니라 한 줄 행.
211
+ app.get('/api/archive', async (req) => {
212
+ const q = (req.query ?? {}) as { offset?: string; limit?: string; q?: string; repo?: string; status?: string };
213
+ const offset = Math.max(0, Number(q.offset) || 0);
214
+ const limit = Math.min(100, Math.max(1, Number(q.limit) || 50));
215
+ const conds = [eq(tasks.status, 'closed')];
216
+ if (q.q) conds.push(like(tasks.title, `%${q.q}%`));
217
+ if (q.repo) conds.push(eq(tasks.repoId, Number(q.repo)));
218
+ const where = conds.length === 1 ? conds[0] : and(...conds);
219
+ let closed = await db.select().from(tasks).where(where).orderBy(desc(tasks.id));
220
+ // status 필터 = 태스크의 run 중 그 상태를 가진 게 있어야 함(런 로드 후 필터)
221
+ const repoName = new Map((await db.select().from(repos)).map((r) => [r.id, r.name]));
222
+ const grpTitle = new Map((await db.select().from(taskGroups)).map((g) => [g.id, g.title]));
223
+ const total0 = closed.length;
224
+ const page = closed.slice(offset, offset + limit);
225
+ const rows = [];
226
+ for (const t of page) {
227
+ const rs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
228
+ if (q.status && !rs.some((r) => r.status === q.status)) continue;
229
+ rows.push({
230
+ taskId: t.id, title: t.title, repoName: repoName.get(t.repoId) ?? '?',
231
+ groupTitle: t.groupId != null ? grpTitle.get(t.groupId) ?? null : null,
232
+ closedAt: t.closedAt ? Math.floor(t.closedAt.getTime() / 1000) : (t.createdAt ? Math.floor(t.createdAt.getTime() / 1000) : 0),
233
+ runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model })),
234
+ });
235
+ }
236
+ // status 필터가 있으면 total 은 근사(페이지 내 필터) — UI 는 rows 로만 판단하니 total0 유지.
237
+ return { total: total0, rows };
238
+ });
239
+
198
240
  // ─── 머신 레지스트리 ────────────────────────────────────────────
199
241
  app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
200
242
 
@@ -540,7 +582,7 @@ export async function buildServer(): Promise<FastifyInstance> {
540
582
  const cleanups = [];
541
583
  for (const r of trs) cleanups.push({ runId: r.id, ...(await cleanupRun(r.id)) });
542
584
 
543
- await db.update(tasks).set({ status: 'closed' }).where(eq(tasks.id, id));
585
+ await db.update(tasks).set({ status: 'closed', closedAt: new Date() }).where(eq(tasks.id, id));
544
586
  broadcast({ type: 'task', taskId: id, status: 'closed' });
545
587
  return { ok: true, taskId: id, cleanups };
546
588
  });