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:
@@ -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 / reject(reject 必带 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;
|
||||
}
|
||||
Reference in New Issue
Block a user