feat: 同步引擎+Agent配置+依赖自动落位+看板大改(预览/md渲染/筛选/徽章组)

后端:
- src/sync/todo-sync.ts: todo.json 单向同步引擎(导入=首次同步,幂等,source_ref 映射,
  subs 复杂度修正为 easy,旧侧 done 历史事实优先 forceDone)
- 依赖自动落位:ready 意图按 deps 落位 blocked,依赖全 done 自动放行,
  daemon 启动 reconcileDeps 对账,手动绕过会弹回
- 新 API: PATCH projects/:id(autonomy/concurrency)、POST :id/sync、GET /api/agents、
  PATCH tasks/:id(title/priority/complexity 重置)
- daemon 定时同步(MAESTRO_SYNC_INTERVAL 默认 300s) + project.synced 事件
- priority 语义翻转: P0 最高/P1 默认/P2 最低,取值限 0..2,排序/映射/MCP/CLI 全跟进
- 静态服务发 no-cache 头(修浏览器吃旧 CSS/JS)
- schema 迁移: tasks.source_ref / projects.last_sync_at(ensureColumn 平滑升级旧库)

看板:
- 全屏预览模式(94vh 读完整方案+就地裁决,Esc/遮罩/裁决自动关闭)
- 产出 markdown 渲染为 HTML(零依赖渲染器,转义优先)
- 任务树筛选(复杂度/状态分组/关键字)+ 顶栏徽章组(待审批/可执行/执行中,hover 展开)
- 依赖可视化:详情 DEPS 区块 + 行内⛓等依赖 + 锚点跳转定位
- 按钮收敛:提交评审/编辑产出移除(CC 经 MCP 操作),界面只留用户动作
- Agent 执行面板 + 项目配置(并发/工作模式)+ 同步按钮

