coxpit 5.27.6 → 6.0.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 +8 -7
- package/package.json +1 -1
- package/src/agentstate.ts +245 -0
- package/src/authkey.ts +30 -0
- package/src/board.ts +43 -9
- package/src/cockpit.ts +959 -34
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +4 -0
- package/src/files.ts +6 -0
- package/src/orchestrator.ts +148 -17
- package/src/server.ts +167 -23
- package/src/workdoc.ts +65 -0
package/src/db/index.ts
CHANGED
|
@@ -113,4 +113,6 @@ export async function ensureSchema(): Promise<void> {
|
|
|
113
113
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_status TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
114
114
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_output TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
115
115
|
try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
|
|
116
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN title TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
117
|
+
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
116
118
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -82,8 +82,12 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
82
82
|
taskId: integer('task_id').notNull(),
|
|
83
83
|
machineId: integer('machine_id').notNull(),
|
|
84
84
|
agent: text('agent').notNull().default('claude-code'),
|
|
85
|
+
title: text('title').notNull().default(''), // v6.0 — 작업 안에서의 역할 이름(예: 구현·기타). 빈값 = 프로바이더 이름/main 으로 표시
|
|
85
86
|
worktreePath: text('worktree_path').notNull().default(''),
|
|
86
87
|
branch: text('branch').notNull().default(''),
|
|
88
|
+
// v6.0 Part P — 격리 없이 repo 체크아웃에서 그대로 도는 에이전트(worktree·브랜치 없음).
|
|
89
|
+
// 결과물은 루트 세션 마커와 같다(worktree_path=repo.path, branch=''); 이 플래그는 "그렇게 띄워라"는 의도다.
|
|
90
|
+
inPlace: integer('in_place', { mode: 'boolean' }).notNull().default(false),
|
|
87
91
|
tmuxWindow: text('tmux_window').notNull().default(''),
|
|
88
92
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
89
93
|
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
package/src/files.ts
CHANGED
|
@@ -28,6 +28,12 @@ function startDir(root: string): string {
|
|
|
28
28
|
return (HOME === root || HOME.startsWith(root + '/')) ? HOME : root;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// 어떤 절대경로가 지금의 뷰어 루트 안인가 — 호출자가 "평소 파일 뷰어로 열면 되는지"를
|
|
32
|
+
// 미리 물어보는 창구(v6.0 W2: WORK.md 는 ~/.coxpit 아래라 기본 루트(홈) 안이다).
|
|
33
|
+
export function withinFilesRoot(p: string): boolean {
|
|
34
|
+
return withinRoot(presolve(p), currentRoot());
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
export type FileKind = 'md' | 'html' | 'pdf' | 'image' | 'text' | 'binary';
|
|
32
38
|
|
|
33
39
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']);
|
package/src/orchestrator.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnap
|
|
|
12
12
|
import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
|
|
13
13
|
import { broadcast } from './hub';
|
|
14
14
|
import { getProvider, type Provider } from './providers';
|
|
15
|
+
import { workContextBlock, removeWorkDoc } from './workdoc';
|
|
15
16
|
|
|
16
17
|
// ── 산출물 계약(deliverable contract) ─────────────────────────
|
|
17
18
|
/** 산출물 타입 5종 — 태스크가 선언할 수 있는 계약 항목. */
|
|
@@ -223,6 +224,35 @@ export function isRunLive(runId: number): boolean {
|
|
|
223
224
|
return liveChildren.has(runId) || adoptedRuns.has(runId);
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
/**
|
|
228
|
+
* 발사 창 — launchRun 이 착수했지만 아직 자식 프로세스가 안 생긴 구간.
|
|
229
|
+
* launchRun 의 첫 문장이라 요청 핸들러가 응답을 돌려주기 **전에** 동기적으로 들어간다.
|
|
230
|
+
* 메모리에만 있어 데몬이 죽으면 같이 사라진다(= 재시작 후 유령이 체크아웃을 물고 있는 일이 없다).
|
|
231
|
+
*/
|
|
232
|
+
const launching = new Set<number>();
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* v6.0 P3 — 이 repo 체크아웃에서 지금 일하고 있는 in-place **에이전트** run 이 있으면 그 id.
|
|
236
|
+
* 한 체크아웃에 에이전트 둘은 서로의 편집을 덮어쓴다 → 발사 전에 이걸로 막는다(409).
|
|
237
|
+
*
|
|
238
|
+
* 살아있음 = 자식 보유/재-adopt(isRunLive) **또는** 발사 창(launching). 후자가 없으면
|
|
239
|
+
* 연달아 들어온 두 요청이 둘 다 통과한다 — launchRun 은 fire-and-forget 이라
|
|
240
|
+
* 두 번째 검사 시점엔 첫 run 의 자식이 아직 안 생겼기 때문.
|
|
241
|
+
* 손 터미널(agent='session'·'workbench')은 세지 않는다 — 사람이 자기 체크아웃에
|
|
242
|
+
* 터미널을 몇 개 열든 그건 사람의 선택이다(D3).
|
|
243
|
+
*/
|
|
244
|
+
export async function liveInPlaceRun(repoId: number): Promise<number | null> {
|
|
245
|
+
const repoTasks = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.repoId, repoId));
|
|
246
|
+
if (!repoTasks.length) return null;
|
|
247
|
+
const ids = repoTasks.map((t) => t.id);
|
|
248
|
+
const rs = (await db.select().from(agentRuns).where(inArray(agentRuns.taskId, ids)))
|
|
249
|
+
.filter((r) => r.inPlace && r.agent !== 'session' && r.agent !== 'workbench');
|
|
250
|
+
for (const r of rs) {
|
|
251
|
+
if (isRunLive(r.id) || launching.has(r.id)) return r.id;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
226
256
|
/** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
|
|
227
257
|
* 파일 기반: 기본 권한(claude acceptEdits · codex workspace-write)이 네트워크를 막아도
|
|
228
258
|
* 파일 쓰기는 되므로, spawn 요청을 워크트리의 .coxpit/spawn.json 으로 받는다. */
|
|
@@ -362,6 +392,7 @@ function remoteKillScript(worktreePath: string): string {
|
|
|
362
392
|
|
|
363
393
|
interface RunContext {
|
|
364
394
|
runId: number;
|
|
395
|
+
taskId: number;
|
|
365
396
|
machine: MachineTarget;
|
|
366
397
|
machineId: number;
|
|
367
398
|
repoId: number;
|
|
@@ -371,6 +402,7 @@ interface RunContext {
|
|
|
371
402
|
real: boolean;
|
|
372
403
|
agent: string;
|
|
373
404
|
model: string;
|
|
405
|
+
inPlace: boolean; // v6.0 P1 — 격리 worktree 없이 repo 체크아웃에서 그대로 돈다
|
|
374
406
|
}
|
|
375
407
|
|
|
376
408
|
async function loadContext(runId: number): Promise<RunContext | null> {
|
|
@@ -405,8 +437,15 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
405
437
|
const declared = parseOutputs(task.outputs);
|
|
406
438
|
if (declared.length) prompt += deliverablesNote(declared);
|
|
407
439
|
|
|
440
|
+
// v6.0 W3 — 작업의 공유 컨텍스트(WORK.md). 격리된 run 은 과거에서 갈라져 나오지만,
|
|
441
|
+
// 여기 적힌 결정은 이 작업 아래 모든 세션에 따라붙는다(파일 컨텍스트는 Part P 의 몫,
|
|
442
|
+
// 결정 컨텍스트는 이쪽 몫). 디자인 캡처·산출물 계약과 같은 시임이라 발사 경로 전부가 덮인다.
|
|
443
|
+
// ⚠️ 편집은 **다음 발사·steer** 부터 닿는다 — 돌고 있는 턴에 끼어드는 마법은 없다.
|
|
444
|
+
prompt += workContextBlock(task.id);
|
|
445
|
+
|
|
408
446
|
return {
|
|
409
447
|
runId,
|
|
448
|
+
taskId: task.id,
|
|
410
449
|
machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
|
|
411
450
|
machineId: m.id,
|
|
412
451
|
repoId: repo.id,
|
|
@@ -416,6 +455,7 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
416
455
|
real: config.agent.real,
|
|
417
456
|
agent: run.agent,
|
|
418
457
|
model: run.model,
|
|
458
|
+
inPlace: run.inPlace,
|
|
419
459
|
};
|
|
420
460
|
}
|
|
421
461
|
|
|
@@ -423,30 +463,41 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
423
463
|
* 한 AgentRun 실행: worktree 생성 → tmux 창(best-effort) → 에이전트 spawn →
|
|
424
464
|
* stdout 라인 파싱하며 이벤트 적재 → 종료 시 files_changed 집계 + status 전이.
|
|
425
465
|
* fire-and-forget. 실패는 status='error' 로 봉인.
|
|
466
|
+
*
|
|
467
|
+
* v6.0 P1 — in-place run 은 1) 을 통째로 건너뛴다: worktree 도 브랜치도 만들지 않고
|
|
468
|
+
* repo 체크아웃(ctx.repoPath)에서 그대로 돈다. 남는 자국은 **루트 세션 마커 그대로**
|
|
469
|
+
* (branch='' · worktreePath=repo.path) 라 merge/PR/sync/cleanup 은 이미 있는 판정이
|
|
470
|
+
* 알아서 비껴간다 — 새 개념이 아니라 이미 있던 표식의 재사용이다.
|
|
426
471
|
*/
|
|
427
472
|
export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
473
|
+
// 발사 창 진입 — 이 한 줄은 호출 즉시(첫 await 전에) 실행되므로, 뒤이어 들어온
|
|
474
|
+
// in-place 요청이 아직 자식이 없는 이 run 을 "없는 것"으로 볼 수 없다(P3 가드).
|
|
475
|
+
launching.add(runId);
|
|
428
476
|
const ctx = await loadContext(runId);
|
|
429
|
-
if (!ctx) return;
|
|
477
|
+
if (!ctx) { launching.delete(runId); return; }
|
|
430
478
|
const useReal = real ?? ctx.real;
|
|
431
479
|
|
|
432
|
-
const
|
|
480
|
+
const inPlace = ctx.inPlace;
|
|
481
|
+
const branch = inPlace ? '' : `coxpit/r${runId}`;
|
|
433
482
|
const wtParent = ppath.join(ppath.dirname(ctx.repoPath), '.coxpit-worktrees');
|
|
434
|
-
const wtPath = ppath.join(wtParent, `r${runId}`);
|
|
483
|
+
const wtPath = inPlace ? ctx.repoPath : ppath.join(wtParent, `r${runId}`);
|
|
435
484
|
const session = `coxpit-r${runId}`;
|
|
436
485
|
|
|
437
486
|
try {
|
|
438
487
|
await setRun(runId, { status: 'preparing', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
|
|
439
488
|
|
|
440
|
-
// 1) worktree 생성(격리 브랜치)
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
489
|
+
// 1) worktree 생성(격리 브랜치) — in-place 는 건너뛴다(격리가 없는 것이 요점).
|
|
490
|
+
if (!inPlace) {
|
|
491
|
+
const prep = await runShellOn(
|
|
492
|
+
ctx.machine,
|
|
493
|
+
`mkdir -p ${shq(wtParent)} && git -C ${shq(ctx.repoPath)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(ctx.baseBranch)}`,
|
|
494
|
+
20000,
|
|
495
|
+
);
|
|
496
|
+
if (!prep.ok) {
|
|
497
|
+
await recordEvent(runId, 'error', (prep.stderr || prep.stdout).trim().slice(0, 500));
|
|
498
|
+
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'worktree add failed' });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
450
501
|
}
|
|
451
502
|
|
|
452
503
|
// 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치).
|
|
@@ -456,7 +507,7 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
456
507
|
`export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d${runEnv} -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
|
|
457
508
|
|
|
458
509
|
await setRun(runId, { status: 'running' });
|
|
459
|
-
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
|
|
510
|
+
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal, ...(inPlace ? { inPlace: true } : {}) }));
|
|
460
511
|
|
|
461
512
|
// 3) 에이전트 spawn(스트리밍)
|
|
462
513
|
// 원격은 ssh 채널이 죽어도 프로세스가 남을 수 있어 pid 파일을 남긴다(stop 시 원격 kill).
|
|
@@ -490,6 +541,8 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
490
541
|
} catch (e) {
|
|
491
542
|
await recordEvent(runId, 'error', String(e).slice(0, 500));
|
|
492
543
|
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
|
|
544
|
+
} finally {
|
|
545
|
+
launching.delete(runId); // 발사 창 해제 — 여기까지 오면 살아있음 판정은 liveChildren 이 이어받는다
|
|
493
546
|
}
|
|
494
547
|
}
|
|
495
548
|
|
|
@@ -645,9 +698,11 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
|
|
|
645
698
|
await recordEvent(runId, mode === 'ask' ? 'ask' : 'steer', message.slice(0, 2000));
|
|
646
699
|
|
|
647
700
|
// Ask 모드 — 세션에 질문만: 파일 수정 없이 답변만 하도록 래핑
|
|
648
|
-
|
|
701
|
+
// 뒤에 붙는 WORK CONTEXT(W3): 작업 중간에 steer 를 받는 에이전트도 그동안 적힌 결정을 본다.
|
|
702
|
+
// 여기서도 편집은 **이 steer 부터** 닿는다(이미 돌고 있던 턴은 건드리지 않는다).
|
|
703
|
+
const finalMessage = (mode === 'ask'
|
|
649
704
|
? `Question about your work in this session (do NOT modify any files, do NOT run write commands — answer concisely):\n${message}`
|
|
650
|
-
: message;
|
|
705
|
+
: message) + workContextBlock(ctx.taskId);
|
|
651
706
|
|
|
652
707
|
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
653
708
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
@@ -1175,7 +1230,8 @@ export async function deleteSession(runId: number): Promise<{ ok: boolean; detai
|
|
|
1175
1230
|
await db.delete(agentRuns).where(eq(agentRuns.id, runId));
|
|
1176
1231
|
// 세션 task 는 run 과 1:1 — 남은 run 이 없으면 task 도 제거해 트리에서 사라지게.
|
|
1177
1232
|
const siblings = await db.select().from(agentRuns).where(eq(agentRuns.taskId, run.taskId));
|
|
1178
|
-
|
|
1233
|
+
// 작업이 사라지면 그 작업의 WORK.md 도 같이 사라진다(W1 — 정본은 task 수명에 매인다).
|
|
1234
|
+
if (task && siblings.length === 0) { await db.delete(tasks).where(eq(tasks.id, task.id)); removeWorkDoc(task.id); }
|
|
1179
1235
|
broadcast({ type: 'run', runId, deleted: true }); // 모든 콘솔이 재하이드레이트 → 행 제거
|
|
1180
1236
|
return { ok: true, detail: 'session deleted (folder preserved)' };
|
|
1181
1237
|
}
|
|
@@ -2087,3 +2143,78 @@ export async function pruneWorktrees(runIds?: number[]): Promise<{
|
|
|
2087
2143
|
|
|
2088
2144
|
return { removed, count: removed.length };
|
|
2089
2145
|
}
|
|
2146
|
+
|
|
2147
|
+
// ── 고아 tmux 세션 수거(reaper) ─────────────────────────────────
|
|
2148
|
+
// run 을 지워도 tmux 세션은 남는다(데몬 재시작·DB 초기화·수동 삭제). 2026-09-17 에 손으로
|
|
2149
|
+
// 13개를 걷어낸 그 일을 제품의 동작으로 만든다. 규칙 셋만 지키면 안전하다:
|
|
2150
|
+
// ① DB 에 **없는** run id 의 `coxpit-r<N>` 만 후보다(살아 있는 run 의 세션은 목록에 아예 안 든다)
|
|
2151
|
+
// ② 페인이 빈 셸 이상을 돌리고 있으면 **표시만** 하고 절대 미리 고르지 않는다
|
|
2152
|
+
// ③ 죽일 때도 '=' 정확 일치 — coxpit-r5 가 coxpit-r50 을 물면 안 된다(전에 물었다)
|
|
2153
|
+
|
|
2154
|
+
/** tmux 이름에서 run id 를 읽는 유일한 형태. 숫자가 아니면 우리 것으로 치지 않는다. */
|
|
2155
|
+
const ORPHAN_SESSION_RE = /^coxpit-r(\d+)$/;
|
|
2156
|
+
/** "빈 셸" 로 볼 pane_current_command 들 — 이 밖이면 뭔가 돌고 있는 것으로 본다. */
|
|
2157
|
+
const IDLE_SHELLS = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh', 'login', '-sh', '-bash', '-zsh']);
|
|
2158
|
+
const LOCAL_MACHINE: MachineTarget = { slug: 'local', kind: 'local', address: '', sshUser: '' };
|
|
2159
|
+
|
|
2160
|
+
export interface OrphanTmuxSession {
|
|
2161
|
+
name: string; // coxpit-r<N>
|
|
2162
|
+
runId: number; // 이름에서 읽은 id (DB 에는 없다 — 그래서 고아다)
|
|
2163
|
+
command: string; // 페인에서 지금 돌고 있는 것(빈 셸이면 셸 이름)
|
|
2164
|
+
idle: boolean; // 모든 페인이 빈 셸인가 = 미리 체크해도 되는가
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/**
|
|
2168
|
+
* 로컬 머신의 고아 tmux 세션 목록. `tmux list-panes -a` 한 번으로 세션별 페인 명령까지 읽고,
|
|
2169
|
+
* DB 에 run 레코드가 있는 이름은 전부 빼고 돌려준다(= 살아 있는 세션은 절대 제안되지 않는다).
|
|
2170
|
+
* tmux 서버가 안 떠 있으면 빈 배열.
|
|
2171
|
+
*/
|
|
2172
|
+
export async function listOrphanTmux(): Promise<OrphanTmuxSession[]> {
|
|
2173
|
+
const r = await runShellOn(
|
|
2174
|
+
LOCAL_MACHINE,
|
|
2175
|
+
`tmux list-panes -a -F '#{session_name}\t#{pane_current_command}' 2>/dev/null || true`,
|
|
2176
|
+
8000,
|
|
2177
|
+
).catch(() => ({ stdout: '' as string }));
|
|
2178
|
+
|
|
2179
|
+
const panes = new Map<string, string[]>();
|
|
2180
|
+
for (const line of String(r.stdout || '').split('\n')) {
|
|
2181
|
+
const [name, cmd] = line.split('\t');
|
|
2182
|
+
if (!name || !ORPHAN_SESSION_RE.test(name)) continue;
|
|
2183
|
+
const arr = panes.get(name) ?? [];
|
|
2184
|
+
arr.push((cmd ?? '').trim());
|
|
2185
|
+
panes.set(name, arr);
|
|
2186
|
+
}
|
|
2187
|
+
if (!panes.size) return [];
|
|
2188
|
+
|
|
2189
|
+
const known = new Set((await db.select().from(agentRuns)).map((x) => x.id));
|
|
2190
|
+
const out: OrphanTmuxSession[] = [];
|
|
2191
|
+
for (const [name, cmds] of panes) {
|
|
2192
|
+
const runId = Number(ORPHAN_SESSION_RE.exec(name)![1]);
|
|
2193
|
+
if (known.has(runId)) continue; // 기록이 있는 run = 고아가 아니다
|
|
2194
|
+
const busy = cmds.find((c) => c && !IDLE_SHELLS.has(c));
|
|
2195
|
+
out.push({ name, runId, command: busy || cmds[0] || '', idle: !busy });
|
|
2196
|
+
}
|
|
2197
|
+
out.sort((a, b) => a.runId - b.runId);
|
|
2198
|
+
return out;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* 선택한 고아 세션 종료. 클라이언트가 보낸 이름을 믿지 않고 **지금 다시 고아 목록을 떠서**
|
|
2203
|
+
* 그 안에 있는 것만 죽인다(그 사이 run 이 생겼거나 이름이 지어졌으면 건너뛴다).
|
|
2204
|
+
* 타깃은 언제나 '=' 정확 일치.
|
|
2205
|
+
*/
|
|
2206
|
+
export async function killTmuxSessions(names: string[]): Promise<{
|
|
2207
|
+
killed: string[]; skipped: Array<{ name: string; reason: string }>; count: number;
|
|
2208
|
+
}> {
|
|
2209
|
+
const allowed = new Set((await listOrphanTmux()).map((o) => o.name));
|
|
2210
|
+
const killed: string[] = [];
|
|
2211
|
+
const skipped: Array<{ name: string; reason: string }> = [];
|
|
2212
|
+
for (const raw of names) {
|
|
2213
|
+
const name = String(raw).trim();
|
|
2214
|
+
if (!allowed.has(name)) { skipped.push({ name, reason: 'not an orphan session (live run, or already gone)' }); continue; }
|
|
2215
|
+
await runShellOn(LOCAL_MACHINE, `tmux kill-session -t ${shq('=' + name)} 2>/dev/null || true`, 8000)
|
|
2216
|
+
.catch(() => { /* best-effort — 이미 사라졌을 수 있다 */ });
|
|
2217
|
+
killed.push(name);
|
|
2218
|
+
}
|
|
2219
|
+
return { killed, skipped, count: killed.length };
|
|
2220
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { loginPageHTML } from './login';
|
|
|
13
13
|
import {
|
|
14
14
|
authMode, authIsOpen, verifyKey, storeKey, clearStored, isExposedBind, signSession, SESSION_COOKIE,
|
|
15
15
|
clientKey, rateCheck, rateFail, rateReset, setupAllowed,
|
|
16
|
+
captureKey, captureKeyIsFixed, rotateCaptureKey, verifyCaptureKey,
|
|
16
17
|
} from './authkey';
|
|
17
18
|
import { config } from './config';
|
|
18
19
|
import { readSettings, writeSettings } from './settings';
|
|
@@ -20,14 +21,16 @@ import { db } from './db';
|
|
|
20
21
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
|
|
21
22
|
import { BOOKMARKLET_JS } from './design';
|
|
22
23
|
import { runShellOn, shq } from './exec';
|
|
23
|
-
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, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getSessionChat } from './orchestrator';
|
|
24
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getSessionChat } from './orchestrator';
|
|
24
25
|
import { openTerm } from './term';
|
|
26
|
+
import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates } from './agentstate';
|
|
25
27
|
import { addSink, removeSink, broadcast } from './hub';
|
|
26
28
|
import { getProvider, listProviders } from './providers';
|
|
27
29
|
import { remoteState, setServe, setFunnel } from './remote';
|
|
28
30
|
import { BOARD_HTML } from './board';
|
|
29
31
|
import { COCKPIT_HTML } from './cockpit';
|
|
30
|
-
import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile } from './files';
|
|
32
|
+
import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile, withinFilesRoot } from './files';
|
|
33
|
+
import { ensureWorkDoc, readWorkDoc, writeWorkDoc, removeWorkDoc, workDocPath, workDocSize } from './workdoc';
|
|
31
34
|
|
|
32
35
|
const require_ = createRequire(import.meta.url);
|
|
33
36
|
|
|
@@ -201,7 +204,14 @@ function ptyMax(): number | null {
|
|
|
201
204
|
}
|
|
202
205
|
|
|
203
206
|
export async function buildServer(): Promise<FastifyInstance> {
|
|
204
|
-
|
|
207
|
+
// 로거: 쿼리스트링의 캡처 키(?k=…)가 데몬 로그에 평문으로 남지 않게 가린다(issue #13).
|
|
208
|
+
// (프록시/엣지 로그는 데몬이 못 막지만, 캡처 키는 저가치·회전가능이라 감수 가능.)
|
|
209
|
+
const app = Fastify({ logger: {
|
|
210
|
+
redact: {
|
|
211
|
+
paths: ['req.url'],
|
|
212
|
+
censor: (v: unknown) => (typeof v === 'string' ? v.replace(/([?&]k=)[^&]*/g, '$1REDACTED') : v),
|
|
213
|
+
},
|
|
214
|
+
} });
|
|
205
215
|
// 살아있는 웹 터미널 수 — pty 압력 조기경보(/api/health)용. openTerm 성공 시 +1, 소켓 close 시 -1.
|
|
206
216
|
// 누수가 재발하면 이 값이 실제 열린 탭보다 커지지 않아도(닫을 때 감소), machine-wide ptmx 대비
|
|
207
217
|
// 이 데몬의 부하를 노출한다. leak 재발 자체는 회귀 테스트(test/pty-fd.mjs)가 잡는다.
|
|
@@ -368,6 +378,9 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
368
378
|
return { ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
|
|
369
379
|
}),
|
|
370
380
|
counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
|
|
381
|
+
// 지금 터미널이 붙어 있는 run 의 에이전트 상태(runId → {state,detail,ts}).
|
|
382
|
+
// 하이드레이션용 — 새로 뜬 코크핏이 다음 agentstate 델타를 기다리지 않게. 붙어 있는 동안만 존재한다.
|
|
383
|
+
agentStates: allAgentStates(),
|
|
371
384
|
// 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
|
|
372
385
|
// authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
|
|
373
386
|
daemon: {
|
|
@@ -503,6 +516,17 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
503
516
|
return pruneWorktrees(runIds);
|
|
504
517
|
});
|
|
505
518
|
|
|
519
|
+
// v6.0 T6 — 고아 tmux 세션(위 worktree 회수와 같은 유지보수 가족). DB 에 run 기록이 없는 `coxpit-r*` 만 목록에 든다.
|
|
520
|
+
// 살아 있는 run 의 세션은 애초에 나오지 않고, 빈 셸이 아닌 것은 idle:false 로 표시만 된다.
|
|
521
|
+
app.get('/api/tmux/orphans', async () => ({ sessions: await listOrphanTmux() }));
|
|
522
|
+
|
|
523
|
+
// 선택 종료 — 이름 배열만 받는다. 서버가 고아 목록을 다시 떠서 그 안의 것만, '=' 정확 일치로 죽인다.
|
|
524
|
+
app.post('/api/tmux/orphans/kill', async (req, reply) => {
|
|
525
|
+
const b = (req.body ?? {}) as { sessions?: unknown };
|
|
526
|
+
if (!Array.isArray(b.sessions)) return reply.code(400).send({ error: 'sessions must be an array of session names' });
|
|
527
|
+
return killTmuxSessions(b.sessions.map((s) => String(s)));
|
|
528
|
+
});
|
|
529
|
+
|
|
506
530
|
// ─── 머신 레지스트리 ────────────────────────────────────────────
|
|
507
531
|
app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
|
|
508
532
|
|
|
@@ -822,14 +846,13 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
822
846
|
});
|
|
823
847
|
|
|
824
848
|
// ─── Design Mode ───────────────────────────────────────────────
|
|
825
|
-
// 캡처
|
|
849
|
+
// 캡처 키 = 마스터 접근키와 분리된 저가치 전용 키(issue #13). 오직 이 캡처 엔드포인트만 허가한다.
|
|
850
|
+
// 인증 꺼짐이면 자유, 아니면 ?k=<capture key> 를 검증(마스터 접근키가 아니라 캡처 키로).
|
|
851
|
+
// setup(마스터 키 미설정) 상태여도 캡처 키는 독립적으로 존재하므로 캡처는 동작한다.
|
|
826
852
|
const captureKeyOk = (req: { query?: unknown }): boolean => {
|
|
827
|
-
|
|
828
|
-
// 인증 꺼짐 → 자유. 아직 키 미설정(setup) → 캡처 불가(키가 없으니 증명 수단 없음).
|
|
829
|
-
if (m.mode === 'disabled') return true;
|
|
830
|
-
if (m.mode === 'setup') return false;
|
|
853
|
+
if (authMode().mode === 'disabled') return true;
|
|
831
854
|
const k = ((req.query ?? {}) as { k?: string }).k ?? '';
|
|
832
|
-
return
|
|
855
|
+
return verifyCaptureKey(k);
|
|
833
856
|
};
|
|
834
857
|
const cors = (reply: { header: (k: string, v: string) => unknown }) => {
|
|
835
858
|
reply.header('access-control-allow-origin', '*');
|
|
@@ -856,6 +879,11 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
856
879
|
|
|
857
880
|
app.get('/api/design', async () => ({ captures: await db.select().from(designCaptures) }));
|
|
858
881
|
|
|
882
|
+
// 캡처 키 조회/회전 — 인증 게이트 뒤(EXEMPT 아님)라 인증된 보드 사용자만 접근한다.
|
|
883
|
+
// 보드가 이걸로 북마클릿 href(?k=…)를 만들고, 회전 버튼으로 새 키를 발급한다.
|
|
884
|
+
app.get('/api/design/capture-key', async () => ({ key: captureKey(), fixed: captureKeyIsFixed() }));
|
|
885
|
+
app.post('/api/design/capture-key/rotate', async () => ({ key: rotateCaptureKey(), fixed: captureKeyIsFixed() }));
|
|
886
|
+
|
|
859
887
|
app.delete('/api/design/:id', async (req, reply) => {
|
|
860
888
|
const id = Number((req.params as { id: string }).id);
|
|
861
889
|
await db.delete(designCaptures).where(eq(designCaptures.id, id));
|
|
@@ -925,24 +953,100 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
925
953
|
return { task: tr[0], runs };
|
|
926
954
|
});
|
|
927
955
|
|
|
928
|
-
// 태스크 이름 변경(=세션 이름 변경).
|
|
956
|
+
// 태스크 이름 변경(=세션 이름 변경) + v6.0 S2 승격(repoId 재부모화).
|
|
957
|
+
// 둘 다 선택 — title 만 보내던 기존 호출은 그대로 동작한다. repoId 는 "이 작업이 어느
|
|
958
|
+
// 프로젝트 것인가"만 바꾼다: run 의 worktreePath 는 손대지 않으므로 터미널은 제 폴더에서 계속 돈다.
|
|
929
959
|
app.patch('/api/tasks/:id', async (req, reply) => {
|
|
930
960
|
const id = Number((req.params as { id: string }).id);
|
|
931
|
-
const b = (req.body ?? {}) as { title?: string };
|
|
961
|
+
const b = (req.body ?? {}) as { title?: string; repoId?: unknown };
|
|
962
|
+
const wantTitle = b.title !== undefined;
|
|
963
|
+
const wantRepo = b.repoId !== undefined && b.repoId !== null;
|
|
964
|
+
if (!wantTitle && !wantRepo) return reply.code(400).send({ error: 'title or repoId required' });
|
|
965
|
+
|
|
932
966
|
const title = (b.title ?? '').trim();
|
|
933
|
-
if (
|
|
934
|
-
|
|
967
|
+
if (wantTitle) {
|
|
968
|
+
if (!title) return reply.code(400).send({ error: 'title required' });
|
|
969
|
+
if (title.length > 140) return reply.code(400).send({ error: 'title too long (max 140)' });
|
|
970
|
+
}
|
|
935
971
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
972
|
+
const task = tr[0];
|
|
973
|
+
if (!task) return reply.code(404).send({ error: 'not found' });
|
|
974
|
+
|
|
975
|
+
let repoId: number | undefined;
|
|
976
|
+
if (wantRepo) {
|
|
977
|
+
const wanted = Number(b.repoId);
|
|
978
|
+
if (!Number.isInteger(wanted)) return reply.code(400).send({ error: 'repoId must be an integer' });
|
|
979
|
+
const rr = await db.select().from(repos).where(eq(repos.id, wanted)).limit(1);
|
|
980
|
+
const repo = rr[0];
|
|
981
|
+
if (!repo) return reply.code(404).send({ error: 'repo not found' });
|
|
982
|
+
// 승격은 한 방향이다 — 프로젝트로 나가는 길만 있고, Scratch 버킷으로 밀어 넣는 길은 없다.
|
|
983
|
+
if (repo.kind === 'sessions') {
|
|
984
|
+
return reply.code(400).send({
|
|
985
|
+
error: 'cannot re-parent into the scratch bucket',
|
|
986
|
+
code: 'SCRATCH_BUCKET',
|
|
987
|
+
detail: 'the sessions bucket is not a project — pick a registered repo',
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
repoId = repo.id;
|
|
991
|
+
}
|
|
992
|
+
const patch: { title?: string; repoId?: number } = {};
|
|
993
|
+
if (wantTitle) patch.title = title;
|
|
994
|
+
if (repoId !== undefined) patch.repoId = repoId;
|
|
995
|
+
await db.update(tasks).set(patch).where(eq(tasks.id, id));
|
|
996
|
+
const outTitle = wantTitle ? title : task.title;
|
|
997
|
+
const outRepo = repoId ?? task.repoId;
|
|
998
|
+
broadcast({ type: 'task', taskId: id, title: outTitle, repoId: outRepo });
|
|
999
|
+
return { ok: true, title: outTitle, repoId: outRepo };
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// ─── v6.0 Part W — WORK.md (작업의 공유 컨텍스트) ───────────────
|
|
1003
|
+
// 정본은 데몬 데이터 디렉터리(~/.coxpit/work/<taskId>.md)에 산다 — git 트리 밖이라
|
|
1004
|
+
// 어떤 worktree 의 diff 에도 뜨지 않고 브랜치가 갈려도 내용이 갈라지지 않는다.
|
|
1005
|
+
// 읽기·쓰기는 **기존 파일 뷰어**(/api/fs/read·write)가 그대로 맡는 것이 기본이다:
|
|
1006
|
+
// ~/.coxpit 은 기본 뷰어 루트(홈) 안이라 이미 통과한다(새 파일 창구를 만들지 않는다).
|
|
1007
|
+
// 여기 셋은 그 앞뒤만 맡는다 — ① 처음 열 때 빈 파일을 만들어 주고(POST),
|
|
1008
|
+
// ② COXPIT_DB/COXPIT_FILES_ROOT 조합 때문에 그 길이 막힌 경우의 대체 경로(GET/PUT).
|
|
1009
|
+
const WORK_DOC_MAX = 512 * 1024;
|
|
1010
|
+
const workTask = async (id: number) => (await db.select().from(tasks).where(eq(tasks.id, id)).limit(1))[0];
|
|
1011
|
+
|
|
1012
|
+
app.post('/api/tasks/:id/work', async (req, reply) => {
|
|
1013
|
+
const id = Number((req.params as { id: string }).id);
|
|
1014
|
+
if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
|
|
1015
|
+
try {
|
|
1016
|
+
const path = ensureWorkDoc(id);
|
|
1017
|
+
// inRoot=true 면 클라이언트는 평소 쓰던 파일 뷰어 페인으로 그냥 연다.
|
|
1018
|
+
return { ok: true, path, inRoot: withinFilesRoot(path), size: workDocSize(id) };
|
|
1019
|
+
} catch (e: any) { return reply.code(500).send({ error: String(e?.message || e) }); }
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
app.get('/api/tasks/:id/work', async (req, reply) => {
|
|
1023
|
+
const id = Number((req.params as { id: string }).id);
|
|
1024
|
+
if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
|
|
1025
|
+
const path = workDocPath(id);
|
|
1026
|
+
const text = readWorkDoc(id);
|
|
1027
|
+
// 뷰어가 /api/fs/read 에서 받던 모양 그대로 — 같은 렌더러가 손 안 대고 붙는다.
|
|
1028
|
+
return { path, name: 'WORK.md', kind: 'md', size: Buffer.byteLength(text, 'utf8'), editable: true, text };
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
app.put('/api/tasks/:id/work', async (req, reply) => {
|
|
1032
|
+
const id = Number((req.params as { id: string }).id);
|
|
1033
|
+
const b = (req.body ?? {}) as { content?: string };
|
|
1034
|
+
if (typeof b.content !== 'string') return reply.code(400).send({ error: 'content required' });
|
|
1035
|
+
if (Buffer.byteLength(b.content, 'utf8') > WORK_DOC_MAX) return reply.code(400).send({ error: 'content exceeds edit cap (512KB)' });
|
|
1036
|
+
if (!(await workTask(id))) return reply.code(404).send({ error: 'task not found' });
|
|
1037
|
+
try {
|
|
1038
|
+
const { path, size } = writeWorkDoc(id, b.content);
|
|
1039
|
+
return { ok: true, path, name: 'WORK.md', size };
|
|
1040
|
+
} catch (e: any) { return reply.code(500).send({ error: String(e?.message || e) }); }
|
|
940
1041
|
});
|
|
941
1042
|
|
|
1043
|
+
// 역할 이름(run.title) 상한 — 탭 한 칸에 들어가는 길이. 작업 이름(140)보다 짧게 둔다.
|
|
1044
|
+
const RUN_TITLE_MAX = 60;
|
|
1045
|
+
|
|
942
1046
|
// N개의 에이전트 run 을 만들고 각자 오케스트레이션 시작(fire-and-forget).
|
|
943
1047
|
app.post('/api/tasks/:id/run', async (req, reply) => {
|
|
944
1048
|
const id = Number((req.params as { id: string }).id);
|
|
945
|
-
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string };
|
|
1049
|
+
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string; title?: string; inPlace?: boolean };
|
|
946
1050
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
947
1051
|
const task = tr[0];
|
|
948
1052
|
if (!task) return reply.code(404).send({ error: 'task not found' });
|
|
@@ -957,16 +1061,36 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
957
1061
|
if (model && (model.length > 64 || !/^[\w.\-:/]*$/.test(model))) {
|
|
958
1062
|
return reply.code(400).send({ error: 'invalid model name' });
|
|
959
1063
|
}
|
|
1064
|
+
// v6.0 T4 — 역할 이름(선택). 작업 안에서 이 run 이 무엇인지(구현·기타). 빈값 = 프로바이더 이름으로 표시.
|
|
1065
|
+
const title = (b.title ?? '').trim().slice(0, RUN_TITLE_MAX);
|
|
1066
|
+
// v6.0 P — 격리는 선택이다. in-place = repo 체크아웃 공유(순차) · worktree = 병렬(나중에 머지).
|
|
1067
|
+
const inPlace = b.inPlace === true;
|
|
1068
|
+
if (inPlace && count > 1) {
|
|
1069
|
+
return reply.code(400).send({
|
|
1070
|
+
error: 'IN_PLACE_FANOUT',
|
|
1071
|
+
detail: 'in-place is sequential — one agent per checkout (count must be 1). launch in a worktree to fan out.',
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
// P3 — 한 체크아웃에 에이전트 둘은 서로를 덮어쓴다. 만들기 **전에** 막고 이유를 말한다.
|
|
1075
|
+
if (inPlace) {
|
|
1076
|
+
const busy = await liveInPlaceRun(task.repoId);
|
|
1077
|
+
if (busy !== null) {
|
|
1078
|
+
return reply.code(409).send({
|
|
1079
|
+
error: 'IN_PLACE_BUSY',
|
|
1080
|
+
detail: `r${busy} is already working in this checkout — steer it, or launch in a worktree`,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
960
1084
|
const created: Array<typeof agentRuns.$inferSelect> = [];
|
|
961
1085
|
for (let i = 0; i < count; i++) {
|
|
962
1086
|
const ins = await db.insert(agentRuns)
|
|
963
|
-
.values({ taskId: id, machineId: rp[0].machineId, agent, model, status: 'pending' })
|
|
1087
|
+
.values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending' })
|
|
964
1088
|
.returning();
|
|
965
1089
|
created.push(ins[0]!);
|
|
966
1090
|
}
|
|
967
1091
|
// 보드가 taskId 를 알도록 생성 브로드캐스트 후 백그라운드 시작.
|
|
968
1092
|
for (const r of created) {
|
|
969
|
-
broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, branch: '', filesChanged: 0 });
|
|
1093
|
+
broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, branch: '', filesChanged: 0 });
|
|
970
1094
|
void launchRun(r.id, b.real);
|
|
971
1095
|
}
|
|
972
1096
|
return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
|
|
@@ -1021,6 +1145,8 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1021
1145
|
for (const r of trs) cleanups.push({ runId: r.id, ...(await cleanupRun(r.id)) });
|
|
1022
1146
|
|
|
1023
1147
|
await db.update(tasks).set({ status: 'closed', closedAt: new Date() }).where(eq(tasks.id, id));
|
|
1148
|
+
// 작업이 닫히면 그 작업의 WORK.md 도 같이 사라진다(W1) — 공유 컨텍스트는 작업의 수명을 산다.
|
|
1149
|
+
removeWorkDoc(id);
|
|
1024
1150
|
broadcast({ type: 'task', taskId: id, status: 'closed' });
|
|
1025
1151
|
return { ok: true, taskId: id, cleanups };
|
|
1026
1152
|
});
|
|
@@ -1034,6 +1160,22 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1034
1160
|
return { run: rr[0], events };
|
|
1035
1161
|
});
|
|
1036
1162
|
|
|
1163
|
+
// v6.0 T4 — run 의 역할 이름 변경(탭 더블클릭). title 만 받는다.
|
|
1164
|
+
// (세션 버킷 run 은 지금까지처럼 태스크 이름을 바꾼다 — 클라이언트가 갈라 부른다.)
|
|
1165
|
+
app.patch('/api/runs/:id', async (req, reply) => {
|
|
1166
|
+
const id = Number((req.params as { id: string }).id);
|
|
1167
|
+
const b = (req.body ?? {}) as { title?: unknown };
|
|
1168
|
+
const title = typeof b.title === 'string' ? b.title.trim() : '';
|
|
1169
|
+
if (!title) return reply.code(400).send({ error: 'title required' });
|
|
1170
|
+
if (title.length > RUN_TITLE_MAX) return reply.code(400).send({ error: `title too long (max ${RUN_TITLE_MAX})` });
|
|
1171
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1172
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1173
|
+
await db.update(agentRuns).set({ title }).where(eq(agentRuns.id, id));
|
|
1174
|
+
// status 를 싣지 않는다 — 이름 변경은 정착 이벤트가 아니다(보드의 settle 알림을 깨우지 않게).
|
|
1175
|
+
broadcast({ type: 'run', runId: id, taskId: rr[0].taskId, title });
|
|
1176
|
+
return { ok: true, title };
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1037
1179
|
app.post('/api/runs/:id/cleanup', async (req, reply) => {
|
|
1038
1180
|
const id = Number((req.params as { id: string }).id);
|
|
1039
1181
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
@@ -1643,10 +1785,12 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1643
1785
|
return;
|
|
1644
1786
|
}
|
|
1645
1787
|
liveTerminals++; // pty 압력 지표(/api/health) — close 에서 정확히 1회 감소
|
|
1788
|
+
agentAttach(id); // 에이전트 상태 추적 시작(refcount — 같은 run 에 여러 클라이언트가 붙어도 tracker 는 하나)
|
|
1646
1789
|
let closed = false;
|
|
1647
1790
|
// 백프레셔 — WS 송신 버퍼가 차면 pty 를 잠시 멈춰 폭주 방지
|
|
1648
1791
|
let paused = false;
|
|
1649
1792
|
term.onData((d) => {
|
|
1793
|
+
agentFeed(id, d);
|
|
1650
1794
|
try {
|
|
1651
1795
|
socket.send(JSON.stringify({ t: 'o', d }));
|
|
1652
1796
|
if (!paused && socket.bufferedAmount > 800_000) { paused = true; try { term.pause(); } catch { /* n/a */ } }
|
|
@@ -1657,16 +1801,16 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1657
1801
|
}, 200);
|
|
1658
1802
|
const keepalive = setInterval(() => { try { socket.ping(); } catch { /* closed */ } }, 30_000);
|
|
1659
1803
|
|
|
1660
|
-
term.onExit(() => { try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
|
|
1804
|
+
term.onExit(() => { agentExit(id); try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
|
|
1661
1805
|
socket.on('message', (raw: Buffer) => {
|
|
1662
1806
|
try {
|
|
1663
1807
|
const m = JSON.parse(raw.toString()) as { t?: string; d?: string; cols?: number; rows?: number };
|
|
1664
|
-
if (m.t === 'i' && typeof m.d === 'string') term.write(m.d);
|
|
1808
|
+
if (m.t === 'i' && typeof m.d === 'string') { agentInput(id); term.write(m.d); }
|
|
1665
1809
|
else if (m.t === 'r' && m.cols && m.rows) term.resize(Math.max(20, Math.min(500, m.cols)), Math.max(5, Math.min(200, m.rows)));
|
|
1666
1810
|
} catch { /* ignore */ }
|
|
1667
1811
|
});
|
|
1668
1812
|
socket.on('close', () => {
|
|
1669
|
-
if (!closed) { closed = true; liveTerminals = Math.max(0, liveTerminals - 1); }
|
|
1813
|
+
if (!closed) { closed = true; liveTerminals = Math.max(0, liveTerminals - 1); agentDetach(id); }
|
|
1670
1814
|
clearInterval(drain); clearInterval(keepalive);
|
|
1671
1815
|
try { term.kill(); } catch { /* gone */ }
|
|
1672
1816
|
});
|