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 +2 -2
- package/bin/coxpit.js +3 -2
- package/package.json +1 -1
- package/src/board.ts +113 -6
- package/src/db/index.ts +1 -0
- package/src/db/schema.ts +1 -0
- package/src/index.ts +8 -0
- package/src/server.ts +49 -7
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
|
|
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.
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
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(
|
|
1101
|
-
if (openRunId===
|
|
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)
|
|
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
|
-
|
|
174
|
-
|
|
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
|
});
|