测试:21 个全过(新增 sync 幂等/迁移/patch/依赖落位/对账幂等)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-12 23:58:28 +08:00
parent c6e66baa19
commit fa472a06a7
18 changed files with 1942 additions and 368 deletions
+71 -5
View File
@@ -1,9 +1,16 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { WebSocketServer, type WebSocket } from 'ws';
import { Store, StoreError } from '../store/index.js';
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';
/** Project 出参:附加 hasTodoJson<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
function projectOut(p: Project): Project & { hasTodoJson: boolean } {
return { ...p, hasTodoJson: hasTodoJson(p.repoPath) };
}
export interface ApiOptions {
store: Store;
@@ -26,25 +33,66 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
});
// ---------- Projects ----------
app.get('/api/projects', () => store.listProjects());
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 store.createProject({
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),
});
}));
});
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;
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);
return projectOut(store.patchProject(id, patch));
});
// 单向同步 <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 前通常为空) ----------
app.get('/api/agents', () => {
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,
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 };
});
app.get('/api/projects/:id/tasks', (req) => {
@@ -83,6 +131,24 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
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);
+2 -1
View File
@@ -50,7 +50,8 @@ async function serveFile(reply: FastifyReply, rawPath: string): Promise<FastifyR
try {
const buf = await readFile(file);
const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
return reply.type(type).send(buf);
// 本地开发工具:禁启发式缓存,每次重新验证(否则浏览器会吃旧 CSS/JS)
return reply.header('cache-control', 'no-cache').type(type).send(buf);
} catch {
return reply.code(404).type('text/plain; charset=utf-8').send('Not Found');
}
+44 -281
View File
@@ -6,19 +6,18 @@
* project add <repoPath> [--name N] [--branch B] [--verify CMD] [--concurrency N]
* project list
* task list <projectIdOrName>
* task add <projectIdOrName> <title> --complexity hard|medium|easy [--parent ID] [--priority N]
* task add <projectIdOrName> <title> --complexity hard|medium|easy [--parent ID] [--priority 0|1|2(P0 最高,默认 P1)]
* next <projectIdOrName>
* approvals [projectIdOrName]
* import-todo <todo.json 路径> --repo <repoPath> [--name N]
* import-todo <repoPath> [--name N] (导入=首次同步,读 <repoPath>/todo/todo.json,幂等)
*
* 环境变量:MAESTRO_URL(默认 http://127.0.0.1:4517
*/
import { parseArgs, type ParseArgsConfig } from 'node:util';
import { readFileSync } from 'node:fs';
import { resolve, basename } from 'node:path';
import type { Project, Task } from '../model/types.js';
import { STATUS_LABEL, type TaskStatus } from '../model/status.js';
import { COMPLEXITY_LABEL, TIER_TO_COMPLEXITY, isComplexity, type Complexity } from '../model/complexity.js';
import { STATUS_LABEL } from '../model/status.js';
import { COMPLEXITY_LABEL, isComplexity } from '../model/complexity.js';
const BASE = process.env.MAESTRO_URL ?? 'http://127.0.0.1:4517';
@@ -31,14 +30,14 @@ const USAGE = `maestro — 多项目 TODO 管理与 Agent 执行系统 CLI
列出所有项目
maestro task list <项目id或名称>
树形列出项目任务(项目名支持模糊匹配)
maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent 父任务id] [--priority 优先级]
maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent 父任务id] [--priority 0|1|2P0 最高,默认 P1]
新建任务
maestro next <项目id或名称>
取下一个可执行任务
maestro approvals [项目id或名称]
列出待审批任务(不带参数 = 全部项目)
maestro import-todo <todo.json 路径> --repo <仓库路径> [--name 项目名]
新建项目并导入旧 todo skill 数据(tier 1/2/3 → hard/medium/easy
maestro import-todo <仓库路径> [--name 项目名]
导入/同步旧 todo skill 数据(读 <仓库路径>/todo/todo.json;项目不存在则建;幂等可重复
maestro --help | help
显示本说明
@@ -194,7 +193,7 @@ function printTaskTree(tasks: Task[]): void {
list.push(t);
byParent.set(key, list);
}
const sortFn = (a: Task, b: Task): number => b.priority - a.priority || a.createdAt.localeCompare(b.createdAt);
const sortFn = (a: Task, b: Task): number => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt); // P0 最高在前
const walk = (parentId: string | null, indent: string): void => {
const list = (byParent.get(parentId) ?? []).sort(sortFn);
for (const t of list) {
@@ -227,11 +226,11 @@ async function cmdTaskAdd(rest: string[]): Promise<void> {
const projectArg = positionals[0];
const title = positionals.slice(1).join(' ').trim();
if (!projectArg || !title) {
fail('用法:maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent ID] [--priority N]');
fail('用法:maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent ID] [--priority 0|1|2(P0 最高,默认 P1)]');
}
if (!isComplexity(values.complexity)) fail('--complexity 必填,且只能是 hard | medium | easy');
const priority = values.priority === undefined ? undefined : Number(values.priority);
if (priority !== undefined && !Number.isFinite(priority)) fail('--priority 必须是数字');
if (priority !== undefined && ![0, 1, 2].includes(priority)) fail('--priority 必须是 0/1/2P0 最高,P1 中,P2 最低)');
const project = await resolveProject(projectArg);
const t = await api<Task>('POST', `/api/projects/${project.id}/tasks`, {
title,
@@ -286,291 +285,55 @@ async function cmdApprovals(rest: string[]): Promise<void> {
);
}
// ---------- import-todo ----------
// ---------- import-todo(同步引擎在 daemon 侧:src/sync/todo-sync.ts,经 POST /api/projects/:id/sync 调用) ----------
interface OldGate {
kind?: string | null;
note?: string | null;
ref?: string | null;
approval?: string | null;
}
interface OldSub {
sid: string;
title?: string;
tier?: number | null;
deps?: string[];
status?: string;
}
interface OldItem {
id: number;
title?: string;
desc?: string | null;
level?: string | null;
tier?: number | null;
tags?: string[];
status?: string;
done?: boolean;
version?: string | null;
subtasks?: OldSub[];
gate?: OldGate | null;
reject_reason?: string | null;
}
interface OldDb {
meta?: { title?: string };
items?: OldItem[];
}
type OldStatus = 'open' | 'doing' | 'done' | 'accepted';
const OLD_STATUS_LABEL: Record<OldStatus, string> = {
open: '待开始',
doing: '开发中',
done: '待验收',
accepted: '已验收',
};
interface ImportStats {
total: number;
byComplexity: Record<Complexity, number>;
done: number;
interface SyncResult {
created: number;
doneAdvanced: number;
skipped: number;
warnings: string[];
}
function oldStatusOf(raw: string | undefined, doneFlag: boolean | undefined): OldStatus {
if (raw === 'open' || raw === 'doing' || raw === 'done' || raw === 'accepted') return raw;
return doneFlag ? 'accepted' : 'open';
}
function complexityOfTier(tier: number | null | undefined, label: string, stats: ImportStats): Complexity {
const c = tier == null ? undefined : TIER_TO_COMPLEXITY[tier];
if (c) return c;
stats.warnings.push(`${label} 缺少有效 tier${tier ?? '无'}),按 medium 导入`);
return 'medium';
}
function priorityOfLevel(level: string | null | undefined): number {
if (level === 'high') return 2;
if (level === 'mid') return 1;
return 0;
}
/** 旧 item 的描述性字段合成产出内容(plan/spec/operations */
function buildImportNote(item: OldItem, st: OldStatus, extra: string[] = []): string {
const lines = [`【从旧 todo 导入】原 #${item.id} · 原状态:${st}${OLD_STATUS_LABEL[st]}`];
if (st === 'doing') lines.push('注:导入前处于「开发中」,导入后回到初始状态,需按新流程重新推进。');
for (const l of extra) lines.push(l);
if (item.desc) lines.push('', item.desc);
if (item.tags?.length) lines.push('', `标签:${item.tags.join(' / ')}`);
if (item.level) lines.push(`原重要度:${item.level}`);
if (item.gate && (item.gate.note || item.gate.ref)) {
const ref = item.gate.ref ? `(详见 ${item.gate.ref}` : '';
lines.push('', `原方案/改动说明(approval=${item.gate.approval ?? '无'}):${item.gate.note ?? ''}${ref}`);
}
if (item.reject_reason) lines.push(`原拒绝原因:${item.reject_reason}`);
if (item.version) lines.push(`原验收版本:${item.version}`);
return lines.join('\n');
}
const PRODUCE_PATH: Record<Complexity, 'plan' | 'spec' | 'operations'> = {
hard: 'plan',
medium: 'spec',
easy: 'operations',
};
async function setProduce(taskId: string, complexity: Complexity, content: string): Promise<void> {
const field = PRODUCE_PATH[complexity];
await api('POST', `/api/tasks/${taskId}/${field}`, { [field]: content });
}
async function doTransition(taskId: string, to: TaskStatus): Promise<void> {
await api('POST', `/api/tasks/${taskId}/transition`, { to });
}
async function doAccept(taskId: string): Promise<void> {
await api('POST', `/api/tasks/${taskId}/decide`, { action: 'accept', actor: 'importer' });
lastSyncAt: string;
}
/**
* 把非 Hard 容器任务沿合法路径推到 done:
* easy: ready → queued → executing → exec_review → accept(done)
* medium: speccing → spec_review → accept(ready) → queued → executing → exec_review → accept(done)
* 推不动时记 warning、保留现状,返回 false。
* 导入 = 首次同步:项目(按 repoPath 匹配)不存在则先创建,然后触发一次 sync。
* 数据固定读 <repoPath>/todo/todo.json;幂等,可重复执行做增量同步。
*/
async function pushLeafToDone(taskId: string, complexity: Complexity, label: string, stats: ImportStats): Promise<boolean> {
try {
if (complexity === 'medium') {
await doTransition(taskId, 'spec_review');
await doAccept(taskId); // → ready
}
await doTransition(taskId, 'queued');
await doTransition(taskId, 'executing');
await doTransition(taskId, 'exec_review');
await doAccept(taskId); // → done
stats.done += 1;
return true;
} catch (e) {
stats.warnings.push(`${label} 推进 done 失败:${(e as Error).message}(已保留当前状态)`);
return false;
}
}
/**
* Hard 容器推进:analyzing → plan_review → accept(decomposed)。
* 子任务全部 done(或无子任务,已在 plan 注明)时再 decomposed → done。
*/
async function pushHardContainer(
taskId: string,
label: string,
hasChildren: boolean,
allChildrenDone: boolean,
stats: ImportStats,
): Promise<void> {
try {
await doTransition(taskId, 'plan_review');
await doAccept(taskId); // → decomposed
if (!hasChildren || allChildrenDone) {
await doTransition(taskId, 'done');
stats.done += 1;
} else {
stats.warnings.push(`${label} 原状态为已完成,但子任务未能全部推到 done,容器停在 decomposed`);
}
} catch (e) {
stats.warnings.push(`${label} 推进 done 失败:${(e as Error).message}(已保留当前状态)`);
}
}
async function importItem(projectId: string, item: OldItem, stats: ImportStats): Promise<void> {
const label = `旧 #${item.id}${item.title ?? '?'}`;
if (!item.title) {
stats.skipped += 1;
stats.warnings.push(`旧 #${item.id} 缺少 title,已跳过`);
return;
}
const st = oldStatusOf(item.status, item.done);
const complexity = complexityOfTier(item.tier, label, stats);
const subs = item.subtasks ?? [];
const isDone = st === 'done' || st === 'accepted';
// 1) 建父任务
const task = await api<Task>('POST', `/api/projects/${projectId}/tasks`, {
title: item.title,
complexity,
priority: priorityOfLevel(item.level),
});
stats.total += 1;
stats.byComplexity[complexity] += 1;
// 2) 写产出字段(hard→plan / medium→spec / easy→operations
const extra: string[] = [];
if (isDone && complexity === 'hard' && subs.length === 0) {
extra.push('注:原任务已完成且无子任务,导入时容器直接推到 done。');
}
await setProduce(task.id, complexity, buildImportNote(item, st, extra));
// 3) 建子任务(sid 依赖 → 新任务 id)
const sidToId = new Map<string, string>();
const childPlan: Array<{ id: string; complexity: Complexity; oldStatus: string; label: string }> = [];
for (const sub of subs) {
const subLabel = `旧子任务 ${sub.sid}${sub.title ?? '?'}`;
if (!sub.title) {
stats.skipped += 1;
stats.warnings.push(`${subLabel} 缺少 title,已跳过`);
continue;
}
const subComplexity = complexityOfTier(sub.tier, subLabel, stats);
const deps: string[] = [];
for (const dep of sub.deps ?? []) {
const depId = sidToId.get(dep.toUpperCase());
if (depId) deps.push(depId);
else stats.warnings.push(`${subLabel} 的依赖 ${dep} 未找到对应任务,已忽略该依赖`);
}
const subStatus = sub.status === 'accepted' ? 'done' : (sub.status ?? 'open');
const child = await api<Task>('POST', `/api/projects/${projectId}/tasks`, {
title: sub.title,
complexity: subComplexity,
parentId: task.id,
deps,
});
sidToId.set(sub.sid.toUpperCase(), child.id);
stats.total += 1;
stats.byComplexity[subComplexity] += 1;
const subNote = [
`【从旧 todo 导入】原子任务 ${sub.sid}(父:旧 #${item.id})· 原状态:${subStatus}`,
subStatus === 'doing' ? '注:导入前处于「开发中」,导入后回到初始状态。' : '',
].filter(Boolean).join('\n');
await setProduce(child.id, subComplexity, subNote);
childPlan.push({ id: child.id, complexity: subComplexity, oldStatus: subStatus, label: subLabel });
}
// 4) 先推子任务,再推父任务
let allChildrenDone = childPlan.length > 0;
for (const c of childPlan) {
if (c.oldStatus === 'done') {
let ok: boolean;
if (c.complexity === 'hard') {
// 已完成的 hard 子任务(无下级)按容器路径收口
await pushHardContainer(c.id, c.label, false, true, stats);
const cur = await api<Task>('GET', `/api/tasks/${c.id}`);
ok = cur.status === 'done';
} else {
ok = await pushLeafToDone(c.id, c.complexity, c.label, stats);
}
if (!ok) allChildrenDone = false;
} else {
allChildrenDone = false; // open/doing:建完即停
}
}
if (!isDone) return; // open / doing:建完即停(doing 已在产出里备注)
if (complexity === 'hard') {
await pushHardContainer(task.id, label, childPlan.length > 0, allChildrenDone, stats);
} else {
await pushLeafToDone(task.id, complexity, label, stats);
}
}
async function cmdImportTodo(rest: string[]): Promise<void> {
const { values, positionals } = parseCmdArgs(rest, {
repo: { type: 'string' },
name: { type: 'string' },
});
const file = positionals[0];
if (!file || !values.repo) {
fail('用法:maestro import-todo <todo.json 路径> --repo <仓库路径> [--name 项目名]');
// 兼容旧用法 `import-todo <todo.json 路径> --repo <仓库路径>`--repo 优先;否则把位置参数当 repoPath
const repoArg = (values.repo as string | undefined) ?? positionals[0];
if (!repoArg) {
fail('用法:maestro import-todo <仓库路径> [--name 项目名](同步引擎固定读 <仓库路径>/todo/todo.json');
}
const filePath = resolve(file);
let db: OldDb;
try {
db = JSON.parse(readFileSync(filePath, 'utf8')) as OldDb;
} catch (e) {
fail(` ${filePath} 失败:${(e as Error).message}`);
}
const items = db.items ?? [];
if (!Array.isArray(items)) fail(`${filePath} 不是合法的旧 todo.json(缺少 items 数组)`);
const repoPath = resolve(values.repo as string);
const name = (values.name as string | undefined) ?? basename(repoPath);
const project = await api<Project>('POST', '/api/projects', { name, repoPath });
console.log(`已创建项目「${project.name}」(${project.id}),开始导入 ${items.length} 条旧任务…\n`);
const stats: ImportStats = {
total: 0,
byComplexity: { hard: 0, medium: 0, easy: 0 },
done: 0,
skipped: 0,
warnings: [],
};
for (const item of items) {
await importItem(project.id, item, stats);
const repoPath = resolve(repoArg);
if (values.repo && positionals[0]) {
const legacyFile = resolve(positionals[0]);
const expected = resolve(repoPath, 'todo', 'todo.json');
if (legacyFile !== expected) {
console.log(`提示:同步引擎固定${expected},忽略指定的 ${legacyFile}`);
}
}
console.log('导入完成:');
console.log(
` 共导入 ${stats.total} 条任务(hard ${stats.byComplexity.hard} / medium ${stats.byComplexity.medium} / easy ${stats.byComplexity.easy}`,
);
console.log(` 推到 done${stats.done} 条 · 跳过:${stats.skipped} 条 · 警告:${stats.warnings.length}`);
for (const w of stats.warnings) console.log(`${w}`);
// 项目不存在则建(按 repoPath 匹配),存在则直接增量同步
const projects = await api<Project[]>('GET', '/api/projects');
let project = projects.find((p) => p.repoPath === repoPath);
if (project) {
console.log(`项目「${project.name}」(${project.id}) 已存在,执行增量同步…\n`);
} else {
const name = (values.name as string | undefined) ?? basename(repoPath);
project = await api<Project>('POST', '/api/projects', { name, repoPath });
console.log(`已创建项目「${project.name}」(${project.id}),开始首次同步…\n`);
}
const r = await api<SyncResult>('POST', `/api/projects/${project.id}/sync`);
console.log('同步完成:');
console.log(` 新建 ${r.created} 条 · 推到 done ${r.doneAdvanced} 条 · 跳过(已映射)${r.skipped} 条 · 警告 ${r.warnings.length}`);
for (const w of r.warnings) console.log(`${w}`);
console.log(` 本次同步时间:${r.lastSyncAt}`);
console.log(`\n查看结果:maestro task list ${project.name}`);
}
+48
View File
@@ -1,12 +1,56 @@
import { statSync } from 'node:fs';
import type { FastifyInstance } from 'fastify';
import { Store } from '../store/index.js';
import { buildServer, attachWebSocket } from '../api/server.js';
import { registerStatic } from '../api/static.js';
import { loadConfig } from './config.js';
import { syncProject, todoJsonPath } from '../sync/todo-sync.js';
/**
* 定时同步:每轮对 active 项目检查 todo.json 的 mtime,比 last_sync_at 新才 sync。
* 间隔由 MAESTRO_SYNC_INTERVAL(秒)控制,默认 300,0=关闭。错误只记日志,不崩 daemon。
*/
function startSyncLoop(store: Store, app: FastifyInstance): NodeJS.Timeout | null {
const intervalSec = Number(process.env.MAESTRO_SYNC_INTERVAL ?? 300);
if (!Number.isFinite(intervalSec) || intervalSec <= 0) {
app.log.info('定时 todo.json 同步已关闭(MAESTRO_SYNC_INTERVAL=0');
return null;
}
const tick = (): void => {
try {
for (const p of store.listProjects()) {
if (p.status !== 'active') continue;
let mtimeIso: string;
try {
mtimeIso = statSync(todoJsonPath(p.repoPath)).mtime.toISOString();
} catch {
continue; // 无 todo.json:跳过
}
if (p.lastSyncAt && mtimeIso <= p.lastSyncAt) continue;
try {
const r = syncProject(store, p.id);
app.log.info(
`定时同步「${p.name}」:created=${r.created} doneAdvanced=${r.doneAdvanced} skipped=${r.skipped} warnings=${r.warnings.length}`,
);
} catch (e) {
app.log.error(`定时同步「${p.name}」失败:${(e as Error).message}`);
}
}
} catch (e) {
app.log.error(`定时同步轮询失败:${(e as Error).message}`);
}
};
const timer = setInterval(tick, intervalSec * 1000);
timer.unref();
app.log.info(`定时 todo.json 同步已启用:每 ${intervalSec}s 一轮`);
return timer;
}
/** maestrod:核心 daemon。Phase 1 = Store + REST/WS API(手动驱动;编排器在 Phase 2 接入)。 */
async function main(): Promise<void> {
const cfg = loadConfig();
const store = new Store(cfg.dbFile);
const rec = store.reconcileDeps(); // 启动对账:ready↔blocked 按依赖纠正存量数据
const app = buildServer({ store, logger: true });
registerStatic(app); // Web 看板(web/ 静态文件)
@@ -14,9 +58,13 @@ async function main(): Promise<void> {
await app.listen({ host: cfg.host, port: cfg.port });
app.log.info(`maestrod 就绪 · db=${cfg.dbFile} · http://${cfg.host}:${cfg.port} · ws ${cfg.host}:${cfg.port}/ws`);
if (rec.blocked || rec.released) app.log.info(`依赖对账:转入等依赖 ${rec.blocked} · 放行可执行 ${rec.released}`);
const syncTimer = startSyncLoop(store, app);
const shutdown = async (): Promise<void> => {
app.log.info('收到退出信号,关闭中…');
if (syncTimer) clearInterval(syncTimer);
await app.close();
store.close();
process.exit(0);
+2 -2
View File
@@ -81,7 +81,7 @@ const subtaskSchema = z.object({
title: z.string().min(1).describe('子任务标题'),
complexity: complexitySchema,
deps: z.array(z.string()).optional().describe('依赖的任务 id 列表(须全部 done 才可执行)'),
priority: z.number().int().optional().describe('优先级,数字越大越优先(默认 0)'),
priority: z.number().int().min(0).max(2).optional().describe('优先级 0/1/2P0 最高、P1 中(默认)、P2 最低'),
});
// ---------- MCP server ----------
@@ -155,7 +155,7 @@ server.registerTool(
complexity: complexitySchema,
parentId: z.string().optional().describe('父任务 id(建子任务时填,最多 3-4 层)'),
deps: z.array(z.string()).optional().describe('依赖的任务 id 列表'),
priority: z.number().int().optional().describe('优先级,数字越大越优先(默认 0)'),
priority: z.number().int().min(0).max(2).optional().describe('优先级 0/1/2P0 最高、P1 中(默认)、P2 最低'),
},
},
async ({ projectId, ...rest }) =>
+3 -1
View File
@@ -21,6 +21,7 @@ export interface Project {
concurrency: number; // 每项目并发执行上限
status: 'active' | 'paused';
createdAt: string;
lastSyncAt: string | null; // 最近一次 todo.json 同步完成时间
}
export interface ApprovalRecord {
@@ -80,7 +81,8 @@ export interface Run {
export type EventType =
| 'task.created' | 'task.updated' | 'status.changed'
| 'approval.requested' | 'approval.granted' | 'approval.rejected'
| 'run.started' | 'run.finished';
| 'run.started' | 'run.finished'
| 'project.synced';
export interface Event {
id: Id;
+15 -1
View File
@@ -5,11 +5,25 @@ import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
/** 打开(或新建)数据库并应用 schema。schema.sql 在 dev(src) 与 build(dist) 两处都与本文件同目录。 */
/** 表已存在但缺列时补列(轻量迁移,旧库平滑升级)。表不存在则交给 schema.sql 全新建表。 */
function ensureColumn(db: Database.Database, table: string, column: string, ddl: string): void {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (cols.length === 0) return; // 表不存在:schema.sql 会带新列建表
if (cols.some((c) => c.name === column)) return; // 列已存在
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
/**
* 打开(或新建)数据库并应用 schema。schema.sql 在 dev(src) 与 build(dist) 两处都与本文件同目录。
* 迁移须在 exec(schema) 之前跑:schema.sql 里的 idx_tasks_source 索引引用 source_ref
* 旧库须先补列,否则建索引会失败。
*/
export function openDb(file: string): Database.Database {
const db = new Database(file);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
ensureColumn(db, 'tasks', 'source_ref', 'source_ref TEXT');
ensureColumn(db, 'projects', 'last_sync_at', 'last_sync_at TEXT');
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
db.exec(schema);
return db;
+1 -1
View File
@@ -1,4 +1,4 @@
export { Store, StoreError } from './store.js';
export type { CreateProjectInput, CreateTaskInput } from './store.js';
export type { CreateProjectInput, CreateTaskInput, PatchProjectInput, PatchTaskInput, ActiveRun } from './store.js';
export { openDb } from './db.js';
export type { DB } from './db.js';
+3
View File
@@ -8,12 +8,14 @@ export interface ProjectRow {
id: string; name: string; repo_path: string; default_branch: string;
verify_cmd: string | null; autonomy: string; model: string | null;
concurrency: number; status: string; created_at: string;
last_sync_at: string | null;
}
export interface TaskRow {
id: string; project_id: string; parent_id: string | null; depth: number;
title: string; complexity: string; status: string; priority: number;
deps: string; plan: string | null; spec: string | null; operations: string | null;
result: string | null; assignee: string | null; created_at: string; updated_at: string;
source_ref: string | null;
}
export interface ApprovalRow {
id: string; task_id: string; gate: string; action: string;
@@ -33,6 +35,7 @@ export function rowToProject(r: ProjectRow): Project {
id: r.id, name: r.name, repoPath: r.repo_path, defaultBranch: r.default_branch,
verifyCmd: r.verify_cmd, autonomy: r.autonomy as Autonomy, model: r.model,
concurrency: r.concurrency, status: r.status as Project['status'], createdAt: r.created_at,
lastSyncAt: r.last_sync_at,
};
}
+6 -3
View File
@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS projects (
model TEXT,
concurrency INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active', -- active | paused
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
last_sync_at TEXT -- 最近一次 todo.json 同步时间
);
CREATE TABLE IF NOT EXISTS tasks (
@@ -23,7 +24,7 @@ CREATE TABLE IF NOT EXISTS tasks (
title TEXT NOT NULL,
complexity TEXT NOT NULL, -- hard | medium | easy
status TEXT NOT NULL DEFAULT 'init',
priority INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 1, -- P0 最高 / P1 中 / P2 最低
deps TEXT NOT NULL DEFAULT '[]', -- JSON array of task ids
plan TEXT, -- Hard:分析 + 拆解
spec TEXT, -- Medium:改动 + 理由
@@ -31,10 +32,12 @@ CREATE TABLE IF NOT EXISTS tasks (
result TEXT, -- JSON TaskResult
assignee TEXT, -- agent | human
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
updated_at TEXT NOT NULL,
source_ref TEXT -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一
);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id, status);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
CREATE INDEX IF NOT EXISTS idx_tasks_source ON tasks(project_id, source_ref);
CREATE TABLE IF NOT EXISTS approvals (
id TEXT PRIMARY KEY,
+248 -12
View File
@@ -24,6 +24,32 @@ export interface CreateTaskInput {
projectId: string; title: string; complexity: Complexity;
parentId?: string | null; priority?: number; deps?: string[];
}
export interface PatchProjectInput {
autonomy?: Autonomy; concurrency?: number; verifyCmd?: string | null;
model?: string | null; status?: 'active' | 'paused';
}
export interface PatchTaskInput {
title?: string; priority?: number; complexity?: Complexity;
}
/** 执行中的 run(联 tasks 取标题/项目),供 GET /api/agents 汇总 */
export interface ActiveRun {
runId: string; taskId: string; taskTitle: string; kind: string;
startedAt: string; projectId: string;
}
const AUTONOMY_VALUES: readonly Autonomy[] = ['manual', 'auto-easy', 'auto-approved'];
/** 优先级取值:P0 最高 / P1 中(默认)/ P2 最低 */
function assertPriority(p: number): void {
if (!Number.isInteger(p) || p < 0 || p > 2) {
throw new StoreError('priority 必须是 0/1/2P0 最高,P1 中,P2 最低)');
}
}
/** 允许修改 complexity 的状态:尚未进入执行/收尾链路 */
const COMPLEXITY_EDITABLE: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
'init', 'analyzing', 'speccing', 'ready', 'plan_review', 'spec_review', 'blocked',
]);
type EventListener = (e: Event) => void;
@@ -67,10 +93,11 @@ export class Store {
default_branch: input.defaultBranch ?? 'main', verify_cmd: input.verifyCmd ?? null,
autonomy: input.autonomy ?? 'manual', model: input.model ?? null,
concurrency: input.concurrency ?? 1, status: 'active', created_at: now(),
last_sync_at: null,
};
this.db.prepare(
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at)
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at)`,
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at,last_sync_at)
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at,@last_sync_at)`,
).run(row);
this.emit(row.id, null, 'task.created', { kind: 'project', name: row.name });
return rowToProject(row);
@@ -86,6 +113,51 @@ export class Store {
return row ? rowToProject(row) : null;
}
/** 部分更新项目配置(autonomy/concurrency/verifyCmd/model/status),带合法性校验。 */
patchProject(projectId: string, patch: PatchProjectInput): Project {
const cur = this.getProject(projectId);
if (!cur) throw new StoreError(`项目不存在: ${projectId}`);
const sets: string[] = [];
const args: Record<string, unknown> = { id: projectId };
if (patch.autonomy !== undefined) {
if (!AUTONOMY_VALUES.includes(patch.autonomy)) {
throw new StoreError('autonomy 必须是 manual | auto-easy | auto-approved');
}
sets.push('autonomy = @autonomy'); args.autonomy = patch.autonomy;
}
if (patch.concurrency !== undefined) {
if (!Number.isInteger(patch.concurrency) || patch.concurrency < 1) {
throw new StoreError('concurrency 必须是 >=1 的整数');
}
sets.push('concurrency = @concurrency'); args.concurrency = patch.concurrency;
}
if (patch.status !== undefined) {
if (patch.status !== 'active' && patch.status !== 'paused') {
throw new StoreError('status 必须是 active | paused');
}
sets.push('status = @status'); args.status = patch.status;
}
if (patch.verifyCmd !== undefined) { sets.push('verify_cmd = @verify_cmd'); args.verify_cmd = patch.verifyCmd; }
if (patch.model !== undefined) { sets.push('model = @model'); args.model = patch.model; }
if (sets.length > 0) {
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args);
this.emit(projectId, null, 'task.updated', { kind: 'project', fields: Object.keys(patch) });
}
return this.getProject(projectId)!;
}
/** 标记项目完成一次 todo.json 同步:写 last_sync_at 并广播 project.syncedpayload=同步统计)。 */
markSynced(projectId: string, payload: Record<string, unknown> = {}): string {
const cur = this.getProject(projectId);
if (!cur) throw new StoreError(`项目不存在: ${projectId}`);
const at = now();
this.db.prepare(`UPDATE projects SET last_sync_at = ? WHERE id = ?`).run(at, projectId);
this.emit(projectId, null, 'project.synced', { ...payload, lastSyncAt: at });
return at;
}
// ---------- Tasks ----------
createTask(input: CreateTaskInput): Task {
const project = this.getProject(input.projectId);
@@ -101,16 +173,23 @@ export class Store {
if (depth > max) throw new StoreError(`层级超限:最多 ${max} 层(父任务 ${parent.complexity}`);
}
const status = initialNextStatus(input.complexity);
if (input.priority !== undefined) assertPriority(input.priority);
let status = initialNextStatus(input.complexity);
// Easy 直达 ready,但有未完成依赖时落位 blocked
const depsArr = input.deps ?? [];
if (status === 'ready' && depsArr.length && !depsArr.every((d) => this.getTaskRow(d)?.status === 'done')) {
status = 'blocked';
}
const row: TaskRow = {
id: id('tsk'), project_id: input.projectId, parent_id: input.parentId ?? null, depth,
title: input.title, complexity: input.complexity, status, priority: input.priority ?? 0,
title: input.title, complexity: input.complexity, status, priority: input.priority ?? 1,
deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null,
result: null, assignee: null, created_at: now(), updated_at: now(),
source_ref: null,
};
this.db.prepare(
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,created_at,updated_at)
VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@created_at,@updated_at)`,
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,created_at,updated_at,source_ref)
VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@created_at,@updated_at,@source_ref)`,
).run(row);
this.emit(input.projectId, row.id, 'task.created', { title: row.title, complexity: row.complexity, status });
return rowToTask(row);
@@ -128,14 +207,14 @@ export class Store {
listTasks(projectId: string): Task[] {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? ORDER BY depth, priority DESC, created_at`,
`SELECT * FROM tasks WHERE project_id = ? ORDER BY depth, priority ASC, created_at`,
).all(projectId) as TaskRow[];
return rows.map((r) => rowToTask(r, this.listApprovals(r.id)));
}
childrenOf(taskId: string): Task[] {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE parent_id = ? ORDER BY priority DESC, created_at`,
`SELECT * FROM tasks WHERE parent_id = ? ORDER BY priority ASC, created_at`,
).all(taskId) as TaskRow[];
return rows.map((r) => rowToTask(r, this.listApprovals(r.id)));
}
@@ -161,6 +240,142 @@ export class Store {
return this.getTask(taskId)!;
}
/**
* 部分更新任务(title/priority/complexity)。
* complexity 修改仅允许尚未进入执行链路的状态(init/analyzing/speccing/ready/plan_review/spec_review/blocked);
* 改后 status 重置为新复杂度的初始态(initialNextStatus)并广播 status.changed。
*/
patchTask(taskId: string, patch: PatchTaskInput): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const fields: string[] = [];
if (patch.title !== undefined) {
if (!patch.title.trim()) throw new StoreError('title 不能为空');
this.db.prepare(`UPDATE tasks SET title = ?, updated_at = ? WHERE id = ?`).run(patch.title, now(), taskId);
fields.push('title');
}
if (patch.priority !== undefined) {
assertPriority(patch.priority);
this.db.prepare(`UPDATE tasks SET priority = ?, updated_at = ? WHERE id = ?`).run(patch.priority, now(), taskId);
fields.push('priority');
}
let statusChange: { from: TaskStatus; to: TaskStatus } | null = null;
if (patch.complexity !== undefined && patch.complexity !== row.complexity) {
const cur = row.status as TaskStatus;
if (!COMPLEXITY_EDITABLE.has(cur)) {
throw new StoreError(
`当前状态 ${STATUS_LABEL[cur]}(${cur}) 不允许修改复杂度(仅限 init/analyzing/speccing/ready/plan_review/spec_review/blocked`,
);
}
const to = this.resolveReady(row, initialNextStatus(patch.complexity)); // ready 落位时考虑依赖
this.db.prepare(`UPDATE tasks SET complexity = ?, status = ?, updated_at = ? WHERE id = ?`)
.run(patch.complexity, to, now(), taskId);
fields.push('complexity');
if (to !== cur) statusChange = { from: cur, to };
}
if (fields.length > 0) {
this.emit(row.project_id, taskId, 'task.updated', { fields });
if (statusChange) {
this.emit(row.project_id, taskId, 'status.changed', { ...statusChange, reason: 'complexity.changed' });
}
}
return this.getTask(taskId)!;
}
// ---------- 旧 todo 来源映射(sync 引擎用) ----------
setSourceRef(taskId: string, ref: string): void {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
this.db.prepare(`UPDATE tasks SET source_ref = ? WHERE id = ?`).run(ref, taskId);
}
getTaskBySourceRef(projectId: string, ref: string): Task | null {
const row = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND source_ref = ?`,
).get(projectId, ref) as TaskRow | undefined;
return row ? rowToTask(row, this.listApprovals(row.id)) : null;
}
/** 项目内全部已映射任务:source_ref → Task */
sourceRefMap(projectId: string): Map<string, Task> {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND source_ref IS NOT NULL`,
).all(projectId) as TaskRow[];
const map = new Map<string, Task>();
for (const r of rows) map.set(r.source_ref!, rowToTask(r));
return map;
}
// ---------- 依赖驱动的 ready/blocked 自动管理 ----------
/** 依赖是否全部 done(未知 id 视为未满足) */
private depsMetRow(row: TaskRow): boolean {
const deps = JSON.parse(row.deps) as string[];
return deps.every((d) => this.getTaskRow(d)?.status === 'done');
}
/** 意图是 ready 时按依赖落位:未满足 → blocked(系统自动管理,不可手动绕过) */
private resolveReady(row: TaskRow, to: TaskStatus): TaskStatus {
return to === 'ready' && !this.depsMetRow(row) ? 'blocked' : to;
}
/** 某任务 done 后:把同项目内依赖已全部满足的 blocked 任务自动放行为 ready */
private releaseDependents(projectId: string): void {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND status = 'blocked'`,
).all(projectId) as TaskRow[];
for (const r of rows) {
if (!this.depsMetRow(r)) continue;
this.db.prepare(`UPDATE tasks SET status = 'ready', updated_at = ? WHERE id = ?`).run(now(), r.id);
this.emit(projectId, r.id, 'status.changed', { from: 'blocked', to: 'ready', auto: 'deps-met' });
}
}
/**
* 全量依赖对账(幂等):ready 但依赖未满足 → blockedblocked 但依赖已满足 → ready。
* daemon 启动时调用,纠正存量数据。
*/
reconcileDeps(projectId?: string): { blocked: number; released: number } {
const pids = projectId ? [projectId] : this.listProjects().map((p) => p.id);
let nBlocked = 0;
let nReleased = 0;
for (const pid of pids) {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND status IN ('ready','blocked')`,
).all(pid) as TaskRow[];
for (const r of rows) {
const met = this.depsMetRow(r);
if (r.status === 'ready' && !met) {
this.db.prepare(`UPDATE tasks SET status = 'blocked', updated_at = ? WHERE id = ?`).run(now(), r.id);
this.emit(pid, r.id, 'status.changed', { from: 'ready', to: 'blocked', auto: 'deps-reconcile' });
nBlocked++;
} else if (r.status === 'blocked' && met) {
this.db.prepare(`UPDATE tasks SET status = 'ready', updated_at = ? WHERE id = ?`).run(now(), r.id);
this.emit(pid, r.id, 'status.changed', { from: 'blocked', to: 'ready', auto: 'deps-reconcile' });
nReleased++;
}
}
}
return { blocked: nBlocked, released: nReleased };
}
/**
* 仅供导入器:旧系统中已完成的任务直接置 done(绕过执行链路与依赖落位——
* 历史事实优先,避免已完成的工作被当作待执行复活),并放行依赖它的任务。
*/
forceDone(taskId: string, actor: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const from = row.status as TaskStatus;
if (from === 'done') return this.getTask(taskId)!;
this.db.prepare(`UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?`).run(now(), taskId);
this.emit(row.project_id, taskId, 'status.changed', { from, to: 'done', auto: 'import-done', actor });
this.releaseDependents(row.project_id);
return this.getTask(taskId)!;
}
/** 受守卫的状态变更:非法流转抛错;记录 status.changed 事件。 */
transition(taskId: string, to: TaskStatus, meta: Record<string, unknown> = {}): Task {
const row = this.getTaskRow(taskId);
@@ -170,8 +385,14 @@ export class Store {
if (!canTransition(from, to)) {
throw new StoreError(`非法状态流转:${STATUS_LABEL[from]}(${from}) → ${STATUS_LABEL[to]}(${to})`);
}
this.db.prepare(`UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`).run(to, now(), taskId);
this.emit(row.project_id, taskId, 'status.changed', { from, to, ...meta });
// 意图 ready 但依赖未满足 → 系统落位 blocked
const actual = this.resolveReady(row, to);
if (from === actual) return this.getTask(taskId)!;
this.db.prepare(`UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`).run(actual, now(), taskId);
this.emit(row.project_id, taskId, 'status.changed', {
from, to: actual, ...(actual !== to ? { requested: to, auto: 'deps-unmet' } : {}), ...meta,
});
if (actual === 'done') this.releaseDependents(row.project_id);
return this.getTask(taskId)!;
}
@@ -207,9 +428,10 @@ export class Store {
throw new StoreError('reject 必须填写改进意见');
}
const to: TaskStatus = action === 'accept'
const intended: TaskStatus = action === 'accept'
? ({ plan: 'decomposed', spec: 'ready', exec: 'done' } as const)[gate]
: ({ plan: 'analyzing', spec: 'speccing', exec: 'ready' } as const)[gate];
const to = this.resolveReady(row, intended); // spec accept / exec reject → ready 时按依赖落位
const txn = this.db.transaction(() => {
const ap: ApprovalRow = {
@@ -222,6 +444,7 @@ export class Store {
});
txn();
this.emit(row.project_id, taskId, action === 'accept' ? 'approval.granted' : 'approval.rejected', { gate, from, to, reason: reason ?? null });
if (to === 'done') this.releaseDependents(row.project_id); // 完成 → 自动放行依赖它的任务
return this.getTask(taskId)!;
}
@@ -252,6 +475,19 @@ export class Store {
return rowToRun(this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(runId) as RunRow);
}
/** 所有进行中的 runstatus='started'),联 tasks 取任务标题与项目。 */
activeRuns(): ActiveRun[] {
const rows = this.db.prepare(
`SELECT r.id AS run_id, r.task_id, r.kind, r.started_at, t.title, t.project_id
FROM runs r JOIN tasks t ON t.id = r.task_id
WHERE r.status = 'started' ORDER BY r.started_at`,
).all() as Array<{ run_id: string; task_id: string; kind: string; started_at: string; title: string; project_id: string }>;
return rows.map((r) => ({
runId: r.run_id, taskId: r.task_id, taskTitle: r.title,
kind: r.kind, startedAt: r.started_at, projectId: r.project_id,
}));
}
listRuns(taskId: string): Run[] {
const rows = this.db.prepare(`SELECT * FROM runs WHERE task_id = ? ORDER BY started_at`).all(taskId) as RunRow[];
return rows.map(rowToRun);
@@ -271,7 +507,7 @@ export class Store {
*/
nextExecutable(projectId: string): Task | null {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND status = 'ready' ORDER BY priority DESC, created_at`,
`SELECT * FROM tasks WHERE project_id = ? AND status = 'ready' ORDER BY priority ASC, created_at`,
).all(projectId) as TaskRow[];
for (const r of rows) {
if (this.childrenOf(r.id).length > 0) continue; // 非叶子跳过
+366
View File
@@ -0,0 +1,366 @@
/**
* todo-sync — 旧 todo skilltodo/todo.json)→ maestro 的单向同步引擎。
*
* - 导入 = 首次同步,同一入口 syncProject(store, projectId)。
* - 映射:tasks.source_reftodo:<id> / todo:<id>/<sid>)项目内唯一标识旧 item/sub。
* 已映射的跳过(skipped),新出现的创建(created)。
* - 复杂度:顶层 item tier 1/2/3 → hard/medium/easy(缺省 medium + warning);subs 一律 easy。
* - 旧状态 done/accepted 且 maestro 侧未 done → 沿 TRANSITIONS 合法路径推到 doneactor=importer
* 计 doneAdvanced);推不动记 warning。
* - 单向:绝不写 todo.json;maestro 侧多出的任务不删;源里消失的 item 记 warning 不删。
* - 幂等:连续两次 sync,第二次 created=0。
*/
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { Store, StoreError } from '../store/index.js';
import type { Task } from '../model/types.js';
import { TIER_TO_COMPLEXITY, type Complexity } from '../model/complexity.js';
import type { TaskStatus } from '../model/status.js';
// ---------- 旧 todo.json schema(以 ~/.claude/skills/todo/todo.mjs 为准) ----------
interface OldGate {
kind?: string | null;
note?: string | null;
ref?: string | null;
approval?: string | null;
}
interface OldSub {
sid: string;
title?: string;
tier?: number | null;
deps?: string[];
status?: string;
}
interface OldItem {
id: number;
title?: string;
desc?: string | null;
level?: string | null;
tier?: number | null;
tags?: string[];
status?: string;
done?: boolean;
version?: string | null;
subtasks?: OldSub[];
gate?: OldGate | null;
reject_reason?: string | null;
}
interface OldDb {
meta?: { title?: string };
items?: OldItem[];
}
type OldStatus = 'open' | 'doing' | 'done' | 'accepted';
const OLD_STATUS_LABEL: Record<OldStatus, string> = {
open: '待开始',
doing: '开发中',
done: '待验收',
accepted: '已验收',
};
export interface SyncResult {
created: number;
doneAdvanced: number;
skipped: number;
warnings: string[];
lastSyncAt: string;
}
/** 旧 todo 数据文件在仓库内的固定位置 */
export function todoJsonPath(repoPath: string): string {
return join(repoPath, 'todo', 'todo.json');
}
export function hasTodoJson(repoPath: string): boolean {
return existsSync(todoJsonPath(repoPath));
}
// ---------- 字段映射 ----------
function oldStatusOf(raw: string | undefined, doneFlag: boolean | undefined): OldStatus {
if (raw === 'open' || raw === 'doing' || raw === 'done' || raw === 'accepted') return raw;
return doneFlag ? 'accepted' : 'open';
}
function complexityOfTier(tier: number | null | undefined, label: string, warnings: string[]): Complexity {
const c = tier == null ? undefined : TIER_TO_COMPLEXITY[tier];
if (c) return c;
warnings.push(`${label} 缺少有效 tier${tier ?? '无'}),按 medium 导入`);
return 'medium';
}
/** 旧 level → 优先级(P0 最高):high→P0low→P2mid/缺省→P1 */
function priorityOfLevel(level: string | null | undefined): number {
if (level === 'high') return 0;
if (level === 'low') return 2;
return 1;
}
/** 旧 item 的描述性字段合成产出内容(plan/spec/operations */
function buildImportNote(item: OldItem, st: OldStatus, extra: string[] = []): string {
const lines = [`【从旧 todo 导入】原 #${item.id} · 原状态:${st}${OLD_STATUS_LABEL[st]}`];
if (st === 'doing') lines.push('注:导入前处于「开发中」,导入后回到初始状态,需按新流程重新推进。');
for (const l of extra) lines.push(l);
if (item.desc) lines.push('', item.desc);
if (item.tags?.length) lines.push('', `标签:${item.tags.join(' / ')}`);
if (item.level) lines.push(`原重要度:${item.level}`);
if (item.gate && (item.gate.note || item.gate.ref)) {
const ref = item.gate.ref ? `(详见 ${item.gate.ref}` : '';
lines.push('', `原方案/改动说明(approval=${item.gate.approval ?? '无'}):${item.gate.note ?? ''}${ref}`);
}
if (item.reject_reason) lines.push(`原拒绝原因:${item.reject_reason}`);
if (item.version) lines.push(`原验收版本:${item.version}`);
return lines.join('\n');
}
const PRODUCE_PATH: Record<Complexity, 'plan' | 'spec' | 'operations'> = {
hard: 'plan',
medium: 'spec',
easy: 'operations',
};
function setProduce(store: Store, taskId: string, complexity: Complexity, content: string): void {
const field = PRODUCE_PATH[complexity];
if (field === 'plan') store.setPlan(taskId, content);
else if (field === 'spec') store.setSpec(taskId, content);
else store.setOperations(taskId, content);
}
// ---------- 沿合法路径推 done ----------
/**
* 把非 Hard 容器的叶子任务从当前状态沿合法路径推到 done:
* easy: ready → queued → executing → exec_review → accept(done)
* medium: speccing → spec_review → accept(ready) → … 同上
* 成功返回 true;推不动记 warning、保留现状,返回 false。
*/
function pushLeafToDone(store: Store, taskId: string, label: string, warnings: string[]): boolean {
try {
let status = store.getTask(taskId)!.status;
if (status === 'speccing') status = store.transition(taskId, 'spec_review').status;
if (status === 'spec_review') status = store.decide(taskId, 'accept', 'importer').status;
// 依赖未满足会被系统落位 blocked;但旧侧已完成是历史事实 → 直接置 done
if (status === 'blocked') status = store.forceDone(taskId, 'importer').status;
if (status === 'ready') status = store.transition(taskId, 'queued').status;
if (status === 'queued') status = store.transition(taskId, 'executing').status;
if (status === 'executing') status = store.transition(taskId, 'exec_review').status;
if (status === 'exec_review') status = store.decide(taskId, 'accept', 'importer').status;
if (status !== 'done') throw new StoreError(`无法从 ${status} 推进`);
return true;
} catch (e) {
warnings.push(`${label} 推进 done 失败:${(e as Error).message}(已保留当前状态)`);
return false;
}
}
/**
* Hard 容器推进:analyzing → plan_review → accept(decomposed)。
* 无子任务或子任务全 done 时再 decomposed → done。返回是否到达 done。
*/
function pushHardContainer(
store: Store,
taskId: string,
label: string,
hasChildren: boolean,
allChildrenDone: boolean,
warnings: string[],
): boolean {
try {
let status = store.getTask(taskId)!.status as TaskStatus;
if (status === 'analyzing') { store.transition(taskId, 'plan_review'); status = 'plan_review'; }
if (status === 'plan_review') { store.decide(taskId, 'accept', 'importer'); status = 'decomposed'; }
if (status === 'decomposed') {
if (!hasChildren || allChildrenDone) {
store.transition(taskId, 'done');
return true;
}
warnings.push(`${label} 原状态为已完成,但子任务未能全部推到 done,容器停在 decomposed`);
return false;
}
if (status !== 'done') throw new StoreError(`无法从 ${status} 推进`);
return false; // 已是 done,本轮没有推进
} catch (e) {
warnings.push(`${label} 推进 done 失败:${(e as Error).message}(已保留当前状态)`);
return false;
}
}
// ---------- 同步主流程 ----------
interface SyncCtx {
store: Store;
projectId: string;
mapped: Map<string, Task>; // source_ref → Task(开始同步时的快照,新建后即时补充)
created: number;
doneAdvanced: number;
skipped: number;
warnings: string[];
}
function syncItem(ctx: SyncCtx, item: OldItem): void {
const { store, projectId } = ctx;
const label = `旧 #${item.id}${item.title ?? '?'}`;
const ref = `todo:${item.id}`;
const st = oldStatusOf(item.status, item.done);
const subs = item.subtasks ?? [];
const itemDone = st === 'done' || st === 'accepted';
// 1) 顶层 item:已映射跳过,否则创建
let parent = ctx.mapped.get(ref) ?? null;
let complexity: Complexity;
if (parent) {
ctx.skipped += 1;
complexity = parent.complexity;
} else {
if (!item.title) {
ctx.skipped += 1;
ctx.warnings.push(`旧 #${item.id} 缺少 title,已跳过`);
return;
}
complexity = complexityOfTier(item.tier, label, ctx.warnings);
parent = store.createTask({
projectId,
title: item.title,
complexity,
priority: priorityOfLevel(item.level),
});
const extra: string[] = [];
if (itemDone && complexity === 'hard' && subs.length === 0) {
extra.push('注:原任务已完成且无子任务,导入时容器直接推到 done。');
}
setProduce(store, parent.id, complexity, buildImportNote(item, st, extra));
store.setSourceRef(parent.id, ref);
ctx.mapped.set(ref, parent);
ctx.created += 1;
}
// 2) 子任务:subs 一律 easy(不继承父 tier
const childPlan: Array<{ id: string; oldDone: boolean; label: string }> = [];
for (const sub of subs) {
const sid = String(sub.sid).toUpperCase();
const subRef = `${ref}/${sid}`;
const subLabel = `旧子任务 ${sid}${sub.title ?? '?'}`;
const subStatus = sub.status === 'accepted' ? 'done' : (sub.status ?? 'open');
let child = ctx.mapped.get(subRef) ?? null;
if (child) {
ctx.skipped += 1;
} else {
if (!sub.title) {
ctx.skipped += 1;
ctx.warnings.push(`${subLabel} 缺少 title,已跳过`);
continue;
}
const deps: string[] = [];
for (const dep of sub.deps ?? []) {
const depTask = ctx.mapped.get(`${ref}/${String(dep).toUpperCase()}`);
if (depTask) deps.push(depTask.id);
else ctx.warnings.push(`${subLabel} 的依赖 ${dep} 未找到对应任务,已忽略该依赖`);
}
child = store.createTask({
projectId,
title: sub.title,
complexity: 'easy', // subs 一律 easy,不继承父 tier
parentId: parent.id,
deps,
});
const subNote = [
`【从旧 todo 导入】原子任务 ${sid}(父:旧 #${item.id})· 原状态:${subStatus}`,
subStatus === 'doing' ? '注:导入前处于「开发中」,导入后回到初始状态。' : '',
].filter(Boolean).join('\n');
setProduce(store, child.id, 'easy', subNote);
store.setSourceRef(child.id, subRef);
ctx.mapped.set(subRef, child);
ctx.created += 1;
}
childPlan.push({ id: child.id, oldDone: subStatus === 'done', label: subLabel });
}
// 3) 旧侧已完成 → maestro 侧沿合法路径推 done(先子后父)
// 注:带依赖的任务会被系统落位 blocked,依赖 done 后自动放行;旧 todo 的 deps 只引用更早条目,
// 按条目顺序推进即满足依赖序。若出现乱序依赖推不动会记 warning,下次同步不再重试(属源数据异常)。
for (const c of childPlan) {
if (!c.oldDone) continue;
const cur = store.getTask(c.id)!;
if (cur.status === 'done') continue;
if (pushLeafToDone(store, c.id, c.label, ctx.warnings)) ctx.doneAdvanced += 1;
}
if (!itemDone) return; // open / doing:建完即停
const curParent = store.getTask(parent.id)!;
if (curParent.status === 'done') return;
if (complexity === 'hard') {
const children = store.childrenOf(parent.id);
const allDone = children.length > 0 && children.every((c) => c.status === 'done');
if (pushHardContainer(store, parent.id, label, children.length > 0, allDone, ctx.warnings)) {
ctx.doneAdvanced += 1;
}
} else {
if (pushLeafToDone(store, parent.id, label, ctx.warnings)) ctx.doneAdvanced += 1;
}
}
/**
* 单向同步 <repoPath>/todo/todo.json → maestro。导入与增量同步同一入口,幂等。
* 文件不存在 / 不合法时抛 StoreErrorAPI 层映射为 400)。
*/
export function syncProject(store: Store, projectId: string): SyncResult {
const project = store.getProject(projectId);
if (!project) throw new StoreError(`项目不存在: ${projectId}`);
const file = todoJsonPath(project.repoPath);
if (!existsSync(file)) throw new StoreError(`未找到 todo/todo.json${file}`);
let db: OldDb;
try {
db = JSON.parse(readFileSync(file, 'utf8')) as OldDb;
} catch (e) {
throw new StoreError(`读取 ${file} 失败:${(e as Error).message}`);
}
const items = db.items;
if (!Array.isArray(items)) throw new StoreError(`${file} 不是合法的旧 todo.json(缺少 items 数组)`);
const ctx: SyncCtx = {
store,
projectId,
mapped: store.sourceRefMap(projectId),
created: 0,
doneAdvanced: 0,
skipped: 0,
warnings: [],
};
// 源里消失的 item/sub:记 warning,不删 maestro 侧任务
const liveRefs = new Set<string>();
for (const item of items) {
liveRefs.add(`todo:${item.id}`);
for (const sub of item.subtasks ?? []) liveRefs.add(`todo:${item.id}/${String(sub.sid).toUpperCase()}`);
}
for (const [ref, task] of ctx.mapped) {
if (!liveRefs.has(ref)) {
ctx.warnings.push(`源中已不存在 ${ref}${task.title}」,maestro 侧任务已保留不删`);
}
}
for (const item of items) {
syncItem(ctx, item);
}
const lastSyncAt = store.markSynced(projectId, {
created: ctx.created,
doneAdvanced: ctx.doneAdvanced,
skipped: ctx.skipped,
warnings: ctx.warnings,
});
return {
created: ctx.created,
doneAdvanced: ctx.doneAdvanced,
skipped: ctx.skipped,
warnings: ctx.warnings,
lastSyncAt,
};
}