coxpit 4.5.0 → 4.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/README.md +9 -1
- package/package.json +1 -1
- package/src/board.ts +944 -61
- package/src/db/index.ts +3 -0
- package/src/db/schema.ts +2 -0
- package/src/orchestrator.ts +453 -7
- package/src/server.ts +251 -8
package/src/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
1
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { resolve as presolve, dirname as pdirname, join as pjoin } from 'node:path';
|
|
4
|
+
import { resolve as presolve, dirname as pdirname, join as pjoin, sep as psep } from 'node:path';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { randomBytes } from 'node:crypto';
|
|
7
7
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
@@ -13,7 +13,7 @@ import { db } from './db';
|
|
|
13
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
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
|
|
16
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees } from './orchestrator';
|
|
17
17
|
import { openTerm } from './term';
|
|
18
18
|
import { addSink, removeSink, broadcast } from './hub';
|
|
19
19
|
import { getProvider, listProviders } from './providers';
|
|
@@ -50,6 +50,19 @@ function mdLiteHTML(src: string): string {
|
|
|
50
50
|
return s;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** 확장자 → content-type 추론(파일 미리보기용). 미지 = octet-stream. */
|
|
54
|
+
function contentTypeFor(path: string): string {
|
|
55
|
+
const ext = (path.split('.').pop() ?? '').toLowerCase();
|
|
56
|
+
const map: Record<string, string> = {
|
|
57
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
58
|
+
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
|
|
59
|
+
avif: 'image/avif', pdf: 'application/pdf', txt: 'text/plain; charset=utf-8',
|
|
60
|
+
md: 'text/plain; charset=utf-8', json: 'application/json', csv: 'text/csv; charset=utf-8',
|
|
61
|
+
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
|
|
62
|
+
};
|
|
63
|
+
return map[ext] ?? 'application/octet-stream';
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
/** 공유 페이지 Documents 섹션 — md 는 mdLiteHTML, html 은 sandbox iframe. */
|
|
54
67
|
function shareDocsHTML(docs: Array<{ path: string; kind: string; content: string }>): string {
|
|
55
68
|
if (!docs.length) return '';
|
|
@@ -242,6 +255,23 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
242
255
|
return { total: total0, rows };
|
|
243
256
|
});
|
|
244
257
|
|
|
258
|
+
// 회수 가능한 고아 worktree — closed task 또는 failed/error/stopped run 의 worktree 만.
|
|
259
|
+
// running/preparing/pending/done 은 절대 포함 안 됨(활성·성공-미머지 보호). authGate 뒤.
|
|
260
|
+
app.get('/api/worktrees', async () => {
|
|
261
|
+
const items = await listReclaimableWorktrees();
|
|
262
|
+
const totalKb = items.reduce((s, w) => s + (w.sizeKb || 0), 0);
|
|
263
|
+
return { items, totalKb };
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// 회수 실행 — body.runIds(선택 부분집합) 또는 전체. cleanupRun 재사용 + git worktree prune.
|
|
267
|
+
app.post('/api/worktrees/prune', async (req) => {
|
|
268
|
+
const b = (req.body ?? {}) as { runIds?: number[] };
|
|
269
|
+
const runIds = Array.isArray(b.runIds)
|
|
270
|
+
? b.runIds.map((n) => Number(n)).filter((n) => Number.isInteger(n))
|
|
271
|
+
: undefined;
|
|
272
|
+
return pruneWorktrees(runIds);
|
|
273
|
+
});
|
|
274
|
+
|
|
245
275
|
// ─── 머신 레지스트리 ────────────────────────────────────────────
|
|
246
276
|
app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
|
|
247
277
|
|
|
@@ -481,13 +511,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
481
511
|
const q = (req.query ?? {}) as { path?: string };
|
|
482
512
|
const start = q.path && q.path.startsWith('/') ? q.path : homedir();
|
|
483
513
|
const p = presolve(start);
|
|
484
|
-
let dirs: Array<{ name: string; isRepo: boolean }> = [];
|
|
514
|
+
let dirs: Array<{ name: string; isRepo: boolean; isEmpty: boolean }> = [];
|
|
485
515
|
let error: string | undefined;
|
|
486
516
|
try {
|
|
487
517
|
const entries = await readdir(p, { withFileTypes: true });
|
|
488
518
|
for (const e of entries) {
|
|
489
519
|
if (!e.isDirectory() || e.name.startsWith('.')) continue;
|
|
490
|
-
|
|
520
|
+
const full = pjoin(p, e.name);
|
|
521
|
+
const isRepo = existsSync(pjoin(full, '.git'));
|
|
522
|
+
// 빈 폴더면 greenfield "Start here" 대상 — 서버 EMPTYDIR 판정(ls -A)과 동일하게
|
|
523
|
+
// 모든 엔트리(닷파일 포함) 0개일 때만. repo 폴더는 Register 로 다루므로 계산 생략.
|
|
524
|
+
let isEmpty = false;
|
|
525
|
+
if (!isRepo) { try { isEmpty = (await readdir(full)).length === 0; } catch { isEmpty = false; } }
|
|
526
|
+
dirs.push({ name: e.name, isRepo, isEmpty });
|
|
491
527
|
if (dirs.length >= 300) break;
|
|
492
528
|
}
|
|
493
529
|
dirs.sort((a, b) => (b.isRepo ? 1 : 0) - (a.isRepo ? 1 : 0) || a.name.localeCompare(b.name));
|
|
@@ -572,7 +608,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
572
608
|
});
|
|
573
609
|
|
|
574
610
|
app.post('/api/tasks', async (req, reply) => {
|
|
575
|
-
const b = (req.body ?? {}) as { repoId?: number; title?: string; prompt?: string; designCaptureId?: number };
|
|
611
|
+
const b = (req.body ?? {}) as { repoId?: number; title?: string; prompt?: string; designCaptureId?: number; outputs?: unknown };
|
|
576
612
|
const repoId = Number(b.repoId);
|
|
577
613
|
const title = (b.title ?? '').trim();
|
|
578
614
|
if (!repoId || !title) return reply.code(400).send({ error: 'repoId and title required' });
|
|
@@ -584,7 +620,9 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
584
620
|
if (!dc[0]) return reply.code(404).send({ error: 'design capture not found' });
|
|
585
621
|
designCaptureId = dc[0].id;
|
|
586
622
|
}
|
|
587
|
-
|
|
623
|
+
// 산출물 계약(선택) — {answer,code,doc,page,file} 만 허용, 중복 제거, JSON 문자열로 저장.
|
|
624
|
+
const outputs = normalizeOutputs(b.outputs);
|
|
625
|
+
const ins = await db.insert(tasks).values({ repoId, title, prompt: b.prompt ?? '', designCaptureId, outputs: JSON.stringify(outputs) }).returning();
|
|
588
626
|
return reply.code(201).send({ ok: true, task: ins[0] });
|
|
589
627
|
});
|
|
590
628
|
|
|
@@ -755,6 +793,115 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
755
793
|
return reply.code(202).send(res);
|
|
756
794
|
});
|
|
757
795
|
|
|
796
|
+
// ─── Goal workroom (v4.6 L1) — 한 그룹(goal/swarm)을 한 방에서 몰기 ────────
|
|
797
|
+
// steerable = 정착(done/failed/stopped) + sessionId 보유 + worktree 살아있음.
|
|
798
|
+
// (steerRun 전제 그대로 — 라이브 run 은 steer 불가, 드라이런은 세션 없음.)
|
|
799
|
+
const groupRuns = async (groupId: number): Promise<{
|
|
800
|
+
group: typeof taskGroups.$inferSelect;
|
|
801
|
+
rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }>;
|
|
802
|
+
} | null> => {
|
|
803
|
+
const gr = await db.select().from(taskGroups).where(eq(taskGroups.id, groupId)).limit(1);
|
|
804
|
+
if (!gr[0]) return null;
|
|
805
|
+
const gts = await db.select().from(tasks).where(eq(tasks.groupId, groupId));
|
|
806
|
+
const rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }> = [];
|
|
807
|
+
for (const t of gts) {
|
|
808
|
+
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
|
|
809
|
+
for (const run of trs) rows.push({ run, task: t });
|
|
810
|
+
}
|
|
811
|
+
rows.sort((a, b) => a.run.id - b.run.id);
|
|
812
|
+
return { group: gr[0], rows };
|
|
813
|
+
};
|
|
814
|
+
const isSteerable = (r: typeof agentRuns.$inferSelect): boolean =>
|
|
815
|
+
!isRunLive(r.id) && ['done', 'failed', 'stopped'].includes(r.status) && !!r.sessionId && !!r.worktreePath;
|
|
816
|
+
|
|
817
|
+
// B1 — 애그리게이트 뷰(방의 chips + 최근 타임라인). 페이로드 다이어트: 최근 200 이벤트만.
|
|
818
|
+
app.get('/api/groups/:id', async (req, reply) => {
|
|
819
|
+
const id = Number((req.params as { id: string }).id);
|
|
820
|
+
const g = await groupRuns(id);
|
|
821
|
+
if (!g) return reply.code(404).send({ error: 'group not found' });
|
|
822
|
+
const runs = g.rows.map(({ run, task }) => ({
|
|
823
|
+
runId: run.id, taskId: task.id, title: task.title, status: run.status,
|
|
824
|
+
agent: run.agent, model: run.model, branch: run.branch, filesChanged: run.filesChanged,
|
|
825
|
+
live: isRunLive(run.id), steerable: isSteerable(run),
|
|
826
|
+
// 수렴 콕핏 결정 행용: 태스크 닫힘 여부 + worktree 생존(터미널 가드·머지 가능성 판단).
|
|
827
|
+
taskStatus: task.status, hasWorktree: !!run.worktreePath,
|
|
828
|
+
}));
|
|
829
|
+
const runIds = g.rows.map((x) => x.run.id);
|
|
830
|
+
// 이벤트: 그룹 run 전체에서 최근 200개(id 순, 오래된 것 먼저 — 방 피드는 append-only).
|
|
831
|
+
const evs = runIds.length
|
|
832
|
+
? (await db.select().from(agentEvents).where(inArray(agentEvents.runId, runIds)))
|
|
833
|
+
.sort((a, b) => a.id - b.id).slice(-200)
|
|
834
|
+
: [];
|
|
835
|
+
return {
|
|
836
|
+
group: { id: g.group.id, kind: g.group.kind, title: g.group.title, coordSessionId: g.group.coordSessionId },
|
|
837
|
+
runs,
|
|
838
|
+
events: evs.map((e) => ({ runId: e.runId, kind: e.kind, payload: e.payload, ts: e.ts })),
|
|
839
|
+
};
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// B2 — "+ New attempt": 그룹에 새 시도(들)를 발사. repo 는 그룹의 기존 태스크에서 상속.
|
|
843
|
+
app.post('/api/groups/:id/spawn', async (req, reply) => {
|
|
844
|
+
const id = Number((req.params as { id: string }).id);
|
|
845
|
+
const b = (req.body ?? {}) as { title?: string; prompt?: string; count?: number; real?: boolean };
|
|
846
|
+
const prompt = (b.prompt ?? '').trim();
|
|
847
|
+
if (!prompt) return reply.code(400).send({ error: 'prompt required' });
|
|
848
|
+
const g = await groupRuns(id);
|
|
849
|
+
if (!g) return reply.code(404).send({ error: 'group not found' });
|
|
850
|
+
if (!g.rows[0]) return reply.code(409).send({ error: 'group has no tasks to inherit a repo from' });
|
|
851
|
+
const repoId = g.rows[0].task.repoId; // 형제는 같은 repo 를 공유
|
|
852
|
+
const title = (b.title ?? '').trim() || prompt.slice(0, 40);
|
|
853
|
+
const count = Math.max(1, Math.min(5, Number(b.count) || 1));
|
|
854
|
+
const created: Array<{ id: number; title: string; runId: number }> = [];
|
|
855
|
+
for (let i = 0; i < count; i++) {
|
|
856
|
+
created.push(await launchGroupTask(id, repoId, title, prompt, b.real === true));
|
|
857
|
+
}
|
|
858
|
+
return reply.code(201).send({ ok: true, tasks: created });
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// B3 — "→ Broadcast": 그룹의 정착·steerable run 전부에 후속 지시. 라이브/드라이는 정직하게 skip.
|
|
862
|
+
app.post('/api/groups/:id/steer', async (req, reply) => {
|
|
863
|
+
const id = Number((req.params as { id: string }).id);
|
|
864
|
+
const b = (req.body ?? {}) as { message?: string; mode?: string };
|
|
865
|
+
const message = (b.message ?? '').trim();
|
|
866
|
+
if (!message) return reply.code(400).send({ error: 'message required' });
|
|
867
|
+
const g = await groupRuns(id);
|
|
868
|
+
if (!g) return reply.code(404).send({ error: 'group not found' });
|
|
869
|
+
const mode = b.mode === 'ask' ? 'ask' : 'work';
|
|
870
|
+
let steered = 0;
|
|
871
|
+
const skipped: Array<{ runId: number; reason: string }> = [];
|
|
872
|
+
let running = 0, noSession = 0;
|
|
873
|
+
// 그룹 규모가 작아 순차 for 루프로 충분(폭주 fan-out 없음).
|
|
874
|
+
for (const { run } of g.rows) {
|
|
875
|
+
const res = await steerRun(run.id, message, mode);
|
|
876
|
+
if (res.ok) { steered++; continue; }
|
|
877
|
+
skipped.push({ runId: run.id, reason: res.detail });
|
|
878
|
+
if (/still running/.test(res.detail)) running++;
|
|
879
|
+
else if (/no agent session/.test(res.detail)) noSession++;
|
|
880
|
+
}
|
|
881
|
+
const parts = [`${steered} steered`];
|
|
882
|
+
if (running) parts.push(`${running} still running (steer after they settle)`);
|
|
883
|
+
if (noSession) parts.push(`${noSession} no session`);
|
|
884
|
+
const otherSkips = skipped.length - running - noSession;
|
|
885
|
+
if (otherSkips > 0) parts.push(`${otherSkips} skipped`);
|
|
886
|
+
return { ok: true, steered, skipped, detail: parts.join(' · ') };
|
|
887
|
+
});
|
|
888
|
+
// NOTE(v4.6): queuing a broadcast to apply to running runs once they settle is
|
|
889
|
+
// explicitly out of scope for L1 — running runs are reported as skipped, not queued.
|
|
890
|
+
|
|
891
|
+
// B4 (L2) — "? Ask": 그룹 스코프 읽기 전용 코디네이터. run 발사·steer·파일 쓰기 절대 없음.
|
|
892
|
+
// askGroupCoordinator 는 getRunDiff(읽기)와 텍스트 반환뿐 — launch/steer/write 경로를 부르지 않는다.
|
|
893
|
+
app.post('/api/groups/:id/ask', async (req, reply) => {
|
|
894
|
+
const id = Number((req.params as { id: string }).id);
|
|
895
|
+
const b = (req.body ?? {}) as { message?: string; real?: boolean };
|
|
896
|
+
const message = (b.message ?? '').trim();
|
|
897
|
+
if (!message) return reply.code(400).send({ error: 'message required' });
|
|
898
|
+
const g = await groupRuns(id);
|
|
899
|
+
if (!g) return reply.code(404).send({ error: 'group not found' });
|
|
900
|
+
const res = await askGroupCoordinator(id, message, b.real === true);
|
|
901
|
+
if (!res.ok) return reply.code(422).send({ error: res.detail });
|
|
902
|
+
return { ok: true, answer: res.answer };
|
|
903
|
+
});
|
|
904
|
+
|
|
758
905
|
// 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
|
|
759
906
|
app.post('/api/integrate', async (req, reply) => {
|
|
760
907
|
const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };
|
|
@@ -817,6 +964,96 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
817
964
|
return { ok: true, docs, source };
|
|
818
965
|
});
|
|
819
966
|
|
|
967
|
+
// ─── 산출물 계약(v4.7 P1) — 카드 목록 · 뷰어 콘텐츠 · 파일 바이트 ──
|
|
968
|
+
// 카드 목록: computeRunOutputs 로 병합(매니페스트+git status+answer). 404 = run 없음.
|
|
969
|
+
app.get('/api/runs/:id/outputs', async (req, reply) => {
|
|
970
|
+
const id = Number((req.params as { id: string }).id);
|
|
971
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
972
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
973
|
+
const outputs = await computeRunOutputs(id);
|
|
974
|
+
return { outputs };
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// 뷰어 콘텐츠: answer/doc → md, page → html(둘 다 loadRunDocs 폴백 재사용).
|
|
978
|
+
// code 는 별도 콘텐츠 없음 — 클라이언트가 기존 /api/runs/:id/diff 를 재사용한다.
|
|
979
|
+
app.get('/api/runs/:id/output', async (req, reply) => {
|
|
980
|
+
const id = Number((req.params as { id: string }).id);
|
|
981
|
+
const q = (req.query ?? {}) as { type?: string; path?: string };
|
|
982
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
983
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
984
|
+
const type = (q.type ?? '').trim();
|
|
985
|
+
if (type === 'answer') {
|
|
986
|
+
const cards = await computeRunOutputs(id);
|
|
987
|
+
const has = cards.some((c) => c.type === 'answer' && c.present);
|
|
988
|
+
const content = has ? (rr[0].exitSummary || '') : '';
|
|
989
|
+
// answer 본문은 result 이벤트가 원천 — exitSummary 는 그 클립(≤500자)이라 여기선 이벤트 우선.
|
|
990
|
+
const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, id));
|
|
991
|
+
let answer = content;
|
|
992
|
+
for (let i = evs.length - 1; i >= 0; i--) {
|
|
993
|
+
if (evs[i]!.kind !== 'result') continue;
|
|
994
|
+
try { const o = JSON.parse(evs[i]!.payload) as { result?: string }; if (typeof o.result === 'string' && o.result.trim()) { answer = o.result.trim(); break; } } catch { /* skip */ }
|
|
995
|
+
}
|
|
996
|
+
return { kind: 'md', content: answer };
|
|
997
|
+
}
|
|
998
|
+
if (type === 'doc' || type === 'page') {
|
|
999
|
+
const wantKind = type === 'doc' ? 'md' : 'html';
|
|
1000
|
+
const { docs } = await loadRunDocs(id); // worktree→snapshot 폴백 내장
|
|
1001
|
+
const path = (q.path ?? '').trim();
|
|
1002
|
+
const doc = path ? docs.find((d) => d.path === path) : docs.find((d) => d.kind === wantKind);
|
|
1003
|
+
if (!doc) return reply.code(404).send({ error: 'output not found' });
|
|
1004
|
+
return { kind: doc.kind === 'html' ? 'html' : 'md', content: doc.content };
|
|
1005
|
+
}
|
|
1006
|
+
if (type === 'code') {
|
|
1007
|
+
// code 는 콘텐츠 뷰어가 없다 — 컬러 diff 는 클라이언트가 /api/runs/:id/diff 로 재사용.
|
|
1008
|
+
return { kind: 'diff', diffUrl: `/api/runs/${id}/diff` };
|
|
1009
|
+
}
|
|
1010
|
+
return reply.code(400).send({ error: 'type must be one of answer|doc|page|code' });
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// 파일 바이트(NEW · 보안 임계) — 이미지/바이너리 미리보기용 raw bytes.
|
|
1014
|
+
// 가드: worktree 루트 기준으로 path 를 해석하고, realpath 가 worktree 밖으로
|
|
1015
|
+
// 벗어나면(.. / 절대경로 / 심볼릭링크 탈출) 거부. 크기 상한 ~10MB. worktree 소멸 후 404.
|
|
1016
|
+
const FILE_MAX = 10 * 1024 * 1024;
|
|
1017
|
+
app.get('/api/runs/:id/file', async (req, reply) => {
|
|
1018
|
+
const id = Number((req.params as { id: string }).id);
|
|
1019
|
+
const q = (req.query ?? {}) as { path?: string };
|
|
1020
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1021
|
+
const run = rr[0];
|
|
1022
|
+
if (!run) return reply.code(404).send({ error: 'not found' });
|
|
1023
|
+
const rel = (q.path ?? '').trim();
|
|
1024
|
+
if (!rel) return reply.code(400).send({ error: 'path required' });
|
|
1025
|
+
// 절대경로 즉시 거부(worktree 밖 강제)
|
|
1026
|
+
if (rel.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(rel)) return reply.code(400).send({ error: 'path must be relative' });
|
|
1027
|
+
if (!run.worktreePath) return reply.code(404).send({ error: 'no worktree' });
|
|
1028
|
+
|
|
1029
|
+
// 원격 머신 파일은 데몬 파일시스템에 없다 — export 와 동일하게 로컬 전용.
|
|
1030
|
+
const mr = await db.select().from(machines).where(eq(machines.id, run.machineId)).limit(1);
|
|
1031
|
+
const mach = mr[0];
|
|
1032
|
+
const isRemote = !!mach && mach.kind !== 'local' && (mach.address ?? '') !== '';
|
|
1033
|
+
if (isRemote) return reply.code(400).send({ error: 'remote file preview not supported' });
|
|
1034
|
+
|
|
1035
|
+
// worktree 루트를 realpath 로 정규화(심링크 해소된 canonical base).
|
|
1036
|
+
let rootReal: string;
|
|
1037
|
+
try { rootReal = await realpath(run.worktreePath); }
|
|
1038
|
+
catch { return reply.code(404).send({ error: 'worktree gone' }); }
|
|
1039
|
+
// 요청 경로 = 루트에 join 후 realpath — 심링크 탈출까지 잡는다.
|
|
1040
|
+
const joined = presolve(rootReal, rel);
|
|
1041
|
+
let targetReal: string;
|
|
1042
|
+
try { targetReal = await realpath(joined); }
|
|
1043
|
+
catch { return reply.code(404).send({ error: 'file not found' }); }
|
|
1044
|
+
// realpath 결과가 worktree 루트 하위가 아니면 탈출 — 거부.
|
|
1045
|
+
const rootWithSep = rootReal.endsWith(psep) ? rootReal : rootReal + psep;
|
|
1046
|
+
if (targetReal !== rootReal && !targetReal.startsWith(rootWithSep)) {
|
|
1047
|
+
return reply.code(403).send({ error: 'path escapes the worktree' });
|
|
1048
|
+
}
|
|
1049
|
+
let st;
|
|
1050
|
+
try { st = await stat(targetReal); } catch { return reply.code(404).send({ error: 'file not found' }); }
|
|
1051
|
+
if (!st.isFile()) return reply.code(404).send({ error: 'not a file' });
|
|
1052
|
+
if (st.size > FILE_MAX) return reply.code(413).send({ error: 'file too large (>10MB)' });
|
|
1053
|
+
const buf = await readFile(targetReal);
|
|
1054
|
+
return reply.type(contentTypeFor(rel)).send(buf);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
820
1057
|
// ─── 에이전트 셀프 오케스트레이션 (run 별 Bearer 토큰 — authGate 예외, 여기서 자체 검증) ──
|
|
821
1058
|
const agentAuth = (req: { headers: { authorization?: string } }): number | null => {
|
|
822
1059
|
const h = req.headers.authorization ?? '';
|
|
@@ -930,7 +1167,13 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
930
1167
|
const rows = Math.max(5, Math.min(200, Number(q.rows) || 24));
|
|
931
1168
|
const info = await getRunTermInfo(id);
|
|
932
1169
|
if (!info) {
|
|
933
|
-
|
|
1170
|
+
// tmuxWindow 가 없음 = 세션이 정리됐거나(닫힌 task·cleanup) run 미존재.
|
|
1171
|
+
// 죽은 세션에 attach 를 시도하지 말고 명확한 사유로 닫는다.
|
|
1172
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1173
|
+
const d = rr[0]
|
|
1174
|
+
? 'terminal unavailable — worktree cleaned (run closed or cleaned up)'
|
|
1175
|
+
: 'run not found';
|
|
1176
|
+
socket.send(JSON.stringify({ t: 'err', d }));
|
|
934
1177
|
socket.close();
|
|
935
1178
|
return;
|
|
936
1179
|
}
|