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');
}
+41 -278
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 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}`);
}
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);
// 项目不存在则建(按 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);
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);
project = await api<Project>('POST', '/api/projects', { name, repoPath });
console.log(`已创建项目「${project.name}」(${project.id}),开始首次同步\n`);
}
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}`);
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,
};
}
+109
View File
@@ -0,0 +1,109 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store, StoreError } from '../src/store/index.js';
function freshStore(): Store {
return new Store(':memory:');
}
// ---------- patchProject ----------
test('patchProject:更新 autonomy/concurrency/verifyCmd/model/status', () => {
const s = freshStore();
const p = s.createProject({ name: 'pp', repoPath: '/tmp/pp-' + Math.random() });
assert.equal(p.lastSyncAt, null);
const updated = s.patchProject(p.id, {
autonomy: 'auto-easy', concurrency: 3, verifyCmd: 'npm test', model: 'sonnet', status: 'paused',
});
assert.equal(updated.autonomy, 'auto-easy');
assert.equal(updated.concurrency, 3);
assert.equal(updated.verifyCmd, 'npm test');
assert.equal(updated.model, 'sonnet');
assert.equal(updated.status, 'paused');
// 部分字段更新不影响其余字段;null 清空
const again = s.patchProject(p.id, { verifyCmd: null, status: 'active' });
assert.equal(again.verifyCmd, null);
assert.equal(again.autonomy, 'auto-easy');
assert.equal(again.status, 'active');
s.close();
});
test('patchProject:非法值被拒(autonomy/concurrency/status', () => {
const s = freshStore();
const p = s.createProject({ name: 'pv', repoPath: '/tmp/pv-' + Math.random() });
assert.throws(() => s.patchProject(p.id, { autonomy: 'yolo' as never }), StoreError);
assert.throws(() => s.patchProject(p.id, { concurrency: 0 }), StoreError);
assert.throws(() => s.patchProject(p.id, { concurrency: 1.5 }), StoreError);
assert.throws(() => s.patchProject(p.id, { status: 'stopped' as never }), StoreError);
assert.throws(() => s.patchProject('prj_nope', { concurrency: 1 }), StoreError);
s.close();
});
// ---------- patchTask ----------
test('patchTasktitle/priority 更新', () => {
const s = freshStore();
const p = s.createProject({ name: 'pt', repoPath: '/tmp/pt-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: '原标题', complexity: 'easy' });
assert.equal(t.priority, 1); // 默认 P1(中)
const u = s.patchTask(t.id, { title: '新标题', priority: 0 });
assert.equal(u.title, '新标题');
assert.equal(u.priority, 0); // P0 最高
assert.equal(u.status, 'ready'); // 未动 complexity 不重置状态
assert.throws(() => s.patchTask(t.id, { title: ' ' }), StoreError);
assert.throws(() => s.patchTask(t.id, { priority: 5 }), StoreError); // 超出 0..2
assert.throws(() => s.patchTask(t.id, { priority: -1 }), StoreError);
s.close();
});
test('patchTaskcomplexity 修改 → status 重置为新初始态 + status.changed 事件', () => {
const s = freshStore();
const events: Array<{ type: string; payload: Record<string, unknown> }> = [];
s.subscribe((e) => events.push({ type: e.type, payload: e.payload }));
const p = s.createProject({ name: 'pc', repoPath: '/tmp/pc-' + Math.random() });
// easy(ready) → hard:重置为 analyzing
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
const u = s.patchTask(t.id, { complexity: 'hard' });
assert.equal(u.complexity, 'hard');
assert.equal(u.status, 'analyzing');
const sc = events.filter((e) => e.type === 'status.changed').at(-1);
assert.equal(sc?.payload.from, 'ready');
assert.equal(sc?.payload.to, 'analyzing');
// hard(analyzing) → medium:重置为 speccing
const u2 = s.patchTask(t.id, { complexity: 'medium' });
assert.equal(u2.status, 'speccing');
// 同值修改 = no-op,不重置
s.transition(t.id, 'spec_review');
const u3 = s.patchTask(t.id, { complexity: 'medium' });
assert.equal(u3.status, 'spec_review');
// spec_review 在允许列表内:可改
const u4 = s.patchTask(t.id, { complexity: 'easy' });
assert.equal(u4.status, 'ready');
s.close();
});
test('patchTask:执行链路状态下改 complexity 被拒(StoreError → API 400', () => {
const s = freshStore();
const p = s.createProject({ name: 'pr', repoPath: '/tmp/pr-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'y', complexity: 'easy' }); // ready
s.transition(t.id, 'queued');
assert.throws(
() => s.patchTask(t.id, { complexity: 'hard' }),
(e: unknown) => e instanceof StoreError && (e as Error).message.includes('不允许修改复杂度'),
);
// queued 下 title 仍可改
assert.equal(s.patchTask(t.id, { title: 'z' }).title, 'z');
// done(终态)同样拒绝
s.transition(t.id, 'executing');
s.transition(t.id, 'exec_review');
s.decide(t.id, 'accept', 'user');
assert.throws(() => s.patchTask(t.id, { complexity: 'medium' }), StoreError);
s.close();
});
+38
View File
@@ -126,3 +126,41 @@ test('事件订阅:状态变更广播', () => {
assert.ok(got.includes('status.changed'));
s.close();
});
test('依赖自动落位:建任务带未完成依赖 → blocked;依赖 done → 自动放行 ready', () => {
const s = freshStore();
const p = s.createProject({ name: 'ab', repoPath: '/tmp/ab-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] });
assert.equal(b.status, 'blocked'); // 建即落位 blocked,而非假 ready
// 手动把 blocked 拉成 ready 也会被按依赖弹回(仍 blocked)
assert.equal(s.transition(b.id, 'ready').status, 'blocked');
// A 走完整闭环到 done → B 自动放行
const evts = [];
s.subscribe((e) => evts.push(e));
s.transition(a.id, 'queued');
s.transition(a.id, 'executing');
s.transition(a.id, 'exec_review');
s.decide(a.id, 'accept', 'user');
assert.equal(s.getTask(b.id).status, 'ready'); // 自动 blocked→ready
const auto = evts.find((e) => e.taskId === b.id && e.payload.auto === 'deps-met');
assert.ok(auto, '应有 deps-met 自动放行事件');
s.close();
});
test('reconcileDeps:存量 ready 但依赖未满足 → 纠正为 blocked(幂等)', () => {
const s = freshStore();
const p = s.createProject({ name: 'rc', repoPath: '/tmp/rc-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] });
// 模拟旧库脏数据:B 被直接写成 ready
s.db.prepare(`UPDATE tasks SET status = 'ready' WHERE id = ?`).run(b.id);
const r1 = s.reconcileDeps(p.id);
assert.equal(r1.blocked, 1);
assert.equal(s.getTask(b.id).status, 'blocked');
const r2 = s.reconcileDeps(p.id); // 幂等
assert.equal(r2.blocked + r2.released, 0);
s.close();
});
+189
View File
@@ -0,0 +1,189 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { Store, StoreError, openDb } from '../src/store/index.js';
import { syncProject, todoJsonPath, hasTodoJson } from '../src/sync/todo-sync.js';
// ---------- fixtures ----------
/** 造一个带 todo/todo.json 的临时仓库目录,返回 repoPathcaller 负责 rmSync */
function makeRepo(items: unknown[]): string {
const repo = mkdtempSync(join(tmpdir(), 'maestro-sync-'));
mkdirSync(join(repo, 'todo'), { recursive: true });
writeFileSync(todoJsonPath(repo), JSON.stringify({ meta: { title: 't' }, seq: items.length, items }, null, 2));
return repo;
}
const FIXTURE_ITEMS = [
{
id: 1, title: '硬任务带子任务', tier: 1, level: 'high', status: 'doing',
desc: '描述文本', tags: ['后端'],
subtasks: [
{ sid: '1A', title: '子Atier1 也必须 easy', tier: 1, deps: [], status: 'open' },
{ sid: '1B', title: '子B', tier: 2, deps: ['1A'], status: 'done' },
],
gate: { kind: 'plan', note: '方案说明', ref: 'doc/x.md', approval: 'pending' },
},
{ id: 2, title: '中任务已验收', tier: 2, level: 'mid', status: 'accepted', version: 'v1.0.0' },
{ id: 3, title: '易任务待验收', tier: 3, level: 'low', status: 'done' },
{ id: 4, title: '缺 tier 默认 medium', status: 'open' },
];
// ---------- 同步引擎 ----------
test('sync:首次导入计数 / subs 一律 easy / 产出字段 / source_ref 映射', () => {
const repo = makeRepo(FIXTURE_ITEMS);
const s = new Store(':memory:');
const events: string[] = [];
s.subscribe((e) => events.push(e.type));
const p = s.createProject({ name: 'sync1', repoPath: repo });
const r = syncProject(s, p.id);
assert.equal(r.created, 6); // 4 items + 2 subs
assert.equal(r.skipped, 0);
// 旧侧已完成:#2(accepted) + #3(done) + 子1B(done) = 3
assert.equal(r.doneAdvanced, 3);
assert.ok(r.lastSyncAt);
// 缺 tier 警告(#4
assert.ok(r.warnings.some((w) => w.includes('缺少有效 tier')));
// project.synced 事件已广播
assert.ok(events.includes('project.synced'));
// last_sync_at 已落库
assert.equal(s.getProject(p.id)!.lastSyncAt, r.lastSyncAt);
// subs 一律 easy(即使旧 tier=1
const subA = s.getTaskBySourceRef(p.id, 'todo:1/1A');
const subB = s.getTaskBySourceRef(p.id, 'todo:1/1B');
assert.equal(subA?.complexity, 'easy');
assert.equal(subB?.complexity, 'easy');
assert.equal(subB?.status, 'done'); // 旧 done → 推到 done
assert.equal(subA?.status, 'ready'); // 旧 open → easy 初始态
assert.equal(subB?.deps[0], subA?.id); // sid 依赖 → 新任务 id
// 顶层复杂度映射 + 产出字段
const t1 = s.getTaskBySourceRef(p.id, 'todo:1');
const t2 = s.getTaskBySourceRef(p.id, 'todo:2');
const t3 = s.getTaskBySourceRef(p.id, 'todo:3');
const t4 = s.getTaskBySourceRef(p.id, 'todo:4');
assert.equal(t1?.complexity, 'hard');
assert.equal(t2?.complexity, 'medium');
assert.equal(t3?.complexity, 'easy');
assert.equal(t4?.complexity, 'medium'); // 缺省 medium
assert.ok(t1?.plan?.includes('描述文本')); // hard → plandesc 合成
assert.ok(t1?.plan?.includes('方案说明')); // gate note 合成
assert.equal(t2?.status, 'done');
assert.equal(t3?.status, 'done');
assert.equal(t1?.status, 'analyzing'); // doing → 初始态,不推进
s.close();
rmSync(repo, { recursive: true, force: true });
});
test('sync:幂等——第二次 created=0、全部 skipped、doneAdvanced=0', () => {
const repo = makeRepo(FIXTURE_ITEMS);
const s = new Store(':memory:');
const p = s.createProject({ name: 'sync2', repoPath: repo });
const first = syncProject(s, p.id);
assert.equal(first.created, 6);
const second = syncProject(s, p.id);
assert.equal(second.created, 0);
assert.equal(second.skipped, 6);
assert.equal(second.doneAdvanced, 0);
s.close();
rmSync(repo, { recursive: true, force: true });
});
test('sync:增量——新 item 只建新的;源里消失的记 warning 不删', () => {
const repo = makeRepo(FIXTURE_ITEMS);
const s = new Store(':memory:');
const p = s.createProject({ name: 'sync3', repoPath: repo });
syncProject(s, p.id);
// 源变化:删掉 #4,新增 #5 + 给 #1 加一个子任务
const items = [
{ ...FIXTURE_ITEMS[0], subtasks: [...(FIXTURE_ITEMS[0] as { subtasks: unknown[] }).subtasks, { sid: '1C', title: '新子C', tier: 1, status: 'open' }] },
FIXTURE_ITEMS[1], FIXTURE_ITEMS[2],
{ id: 5, title: '新增任务', tier: 3, status: 'open' },
];
writeFileSync(todoJsonPath(repo), JSON.stringify({ items }));
const r = syncProject(s, p.id);
assert.equal(r.created, 2); // 1C + #5
assert.ok(r.warnings.some((w) => w.includes('todo:4') && w.includes('保留')));
assert.ok(s.getTaskBySourceRef(p.id, 'todo:4')); // 没删
assert.equal(s.getTaskBySourceRef(p.id, 'todo:1/1C')?.complexity, 'easy');
s.close();
rmSync(repo, { recursive: true, force: true });
});
test('synctodo.json 不存在 → StoreErrorAPI 层 400),消息含路径', () => {
const repo = mkdtempSync(join(tmpdir(), 'maestro-norepo-'));
const s = new Store(':memory:');
const p = s.createProject({ name: 'sync4', repoPath: repo });
assert.equal(hasTodoJson(repo), false);
assert.throws(
() => syncProject(s, p.id),
(e: unknown) => e instanceof StoreError && (e as Error).message === `未找到 todo/todo.json${todoJsonPath(repo)}`,
);
s.close();
rmSync(repo, { recursive: true, force: true });
});
// ---------- 旧库轻量迁移 ----------
test('迁移:旧版库(无 source_ref/last_sync_at)打开后补列且数据完好', () => {
const dir = mkdtempSync(join(tmpdir(), 'maestro-mig-'));
const file = join(dir, 'old.sqlite');
// 用 v0.1 schema 手工造旧库(不含新列)
const old = new Database(file);
old.exec(`
CREATE TABLE projects (
id TEXT PRIMARY KEY, name TEXT NOT NULL, repo_path TEXT NOT NULL UNIQUE,
default_branch TEXT NOT NULL DEFAULT 'main', verify_cmd TEXT,
autonomy TEXT NOT NULL DEFAULT 'manual', model TEXT,
concurrency INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL
);
CREATE TABLE tasks (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, depth INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL, complexity TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'init',
priority INTEGER NOT NULL DEFAULT 0, deps TEXT NOT NULL DEFAULT '[]',
plan TEXT, spec TEXT, operations TEXT, result TEXT, assignee TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
INSERT INTO projects (id,name,repo_path,created_at) VALUES ('prj_old','旧项目','/tmp/old-repo','2026-01-01T00:00:00Z');
INSERT INTO tasks (id,project_id,title,complexity,status,created_at,updated_at)
VALUES ('tsk_old','prj_old','旧任务','easy','ready','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z');
`);
old.close();
// 新代码打开 → 自动补列
const db = openDb(file);
const projCols = (db.prepare(`PRAGMA table_info(projects)`).all() as Array<{ name: string }>).map((c) => c.name);
const taskCols = (db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>).map((c) => c.name);
assert.ok(projCols.includes('last_sync_at'));
assert.ok(taskCols.includes('source_ref'));
db.close();
// Store 能正常读旧数据,新字段为 null
const s = new Store(file);
const projects = s.listProjects();
assert.equal(projects.length, 1);
assert.equal(projects[0].name, '旧项目');
assert.equal(projects[0].lastSyncAt, null);
const t = s.getTask('tsk_old');
assert.equal(t?.title, '旧任务');
// 新方法在迁移后的旧库上可用
s.setSourceRef('tsk_old', 'todo:99');
assert.equal(s.getTaskBySourceRef('prj_old', 'todo:99')?.id, 'tsk_old');
s.close();
rmSync(dir, { recursive: true, force: true });
});
+487 -57
View File
@@ -24,13 +24,26 @@ const CPLX_LABEL = { hard: 'HARD', medium: 'MED', easy: 'EASY' };
const EVENT_LABEL = {
'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更',
'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回',
'run.started': '运行开始', 'run.finished': '运行结束',
'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步',
};
const EVENT_CLASS = {
'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status',
'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve',
'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run',
'project.synced': 'ev-created',
};
const AUTONOMY_LABEL = {
manual: '手动', 'auto-easy': '自动执行 Easy', 'auto-approved': '自动执行已批准',
};
// 任务树筛选:状态按状态机分组(组可整组开关,组内可单选)
const FILTER_GROUPS = [
['待办', ['init', 'ready', 'blocked']],
['进行中', ['analyzing', 'speccing', 'queued', 'executing', 'decomposed']],
['待审批', ['plan_review', 'spec_review', 'exec_review']],
['异常', ['failed', 'needs_attention']],
['挂起', ['paused', 'cancelled']],
['完成', ['done']],
];
// ── 全局状态 ──
const S = {
@@ -42,6 +55,13 @@ const S = {
collapsed: new Set(),
expanded: new Set(),
rejectOpen: new Set(),
filter: { cplx: new Set(), status: new Set(), kw: '' },// 任务树筛选(内存态)
matchCount: 0,
agents: null, // GET /api/agents 结果(404 时为 null
cplxMenuFor: null, // 复杂度下拉打开的任务 id
syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast)
previewId: null, // 全局预览中的任务 id
previewReject: false, // 预览层内驳回意见框是否展开
};
const $ = (sel) => document.querySelector(sel);
@@ -54,6 +74,83 @@ function fmtTime(iso) {
try { return new Date(iso).toLocaleTimeString('zh-CN', { hour12: false }); }
catch { return iso; }
}
function fmtRel(iso) {
if (!iso) return '';
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return String(iso);
const s = Math.floor(ms / 1000);
if (s < 60) return '刚刚';
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return `${Math.floor(h / 24)} 天前`;
}
// ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ──
function mdToHtml(src) {
const inline = (s) => s
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
const lines = esc(src).split('\n');
const out = [];
let i = 0;
let para = [];
let list = null; // {type:'ul'|'ol', items:[]}
const flushPara = () => { if (para.length) { out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; } };
const flushList = () => {
if (list) {
out.push(`<${list.type}>${list.items.map((x) => `<li>${inline(x)}</li>`).join('')}</${list.type}>`);
list = null;
}
};
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) { // 围栏代码块
flushPara(); flushList();
const buf = []; i++;
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
i++;
out.push(`<pre class="codeblock">${buf.join('\n')}</pre>`);
continue;
}
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s\-:|]+\|\s*$/.test(lines[i + 1])) {
flushPara(); flushList(); // 表格
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => inline(c.trim()));
const head = cells(line);
i += 2;
const rows = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
out.push(`<table><thead><tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${
rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
continue;
}
let m;
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { // 标题
flushPara(); flushList();
const lv = m[1].length;
out.push(`<div class="md-h md-h${lv}">${inline(m[2])}</div>`);
i++; continue;
}
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { flushPara(); flushList(); out.push('<hr>'); i++; continue; }
if ((m = line.match(/^\s*&gt;\s?(.*)$/))) { flushPara(); flushList(); out.push(`<blockquote>${inline(m[1])}</blockquote>`); i++; continue; }
if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
flushPara();
if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; }
list.items.push(m[1]); i++; continue;
}
if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) {
flushPara();
if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; }
list.items.push(m[1]); i++; continue;
}
if (/^\s*$/.test(line)) { flushPara(); flushList(); i++; continue; }
para.push(line); i++;
}
flushPara(); flushList();
return out.join('\n');
}
// ── API ──
async function api(path, opts = {}) {
@@ -103,6 +200,12 @@ async function loadProjectData() {
S.approvals = approvals;
}
// agents 端点未就绪(404)时静默降级为空态,不打扰用户
async function loadAgents() {
try { S.agents = await api('/api/agents'); }
catch { S.agents = null; }
}
async function refresh() {
try {
await loadProjectData();
@@ -113,7 +216,7 @@ async function refresh() {
async function fullRefresh() {
try {
await loadProjects();
await loadProjectData();
await Promise.all([loadProjectData(), loadAgents()]);
renderAll();
} catch (e) { toast(e.message); }
}
@@ -142,10 +245,13 @@ function renderAll() {
const snap = snapshotDrafts();
renderSidebar();
renderTopbar();
renderAgents();
renderGates();
renderTree();
renderFilterBar();
renderEvents();
renderParentOptions();
renderPreview();
restoreDrafts(snap);
}
@@ -169,11 +275,71 @@ function renderTopbar() {
$('#projMeta').textContent = p
? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}`
: '';
const gc = $('#gateCount');
if (S.approvals.length) {
gc.hidden = false;
gc.textContent = `${S.approvals.length} 项待审批`;
} else gc.hidden = true;
// 徽章组:待审批 / 可执行(叶子+ready+依赖全 done,与编排器领取口径一致)/ 执行中
$('#badgeGroup').hidden = !p;
const hasKids = new Set(S.tasks.filter((x) => x.parentId).map((x) => x.parentId));
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const nReady = S.tasks.filter((x) =>
x.status === 'ready' && !hasKids.has(x.id) &&
(x.deps || []).every((d) => byId.get(d)?.status === 'done'),
).length;
const nRun = S.tasks.filter((x) => x.status === 'executing').length;
const setBdg = (sel, n) => {
const el = $(sel);
el.querySelector('.bdg-n').textContent = n;
el.classList.toggle('zero', n === 0);
};
setBdg('#bdgGate', S.approvals.length);
setBdg('#bdgReady', nReady);
setBdg('#bdgRun', nRun);
// 同步 todo / 项目配置入口
$('#topActions').hidden = !p;
if (p) {
const sb = $('#btnSync');
const noTodo = p.hasTodoJson === false; // 字段未交付(undefined)时不禁用,错误由 toast 兜底
sb.disabled = noTodo;
sb.title = noTodo
? '未发现 todo.json —— 约定路径 <repo>/todo/todo.json'
: '从 <repo>/todo/todo.json 同步任务';
$('#syncMeta').textContent = p.lastSyncAt ? `${fmtRel(p.lastSyncAt)}同步` : '';
$('#cfgCurrent').textContent =
`当前:并发 ${p.concurrency ?? '—'} · 模式 ${AUTONOMY_LABEL[p.autonomy] || p.autonomy || '—'}`;
} else {
$('#configPanel').hidden = true;
}
}
// ── 渲染:Agent 执行面板 ──
function renderAgents() {
const body = $('#agentBody');
const a = S.agents;
const total = a && Number(a.totalActive) > 0 ? Number(a.totalActive) : 0;
$('#agentTotal').textContent = total ? `· ${total}` : '';
if (!total) {
body.innerHTML = `<div class="agent-empty">无 agent 在执行(自动执行将在 Phase 2 启用)</div>`;
return;
}
const groups = (a.agents || []).filter((g) => g.active && g.active.length);
body.innerHTML = `
<div class="agent-flex">
<div class="agent-big">${total}<span class="agent-big-sub">ACTIVE</span></div>
<div class="agent-groups">
${groups.map((g) => `
<div class="agent-proj">
<div class="agent-proj-head"><b>${esc(g.projectName)}</b>
<span class="agent-proj-meta">并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')}</span>
</div>
${g.active.map((r) => `
<div class="agent-run">
<span class="agent-run-dot"></span>
<span class="agent-run-title">${esc(r.taskTitle)}</span>
<span class="agent-run-kind">${esc(r.kind || '')}</span>
<span class="agent-run-time">${fmtRel(r.startedAt)}开始</span>
</div>`).join('')}
</div>`).join('')}
</div>
</div>`;
}
// ── 渲染:审批闸 ──
@@ -182,7 +348,7 @@ function gateDocs(t) {
const blocks = [];
const doc = (label, text) => blocks.push(
`<div class="gate-doc-label">${label}</div>` +
(text ? `<pre class="doc">${esc(text)}</pre>` : `<pre class="doc empty">(未填写)</pre>`));
(text ? `<div class="doc md">${mdToHtml(text)}</div>` : `<pre class="doc empty">(未填写)</pre>`));
if (gate === 'plan') doc('PLAN · 分析与拆解', t.plan);
if (gate === 'spec') doc('SPEC · 改动方案', t.spec);
if (gate === 'exec') {
@@ -218,6 +384,7 @@ function renderGates() {
<span class="gate-title">${esc(t.title)}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<button class="btn btn-ghost btn-xs gate-preview-btn" data-action="preview-open" data-id="${esc(t.id)}" title="全屏预览方案">⛶ 预览</button>
</div>
<div class="gate-body">${gateDocs(t)}</div>
<div class="gate-actions">
@@ -235,9 +402,58 @@ function renderGates() {
}).join('');
}
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
function renderPreview() {
const root = $('#previewRoot');
if (!S.previewId) { root.hidden = true; root.innerHTML = ''; return; }
const t = S.approvals.find((x) => x.id === S.previewId) || S.tasks.find((x) => x.id === S.previewId);
// 任务已离开闸(被裁决/状态变化)→ 自动关闭
if (!t || !GATE_OF[t.status]) { S.previewId = null; S.previewReject = false; root.hidden = true; root.innerHTML = ''; return; }
const gate = GATE_OF[t.status];
root.hidden = false;
root.innerHTML = `
<div class="preview-mask" data-action="preview-close"></div>
<div class="preview-panel">
<div class="preview-head">
<span class="gate-kind">${GATE_LABEL[gate]}</span>
<span class="preview-title">${esc(t.title)}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<span class="spacer"></span>
<button class="btn btn-ghost" data-action="preview-close" title="关闭(Esc">✕</button>
</div>
<div class="preview-body">${gateDocs(t)}</div>
<div class="preview-foot">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">✓ 接受</button>
<button class="btn btn-reject" data-action="preview-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
<span class="proj-meta">id ${esc(t.id)} · 更新 ${fmtTime(t.updatedAt)}</span>
${S.previewReject ? `
<div class="reject-form preview-reject">
<textarea id="prev-rej-${esc(t.id)}" placeholder="驳回意见(必填)——说明问题与改进方向"></textarea>
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(t.id)}" data-ta="prev-rej-${esc(t.id)}">确认驳回</button>
<button class="btn" data-action="preview-reject-toggle" data-id="${esc(t.id)}">收起</button>
</div>` : ''}
</div>
</div>`;
if (S.previewReject) {
const ta = document.getElementById(`prev-rej-${t.id}`);
if (ta) ta.focus();
}
}
// ── 渲染:任务树 ──
function cplxBadge(c) {
return `<span class="cplx cplx-${esc(c)}">${CPLX_LABEL[c] || esc(c)}</span>`;
// taskId 传入时徽章可点击 → 弹出复杂度选择(审批闸卡片不传,保持只读)
function cplxBadge(c, taskId) {
const text = CPLX_LABEL[c] || esc(c);
if (!taskId) return `<span class="cplx cplx-${esc(c)}">${text}</span>`;
const open = S.cplxMenuFor === taskId;
const pop = open ? `<span class="cplx-pop">${['hard', 'medium', 'easy'].map((x) =>
`<button type="button" class="cplx cplx-${x} ${x === c ? 'cur' : ''}" data-action="cplx-set" data-id="${esc(taskId)}" data-cplx="${x}">${CPLX_LABEL[x]}</button>`,
).join('')}</span>` : '';
return `<span class="cplx-wrap">
<button type="button" class="cplx cplx-${esc(c)} cplx-btn" data-action="cplx-menu" data-id="${esc(taskId)}" title="点击修改复杂度(状态会按新复杂度重置)">${text}</button>
${pop}
</span>`;
}
function statusChip(st) {
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${STATUS_LABEL[st] || esc(st)}</span>`;
@@ -253,6 +469,60 @@ function childrenMap() {
return m;
}
// ── 筛选 ──
function filterActive() {
const f = S.filter;
return f.cplx.size > 0 || f.status.size > 0 || f.kw.trim() !== '';
}
function matchTask(t) {
const f = S.filter;
if (f.cplx.size && !f.cplx.has(t.complexity)) return false;
if (f.status.size && !f.status.has(t.status)) return false;
const kw = f.kw.trim().toLowerCase();
if (kw && !String(t.title).toLowerCase().includes(kw)) return false;
return true;
}
// 命中节点 + 其全部祖先(保留父链以维持树形)
function computeVisible() {
S.matchCount = 0;
if (!filterActive()) return null;
const byId = new Map(S.tasks.map((t) => [t.id, t]));
const visible = new Set();
const matched = new Set();
for (const t of S.tasks) {
if (!matchTask(t)) continue;
matched.add(t.id);
let cur = t;
while (cur && !visible.has(cur.id)) {
visible.add(cur.id);
cur = cur.parentId ? byId.get(cur.parentId) : null;
}
}
S.matchCount = matched.size;
return { visible, matched };
}
function renderFilterBar() {
const bar = $('#filterBar');
bar.hidden = !S.currentProjectId;
if (bar.hidden) return;
const f = S.filter;
$('#filterCplx').innerHTML = ['hard', 'medium', 'easy'].map((c) =>
`<button type="button" class="fchip fc-${c} ${f.cplx.has(c) ? 'on' : ''}" data-action="filter-cplx" data-cplx="${c}">${CPLX_LABEL[c]}</button>`,
).join('');
$('#filterStatus').innerHTML = FILTER_GROUPS.map(([name, sts], gi) => {
const sel = sts.filter((s) => f.status.has(s)).length;
const gCls = sel === sts.length ? 'on' : (sel ? 'part' : '');
return `<span class="fgroup">
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}">${name}</button>
${sts.map((s) => `<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${STATUS_LABEL[s]}</button>`).join('')}
</span>`;
}).join('');
const active = filterActive();
$('#filterClear').hidden = !active;
$('#filterCount').textContent = active ? `命中 ${S.matchCount} / ${S.tasks.length}` : '';
}
function renderTree() {
const root = $('#taskTree');
if (!S.currentProjectId) {
@@ -264,22 +534,37 @@ function renderTree() {
return;
}
const m = childrenMap();
const vis = computeVisible();
if (vis && !vis.visible.size) {
root.innerHTML = `<div class="tree-empty"><b>NO MATCH</b>没有任务命中当前筛选 —— 放宽条件或「清除筛选」</div>`;
return;
}
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const renderNode = (t) => {
if (vis && !vis.visible.has(t.id)) return '';
const kids = m.get(t.id) || [];
const collapsed = S.collapsed.has(t.id);
const collapsed = !vis && S.collapsed.has(t.id); // 筛选时强制展开,保证命中可见
const expanded = S.expanded.has(t.id);
const isGate = !!GATE_OF[t.status];
const isCtx = vis && !vis.matched.has(t.id); // 仅作为父链保留的节点,弱化显示
const caret = kids.length
? `<span class="caret ${collapsed ? '' : 'open'}" data-action="toggle-collapse" data-id="${esc(t.id)}">▶</span>`
: `<span class="caret leaf">·</span>`;
return `
<div class="task-node depth-${t.depth}">
<div class="task-row ${isGate ? 'is-gate' : ''} ${expanded ? 'expanded' : ''}" data-action="toggle-detail" data-id="${esc(t.id)}">
<div class="task-row ${isGate ? 'is-gate' : ''} ${expanded ? 'expanded' : ''} ${isCtx ? 'filter-ctx' : ''}" data-action="toggle-detail" data-id="${esc(t.id)}">
${caret}
<span class="t-title">${esc(t.title)}<span class="t-id">${esc(t.id.slice(-6))}</span></span>
<span class="spacer"></span>
<span class="t-prio ${t.priority > 0 ? 'hot' : ''}">P${t.priority}</span>
${cplxBadge(t.complexity)}
${(() => {
// blocked(系统按依赖自动落位)→ 显示在等哪几条
if (t.status !== 'blocked' || !(t.deps || []).length) return '';
const unmet = t.deps.filter((d) => byId.get(d)?.status !== 'done');
if (!unmet.length) return '';
return `<span class="t-deps-wait" title="等待依赖完成:${unmet.map((d) => esc(byId.get(d)?.title || d)).join('、')}">⛓ 等依赖 ${unmet.length}</span>`;
})()}
<span class="t-prio ${t.priority === 0 ? 'hot' : ''}">P${t.priority}</span>
${cplxBadge(t.complexity, t.id)}
${statusChip(t.status)}
</div>
${expanded ? renderDetail(t) : ''}
@@ -299,25 +584,18 @@ function writableField(t) {
function renderDetail(t) {
const parts = [];
const w = writableField(t);
// 已有产出
// 产出全部只读展示:写产出与提交评审由 Claude Code 经 MCP 完成,看板只留用户动作(审批/配置/同步)
const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']];
for (const [f, label] of docs) {
if (t[f]) parts.push(`<div><div class="detail-label">${label}</div><pre class="doc">${esc(t[f])}</pre></div>`);
if (!t[f]) continue;
parts.push(`<div><div class="detail-label">${label}</div><div class="doc md">${mdToHtml(t[f])}</div></div>`);
}
// 写产出入口
const w = writableField(t);
if (w) {
parts.push(`
<div class="write-box">
<div class="detail-label">填写 ${w.label}</div>
<textarea id="out-${esc(t.id)}" placeholder="多行文本…">${esc(t[w.field] || '')}</textarea>
<div class="form-actions form-row">
<button class="btn" data-action="save-output" data-id="${esc(t.id)}" data-field="${w.field}">保存${w.field}</button>
${w.next ? `<button class="btn btn-solid" data-action="submit-review" data-id="${esc(t.id)}" data-to="${w.next}">提交评审 →</button>` : ''}
</div>
</div>`);
// 当前阶段应产出但还没有内容 → 占位提示
if (w && !t[w.field]) {
parts.push(`<div><div class="detail-label">${w.label}</div><div class="doc doc-pending">待 Claude Code 产出(经 MCP 写入并提交评审)</div></div>`);
}
// result
@@ -349,9 +627,25 @@ function renderDetail(t) {
</div>`);
}
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取)
if (t.deps && t.deps.length) {
const items = t.deps.map((d) => {
const dt = S.tasks.find((x) => x.id === d);
const ok = dt && dt.status === 'done';
return `<div class="dep-item ${ok ? 'ok' : 'wait'}" data-action="goto-task" data-id="${esc(d)}" title="点击定位到该任务">
<span class="dep-mark">${ok ? '✓' : '⛓'}</span>
${dt ? statusChip(dt.status) : ''}
<span class="dep-title">${esc(dt ? dt.title : d)}</span>
<span class="t-id">${esc(d.slice(-6))}</span>
<span class="dep-go">↧</span>
</div>`;
}).join('');
parts.push(`<div><div class="detail-label">DEPS · 依赖(全部完成才可执行)</div><div class="dep-list">${items}</div></div>`);
}
if (!parts.length) parts.push(`<div class="proj-meta">暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}</div>`);
parts.push(`<div class="proj-meta">id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}${t.deps && t.deps.length ? ' · 依赖 ' + t.deps.map(esc).join(', ') : ''}</div>`);
parts.push(`<div class="proj-meta">id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}</div>`);
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
}
@@ -373,6 +667,9 @@ function eventDetail(e) {
return `${p.title || name}${CPLX_LABEL[p.complexity] || ''}`;
}
if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`;
if (e.type === 'project.synced') {
return `新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`;
}
if (e.type === 'run.started' || e.type === 'run.finished') {
return `${name} · ${p.kind || ''} ${p.status || ''}`;
}
@@ -415,6 +712,12 @@ async function act(fn, okMsg) {
document.addEventListener('click', (ev) => {
const el = ev.target.closest('[data-action]');
// 复杂度下拉:点击弹层外任意处关闭
if (S.cplxMenuFor && !ev.target.closest('.cplx-wrap')) {
S.cplxMenuFor = null;
renderTree();
}
if (!el) return;
const action = el.dataset.action;
const id = el.dataset.id;
@@ -424,6 +727,8 @@ document.addEventListener('click', (ev) => {
if (S.currentProjectId !== id) {
S.currentProjectId = id;
S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear();
S.cplxMenuFor = null;
$('#configPanel').hidden = true;
refresh();
}
break;
@@ -472,8 +777,25 @@ document.addEventListener('click', (ev) => {
}
break;
case 'preview-open':
S.previewId = id;
S.previewReject = false;
renderPreview();
break;
case 'preview-close':
S.previewId = null;
S.previewReject = false;
renderPreview();
break;
case 'preview-reject-toggle':
S.previewReject = !S.previewReject;
renderPreview();
break;
case 'gate-reject-confirm': {
const ta = document.getElementById(`rej-${id}`);
const ta = document.getElementById(el.dataset.ta || `rej-${id}`);
const reason = ta ? ta.value.trim() : '';
if (!reason) { toast('驳回意见不能为空'); if (ta) ta.focus(); return; }
S.rejectOpen.delete(id);
@@ -483,36 +805,118 @@ document.addEventListener('click', (ev) => {
break;
}
case 'save-output': {
const field = el.dataset.field;
const ta = document.getElementById(`out-${id}`);
const value = ta ? ta.value.trim() : '';
if (!value) { toast(`${field} 内容不能为空`); return; }
act(() => api(`/api/tasks/${id}/${field}`, {
method: 'POST', body: JSON.stringify({ [field]: value }),
}), `${field} 已保存`);
case 'goto-task': {
const target = S.tasks.find((x) => x.id === id);
if (!target) { toast('任务不在当前项目'); break; }
// 被筛选隐藏时清除筛选,保证可见
const vis = computeVisible();
if (vis && !vis.visible.has(id)) {
S.filter.cplx.clear(); S.filter.status.clear(); S.filter.kw = '';
renderFilterBar();
}
// 展开全部祖先 + 展开目标详情
let cur = target;
while (cur && cur.parentId) {
S.collapsed.delete(cur.parentId);
cur = S.tasks.find((x) => x.id === cur.parentId);
}
S.expanded.add(id);
renderTree();
const row = document.querySelector(`.task-row[data-id="${CSS.escape(id)}"]`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('flash');
setTimeout(() => row.classList.remove('flash'), 1800);
}
break;
}
case 'submit-review': {
const to = el.dataset.to;
const ta = document.getElementById(`out-${id}`);
const task = S.tasks.find((t) => t.id === id);
const field = task && writableField(task) ? writableField(task).field : null;
const value = ta ? ta.value.trim() : '';
act(async () => {
// 先保存当前草稿(有内容才保存),再流转进评审闸
if (field && value) {
await api(`/api/tasks/${id}/${field}`, {
method: 'POST', body: JSON.stringify({ [field]: value }),
});
}
await api(`/api/tasks/${id}/transition`, {
method: 'POST', body: JSON.stringify({ to }),
});
}, '已提交评审');
case 'cplx-menu':
S.cplxMenuFor = S.cplxMenuFor === id ? null : id;
renderTree();
break;
case 'cplx-set': {
const c = el.dataset.cplx;
S.cplxMenuFor = null;
act(() => api(`/api/tasks/${id}`, {
method: 'PATCH', body: JSON.stringify({ complexity: c }),
}), `复杂度已改为 ${CPLX_LABEL[c]}(状态按新复杂度重置)`);
break;
}
// ── 任务树筛选 ──
case 'filter-cplx': {
const c = el.dataset.cplx;
S.filter.cplx.has(c) ? S.filter.cplx.delete(c) : S.filter.cplx.add(c);
renderTree(); renderFilterBar();
break;
}
case 'filter-status': {
const st = el.dataset.st;
S.filter.status.has(st) ? S.filter.status.delete(st) : S.filter.status.add(st);
renderTree(); renderFilterBar();
break;
}
case 'filter-group': {
const g = FILTER_GROUPS[Number(el.dataset.gi)];
if (!g) break;
const sts = g[1];
const all = sts.every((s) => S.filter.status.has(s));
sts.forEach((s) => { all ? S.filter.status.delete(s) : S.filter.status.add(s); });
renderTree(); renderFilterBar();
break;
}
case 'filter-clear':
S.filter.cplx.clear();
S.filter.status.clear();
S.filter.kw = '';
$('#filterKw').value = '';
renderTree(); renderFilterBar();
break;
// ── 同步 todo ──
case 'sync-todo': {
if (!S.currentProjectId) break;
el.disabled = true;
S.syncReqAt = Date.now();
api(`/api/projects/${S.currentProjectId}/sync`, { method: 'POST', body: JSON.stringify({}) })
.then((r) => {
toast(`同步完成:新建 ${r.created ?? 0} · 推进 done ${r.doneAdvanced ?? 0} · 跳过 ${r.skipped ?? 0}`, 'ok');
(r.warnings || []).forEach((wmsg) => toast(String(wmsg), 'warn'));
return fullRefresh();
})
.catch((e) => { toast(e.message); el.disabled = false; });
break;
}
// ── Agent 配置 ──
case 'toggle-config': {
const panel = $('#configPanel');
panel.hidden = !panel.hidden;
if (!panel.hidden) {
const p = S.projects.find((x) => x.id === S.currentProjectId);
if (p) {
$('#cfgConcurrency').value = p.concurrency ?? 1;
$('#cfgAutonomy').value = AUTONOMY_LABEL[p.autonomy] ? p.autonomy : 'manual';
}
}
break;
}
case 'save-config': {
if (!S.currentProjectId) break;
const cc = Number($('#cfgConcurrency').value);
if (!Number.isInteger(cc) || cc < 1) { toast('最大并发必须是 ≥1 的整数'); break; }
const autonomy = $('#cfgAutonomy').value;
act(async () => {
await api(`/api/projects/${S.currentProjectId}`, {
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy }),
});
await loadProjects(); // 刷新当前值显示
}, '配置已保存');
break;
}
}
});
@@ -551,7 +955,7 @@ $('#newTaskPanel').addEventListener('submit', (ev) => {
const body = {
title,
complexity: f.complexity.value,
priority: Number(f.priority.value || 0),
priority: Number(f.priority.value ?? 1),
};
if (f.parentId.value) body.parentId = f.parentId.value;
act(async () => {
@@ -562,9 +966,20 @@ $('#newTaskPanel').addEventListener('submit', (ev) => {
}, '任务已创建');
});
// 关键字筛选(轻防抖)
let kwTimer = null;
$('#filterKw').addEventListener('input', (ev) => {
S.filter.kw = ev.target.value;
clearTimeout(kwTimer);
kwTimer = setTimeout(() => { renderTree(); renderFilterBar(); }, 150);
});
// Esc 关模态
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') $('#modalRoot').hidden = true;
if (ev.key === 'Escape') {
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
$('#modalRoot').hidden = true;
}
});
// ── WebSocket 实时刷新(指数退避重连) ──
@@ -594,6 +1009,18 @@ function connectWs() {
ws.onmessage = (msg) => {
let evt;
try { evt = JSON.parse(msg.data); } catch { return; }
if (evt.type === 'project.synced') {
// 本端刚发起的 sync 由 POST 响应负责 toast,避免重复
if (Date.now() - S.syncReqAt > 3000) {
const p = evt.payload || {};
toast(`todo 同步:新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`, 'ok');
}
fullRefresh();
return;
}
if (evt.type === 'run.started' || evt.type === 'run.finished') {
loadAgents().then(renderAgents).catch(() => {});
}
if (evt.projectId === S.currentProjectId) {
scheduleRefresh();
} else if (evt.type === 'task.created' && evt.payload && evt.payload.kind === 'project') {
@@ -611,5 +1038,8 @@ function connectWs() {
ws.onerror = () => { try { ws.close(); } catch { /* noop */ } };
}
// 相对时间(agent 开始时间 / 上次同步)定期重绘
setInterval(() => { renderAgents(); renderTopbar(); }, 30000);
// ── 启动 ──
fullRefresh().then(connectWs);
+56 -2
View File
@@ -39,9 +39,47 @@
<div id="projMeta" class="proj-meta"></div>
</div>
<div class="spacer"></div>
<div id="gateCount" class="gate-count" hidden></div>
<div id="badgeGroup" class="badge-group" hidden>
<span id="bdgGate" class="bdg bdg-gate" title="等待你裁决的审批闸">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">项待审批</span>
</span>
<span id="bdgReady" class="bdg bdg-ready" title="叶子任务 · 可执行 · 依赖全部完成">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">可执行</span>
</span>
<span id="bdgRun" class="bdg bdg-run" title="agent 正在执行">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">执行中</span>
</span>
</div>
<div id="topActions" class="top-actions" hidden>
<span id="syncMeta" class="sync-meta"></span>
<button id="btnSync" class="btn btn-xs" data-action="sync-todo">⟳ 同步 todo</button>
<button class="btn btn-xs" data-action="toggle-config" title="项目配置(并发 / 工作模式)">⚙ 配置</button>
</div>
</header>
<section id="configPanel" class="panel config-panel" hidden>
<div class="form-row">
<label class="narrow">最大并发
<input id="cfgConcurrency" type="number" min="1" step="1" value="1">
</label>
<label>工作模式
<select id="cfgAutonomy">
<option value="manual">manual · 手动</option>
<option value="auto-easy">auto-easy · 自动执行 Easy</option>
<option value="auto-approved">auto-approved · 自动执行已批准</option>
</select>
</label>
<span id="cfgCurrent" class="cfg-current"></span>
<button class="btn btn-solid" data-action="save-config">保存配置</button>
<button class="btn" data-action="toggle-config">收起</button>
</div>
</section>
<section id="agentSection" class="agent-section">
<div class="sec-head"><span class="head-mark cyan"></span>Agent 执行<span id="agentTotal" class="agent-total"></span></div>
<div id="agentBody"></div>
</section>
<section id="gateSection" class="gate-section" hidden>
<div class="gate-stripe"></div>
<div class="sec-head gate-head"><span class="head-mark amber"></span>审批闸 · 等待裁决</div>
@@ -72,7 +110,11 @@
<select name="parentId"><option value="">(顶层)</option></select>
</label>
<label class="narrow">优先级
<input name="priority" type="number" value="0" step="1">
<select name="priority">
<option value="0">P0 · 高</option>
<option value="1" selected>P1 · 中</option>
<option value="2">P2 · 低</option>
</select>
</label>
</div>
<div class="form-row form-actions">
@@ -81,6 +123,17 @@
</div>
</form>
<div id="filterBar" class="filter-bar" hidden>
<div class="filter-row">
<input id="filterKw" type="text" placeholder="⌕ 搜索标题…" autocomplete="off">
<span id="filterCplx" class="fchips"></span>
<span class="spacer"></span>
<span id="filterCount" class="filter-count"></span>
<button class="btn btn-ghost btn-xs" id="filterClear" data-action="filter-clear" hidden>✕ 清除筛选</button>
</div>
<div id="filterStatus" class="filter-row"></div>
</div>
<div id="taskTree"></div>
</section>
</main>
@@ -116,6 +169,7 @@
</form>
</div>
<div id="previewRoot" class="preview-root" hidden></div>
<div id="toastRoot" class="toast-root"></div>
<script src="app.js"></script>
</body>
+252
View File
@@ -113,6 +113,7 @@ body::before {
#eventPanel .sec-head { padding: 18px 16px 10px; background: linear-gradient(var(--bg-deep) 75%, transparent); }
.head-mark { color: var(--green); }
.head-mark.amber { color: var(--amber); }
.head-mark.cyan { color: var(--cyan); }
.side-head .btn, .sec-head .btn { margin-left: auto; letter-spacing: .1em; }
/* ── 项目列表 ─────────────────────────────────────────────── */
@@ -158,6 +159,66 @@ body::before {
animation: pulse 2.2s infinite;
}
/* 顶栏动作(同步 todo / 配置) */
.top-actions { display: flex; align-items: center; gap: 8px; }
.sync-meta { font-size: 10.5px; color: var(--faint); letter-spacing: .06em; }
/* ── 项目配置面板 ─────────────────────────────────────────── */
.config-panel { padding: 12px 14px; margin-top: 12px; animation: rise .18s ease both; }
.config-panel .form-row { align-items: flex-end; }
.cfg-current {
font-size: 11px; color: var(--muted); letter-spacing: .06em;
margin-left: auto; padding-bottom: 7px;
}
/* ── Agent 执行面板 ──────────────────────────────────────── */
.agent-total { color: var(--cyan); letter-spacing: .08em; }
.agent-empty {
padding: 12px 14px; color: var(--faint); font-size: 12px;
border: 1px dashed var(--line);
}
.agent-flex {
display: flex; gap: 20px; align-items: stretch;
background: var(--panel); border: 1px solid var(--cyan-dim);
padding: 12px 16px;
animation: rise .2s ease both;
}
.agent-big {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-width: 64px; padding: 4px 8px;
font-size: 40px; font-weight: 700; line-height: 1;
color: var(--cyan); text-shadow: 0 0 18px rgba(89,200,216,.5);
border-right: 1px solid var(--line-soft);
}
.agent-big-sub {
font-size: 9px; font-weight: 600; letter-spacing: .3em;
color: var(--muted); margin-top: 6px;
}
.agent-groups { flex: 1; display: grid; gap: 10px; min-width: 0; }
.agent-proj-head {
display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap;
font-size: 12px; margin-bottom: 3px;
}
.agent-proj-head b { color: var(--ink); letter-spacing: .04em; }
.agent-proj-meta { font-size: 10.5px; color: var(--faint); letter-spacing: .06em; }
.agent-run {
display: flex; gap: 8px; align-items: center;
font-size: 12px; padding: 3px 0;
border-bottom: 1px dashed var(--line-soft);
}
.agent-run:last-child { border-bottom: none; }
.agent-run-dot {
flex: none; width: 6px; height: 6px; border-radius: 50%;
background: var(--cyan); box-shadow: 0 0 8px var(--cyan);
animation: pulse .9s infinite;
}
.agent-run-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-run-kind {
flex: none; font-size: 10px; letter-spacing: .1em;
color: var(--cyan); border: 1px solid var(--cyan-dim); padding: 0 6px;
}
.agent-run-time { flex: none; margin-left: auto; font-size: 10.5px; color: var(--faint); }
/* ── 审批闸 ─────────────────────────────────────────────── */
.gate-section { margin-top: 18px; }
.gate-stripe {
@@ -279,6 +340,36 @@ textarea { resize: vertical; min-height: 72px; width: 100%; }
.seg input:checked + label.seg-m { background: var(--amber-dim); color: var(--amber); }
.seg input:checked + label.seg-e { background: var(--green-dim); color: var(--green); }
/* ── 任务树筛选栏 ─────────────────────────────────────────── */
.filter-bar {
background: var(--panel); border: 1px solid var(--line);
padding: 8px 10px; margin-bottom: 12px;
display: flex; flex-direction: column; gap: 7px;
}
.filter-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
#filterKw { width: 190px; padding: 4px 9px; font-size: 12px; }
.fchips { display: inline-flex; gap: 6px; }
.fchip {
font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em;
background: transparent; color: var(--faint);
border: 1px solid var(--line); padding: 2px 8px;
cursor: pointer; transition: all .1s;
}
.fchip:hover { color: var(--ink); border-color: var(--muted); }
.fchip.on { color: var(--green); border-color: var(--green-dim); background: rgba(95,221,125,.08); }
.fc-hard.on { color: var(--red); border-color: var(--red-dim); background: rgba(255,93,93,.08); }
.fc-medium.on { color: var(--amber); border-color: var(--amber-dim); background: rgba(240,180,41,.08); }
.fgroup {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 4px; border: 1px dashed var(--line-soft);
}
.fgroup-chip { color: var(--muted); }
.fgroup-chip.part { color: var(--amber); border-color: var(--amber-dim); }
.fst { font-weight: 400; letter-spacing: .04em; font-size: 10.5px; }
.filter-count { font-size: 11px; color: var(--amber); letter-spacing: .08em; }
.task-row.filter-ctx { opacity: .68; }
.task-row.filter-ctx .t-title { color: var(--muted); }
/* ── 任务树 ─────────────────────────────────────────────── */
.tree-empty {
padding: 46px 0; text-align: center; color: var(--faint);
@@ -324,6 +415,22 @@ textarea { resize: vertical; min-height: 72px; width: 100%; }
.cplx-medium { color: var(--amber); border-color: var(--amber-dim); background: rgba(240,180,41,.07); }
.cplx-easy { color: var(--green); border-color: var(--green-dim); background: rgba(95,221,125,.07); }
/* 复杂度徽章可点 + 下拉 */
.cplx-wrap { position: relative; display: inline-flex; flex: none; }
button.cplx { font-family: var(--mono); cursor: default; }
button.cplx-btn { cursor: pointer; transition: box-shadow .12s; }
button.cplx-btn:hover { box-shadow: 0 0 8px rgba(216,228,212,.18); }
.cplx-pop {
position: absolute; top: calc(100% + 5px); right: 0; z-index: 60;
display: flex; gap: 5px;
background: var(--bg-deep); border: 1px solid var(--line);
padding: 6px; box-shadow: 0 10px 30px rgba(0,0,0,.65);
animation: rise .12s ease both;
}
.cplx-pop .cplx { cursor: pointer; }
.cplx-pop .cplx:hover { filter: brightness(1.35); }
.cplx-pop .cplx.cur { outline: 1px solid currentColor; outline-offset: 1px; }
/* 状态 chip */
.chip {
flex: none; display: inline-flex; align-items: center; gap: 5px;
@@ -360,6 +467,7 @@ textarea { resize: vertical; min-height: 72px; width: 100%; }
}
.detail-grid { display: grid; gap: 14px; }
.detail-label { font-size: 10.5px; color: var(--muted); letter-spacing: .18em; margin-bottom: 4px; }
.doc-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.approval-item {
display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap;
@@ -439,5 +547,149 @@ li.ev-updated { --ev: var(--muted); }
}
.toast.err { border-color: var(--red); color: var(--red); }
.toast.ok { border-color: var(--green-dim); color: var(--green); }
.toast.warn { border-color: var(--amber-dim); color: var(--amber); }
.toast.out { opacity: 0; transition: opacity .3s; }
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } }
/* 待 CC 产出占位 */
.doc-pending { padding: 10px 12px; border: 1px dashed var(--line, #2a3a2a); color: var(--dim, #6a7a6a); font-style: italic; }
/* ── 全局预览模式(全屏读方案 + 就地裁决) ── */
.preview-root { position: fixed; inset: 0; z-index: 1100; display: grid; place-items: center; }
.preview-root[hidden] { display: none; }
.preview-mask { position: absolute; inset: 0; background: rgba(4,6,5,.86); backdrop-filter: blur(3px); }
.preview-panel {
position: relative; display: flex; flex-direction: column;
width: min(1080px, 94vw); height: 94vh;
background: var(--panel); border: 1px solid var(--amber-dim);
box-shadow: 0 0 0 1px var(--line-soft), 0 24px 64px rgba(0,0,0,.6);
}
.preview-head {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 12px 16px; border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.preview-title { font-weight: 700; font-size: 15px; color: var(--ink); }
.preview-body {
flex: 1; overflow-y: auto; padding: 16px 20px 28px;
}
.preview-body .doc {
font-size: 13.5px; line-height: 1.7;
white-space: pre-wrap; word-break: break-word;
max-height: none; overflow-y: visible; /* 解除闸卡片 pre.doc 的 280px 限高,由 .preview-body 统一滚动 */
}
.preview-body .gate-doc-label { margin-top: 14px; }
.preview-foot {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 12px 16px; border-top: 1px solid var(--line);
background: var(--panel-2);
}
.preview-reject { flex-basis: 100%; }
.gate-preview-btn { margin-left: auto; }
/* ── Markdown 渲染产出(.doc.md:闸卡片 / 预览 / 任务详情统一) ── */
.doc.md {
background: var(--bg-deep);
border: 1px solid var(--line-soft);
border-left: 2px solid var(--green-dim);
padding: 12px 16px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.7;
color: var(--ink);
white-space: normal; word-break: break-word;
max-height: 280px; overflow-y: auto;
}
.gate-body .doc.md { max-height: 280px; }
.preview-body .doc.md { max-height: none; overflow-y: visible; font-size: 13.5px; }
.task-detail .doc.md { max-height: 360px; }
.doc.md .md-h { font-weight: 700; margin: 14px 0 6px; color: var(--green); }
.doc.md .md-h:first-child { margin-top: 0; }
.doc.md .md-h1 { font-size: 1.25em; border-bottom: 1px solid var(--line); padding-bottom: 4px; }
.doc.md .md-h2 { font-size: 1.15em; color: var(--amber); }
.doc.md .md-h3 { font-size: 1.05em; }
.doc.md .md-h4, .doc.md .md-h5, .doc.md .md-h6 { font-size: 1em; color: var(--muted); }
.doc.md p { margin: 6px 0; }
.doc.md ul, .doc.md ol { margin: 6px 0; padding-left: 22px; }
.doc.md li { margin: 3px 0; }
.doc.md code {
background: var(--panel-2); border: 1px solid var(--line-soft);
padding: 0 4px; color: var(--cyan); font-size: .95em;
}
.doc.md pre.codeblock {
background: var(--panel-2); border: 1px solid var(--line-soft);
border-left: 2px solid var(--cyan-dim);
padding: 10px 12px; margin: 8px 0;
white-space: pre-wrap; word-break: break-word; overflow-x: auto;
}
.doc.md pre.codeblock code { background: none; border: none; padding: 0; }
.doc.md blockquote {
margin: 8px 0; padding: 6px 12px;
border-left: 2px solid var(--amber-dim); color: var(--muted);
background: var(--panel-2);
}
.doc.md hr { border: none; border-top: 1px dashed var(--line); margin: 12px 0; }
.doc.md table { border-collapse: collapse; margin: 8px 0; width: 100%; }
.doc.md th, .doc.md td { border: 1px solid var(--line); padding: 5px 10px; text-align: left; }
.doc.md th { background: var(--panel-2); color: var(--green); }
.doc.md a { color: var(--cyan); }
/* 顶栏:可执行数量徽章(与 gate-count 同形制,绿色系) */
.ready-count {
font-size: 11px; letter-spacing: .12em; color: var(--green);
border: 1px solid var(--green-dim); padding: 4px 10px;
}
/* ── 顶栏徽章组:默认 图标+数量,hover 展开文字 ── */
.badge-group { display: flex; align-items: center; gap: 8px; }
.bdg {
display: inline-flex; align-items: center;
font-size: 11.5px; line-height: 1; letter-spacing: .08em;
height: 28px; padding: 0 10px; /* 固定高度对齐旁边的 .btn,防字形差异撑高 */
cursor: default; white-space: nowrap;
}
.bdg .bdg-ico {
margin-right: 5px; font-size: 12px; line-height: 1;
font-family: var(--mono); /* 锁等宽字体,避免符号掉进 emoji 字体放大 */
}
.bdg .bdg-n { font-weight: 700; }
.bdg .bdg-label {
max-width: 0; opacity: 0; overflow: hidden;
transition: max-width .28s ease, opacity .22s ease, margin-left .28s ease;
}
.bdg:hover .bdg-label { max-width: 8em; opacity: 1; margin-left: 5px; }
.bdg-gate { color: var(--amber); border: 1px solid var(--amber-dim); }
.bdg-gate:not(.zero) { animation: pulse 2.2s infinite; }
.bdg-ready { color: var(--green); border: 1px solid var(--green-dim); }
.bdg-run { color: var(--cyan); border: 1px solid var(--cyan-dim); }
.bdg-run:not(.zero) .bdg-ico { animation: pulse .9s infinite; }
.bdg.zero { color: var(--faint); border-color: var(--line-soft); animation: none; }
/* ── 依赖可视化 ── */
.t-deps-wait {
flex: none; font-size: 10.5px; letter-spacing: .06em;
color: var(--amber); border: 1px dashed var(--amber-dim);
padding: 1px 7px; line-height: 1.5; white-space: nowrap;
}
.dep-list { display: grid; gap: 4px; }
.dep-item {
display: flex; align-items: center; gap: 8px;
font-size: 12px; padding: 4px 10px;
background: var(--bg-deep); border: 1px solid var(--line-soft);
border-left: 2px solid var(--amber-dim);
}
.dep-item.ok { border-left-color: var(--green-dim); }
.dep-item.ok .dep-mark { color: var(--green); }
.dep-item.wait .dep-mark { color: var(--amber); }
.dep-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 依赖锚点跳转 */
.dep-item { cursor: pointer; transition: border-color .12s, background .12s; }
.dep-item:hover { background: var(--panel-2); border-color: var(--muted); }
.dep-go { margin-left: auto; color: var(--faint); font-size: 12px; }
.dep-item:hover .dep-go { color: var(--cyan); }
.task-row.flash { animation: locate-flash 1.8s ease-out; }
@keyframes locate-flash {
0%, 35% { background: var(--amber-dim); box-shadow: inset 2px 0 0 var(--amber); }
100% { background: transparent; box-shadow: none; }
}