Files
maestro/src/api/server.ts
T
wangjia 60265656e2 feat: MCP/CLI 追平 Phase 2 后端能力(score/复审/合并/额度/归档)
任务 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>
2026-06-13 10:35:34 +08:00

328 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 API60s 缓存) */
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/complexitycomplexity 重置逻辑在 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 / rejectreject 必带 reason
// exec 闸的 accept = 通过并合并(PR 闭环):先 merge 再 decidemerge 失败 → 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;
}