60265656e2
任务 ID:tsk_sjzC4B9ctg6W MCP(src/mcp/index.ts,重构为可测的 createTools(apiFn)): - 读:新增 get_agents(自治/并发/调度/模型/在途)、get_usage(订阅额度)、 list_archived(终态分页);get_task 补 score(自身分+链条惯性+解锁加权) 与 review(summary/verdict/securitySummary/securityVerdict)显式字段。 - 写:新增 patch_task(复用 store.patchTask 校验)、patch_project、sync_project、 decide(action + merge:通过并合并 / 仅通过;reject 必带 reason)。 - 复核全部工具 description 与状态机语义一致;entry-guard 便于 import 测试。 CLI(src/cli/index.ts)对齐子命令: - project set / task patch / decide <id> accept|reject [--no-merge] / agents / usage / archive。两端均薄封装走 REST,不重复实现。 API(src/api/server.ts):新增 GET /api/usage 单独透传订阅额度。 测试:新增 test/mcp.test.ts(zod schema 边界 + 对 mock api 的 method/path/body 断言 + score/分页/错误透传),npm test 103 全过、typecheck/build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
14 KiB
TypeScript
328 lines
14 KiB
TypeScript
import Fastify, { type FastifyInstance } from 'fastify';
|
||
import { WebSocketServer, type WebSocket } from 'ws';
|
||
import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTaskInput } 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 { mergeBranch } from '../executor/merge.js';
|
||
import { git, removeWorktree } from '../executor/worktree.js';
|
||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||
import { createReadStream } from 'node:fs';
|
||
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
|
||
|
||
/** Project 出参:附加 hasTodoJson(<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
|
||
function projectOut(p: Project): Project & { hasTodoJson: boolean } {
|
||
return { ...p, hasTodoJson: hasTodoJson(p.repoPath) };
|
||
}
|
||
|
||
export interface ApiOptions {
|
||
store: Store;
|
||
logger?: boolean;
|
||
/** Claude 订阅额度查询(测试注入;默认直连 OAuth usage API,60s 缓存) */
|
||
getUsage?: () => Promise<UsageInfo | null>;
|
||
}
|
||
|
||
/**
|
||
* REST + WebSocket API。所有写操作走 Store(带状态机守卫 / 审批闸)。
|
||
* Store 的事件经 subscribe 广播到所有 WS 客户端,驱动看板实时刷新。
|
||
*/
|
||
export function buildServer(opts: ApiOptions): FastifyInstance {
|
||
const { store } = opts;
|
||
const app = Fastify({ logger: opts.logger ?? false });
|
||
|
||
// 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(projectOut));
|
||
|
||
app.post('/api/projects', (req) => {
|
||
const b = req.body as Record<string, unknown>;
|
||
if (!b?.name || !b?.repoPath) throw new StoreError('name 与 repoPath 必填');
|
||
return projectOut(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: b.autonomy as never, model: b.model === undefined ? undefined : (b.model === null ? null : String(b.model)),
|
||
concurrency: b.concurrency === undefined ? undefined : Number(b.concurrency),
|
||
maxRetries: b.maxRetries === undefined ? undefined : Number(b.maxRetries),
|
||
timeoutMs: b.timeoutMs === undefined ? undefined : Number(b.timeoutMs),
|
||
}));
|
||
});
|
||
|
||
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.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);
|
||
return projectOut(store.patchProject(id, patch));
|
||
});
|
||
|
||
// 项目重排序(侧栏拖动):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(projectOut);
|
||
});
|
||
|
||
// 项目 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 订阅额度(5 小时 + 周窗口)单独透传;查询失败 → null(前端/CLI 降级显示)
|
||
app.get('/api/usage', async () => await getUsage());
|
||
|
||
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 };
|
||
});
|
||
|
||
// ---------- 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 必填');
|
||
if (!isComplexity(b.complexity)) throw new StoreError('complexity 必须是 hard|medium|easy');
|
||
return store.createTask({
|
||
projectId: id, title: String(b.title), complexity: 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,
|
||
});
|
||
});
|
||
|
||
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;
|
||
});
|
||
|
||
// 部分更新任务(title/priority/complexity;complexity 重置逻辑在 Store.patchTask)
|
||
app.patch('/api/tasks/:id', (req) => {
|
||
const { id } = req.params as { id: string };
|
||
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;
|
||
}
|
||
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);
|
||
});
|
||
|
||
// 单任务全量事件(升序):归档详情的状态流转时间线
|
||
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 project = store.getProject(task.projectId);
|
||
if (!project) throw new StoreError(`项目不存在: ${task.projectId}`);
|
||
const { branch, worktree } = task.result;
|
||
const mr = await mergeBranch(project.repoPath, branch, project.defaultBranch, task.id);
|
||
if (!mr.ok) {
|
||
// 自动合并失败(多为冲突)→ 建/复用最高优先级补救任务来完成合并,原任务留在审核闸
|
||
const rem = store.ensureMergeRemediationTask(task.id, {
|
||
branch, targetBranch: project.defaultBranch, conflictError: mr.error ?? '未知冲突',
|
||
});
|
||
throw new StoreError(`合并失败:${mr.error}(原任务保留在审核闸;已建最高优先级补救任务 ${rem.id}「${rem.title}」来完成合并)`);
|
||
}
|
||
|
||
store.decide(id, 'accept', b.actor ?? 'user', b.reason ?? null);
|
||
// 合并产物记录:复用 prUrl 字段写 merged:<mergeCommit>
|
||
store.setResult(id, { ...task.result, prUrl: `merged:${mr.mergeCommit}` });
|
||
// 异步回收:执行 worktree + 已合并的任务分支(失败只记日志,不影响响应)
|
||
void (async () => {
|
||
try {
|
||
if (worktree) await removeWorktree(project.repoPath, worktree);
|
||
} catch (e) {
|
||
app.log.error(`任务 ${id} 合并后清理 worktree 失败(不影响结果):${(e as Error).message}`);
|
||
}
|
||
try {
|
||
await git(project.repoPath, ['branch', '-d', branch]);
|
||
} catch (e) {
|
||
app.log.error(`任务 ${id} 合并后删除分支 ${branch} 失败(不影响结果):${(e as Error).message}`);
|
||
}
|
||
})();
|
||
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;
|
||
}
|