7884182d2b
新增用户级设置项 globalConcurrency(settings 表,独立于新建项目默认值 来源 concurrency),缺省 0 = 不限,向后兼容无需 DB 迁移。 - store: UserSettings 加 globalConcurrency 字段 + 默认 0;新增 globalInflightCount()(跨所有项目 started run 总数,与 inflightTaskIds 同口径) - orchestrator.claimTick: 叠加全局闸,与 per-project 闸串联(都过才领); 轮初查一次全局在途、轮内手动 ++,达上限即 return 跨所有项目停止领取 - api: PUT /api/settings 支持 globalConcurrency(空/null 归一为 0) - web: 侧栏「全局设置」入口 + 模态,编辑/回显/校验(≥0 整数) - test: 新增全局闸达上限跨项目阻止 / 未达上限正常领取 / 边界 / 轮初短路 用例 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
537 lines
26 KiB
TypeScript
537 lines
26 KiB
TypeScript
import Fastify, { type FastifyInstance } from 'fastify';
|
||
import { WebSocketServer, type WebSocket } from 'ws';
|
||
import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTaskInput, type UserSettings } from '../store/index.js';
|
||
import type { Complexity } from '../model/complexity.js';
|
||
import { isComplexity } from '../model/complexity.js';
|
||
import type { TaskStatus } from '../model/status.js';
|
||
import type { Project, Autonomy } from '../model/types.js';
|
||
import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
|
||
import { resolvedExecutorModels } from '../executor/models.js';
|
||
import { classifyComplexity, type ClassifierFn } from '../executor/classify.js';
|
||
import { acceptAndMerge } from '../executor/exec-merge.js';
|
||
import { createWorktree, git } from '../executor/worktree.js';
|
||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||
import { readTranscript, TranscriptError } from '../executor/transcript.js';
|
||
import { createReadStream, createWriteStream, mkdirSync, readFileSync, statSync } from 'node:fs';
|
||
import { transcriptDir } from '../executor/cc.js';
|
||
import { homedir } from 'node:os';
|
||
import { join, basename } from 'node:path';
|
||
import { pipeline } from 'node:stream/promises';
|
||
import multipart from '@fastify/multipart';
|
||
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
|
||
|
||
/** 数据根目录(与 protocol/worktree 同约定):<MAESTRO_DATA_DIR 或 ~/.maestro> */
|
||
function dataDir(): string {
|
||
return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
|
||
}
|
||
|
||
/** Project 出参:附加 hasTodoJson(<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
|
||
function projectOut(p: Project, store: Store): Project & { hasTodoJson: boolean; summary: ReturnType<Store['projectSummary']> } {
|
||
return { ...p, hasTodoJson: hasTodoJson(p.repoPath), summary: store.projectSummary(p.id) };
|
||
}
|
||
|
||
export interface ApiOptions {
|
||
store: Store;
|
||
logger?: boolean;
|
||
/** Claude 订阅额度查询(测试注入;默认直连 OAuth usage API,60s 缓存) */
|
||
getUsage?: () => Promise<UsageInfo | null>;
|
||
/** AUTO 复杂度分类器(测试注入;默认 classifyComplexity,用 sonnet 起一次轻量调用) */
|
||
classify?: ClassifierFn;
|
||
}
|
||
|
||
/**
|
||
* REST + WebSocket API。所有写操作走 Store(带状态机守卫 / 审批闸)。
|
||
* Store 的事件经 subscribe 广播到所有 WS 客户端,驱动看板实时刷新。
|
||
*/
|
||
export function buildServer(opts: ApiOptions): FastifyInstance {
|
||
const { store } = opts;
|
||
const classify = opts.classify ?? ((title, description, cwd) => classifyComplexity(title, description, { cwd }));
|
||
const app = Fastify({ logger: opts.logger ?? false });
|
||
|
||
// 附件上传:multipart/form-data(单文件 ≤25MB,单次 ≤10 个)
|
||
app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024, files: 10 } });
|
||
|
||
// Store 错误 → 400(业务校验),其余 → 500
|
||
app.setErrorHandler((err, _req, reply) => {
|
||
if (err instanceof StoreError) return reply.code(400).send({ error: err.message });
|
||
app.log.error(err);
|
||
return reply.code(500).send({ error: err.message ?? 'internal error' });
|
||
});
|
||
|
||
// ---------- Projects ----------
|
||
app.get('/api/projects', () => store.listProjects().map((p) => projectOut(p, store)));
|
||
|
||
app.post('/api/projects', (req) => {
|
||
const b = req.body as Record<string, unknown>;
|
||
if (!b?.name || !b?.repoPath) throw new StoreError('name 与 repoPath 必填');
|
||
// 用户级全局默认(核心策略子集):body 显式给则用 body,否则套默认
|
||
const s = store.getSettings();
|
||
const pick = <T>(key: string, fallback: T): T => (b[key] !== undefined ? (b[key] as T) : fallback);
|
||
const created = store.createProject({
|
||
name: String(b.name), repoPath: String(b.repoPath),
|
||
defaultBranch: b.defaultBranch ? String(b.defaultBranch) : undefined,
|
||
verifyCmd: b.verifyCmd === undefined ? undefined : (b.verifyCmd === null ? null : String(b.verifyCmd)),
|
||
autonomy: pick('autonomy', s.autonomy) as never,
|
||
model: b.model !== undefined ? (b.model === null ? null : String(b.model)) : s.model,
|
||
concurrency: Number(pick('concurrency', s.concurrency)),
|
||
maxRetries: Number(pick('maxRetries', s.maxRetries)),
|
||
timeoutMs: Number(pick('timeoutMs', s.timeoutMs)),
|
||
});
|
||
// createProject 不接受的默认字段(自动放行 / 预算)经 patchProject 套用
|
||
return projectOut(store.patchProject(created.id, {
|
||
autoApprovePlan: Boolean(pick('autoApprovePlan', s.autoApprovePlan)),
|
||
autoApproveExec: Boolean(pick('autoApproveExec', s.autoApproveExec)),
|
||
budgetUsd: ((): number | null => { const v = pick<number | null>('budgetUsd', s.budgetUsd); return v === null ? null : Number(v); })(),
|
||
budgetPeriod: pick('budgetPeriod', s.budgetPeriod) as 'day' | 'month',
|
||
}), store);
|
||
});
|
||
|
||
app.get('/api/projects/:id', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const p = store.getProject(id);
|
||
if (!p) throw new StoreError(`项目不存在: ${id}`);
|
||
return projectOut(p, store);
|
||
});
|
||
|
||
// 部分更新项目配置(校验在 Store.patchProject)
|
||
app.patch('/api/projects/:id', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const b = (req.body ?? {}) as Record<string, unknown>;
|
||
const patch: PatchProjectInput = {};
|
||
if (b.autonomy !== undefined) patch.autonomy = b.autonomy as Autonomy;
|
||
if (b.concurrency !== undefined) patch.concurrency = Number(b.concurrency);
|
||
if (b.status !== undefined) patch.status = b.status as 'active' | 'paused';
|
||
if (b.verifyCmd !== undefined) patch.verifyCmd = b.verifyCmd === null ? null : String(b.verifyCmd);
|
||
if (b.model !== undefined) patch.model = b.model === null ? null : String(b.model);
|
||
if (b.logo !== undefined) patch.logo = b.logo === null || b.logo === '' ? null : String(b.logo);
|
||
if (b.maxRetries !== undefined) patch.maxRetries = Number(b.maxRetries);
|
||
if (b.timeoutMs !== undefined) patch.timeoutMs = Number(b.timeoutMs);
|
||
if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd);
|
||
if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month';
|
||
if (b.autoApprovePlan !== undefined) patch.autoApprovePlan = Boolean(b.autoApprovePlan);
|
||
if (b.autoApproveExec !== undefined) patch.autoApproveExec = Boolean(b.autoApproveExec);
|
||
if (b.checks !== undefined) patch.checks = b.checks === null || b.checks === '' ? null : String(b.checks);
|
||
if (b.agentRules !== undefined) patch.agentRules = b.agentRules === null || b.agentRules === '' ? null : String(b.agentRules);
|
||
if (b.models !== undefined) patch.models = b.models === null || b.models === '' ? null : (b.models as PatchProjectInput['models']);
|
||
return projectOut(store.patchProject(id, patch), store);
|
||
});
|
||
|
||
// 项目重排序(侧栏拖动):body { order: [projectId, ...] }
|
||
app.post('/api/projects/reorder', (req) => {
|
||
const b = (req.body ?? {}) as { order?: unknown };
|
||
if (!Array.isArray(b.order)) throw new StoreError('order 必须是项目 id 数组');
|
||
return store.reorderProjects(b.order.map(String)).map((p) => projectOut(p, store));
|
||
});
|
||
|
||
// ---------- 用户级全局默认配置(per-user,新建项目默认值来源)----------
|
||
app.get('/api/settings', () => store.getSettings());
|
||
app.put('/api/settings', (req) => {
|
||
const b = (req.body ?? {}) as Record<string, unknown>;
|
||
const patch: Partial<UserSettings> = {};
|
||
if (b.autonomy !== undefined) patch.autonomy = b.autonomy as Autonomy;
|
||
if (b.concurrency !== undefined) patch.concurrency = Number(b.concurrency);
|
||
if (b.maxRetries !== undefined) patch.maxRetries = Number(b.maxRetries);
|
||
if (b.timeoutMs !== undefined) patch.timeoutMs = Number(b.timeoutMs);
|
||
if (b.autoApprovePlan !== undefined) patch.autoApprovePlan = Boolean(b.autoApprovePlan);
|
||
if (b.autoApproveExec !== undefined) patch.autoApproveExec = Boolean(b.autoApproveExec);
|
||
if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd);
|
||
if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month';
|
||
if (b.model !== undefined) patch.model = b.model === null || b.model === '' ? null : String(b.model);
|
||
if (b.globalConcurrency !== undefined)
|
||
patch.globalConcurrency = b.globalConcurrency === null || b.globalConcurrency === '' ? 0 : Number(b.globalConcurrency);
|
||
return store.putSettings(patch);
|
||
});
|
||
|
||
// 彻底删除项目(连带 tasks/runs/approvals/events,不可恢复)
|
||
app.delete('/api/projects/:id', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
store.deleteProject(id);
|
||
return { ok: true };
|
||
});
|
||
|
||
// 项目 logo:仓库内文件 → 流式返回;外链头像 → 302;无 → 404(前端用首字母徽章兜底)
|
||
app.get('/api/projects/:id/logo', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
const p = store.getProject(id);
|
||
if (!p) return reply.code(404).send();
|
||
const r = await resolveLogo(p);
|
||
if (!r) return reply.code(404).send();
|
||
if (r.type === 'redirect') return reply.redirect(r.url);
|
||
const ext = r.path.split('.').pop()?.toLowerCase() ?? '';
|
||
reply.header('cache-control', 'no-cache').type(LOGO_MIME[ext] ?? 'application/octet-stream');
|
||
return reply.send(createReadStream(r.path));
|
||
});
|
||
|
||
// 单向同步 <repoPath>/todo/todo.json → maestro(导入=首次同步,幂等)
|
||
app.post('/api/projects/:id/sync', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return syncProject(store, id);
|
||
});
|
||
|
||
// ---------- Agents(每项目一条;active 来自 runs 表 status='started',执行器 Phase 2 前通常为空) ----------
|
||
// usage = Claude 订阅额度(执行 agent 烧的就是这个池子);查询失败 → null,前端降级显示
|
||
const getUsage = opts.getUsage ?? createUsageFetcher({ log: app.log });
|
||
app.get('/api/agents', async () => {
|
||
const runs = store.activeRuns();
|
||
const byProject = new Map<string, ActiveRun[]>();
|
||
for (const r of runs) {
|
||
const list = byProject.get(r.projectId) ?? [];
|
||
list.push(r);
|
||
byProject.set(r.projectId, list);
|
||
}
|
||
const agents = store.listProjects().map((p) => ({
|
||
projectId: p.id,
|
||
projectName: p.name,
|
||
autonomy: p.autonomy,
|
||
concurrency: p.concurrency,
|
||
status: p.status,
|
||
scheduling: 'score', // 调度模式:score 优先(自身分+链条惯性+解锁加权)
|
||
models: resolvedExecutorModels(p), // 各复杂度实际使用的执行模型
|
||
active: (byProject.get(p.id) ?? []).map((r) => ({
|
||
runId: r.runId, taskId: r.taskId, taskTitle: r.taskTitle, kind: r.kind, startedAt: r.startedAt,
|
||
})),
|
||
}));
|
||
return { totalActive: runs.length, agents, usage: await getUsage() };
|
||
});
|
||
|
||
// 用量:Claude 订阅额度(5h+周配额,查询失败→null)+ 成本明细(按项目/模型/总计,按 period 窗口)。
|
||
// ?period=day|month(默认 month);?project=<id> 限定单项目成本。
|
||
app.get('/api/usage', async (req) => {
|
||
const q = req.query as { period?: string; project?: string };
|
||
const period = q.period === 'day' ? 'day' : q.period === 'week' ? 'week' : 'month';
|
||
const quota = await getUsage();
|
||
const cost = store.costSummary(period, q.project && q.project.trim() ? q.project.trim() : undefined);
|
||
return { ...(quota ?? {}), cost };
|
||
});
|
||
|
||
// 用量详情:时间序列(按天/按月)+ 分项目下钻,供侧栏「详情」弹框。
|
||
app.get('/api/usage/detail', (req) => {
|
||
const q = req.query as { granularity?: string };
|
||
const granularity = q.granularity === 'month' ? 'month' : q.granularity === 'week' ? 'week' : 'day';
|
||
return store.usageDetail(granularity);
|
||
});
|
||
|
||
// 健康检查:daemon 存活 + DB 可读 + 在途 run 数。
|
||
app.get('/api/health', () => {
|
||
let db = true;
|
||
let inflight = 0;
|
||
try { inflight = store.activeRuns().length; } catch { db = false; }
|
||
return { ok: db, db, inflight, at: new Date().toISOString() };
|
||
});
|
||
|
||
app.get('/api/projects/:id/tasks', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.listTasks(id);
|
||
});
|
||
|
||
app.get('/api/projects/:id/events', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.listEvents(id);
|
||
});
|
||
|
||
app.get('/api/projects/:id/next', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.nextExecutable(id) ?? { next: null };
|
||
});
|
||
|
||
// ---------- Metrics(健康度 + 成本聚合)----------
|
||
// GET /api/metrics?project=<id>;不带 project = 全部项目聚合。
|
||
app.get('/api/metrics', (req) => {
|
||
const q = req.query as { project?: string };
|
||
return store.metrics(q.project && q.project.trim() ? q.project.trim() : undefined);
|
||
});
|
||
|
||
// ---------- Tasks ----------
|
||
app.post('/api/projects/:id/tasks', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const b = req.body as Record<string, unknown>;
|
||
if (!b?.title) throw new StoreError('title 必填');
|
||
// 'auto' = 由模型判定:先以 medium 落库占位,建任务后异步回填(不阻塞返回)
|
||
const auto = b.complexity === 'auto';
|
||
if (!auto && !isComplexity(b.complexity)) throw new StoreError('complexity 必须是 auto|hard|medium|easy');
|
||
const task = store.createTask({
|
||
projectId: id, title: String(b.title), complexity: auto ? 'medium' : (b.complexity as Complexity),
|
||
parentId: b.parentId ? String(b.parentId) : null,
|
||
priority: b.priority === undefined ? undefined : Number(b.priority),
|
||
deps: Array.isArray(b.deps) ? (b.deps as string[]) : undefined,
|
||
scopeFiles: Array.isArray(b.scopeFiles) ? (b.scopeFiles as string[]) : undefined,
|
||
});
|
||
if (auto) {
|
||
// 异步分类回填:classifyComplexity 自身永不抛错(失败兜底 medium),外层再兜一层防御
|
||
const cwd = store.getProject(id)?.repoPath ?? process.cwd();
|
||
void classify(task.title, undefined, cwd)
|
||
.then(({ complexity, reason }) => store.applyAutoComplexity(task.id, complexity, reason))
|
||
.catch((e: unknown) => app.log.error(`任务 ${task.id} AUTO 复杂度分类失败(保留占位 medium):${(e as Error).message}`));
|
||
}
|
||
return task;
|
||
});
|
||
|
||
app.get('/api/tasks/:id', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const t = store.getTask(id);
|
||
if (!t) throw new StoreError(`任务不存在: ${id}`);
|
||
return t;
|
||
});
|
||
|
||
// 附件上传(multipart files[]):落盘 <data>/tasks/<id>/attachments/,元数据写 tasks.attachments
|
||
app.post('/api/tasks/:id/attachments', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` });
|
||
const dir = join(dataDir(), 'tasks', id, 'attachments');
|
||
mkdirSync(dir, { recursive: true });
|
||
const saved: Array<{ name: string; type: string; path: string }> = [];
|
||
for await (const part of req.files()) {
|
||
const safe = basename(part.filename).replace(/[^\w.\-]/g, '_') || `file-${Date.now()}`;
|
||
await pipeline(part.file, createWriteStream(join(dir, safe)));
|
||
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename} 超过 25MB 上限` });
|
||
saved.push({ name: part.filename, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
|
||
}
|
||
if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' });
|
||
return { attachments: store.addAttachments(id, saved) };
|
||
});
|
||
|
||
// 人工接管:准备(或复用)任务 worktree,返回用户在自己终端起交互式 claude 的命令。
|
||
// 适用卡住/需人工的任务——人接手手动改。不在 daemon 内起交互会话(无 TTY)。
|
||
app.post('/api/tasks/:id/takeover', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
const task = store.getTask(id);
|
||
if (!task) return reply.code(404).send({ error: `任务不存在: ${id}` });
|
||
if (store.hasInflightRun(id)) return reply.code(400).send({ error: '任务有在途 run,无法接管' });
|
||
const project = store.getProject(task.projectId);
|
||
if (!project) return reply.code(404).send({ error: `项目不存在: ${task.projectId}` });
|
||
let worktree = task.result?.worktree ?? null;
|
||
let branch = task.result?.branch ?? null;
|
||
if (!worktree) {
|
||
const wt = await createWorktree(project.repoPath, id, project.defaultBranch);
|
||
worktree = wt.dir; branch = wt.branch;
|
||
}
|
||
return { taskId: id, worktree, branch, console: `cd ${worktree} && claude` };
|
||
});
|
||
|
||
// 部分更新任务(title/priority/complexity;complexity 重置逻辑在 Store.patchTask)
|
||
app.patch('/api/tasks/:id', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
if (store.hasInflightRun(id)) {
|
||
throw new StoreError('任务有在途 run,执行期间禁止修改');
|
||
}
|
||
const b = (req.body ?? {}) as Record<string, unknown>;
|
||
const patch: PatchTaskInput = {};
|
||
if (b.title !== undefined) patch.title = String(b.title);
|
||
if (b.priority !== undefined) {
|
||
const n = Number(b.priority);
|
||
if (!Number.isFinite(n)) throw new StoreError('priority 必须是数字');
|
||
patch.priority = n;
|
||
}
|
||
if (b.complexity !== undefined) {
|
||
if (!isComplexity(b.complexity)) throw new StoreError('complexity 必须是 hard|medium|easy');
|
||
patch.complexity = b.complexity;
|
||
}
|
||
if (b.deps !== undefined) {
|
||
if (!Array.isArray(b.deps) || !b.deps.every((d) => typeof d === 'string')) {
|
||
throw new StoreError('deps 必须是任务 id 字符串数组');
|
||
}
|
||
patch.deps = b.deps as string[];
|
||
}
|
||
if (b.scopeFiles !== undefined) {
|
||
if (b.scopeFiles !== null && (!Array.isArray(b.scopeFiles) || !b.scopeFiles.every((f) => typeof f === 'string'))) {
|
||
throw new StoreError('scopeFiles 必须是文件 glob 字符串数组或 null');
|
||
}
|
||
patch.scopeFiles = b.scopeFiles as string[] | null;
|
||
}
|
||
return store.patchTask(id, patch);
|
||
});
|
||
|
||
app.get('/api/tasks/:id/children', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.childrenOf(id);
|
||
});
|
||
|
||
app.get('/api/tasks/:id/runs', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.listRuns(id);
|
||
});
|
||
|
||
// 任务改动 diff(结果评审用):git diff defaultBranch...branch,按文件切分返回,供点击文件看具体改动
|
||
app.get('/api/tasks/:id/diff', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const task = store.getTask(id);
|
||
if (!task) throw new StoreError(`任务不存在: ${id}`);
|
||
const project = store.getProject(task.projectId);
|
||
const branch = task.result?.branch;
|
||
if (!project || !branch) return { files: [] };
|
||
let raw = '';
|
||
try { raw = await git(project.repoPath, ['diff', `${project.defaultBranch}...${branch}`]); } catch { return { files: [] }; }
|
||
const files = raw.split(/^diff --git /m).filter(Boolean).map((p) => {
|
||
const m = p.match(/^a\/(\S+)\s+b\/(\S+)/);
|
||
const path = m ? m[2] : (p.split('\n')[0] || '').trim();
|
||
return { path, diff: 'diff --git ' + p };
|
||
});
|
||
return { files };
|
||
});
|
||
|
||
// 实时流(SSE):跟随任务「最近一条 run」的 transcript 文件,把新写入的 agent 消息逐行推送。
|
||
// 活跃 run(status=started)= 真实时;已结束 run = 一次性回放后 end。客户端 EventSource 消费。
|
||
app.get('/api/tasks/:id/stream', (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
const runs = store.listRuns(id);
|
||
const run = runs.find((r) => r.status === 'started') ?? runs[runs.length - 1];
|
||
if (!run) return reply.code(404).send({ error: '该任务暂无 run 可流式' });
|
||
const file = join(transcriptDir(), `${run.id}.jsonl`);
|
||
reply.hijack();
|
||
reply.raw.writeHead(200, {
|
||
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',
|
||
Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
|
||
});
|
||
reply.raw.write(`event: open\ndata: {"runId":"${run.id}","kind":"${run.kind}"}\n\n`);
|
||
let offset = 0;
|
||
let closed = false;
|
||
const stop = () => { if (closed) return; closed = true; clearInterval(timer); reply.raw.end(); };
|
||
const tick = (): void => {
|
||
if (closed) return;
|
||
try {
|
||
const size = statSync(file).size;
|
||
if (size > offset) {
|
||
const chunk = readFileSync(file).subarray(offset).toString('utf8');
|
||
offset = size;
|
||
for (const line of chunk.split('\n')) if (line.trim()) reply.raw.write(`data: ${line}\n\n`);
|
||
}
|
||
} catch { /* 文件尚未创建:等下一拍 */ }
|
||
const cur = store.getRun(run.id);
|
||
if (!cur || cur.status !== 'started') {
|
||
reply.raw.write('event: end\ndata: {}\n\n');
|
||
stop();
|
||
}
|
||
};
|
||
const timer = setInterval(tick, 500);
|
||
tick();
|
||
req.raw.on('close', stop);
|
||
});
|
||
|
||
// 执行转录回放:读 run.transcriptRef 指向的 jsonl,逐行解析成脱敏后的结构化消息数组。
|
||
// 校验:runId 合法 → 关联任务/项目存在(可访问)→ 有转录记录 → 路径在 transcriptDir 下(防穿越)。
|
||
app.get('/api/runs/:id/transcript', (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
const run = store.getRun(id);
|
||
if (!run) return reply.code(404).send({ error: `run 不存在: ${id}` });
|
||
const task = store.getTask(run.taskId);
|
||
if (!task) return reply.code(404).send({ error: `run 关联任务不存在: ${run.taskId}` });
|
||
if (!store.getProject(task.projectId)) return reply.code(404).send({ error: `run 关联项目不存在: ${task.projectId}` });
|
||
if (!run.transcriptRef) return reply.code(404).send({ error: '该 run 没有转录记录' });
|
||
try {
|
||
const messages = readTranscript(run.transcriptRef);
|
||
return { runId: run.id, taskId: run.taskId, kind: run.kind, status: run.status, messages };
|
||
} catch (e) {
|
||
if (e instanceof TranscriptError) {
|
||
return reply.code(e.code === 'invalid_path' ? 400 : 404).send({ error: e.message });
|
||
}
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
// 单任务全量事件(升序):归档详情的状态流转时间线
|
||
app.get('/api/tasks/:id/events', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.listTaskEvents(id);
|
||
});
|
||
|
||
app.post('/api/tasks/:id/plan', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const b = req.body as { plan?: string };
|
||
if (!b?.plan) throw new StoreError('plan 必填');
|
||
return store.setPlan(id, b.plan);
|
||
});
|
||
|
||
app.post('/api/tasks/:id/spec', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const b = req.body as { spec?: string };
|
||
if (!b?.spec) throw new StoreError('spec 必填');
|
||
return store.setSpec(id, b.spec);
|
||
});
|
||
|
||
app.post('/api/tasks/:id/operations', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const b = req.body as { operations?: string };
|
||
if (!b?.operations) throw new StoreError('operations 必填');
|
||
return store.setOperations(id, b.operations);
|
||
});
|
||
|
||
app.post('/api/tasks/:id/transition', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const b = req.body as { to?: string; meta?: Record<string, unknown> };
|
||
if (!b?.to) throw new StoreError('to 必填');
|
||
return store.transition(id, b.to as TaskStatus, b.meta ?? {});
|
||
});
|
||
|
||
// 取消任务(transition → cancelled;状态机已守卫合法流转)
|
||
app.post('/api/tasks/:id/cancel', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.transition(id, 'cancelled', { by: 'user' });
|
||
});
|
||
|
||
// 彻底删除任务及其子孙(不可恢复,cascade)
|
||
app.delete('/api/tasks/:id', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.deleteTask(id);
|
||
});
|
||
|
||
// needs_attention 任务一键重投:重置重试基线 + 转 queued,让编排器下一轮重新领取
|
||
app.post('/api/tasks/:id/requeue', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
return store.requeueTask(id);
|
||
});
|
||
|
||
// 审批闸:accept / reject(reject 必带 reason)
|
||
// exec 闸的 accept = 通过并合并(PR 闭环):先 merge 再 decide;merge 失败 → 400,任务保留在审核闸。
|
||
app.post('/api/tasks/:id/decide', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const b = req.body as { action?: string; actor?: string; reason?: string | null; merge?: boolean };
|
||
if (b?.action !== 'accept' && b?.action !== 'reject') throw new StoreError('action 必须是 accept|reject');
|
||
|
||
const task = store.getTask(id);
|
||
// merge:false = 仅通过不合并(逃生口:如目标分支正被用户工作区检出导致自动合并不可用)
|
||
if (task && task.status === 'exec_review' && b.action === 'accept' && task.result?.branch && b.merge !== false) {
|
||
const outcome = await acceptAndMerge(store, app.log, task, b.actor ?? 'user');
|
||
if (!outcome.ok) {
|
||
if (outcome.remediationTaskId) {
|
||
const rem = store.getTask(outcome.remediationTaskId);
|
||
throw new StoreError(`合并失败:${outcome.error}(原任务保留在审核闸;已建最高优先级补救任务 ${rem?.id}「${rem?.title}」来完成合并)`);
|
||
}
|
||
throw new StoreError(`合并失败:${outcome.error}`);
|
||
}
|
||
return store.getTask(id);
|
||
}
|
||
|
||
// 非 exec 闸(或 reject / 无分支结果):行为完全不变
|
||
return store.decide(id, b.action, b.actor ?? 'user', b.reason ?? null);
|
||
});
|
||
|
||
// ---------- Approvals ----------
|
||
app.get('/api/approvals', (req) => {
|
||
const q = req.query as { projectId?: string };
|
||
return store.pendingApprovals(q.projectId);
|
||
});
|
||
|
||
return app;
|
||
}
|
||
|
||
/** 在 Fastify 的底层 http server 上挂 WebSocket,把 Store 事件广播给所有客户端。 */
|
||
export function attachWebSocket(app: FastifyInstance, store: Store): WebSocketServer {
|
||
const wss = new WebSocketServer({ server: app.server, path: '/ws' });
|
||
const clients = new Set<WebSocket>();
|
||
|
||
wss.on('connection', (ws) => {
|
||
clients.add(ws);
|
||
ws.on('close', () => clients.delete(ws));
|
||
ws.on('error', () => clients.delete(ws));
|
||
});
|
||
|
||
store.subscribe((evt) => {
|
||
const msg = JSON.stringify(evt);
|
||
for (const ws of clients) {
|
||
if (ws.readyState === ws.OPEN) ws.send(msg);
|
||
}
|
||
});
|
||
|
||
return wss;
|
||
}
|