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:
+71
-5
@@ -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/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);
|
||||
|
||||
+2
-1
@@ -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
@@ -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|2,P0 最高,默认 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/2(P0 最高,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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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/2:P0 最高、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/2:P0 最高、P1 中(默认)、P2 最低'),
|
||||
},
|
||||
},
|
||||
async ({ projectId, ...rest }) =>
|
||||
|
||||
+3
-1
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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/2(P0 最高,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.synced(payload=同步统计)。 */
|
||||
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 但依赖未满足 → blocked;blocked 但依赖已满足 → 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);
|
||||
}
|
||||
|
||||
/** 所有进行中的 run(status='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; // 非叶子跳过
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* todo-sync — 旧 todo skill(todo/todo.json)→ maestro 的单向同步引擎。
|
||||
*
|
||||
* - 导入 = 首次同步,同一入口 syncProject(store, projectId)。
|
||||
* - 映射:tasks.source_ref(todo:<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 合法路径推到 done(actor=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→P0,low→P2,mid/缺省→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。导入与增量同步同一入口,幂等。
|
||||
* 文件不存在 / 不合法时抛 StoreError(API 层映射为 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user