coxpit 4.3.1 → 4.6.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 +13 -1
- package/package.json +1 -1
- package/src/board.ts +672 -12
- package/src/db/index.ts +1 -0
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +136 -7
- package/src/providers.ts +14 -5
- package/src/remote.ts +148 -0
- package/src/server.ts +215 -7
package/src/server.ts
CHANGED
|
@@ -13,10 +13,11 @@ 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 } from './orchestrator';
|
|
17
17
|
import { openTerm } from './term';
|
|
18
18
|
import { addSink, removeSink, broadcast } from './hub';
|
|
19
19
|
import { getProvider, listProviders } from './providers';
|
|
20
|
+
import { remoteState, setServe, setFunnel } from './remote';
|
|
20
21
|
import { BOARD_HTML } from './board';
|
|
21
22
|
|
|
22
23
|
const require_ = createRequire(import.meta.url);
|
|
@@ -202,7 +203,11 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
202
203
|
runs: rns.map((r) => ({ ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP) })),
|
|
203
204
|
counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
|
|
204
205
|
// 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
|
|
205
|
-
|
|
206
|
+
// authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
|
|
207
|
+
daemon: {
|
|
208
|
+
version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath,
|
|
209
|
+
authOpen: config.auth.disabled || config.auth.pass === '',
|
|
210
|
+
},
|
|
206
211
|
providers: listProviders(),
|
|
207
212
|
};
|
|
208
213
|
});
|
|
@@ -339,7 +344,8 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
339
344
|
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
340
345
|
|
|
341
346
|
// 기본 브랜치는 "지금 체크아웃된 브랜치"가 아니라 repo 의 진짜 기본값:
|
|
342
|
-
// origin/HEAD → 로컬 main/master → 현재 HEAD 순으로 감지.
|
|
347
|
+
// origin/HEAD → 로컬 main/master → 현재 HEAD(symbolic-ref, unborn 무에러) 순으로 감지.
|
|
348
|
+
// 마지막 세그먼트는 커밋 존재 여부(--verify HEAD) — 커밋 0개 repo 는 등록 거절.
|
|
343
349
|
const g = `git -C ${shq(path)}`;
|
|
344
350
|
const cmd =
|
|
345
351
|
`${g} rev-parse --is-inside-work-tree 2>&1` +
|
|
@@ -348,7 +354,9 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
348
354
|
` && echo '---C---'` +
|
|
349
355
|
` && { ${g} show-ref --verify -q refs/heads/main && echo main || { ${g} show-ref --verify -q refs/heads/master && echo master; } || true; }` +
|
|
350
356
|
` && echo '---D---'` +
|
|
351
|
-
` && ${g}
|
|
357
|
+
` && { ${g} symbolic-ref --short HEAD 2>/dev/null || true; }` + // unborn 에서도 무에러
|
|
358
|
+
` && echo '---E---'` +
|
|
359
|
+
` && { ${g} rev-parse --verify -q HEAD >/dev/null 2>&1 && echo yes || echo no; }`;
|
|
352
360
|
const r = await runShellOn(m, cmd);
|
|
353
361
|
const isRepo = r.ok && /(^|\n)true(\n|$)/.test(r.stdout);
|
|
354
362
|
if (!isRepo) {
|
|
@@ -359,9 +367,17 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
359
367
|
}
|
|
360
368
|
const seg = (a: string, b: string): string =>
|
|
361
369
|
((r.stdout.split(a)[1] ?? '').split(b)[0] ?? '').trim();
|
|
370
|
+
const hasCommit = (r.stdout.split('---E---')[1] ?? '').trim() === 'yes';
|
|
371
|
+
if (!hasCommit) {
|
|
372
|
+
return reply.code(400).send({
|
|
373
|
+
error: 'this repository has no commits yet',
|
|
374
|
+
code: 'NO_COMMITS',
|
|
375
|
+
hint: 'make an initial commit first — or use Start a new project to have coxpit do it',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
362
378
|
const originHead = seg('---B---', '---C---').replace(/^origin\//, '');
|
|
363
379
|
const localMain = seg('---C---', '---D---');
|
|
364
|
-
const headNow = (
|
|
380
|
+
const headNow = seg('---D---', '---E---');
|
|
365
381
|
const branch = originHead || localMain || headNow || 'main';
|
|
366
382
|
const name = (b.name ?? '').trim() || path.split('/').filter(Boolean).pop() || path;
|
|
367
383
|
|
|
@@ -372,6 +388,63 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
372
388
|
return reply.code(201).send({ ok: true, repo: ins[0] });
|
|
373
389
|
});
|
|
374
390
|
|
|
391
|
+
// greenfield — "Start a new project": 빈/미존재/커밋없는 경로에만 git init + 빈 초기 커밋을
|
|
392
|
+
// 심고 등록한다. coxpit 이 git init 을 하는 유일한 자리 — 파일 있는 폴더는 절대 건드리지 않는다.
|
|
393
|
+
app.post('/api/repos/new', async (req, reply) => {
|
|
394
|
+
const b = (req.body ?? {}) as { machineSlug?: string; path?: string; name?: string };
|
|
395
|
+
const machineSlug = (b.machineSlug ?? '').trim();
|
|
396
|
+
const path = (b.path ?? '').trim();
|
|
397
|
+
if (!machineSlug || !path) return reply.code(400).send({ error: 'machineSlug and path required' });
|
|
398
|
+
if (!path.startsWith('/')) return reply.code(400).send({ error: 'path must be absolute' });
|
|
399
|
+
|
|
400
|
+
const mr = await db.select().from(machines).where(eq(machines.slug, machineSlug)).limit(1);
|
|
401
|
+
const m = mr[0];
|
|
402
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
403
|
+
|
|
404
|
+
const g = `git -C ${shq(path)}`;
|
|
405
|
+
const probe =
|
|
406
|
+
`if [ ! -e ${shq(path)} ]; then echo MISSING;` +
|
|
407
|
+
` elif [ ! -d ${shq(path)} ]; then echo NOTDIR;` +
|
|
408
|
+
` elif [ -d ${shq(path)}/.git ]; then { ${g} rev-parse --verify -q HEAD >/dev/null 2>&1 && echo REPO_HAS_COMMITS || echo REPO_EMPTY; };` +
|
|
409
|
+
` elif [ -z "$(ls -A ${shq(path)} 2>/dev/null)" ]; then echo EMPTYDIR;` +
|
|
410
|
+
` else echo NONEMPTY; fi`;
|
|
411
|
+
const pr = await runShellOn(m, probe, 20000);
|
|
412
|
+
if (!pr.ok) return reply.code(400).send({ error: 'could not inspect path', detail: (pr.stdout || pr.stderr).trim().slice(0, 400) });
|
|
413
|
+
const kind = pr.stdout.trim().split('\n').pop()?.trim() ?? '';
|
|
414
|
+
|
|
415
|
+
if (kind === 'NOTDIR') return reply.code(400).send({ error: 'path is not a directory' });
|
|
416
|
+
if (kind === 'REPO_HAS_COMMITS') return reply.code(409).send({ error: 'already a repository with commits — use Register' });
|
|
417
|
+
if (kind === 'NONEMPTY') return reply.code(409).send({ error: 'folder is not empty — greenfield never touches existing files' });
|
|
418
|
+
if (kind !== 'MISSING' && kind !== 'EMPTYDIR' && kind !== 'REPO_EMPTY') {
|
|
419
|
+
return reply.code(400).send({ error: 'could not classify path', detail: (pr.stdout || pr.stderr).trim().slice(0, 400) });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// init(필요 시) + 빈 초기 커밋 — 이 커밋이 worktree 가 브랜치할 base.
|
|
423
|
+
// mergeRun 과 동일 관례: coxpit ident, gpgsign off.
|
|
424
|
+
const seed =
|
|
425
|
+
`mkdir -p ${shq(path)} && cd ${shq(path)}` +
|
|
426
|
+
` && { [ -d .git ] || git init -b main; }` +
|
|
427
|
+
` && git -c user.name='coxpit' -c user.email='coxpit@local' -c commit.gpgsign=false` +
|
|
428
|
+
` commit --allow-empty -m 'coxpit: initial commit'`;
|
|
429
|
+
const sr = await runShellOn(m, seed, 20000);
|
|
430
|
+
if (!sr.ok) return reply.code(422).send({ error: 'could not initialize the project', detail: (sr.stdout || sr.stderr).trim().slice(0, 400) });
|
|
431
|
+
|
|
432
|
+
// REPO_EMPTY 는 기존 unborn 브랜치가 master 일 수 있음 — seed 후 실제 브랜치를 읽는다.
|
|
433
|
+
// init 케이스는 항상 main.
|
|
434
|
+
let branch = 'main';
|
|
435
|
+
if (kind === 'REPO_EMPTY') {
|
|
436
|
+
const br = await runShellOn(m, `git -C ${shq(path)} symbolic-ref --short HEAD 2>/dev/null || echo main`, 10000);
|
|
437
|
+
branch = br.stdout.trim().split('\n').pop()?.trim() || 'main';
|
|
438
|
+
}
|
|
439
|
+
const name = (b.name ?? '').trim() || path.split('/').filter(Boolean).pop() || path;
|
|
440
|
+
|
|
441
|
+
const ins = await db.insert(repos).values({
|
|
442
|
+
machineId: m.id, path, name, defaultBranch: branch,
|
|
443
|
+
}).returning();
|
|
444
|
+
|
|
445
|
+
return reply.code(201).send({ ok: true, repo: ins[0] });
|
|
446
|
+
});
|
|
447
|
+
|
|
375
448
|
// repo 삭제 — 열린 태스크가 있으면 거부(이력 보호).
|
|
376
449
|
app.delete('/api/repos/:id', async (req, reply) => {
|
|
377
450
|
const id = Number((req.params as { id: string }).id);
|
|
@@ -408,13 +481,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
408
481
|
const q = (req.query ?? {}) as { path?: string };
|
|
409
482
|
const start = q.path && q.path.startsWith('/') ? q.path : homedir();
|
|
410
483
|
const p = presolve(start);
|
|
411
|
-
let dirs: Array<{ name: string; isRepo: boolean }> = [];
|
|
484
|
+
let dirs: Array<{ name: string; isRepo: boolean; isEmpty: boolean }> = [];
|
|
412
485
|
let error: string | undefined;
|
|
413
486
|
try {
|
|
414
487
|
const entries = await readdir(p, { withFileTypes: true });
|
|
415
488
|
for (const e of entries) {
|
|
416
489
|
if (!e.isDirectory() || e.name.startsWith('.')) continue;
|
|
417
|
-
|
|
490
|
+
const full = pjoin(p, e.name);
|
|
491
|
+
const isRepo = existsSync(pjoin(full, '.git'));
|
|
492
|
+
// 빈 폴더면 greenfield "Start here" 대상 — 서버 EMPTYDIR 판정(ls -A)과 동일하게
|
|
493
|
+
// 모든 엔트리(닷파일 포함) 0개일 때만. repo 폴더는 Register 로 다루므로 계산 생략.
|
|
494
|
+
let isEmpty = false;
|
|
495
|
+
if (!isRepo) { try { isEmpty = (await readdir(full)).length === 0; } catch { isEmpty = false; } }
|
|
496
|
+
dirs.push({ name: e.name, isRepo, isEmpty });
|
|
418
497
|
if (dirs.length >= 300) break;
|
|
419
498
|
}
|
|
420
499
|
dirs.sort((a, b) => (b.isRepo ? 1 : 0) - (a.isRepo ? 1 : 0) || a.name.localeCompare(b.name));
|
|
@@ -466,6 +545,28 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
466
545
|
app.get('/design/bookmarklet.js', async (_req, reply) =>
|
|
467
546
|
reply.type('text/javascript').header('cache-control', 'no-store').send(BOOKMARKLET_JS));
|
|
468
547
|
|
|
548
|
+
// ─── Remote access (v4.5) ──────────────────────────────────────
|
|
549
|
+
// coxpit DETECTS the user's own Tailscale and DRIVES serve/funnel — it never
|
|
550
|
+
// hosts a relay or issues a coxpit-branded URL. Truth is read live from the
|
|
551
|
+
// CLI each call (no DB state). Owner-only (behind the normal authGate).
|
|
552
|
+
app.get('/api/remote', async () => remoteState(config.port));
|
|
553
|
+
|
|
554
|
+
// Serve = tailnet-only HTTPS (safe by default) — no auth guard needed.
|
|
555
|
+
app.post('/api/remote/serve', async (req) => {
|
|
556
|
+
const b = (req.body ?? {}) as { on?: boolean };
|
|
557
|
+
return setServe(config.port, b.on === true);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Funnel = PUBLIC internet. Refuse to expose shells without a password:
|
|
561
|
+
// Funnel has no Tailscale-side auth, so coxpit's basic auth is the only gate.
|
|
562
|
+
app.post('/api/remote/funnel', async (req, reply) => {
|
|
563
|
+
const b = (req.body ?? {}) as { on?: boolean };
|
|
564
|
+
if (b.on === true && (config.auth.disabled || config.auth.pass === '')) {
|
|
565
|
+
return reply.code(409).send({ error: 'set a password first', code: 'NO_AUTH' });
|
|
566
|
+
}
|
|
567
|
+
return setFunnel(config.port, b.on === true);
|
|
568
|
+
});
|
|
569
|
+
|
|
469
570
|
// ─── Task ──────────────────────────────────────────────────────
|
|
470
571
|
app.get('/api/tasks', async (req) => {
|
|
471
572
|
const q = (req.query ?? {}) as { repo?: string };
|
|
@@ -660,6 +761,113 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
660
761
|
return reply.code(202).send(res);
|
|
661
762
|
});
|
|
662
763
|
|
|
764
|
+
// ─── Goal workroom (v4.6 L1) — 한 그룹(goal/swarm)을 한 방에서 몰기 ────────
|
|
765
|
+
// steerable = 정착(done/failed/stopped) + sessionId 보유 + worktree 살아있음.
|
|
766
|
+
// (steerRun 전제 그대로 — 라이브 run 은 steer 불가, 드라이런은 세션 없음.)
|
|
767
|
+
const groupRuns = async (groupId: number): Promise<{
|
|
768
|
+
group: typeof taskGroups.$inferSelect;
|
|
769
|
+
rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }>;
|
|
770
|
+
} | null> => {
|
|
771
|
+
const gr = await db.select().from(taskGroups).where(eq(taskGroups.id, groupId)).limit(1);
|
|
772
|
+
if (!gr[0]) return null;
|
|
773
|
+
const gts = await db.select().from(tasks).where(eq(tasks.groupId, groupId));
|
|
774
|
+
const rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }> = [];
|
|
775
|
+
for (const t of gts) {
|
|
776
|
+
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
|
|
777
|
+
for (const run of trs) rows.push({ run, task: t });
|
|
778
|
+
}
|
|
779
|
+
rows.sort((a, b) => a.run.id - b.run.id);
|
|
780
|
+
return { group: gr[0], rows };
|
|
781
|
+
};
|
|
782
|
+
const isSteerable = (r: typeof agentRuns.$inferSelect): boolean =>
|
|
783
|
+
!isRunLive(r.id) && ['done', 'failed', 'stopped'].includes(r.status) && !!r.sessionId && !!r.worktreePath;
|
|
784
|
+
|
|
785
|
+
// B1 — 애그리게이트 뷰(방의 chips + 최근 타임라인). 페이로드 다이어트: 최근 200 이벤트만.
|
|
786
|
+
app.get('/api/groups/:id', async (req, reply) => {
|
|
787
|
+
const id = Number((req.params as { id: string }).id);
|
|
788
|
+
const g = await groupRuns(id);
|
|
789
|
+
if (!g) return reply.code(404).send({ error: 'group not found' });
|
|
790
|
+
const runs = g.rows.map(({ run, task }) => ({
|
|
791
|
+
runId: run.id, taskId: task.id, title: task.title, status: run.status,
|
|
792
|
+
agent: run.agent, model: run.model, branch: run.branch, filesChanged: run.filesChanged,
|
|
793
|
+
live: isRunLive(run.id), steerable: isSteerable(run),
|
|
794
|
+
}));
|
|
795
|
+
const runIds = g.rows.map((x) => x.run.id);
|
|
796
|
+
// 이벤트: 그룹 run 전체에서 최근 200개(id 순, 오래된 것 먼저 — 방 피드는 append-only).
|
|
797
|
+
const evs = runIds.length
|
|
798
|
+
? (await db.select().from(agentEvents).where(inArray(agentEvents.runId, runIds)))
|
|
799
|
+
.sort((a, b) => a.id - b.id).slice(-200)
|
|
800
|
+
: [];
|
|
801
|
+
return {
|
|
802
|
+
group: { id: g.group.id, kind: g.group.kind, title: g.group.title, coordSessionId: g.group.coordSessionId },
|
|
803
|
+
runs,
|
|
804
|
+
events: evs.map((e) => ({ runId: e.runId, kind: e.kind, payload: e.payload, ts: e.ts })),
|
|
805
|
+
};
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// B2 — "+ New attempt": 그룹에 새 시도(들)를 발사. repo 는 그룹의 기존 태스크에서 상속.
|
|
809
|
+
app.post('/api/groups/:id/spawn', async (req, reply) => {
|
|
810
|
+
const id = Number((req.params as { id: string }).id);
|
|
811
|
+
const b = (req.body ?? {}) as { title?: string; prompt?: string; count?: number; real?: boolean };
|
|
812
|
+
const prompt = (b.prompt ?? '').trim();
|
|
813
|
+
if (!prompt) return reply.code(400).send({ error: 'prompt required' });
|
|
814
|
+
const g = await groupRuns(id);
|
|
815
|
+
if (!g) return reply.code(404).send({ error: 'group not found' });
|
|
816
|
+
if (!g.rows[0]) return reply.code(409).send({ error: 'group has no tasks to inherit a repo from' });
|
|
817
|
+
const repoId = g.rows[0].task.repoId; // 형제는 같은 repo 를 공유
|
|
818
|
+
const title = (b.title ?? '').trim() || prompt.slice(0, 40);
|
|
819
|
+
const count = Math.max(1, Math.min(5, Number(b.count) || 1));
|
|
820
|
+
const created: Array<{ id: number; title: string; runId: number }> = [];
|
|
821
|
+
for (let i = 0; i < count; i++) {
|
|
822
|
+
created.push(await launchGroupTask(id, repoId, title, prompt, b.real === true));
|
|
823
|
+
}
|
|
824
|
+
return reply.code(201).send({ ok: true, tasks: created });
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// B3 — "→ Broadcast": 그룹의 정착·steerable run 전부에 후속 지시. 라이브/드라이는 정직하게 skip.
|
|
828
|
+
app.post('/api/groups/:id/steer', async (req, reply) => {
|
|
829
|
+
const id = Number((req.params as { id: string }).id);
|
|
830
|
+
const b = (req.body ?? {}) as { message?: string; mode?: string };
|
|
831
|
+
const message = (b.message ?? '').trim();
|
|
832
|
+
if (!message) return reply.code(400).send({ error: 'message required' });
|
|
833
|
+
const g = await groupRuns(id);
|
|
834
|
+
if (!g) return reply.code(404).send({ error: 'group not found' });
|
|
835
|
+
const mode = b.mode === 'ask' ? 'ask' : 'work';
|
|
836
|
+
let steered = 0;
|
|
837
|
+
const skipped: Array<{ runId: number; reason: string }> = [];
|
|
838
|
+
let running = 0, noSession = 0;
|
|
839
|
+
// 그룹 규모가 작아 순차 for 루프로 충분(폭주 fan-out 없음).
|
|
840
|
+
for (const { run } of g.rows) {
|
|
841
|
+
const res = await steerRun(run.id, message, mode);
|
|
842
|
+
if (res.ok) { steered++; continue; }
|
|
843
|
+
skipped.push({ runId: run.id, reason: res.detail });
|
|
844
|
+
if (/still running/.test(res.detail)) running++;
|
|
845
|
+
else if (/no agent session/.test(res.detail)) noSession++;
|
|
846
|
+
}
|
|
847
|
+
const parts = [`${steered} steered`];
|
|
848
|
+
if (running) parts.push(`${running} still running (steer after they settle)`);
|
|
849
|
+
if (noSession) parts.push(`${noSession} no session`);
|
|
850
|
+
const otherSkips = skipped.length - running - noSession;
|
|
851
|
+
if (otherSkips > 0) parts.push(`${otherSkips} skipped`);
|
|
852
|
+
return { ok: true, steered, skipped, detail: parts.join(' · ') };
|
|
853
|
+
});
|
|
854
|
+
// NOTE(v4.6): queuing a broadcast to apply to running runs once they settle is
|
|
855
|
+
// explicitly out of scope for L1 — running runs are reported as skipped, not queued.
|
|
856
|
+
|
|
857
|
+
// B4 (L2) — "? Ask": 그룹 스코프 읽기 전용 코디네이터. run 발사·steer·파일 쓰기 절대 없음.
|
|
858
|
+
// askGroupCoordinator 는 getRunDiff(읽기)와 텍스트 반환뿐 — launch/steer/write 경로를 부르지 않는다.
|
|
859
|
+
app.post('/api/groups/:id/ask', async (req, reply) => {
|
|
860
|
+
const id = Number((req.params as { id: string }).id);
|
|
861
|
+
const b = (req.body ?? {}) as { message?: string; real?: boolean };
|
|
862
|
+
const message = (b.message ?? '').trim();
|
|
863
|
+
if (!message) return reply.code(400).send({ error: 'message required' });
|
|
864
|
+
const g = await groupRuns(id);
|
|
865
|
+
if (!g) return reply.code(404).send({ error: 'group not found' });
|
|
866
|
+
const res = await askGroupCoordinator(id, message, b.real === true);
|
|
867
|
+
if (!res.ok) return reply.code(422).send({ error: res.detail });
|
|
868
|
+
return { ok: true, answer: res.answer };
|
|
869
|
+
});
|
|
870
|
+
|
|
663
871
|
// 통합 — 여러 run(태스크 무관)을 base 에 순차 머지, 충돌은 통합 에이전트 자동 발사.
|
|
664
872
|
app.post('/api/integrate', async (req, reply) => {
|
|
665
873
|
const b = (req.body ?? {}) as { runIds?: number[]; real?: boolean };
|