feat: Phase1 核心——模型/状态机 + SQLite Store(守卫+审批闸) + REST/WS API + daemon

- src/model: 复杂度分级、16 状态状态机、实体类型
- src/store: better-sqlite3 接 schema,transition 受 canTransition 守卫,
  decide 审批闸(reject 必带改进意见),事件订阅广播,nextExecutable
- src/api: Fastify REST + ws 事件广播(/ws)
- src/daemon: maestrod 入口(env 配置,默认 ~/.maestro :4517)
- test: 9 个生命周期单测全过;typecheck/build 干净;REST+WS 端到端实跑验证

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-12 20:44:45 +08:00
commit 3172718a94
18 changed files with 2757 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { WebSocketServer, type WebSocket } from 'ws';
import { Store, StoreError } from '../store/index.js';
import type { Complexity } from '../model/complexity.js';
import { isComplexity } from '../model/complexity.js';
import type { TaskStatus } from '../model/status.js';
export interface ApiOptions {
store: Store;
logger?: boolean;
}
/**
* 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());
app.post('/api/projects', (req) => {
const b = req.body as Record<string, unknown>;
if (!b?.name || !b?.repoPath) throw new StoreError('name 与 repoPath 必填');
return 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),
});
});
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 p;
});
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;
});
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.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 ?? {});
});
// 审批闸:accept / rejectreject 必带 reason
app.post('/api/tasks/:id/decide', (req) => {
const { id } = req.params as { id: string };
const b = req.body as { action?: string; actor?: string; reason?: string | null };
if (b?.action !== 'accept' && b?.action !== 'reject') throw new StoreError('action 必须是 accept|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;
}