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