From fa472a06a76d77f612981f66ded774bd1cd3d6cb Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 12 Jun 2026 23:58:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E5=BC=95=E6=93=8E+Ag?= =?UTF-8?q?ent=E9=85=8D=E7=BD=AE+=E4=BE=9D=E8=B5=96=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=90=BD=E4=BD=8D+=E7=9C=8B=E6=9D=BF=E5=A4=A7=E6=94=B9(?= =?UTF-8?q?=E9=A2=84=E8=A7=88/md=E6=B8=B2=E6=9F=93/=E7=AD=9B=E9=80=89/?= =?UTF-8?q?=E5=BE=BD=E7=AB=A0=E7=BB=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 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 --- src/api/server.ts | 76 +++++- src/api/static.ts | 3 +- src/cli/index.ts | 325 ++++--------------------- src/daemon/index.ts | 48 ++++ src/mcp/index.ts | 4 +- src/model/types.ts | 4 +- src/store/db.ts | 16 +- src/store/index.ts | 2 +- src/store/mappers.ts | 3 + src/store/schema.sql | 9 +- src/store/store.ts | 260 +++++++++++++++++++- src/sync/todo-sync.ts | 366 ++++++++++++++++++++++++++++ test/patch.test.ts | 109 +++++++++ test/store.test.ts | 38 +++ test/sync.test.ts | 189 +++++++++++++++ web/app.js | 544 +++++++++++++++++++++++++++++++++++++----- web/index.html | 58 ++++- web/style.css | 256 +++++++++++++++++++- 18 files changed, 1942 insertions(+), 368 deletions(-) create mode 100644 src/sync/todo-sync.ts create mode 100644 test/patch.test.ts create mode 100644 test/sync.test.ts diff --git a/src/api/server.ts b/src/api/server.ts index c776146..e1f9823 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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(/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; 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; + 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)); + }); + + // 单向同步 /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(); + 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; + 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); diff --git a/src/api/static.ts b/src/api/static.ts index ed14b76..2c4d60c 100644 --- a/src/api/static.ts +++ b/src/api/static.ts @@ -50,7 +50,8 @@ async function serveFile(reply: FastifyReply, rawPath: string): Promise [--name N] [--branch B] [--verify CMD] [--concurrency N] * project list * task list - * task add --complexity hard|medium|easy [--parent ID] [--priority N] + * task add <projectIdOrName> <title> --complexity hard|medium|easy [--parent ID] [--priority 0|1|2(P0 最高,默认 P1)] * next <projectIdOrName> * approvals [projectIdOrName] - * import-todo <todo.json 路径> --repo <repoPath> [--name N] + * import-todo <repoPath> [--name N] (导入=首次同步,读 <repoPath>/todo/todo.json,幂等) * * 环境变量:MAESTRO_URL(默认 http://127.0.0.1:4517) */ import { parseArgs, type ParseArgsConfig } from 'node:util'; -import { readFileSync } from 'node:fs'; import { resolve, basename } from 'node:path'; import type { Project, Task } from '../model/types.js'; -import { STATUS_LABEL, type TaskStatus } from '../model/status.js'; -import { COMPLEXITY_LABEL, TIER_TO_COMPLEXITY, isComplexity, type Complexity } from '../model/complexity.js'; +import { STATUS_LABEL } from '../model/status.js'; +import { COMPLEXITY_LABEL, isComplexity } from '../model/complexity.js'; const BASE = process.env.MAESTRO_URL ?? 'http://127.0.0.1:4517'; @@ -31,14 +30,14 @@ const USAGE = `maestro — 多项目 TODO 管理与 Agent 执行系统 CLI 列出所有项目 maestro task list <项目id或名称> 树形列出项目任务(项目名支持模糊匹配) - maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent 父任务id] [--priority 优先级] + maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent 父任务id] [--priority 0|1|2,P0 最高,默认 P1] 新建任务 maestro next <项目id或名称> 取下一个可执行任务 maestro approvals [项目id或名称] 列出待审批任务(不带参数 = 全部项目) - maestro import-todo <todo.json 路径> --repo <仓库路径> [--name 项目名] - 新建项目并导入旧 todo skill 的数据(tier 1/2/3 → hard/medium/easy) + maestro import-todo <仓库路径> [--name 项目名] + 导入/同步旧 todo skill 数据(读 <仓库路径>/todo/todo.json;项目不存在则建;幂等可重复) maestro --help | help 显示本说明 @@ -194,7 +193,7 @@ function printTaskTree(tasks: Task[]): void { list.push(t); byParent.set(key, list); } - const sortFn = (a: Task, b: Task): number => b.priority - a.priority || a.createdAt.localeCompare(b.createdAt); + const sortFn = (a: Task, b: Task): number => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt); // P0 最高在前 const walk = (parentId: string | null, indent: string): void => { const list = (byParent.get(parentId) ?? []).sort(sortFn); for (const t of list) { @@ -227,11 +226,11 @@ async function cmdTaskAdd(rest: string[]): Promise<void> { const projectArg = positionals[0]; const title = positionals.slice(1).join(' ').trim(); if (!projectArg || !title) { - fail('用法:maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent ID] [--priority N]'); + fail('用法:maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent ID] [--priority 0|1|2(P0 最高,默认 P1)]'); } if (!isComplexity(values.complexity)) fail('--complexity 必填,且只能是 hard | medium | easy'); const priority = values.priority === undefined ? undefined : Number(values.priority); - if (priority !== undefined && !Number.isFinite(priority)) fail('--priority 必须是数字'); + if (priority !== undefined && ![0, 1, 2].includes(priority)) fail('--priority 必须是 0/1/2(P0 最高,P1 中,P2 最低)'); const project = await resolveProject(projectArg); const t = await api<Task>('POST', `/api/projects/${project.id}/tasks`, { title, @@ -286,291 +285,55 @@ async function cmdApprovals(rest: string[]): Promise<void> { ); } -// ---------- import-todo ---------- +// ---------- import-todo(同步引擎在 daemon 侧:src/sync/todo-sync.ts,经 POST /api/projects/:id/sync 调用) ---------- -interface OldGate { - kind?: string | null; - note?: string | null; - ref?: string | null; - approval?: string | null; -} -interface OldSub { - sid: string; - title?: string; - tier?: number | null; - deps?: string[]; - status?: string; -} -interface OldItem { - id: number; - title?: string; - desc?: string | null; - level?: string | null; - tier?: number | null; - tags?: string[]; - status?: string; - done?: boolean; - version?: string | null; - subtasks?: OldSub[]; - gate?: OldGate | null; - reject_reason?: string | null; -} -interface OldDb { - meta?: { title?: string }; - items?: OldItem[]; -} - -type OldStatus = 'open' | 'doing' | 'done' | 'accepted'; -const OLD_STATUS_LABEL: Record<OldStatus, string> = { - open: '待开始', - doing: '开发中', - done: '待验收', - accepted: '已验收', -}; - -interface ImportStats { - total: number; - byComplexity: Record<Complexity, number>; - done: number; +interface SyncResult { + created: number; + doneAdvanced: number; skipped: number; warnings: string[]; -} - -function oldStatusOf(raw: string | undefined, doneFlag: boolean | undefined): OldStatus { - if (raw === 'open' || raw === 'doing' || raw === 'done' || raw === 'accepted') return raw; - return doneFlag ? 'accepted' : 'open'; -} - -function complexityOfTier(tier: number | null | undefined, label: string, stats: ImportStats): Complexity { - const c = tier == null ? undefined : TIER_TO_COMPLEXITY[tier]; - if (c) return c; - stats.warnings.push(`${label} 缺少有效 tier(${tier ?? '无'}),按 medium 导入`); - return 'medium'; -} - -function priorityOfLevel(level: string | null | undefined): number { - if (level === 'high') return 2; - if (level === 'mid') return 1; - return 0; -} - -/** 旧 item 的描述性字段合成产出内容(plan/spec/operations) */ -function buildImportNote(item: OldItem, st: OldStatus, extra: string[] = []): string { - const lines = [`【从旧 todo 导入】原 #${item.id} · 原状态:${st}(${OLD_STATUS_LABEL[st]})`]; - if (st === 'doing') lines.push('注:导入前处于「开发中」,导入后回到初始状态,需按新流程重新推进。'); - for (const l of extra) lines.push(l); - if (item.desc) lines.push('', item.desc); - if (item.tags?.length) lines.push('', `标签:${item.tags.join(' / ')}`); - if (item.level) lines.push(`原重要度:${item.level}`); - if (item.gate && (item.gate.note || item.gate.ref)) { - const ref = item.gate.ref ? `(详见 ${item.gate.ref})` : ''; - lines.push('', `原方案/改动说明(approval=${item.gate.approval ?? '无'}):${item.gate.note ?? ''}${ref}`); - } - if (item.reject_reason) lines.push(`原拒绝原因:${item.reject_reason}`); - if (item.version) lines.push(`原验收版本:${item.version}`); - return lines.join('\n'); -} - -const PRODUCE_PATH: Record<Complexity, 'plan' | 'spec' | 'operations'> = { - hard: 'plan', - medium: 'spec', - easy: 'operations', -}; - -async function setProduce(taskId: string, complexity: Complexity, content: string): Promise<void> { - const field = PRODUCE_PATH[complexity]; - await api('POST', `/api/tasks/${taskId}/${field}`, { [field]: content }); -} - -async function doTransition(taskId: string, to: TaskStatus): Promise<void> { - await api('POST', `/api/tasks/${taskId}/transition`, { to }); -} - -async function doAccept(taskId: string): Promise<void> { - await api('POST', `/api/tasks/${taskId}/decide`, { action: 'accept', actor: 'importer' }); + lastSyncAt: string; } /** - * 把非 Hard 容器任务沿合法路径推到 done: - * easy: ready → queued → executing → exec_review → accept(done) - * medium: speccing → spec_review → accept(ready) → queued → executing → exec_review → accept(done) - * 推不动时记 warning、保留现状,返回 false。 + * 导入 = 首次同步:项目(按 repoPath 匹配)不存在则先创建,然后触发一次 sync。 + * 数据固定读 <repoPath>/todo/todo.json;幂等,可重复执行做增量同步。 */ -async function pushLeafToDone(taskId: string, complexity: Complexity, label: string, stats: ImportStats): Promise<boolean> { - try { - if (complexity === 'medium') { - await doTransition(taskId, 'spec_review'); - await doAccept(taskId); // → ready - } - await doTransition(taskId, 'queued'); - await doTransition(taskId, 'executing'); - await doTransition(taskId, 'exec_review'); - await doAccept(taskId); // → done - stats.done += 1; - return true; - } catch (e) { - stats.warnings.push(`${label} 推进 done 失败:${(e as Error).message}(已保留当前状态)`); - return false; - } -} - -/** - * Hard 容器推进:analyzing → plan_review → accept(decomposed)。 - * 子任务全部 done(或无子任务,已在 plan 注明)时再 decomposed → done。 - */ -async function pushHardContainer( - taskId: string, - label: string, - hasChildren: boolean, - allChildrenDone: boolean, - stats: ImportStats, -): Promise<void> { - try { - await doTransition(taskId, 'plan_review'); - await doAccept(taskId); // → decomposed - if (!hasChildren || allChildrenDone) { - await doTransition(taskId, 'done'); - stats.done += 1; - } else { - stats.warnings.push(`${label} 原状态为已完成,但子任务未能全部推到 done,容器停在 decomposed`); - } - } catch (e) { - stats.warnings.push(`${label} 推进 done 失败:${(e as Error).message}(已保留当前状态)`); - } -} - -async function importItem(projectId: string, item: OldItem, stats: ImportStats): Promise<void> { - const label = `旧 #${item.id}「${item.title ?? '?'}」`; - if (!item.title) { - stats.skipped += 1; - stats.warnings.push(`旧 #${item.id} 缺少 title,已跳过`); - return; - } - const st = oldStatusOf(item.status, item.done); - const complexity = complexityOfTier(item.tier, label, stats); - const subs = item.subtasks ?? []; - const isDone = st === 'done' || st === 'accepted'; - - // 1) 建父任务 - const task = await api<Task>('POST', `/api/projects/${projectId}/tasks`, { - title: item.title, - complexity, - priority: priorityOfLevel(item.level), - }); - stats.total += 1; - stats.byComplexity[complexity] += 1; - - // 2) 写产出字段(hard→plan / medium→spec / easy→operations) - const extra: string[] = []; - if (isDone && complexity === 'hard' && subs.length === 0) { - extra.push('注:原任务已完成且无子任务,导入时容器直接推到 done。'); - } - await setProduce(task.id, complexity, buildImportNote(item, st, extra)); - - // 3) 建子任务(sid 依赖 → 新任务 id) - const sidToId = new Map<string, string>(); - const childPlan: Array<{ id: string; complexity: Complexity; oldStatus: string; label: string }> = []; - for (const sub of subs) { - const subLabel = `旧子任务 ${sub.sid}「${sub.title ?? '?'}」`; - if (!sub.title) { - stats.skipped += 1; - stats.warnings.push(`${subLabel} 缺少 title,已跳过`); - continue; - } - const subComplexity = complexityOfTier(sub.tier, subLabel, stats); - const deps: string[] = []; - for (const dep of sub.deps ?? []) { - const depId = sidToId.get(dep.toUpperCase()); - if (depId) deps.push(depId); - else stats.warnings.push(`${subLabel} 的依赖 ${dep} 未找到对应任务,已忽略该依赖`); - } - const subStatus = sub.status === 'accepted' ? 'done' : (sub.status ?? 'open'); - const child = await api<Task>('POST', `/api/projects/${projectId}/tasks`, { - title: sub.title, - complexity: subComplexity, - parentId: task.id, - deps, - }); - sidToId.set(sub.sid.toUpperCase(), child.id); - stats.total += 1; - stats.byComplexity[subComplexity] += 1; - const subNote = [ - `【从旧 todo 导入】原子任务 ${sub.sid}(父:旧 #${item.id})· 原状态:${subStatus}`, - subStatus === 'doing' ? '注:导入前处于「开发中」,导入后回到初始状态。' : '', - ].filter(Boolean).join('\n'); - await setProduce(child.id, subComplexity, subNote); - childPlan.push({ id: child.id, complexity: subComplexity, oldStatus: subStatus, label: subLabel }); - } - - // 4) 先推子任务,再推父任务 - let allChildrenDone = childPlan.length > 0; - for (const c of childPlan) { - if (c.oldStatus === 'done') { - let ok: boolean; - if (c.complexity === 'hard') { - // 已完成的 hard 子任务(无下级)按容器路径收口 - await pushHardContainer(c.id, c.label, false, true, stats); - const cur = await api<Task>('GET', `/api/tasks/${c.id}`); - ok = cur.status === 'done'; - } else { - ok = await pushLeafToDone(c.id, c.complexity, c.label, stats); - } - if (!ok) allChildrenDone = false; - } else { - allChildrenDone = false; // open/doing:建完即停 - } - } - - if (!isDone) return; // open / doing:建完即停(doing 已在产出里备注) - - if (complexity === 'hard') { - await pushHardContainer(task.id, label, childPlan.length > 0, allChildrenDone, stats); - } else { - await pushLeafToDone(task.id, complexity, label, stats); - } -} - async function cmdImportTodo(rest: string[]): Promise<void> { const { values, positionals } = parseCmdArgs(rest, { repo: { type: 'string' }, name: { type: 'string' }, }); - const file = positionals[0]; - if (!file || !values.repo) { - fail('用法:maestro import-todo <todo.json 路径> --repo <仓库路径> [--name 项目名]'); + // 兼容旧用法 `import-todo <todo.json 路径> --repo <仓库路径>`:--repo 优先;否则把位置参数当 repoPath + const repoArg = (values.repo as string | undefined) ?? positionals[0]; + if (!repoArg) { + fail('用法:maestro import-todo <仓库路径> [--name 项目名](同步引擎固定读 <仓库路径>/todo/todo.json)'); } - const filePath = resolve(file); - let db: OldDb; - try { - db = JSON.parse(readFileSync(filePath, 'utf8')) as OldDb; - } catch (e) { - fail(`读取 ${filePath} 失败:${(e as Error).message}`); - } - const items = db.items ?? []; - if (!Array.isArray(items)) fail(`${filePath} 不是合法的旧 todo.json(缺少 items 数组)`); - - const repoPath = resolve(values.repo as string); - const name = (values.name as string | undefined) ?? basename(repoPath); - const project = await api<Project>('POST', '/api/projects', { name, repoPath }); - console.log(`已创建项目「${project.name}」(${project.id}),开始导入 ${items.length} 条旧任务…\n`); - - const stats: ImportStats = { - total: 0, - byComplexity: { hard: 0, medium: 0, easy: 0 }, - done: 0, - skipped: 0, - warnings: [], - }; - for (const item of items) { - await importItem(project.id, item, stats); + const repoPath = resolve(repoArg); + if (values.repo && positionals[0]) { + const legacyFile = resolve(positionals[0]); + const expected = resolve(repoPath, 'todo', 'todo.json'); + if (legacyFile !== expected) { + console.log(`提示:同步引擎固定读 ${expected},忽略指定的 ${legacyFile}`); + } } - console.log('导入完成:'); - console.log( - ` 共导入 ${stats.total} 条任务(hard ${stats.byComplexity.hard} / medium ${stats.byComplexity.medium} / easy ${stats.byComplexity.easy})`, - ); - console.log(` 推到 done:${stats.done} 条 · 跳过:${stats.skipped} 条 · 警告:${stats.warnings.length} 条`); - for (const w of stats.warnings) console.log(` ⚠ ${w}`); + // 项目不存在则建(按 repoPath 匹配),存在则直接增量同步 + const projects = await api<Project[]>('GET', '/api/projects'); + let project = projects.find((p) => p.repoPath === repoPath); + if (project) { + console.log(`项目「${project.name}」(${project.id}) 已存在,执行增量同步…\n`); + } else { + const name = (values.name as string | undefined) ?? basename(repoPath); + project = await api<Project>('POST', '/api/projects', { name, repoPath }); + console.log(`已创建项目「${project.name}」(${project.id}),开始首次同步…\n`); + } + + const r = await api<SyncResult>('POST', `/api/projects/${project.id}/sync`); + console.log('同步完成:'); + console.log(` 新建 ${r.created} 条 · 推到 done ${r.doneAdvanced} 条 · 跳过(已映射)${r.skipped} 条 · 警告 ${r.warnings.length} 条`); + for (const w of r.warnings) console.log(` ⚠ ${w}`); + console.log(` 本次同步时间:${r.lastSyncAt}`); console.log(`\n查看结果:maestro task list ${project.name}`); } diff --git a/src/daemon/index.ts b/src/daemon/index.ts index a0ca6ff..765d5ab 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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); diff --git a/src/mcp/index.ts b/src/mcp/index.ts index d05ab2f..716ab4a 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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 }) => diff --git a/src/model/types.ts b/src/model/types.ts index 91d148c..82d6f9d 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -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; diff --git a/src/store/db.ts b/src/store/db.ts index 781e6f6..ea54a25 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -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; diff --git a/src/store/index.ts b/src/store/index.ts index b93d8e2..cd84c3f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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'; diff --git a/src/store/mappers.ts b/src/store/mappers.ts index 8f75891..63963cd 100644 --- a/src/store/mappers.ts +++ b/src/store/mappers.ts @@ -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, }; } diff --git a/src/store/schema.sql b/src/store/schema.sql index ed4e7cb..929468c 100644 --- a/src/store/schema.sql +++ b/src/store/schema.sql @@ -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, diff --git a/src/store/store.ts b/src/store/store.ts index a9f111b..76aed9e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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; // 非叶子跳过 diff --git a/src/sync/todo-sync.ts b/src/sync/todo-sync.ts new file mode 100644 index 0000000..4a44fb4 --- /dev/null +++ b/src/sync/todo-sync.ts @@ -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, + }; +} diff --git a/test/patch.test.ts b/test/patch.test.ts new file mode 100644 index 0000000..0fc88de --- /dev/null +++ b/test/patch.test.ts @@ -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(); +}); diff --git a/test/store.test.ts b/test/store.test.ts index dc5545f..701fa7f 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -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(); +}); diff --git a/test/sync.test.ts b/test/sync.test.ts new file mode 100644 index 0000000..912dbef --- /dev/null +++ b/test/sync.test.ts @@ -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 }); +}); diff --git a/web/app.js b/web/app.js index 0158cc5..190840c 100644 --- a/web/app.js +++ b/web/app.js @@ -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); diff --git a/web/index.html b/web/index.html index 4fc63d7..7bc0306 100644 --- a/web/index.html +++ b/web/index.html @@ -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> diff --git a/web/style.css b/web/style.css index bdec40d..4aa1135 100644 --- a/web/style.css +++ b/web/style.css @@ -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; @@ -437,7 +545,151 @@ li.ev-updated { --ev: var(--muted); } animation: toastIn .18s ease both; box-shadow: 0 8px 30px rgba(0,0,0,.55); } -.toast.err { border-color: var(--red); color: var(--red); } -.toast.ok { border-color: var(--green-dim); color: var(--green); } +.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; } +}