Compare commits

...

34 Commits

Author SHA1 Message Date
maestro 7f014ba246 merge: maestro/tsk_JKAzHuXvSw7h [tsk_JKAzHuXvSw7h] 2026-06-30 09:22:42 +08:00
wangjia d0aa53c79d fix(usage): 修按天分桶时区错位 + 区分账号额度/maestro用量口径 + 成本说明文档 (tsk_JKAzHuXvSw7h)
全局面板「用量」数据问题修复与澄清:

1. 时区分桶根因(store.ts):按天/周/月分桶与今天/本月窗口起点原全用 UTC,
   UTC+8 用户本地 00:00–08:00 的 run 被算进前一天、当天窗口晚 8h → 柱状图偏移
   一天、当天偏小。新增 localDay/localMonth,periodStartISO/weekStartLabel/
   usageDetail 改用 daemon 本地时区(SQL 比绝对时刻仍正确)。

2. UI 口径区分(design/ui_kits/console):额度条标注账号级含交互会话,AGENT 卡/
   详情弹框标注仅统计 maestro 任务执行,$ 加 API 列表价估算非实际扣费提示并以 ≈
   前缀,模型列加近似角标。新增 4 个 i18n key×5 语言。

3. 文档:新增 docs/usage-cost-explained.html(两数据源/为何对不上/cost 公式/
   单价表/本地时区分桶/已知近似),登记进 docs/index.html 知识库调研。

4. 测试:budget.test.ts 新增 UTC+8 跨日分桶用例。typecheck + 268 测试全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:18:36 +08:00
maestro ea3ad710ff merge: maestro/tsk_BJLguXuWkyq4 [tsk_BJLguXuWkyq4] 2026-06-30 09:13:27 +08:00
maestro 02371b3f89 merge: maestro/tsk_EmZ6vjtVbrOk [tsk_EmZ6vjtVbrOk] 2026-06-30 09:08:52 +08:00
wangjia 42f9788b0a feat(executor): 人工驳回意见喂回返工 agent prompt (tsk_BJLguXuWkyq4)
照搬现有 L1「上次失败原因」链路,新增平行的「人工驳回意见」注入链路:

- store.lastRejectReasonOf(taskId, gate?):从 approvals 取最近一次 reject reason,
  支持按闸过滤(exec/spec/plan),无 reject 返回 null
- orchestrator.claimOne:按返工角色对应闸装配 task.lastRejectReason 下发 job
  (executor→exec / planner-spec→spec / planner-decompose→plan)
- runner.lastRejectLines + buildPrompt/buildPlannerPrompt 拼接驳回段
  (紧接失败段之后,2000 字截断);buildConflictPrompt 不注入
- types.Task 新增 lastRejectReason 临时装配字段(仅注入 prompt,不持久化)
- test/memory-inject.test.ts:覆盖 store gate 过滤、三处注入、截断、边界、顺序

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:08:26 +08:00
wangjia 0c1f630dca 前端:任务详情展示审批/驳回记录 + 任务行 ID 不被长标题挤掉 + i18n 五语 (tsk_EmZ6vjtVbrOk)
- TaskDetail 渲染 _raw.approvals:闸名·通过/驳回 + 审批人 + 时间,
  驳回用红色边框+红底高亮块突出 reason(t.rejectReason 标签),
  通过用绿色、reason 走低调 ↳ 行;无审批记录不渲染该分区。
- 安全:reason/actor/gate 全部走 React 文本节点(默认转义),
  不经 MaestroRichDoc/innerHTML,避免同源存储型 XSS。
- TaskRow:taskid 拆为独立 flex:none span,title 加 minWidth:0,
  长标题省略号截断时 id 完整不被挤掉;ArchiveRow 同步处理保持一致。
- i18n:zh/en/es/ja/fr 五语新增 rejectReason 键。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:04:42 +08:00
maestro e115e065e6 merge: maestro/tsk_sp0DwZ2Kc319 [tsk_sp0DwZ2Kc319] 2026-06-30 08:56:35 +08:00
wangjia 5443ffa21c fix(console): 评审/归档卡片的 markdown 渲染成 HTML
内联评审闸卡片与归档详情此前把 agent 产出的 markdown(plan/spec/
operations)直接塞进 <pre> 当纯文本,满屏裸 ## / - / 反引号未渲染。
改为在 ui_kit 层用 window.MaestroRichDoc(marked + DOMPurify 净化)
渲染,与 TaskTree 既有写法一致;保留 280px 限高滚动与容器样式。

- GateSection.jsx: GateCard doc 改为 undefined,children 内对非
  attention 闸用 MaestroRichDoc 渲染 a.doc;删除未引用的 reportBox 死代码
- ArchiveSection.jsx: doc() 助手由 <pre> 改为 MaestroRichDoc 容器,
  SPEC/OPERATIONS 两块随之正确渲染
- 安全:两处均走 RichDoc 的 DOMPurify 净化,挡住 same-origin 存储型 XSS;
  不绕过 sanitize 直接注入 HTML。CDN 不可用时回退纯文本不崩
- 不改 GateCard.jsx(避免底层组件反向依赖 ui_kit 全局 + 触发 bundle 重编)
- DiffBody 的 <pre> 渲染 git diff(非 markdown)保持不动

类似但未改:console_mobile/MobileViews.jsx 为独立非在线 demo,其
index.html 未加载 RichDoc/marked/DOMPurify,修复需整套引入,超出本次范围。

tsk_sp0DwZ2Kc319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:30:15 +08:00
maestro 382afe3322 merge: maestro/tsk_0u7OP3RFBAqr [tsk_0u7OP3RFBAqr] 2026-06-30 08:05:08 +08:00
wangjia a59588a0ce fix(console): 侧栏项目 agent 徽标改用 s.running,修复 planner/spec 阶段徽标缺失 (tsk_0u7OP3RFBAqr)
侧栏项目 agent 计数徽标原绑定 s.executing(仅统计任务恰处 executing 状态),
与「运行中」青点(s.running)及 tooltip『N agents running』语义不一致。
当 Hard(analyzing/planner) 或 Medium(speccing) 任务有在途 agent 时,s.executing=0
导致徽标缺失(如 maestro 项目)。改取 s.running(runs.status=started,覆盖各阶段)。

新增 test/adapt.test.ts 锁定该映射。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:02:22 +08:00
maestro ee9232e575 merge: maestro/tsk_sTHRFtFoHf-G [tsk_sTHRFtFoHf-G] 2026-06-30 07:45:44 +08:00
wangjia 8d3192e7cb app接线: 上传去重响应/已存附件删除/缩略图白名单对齐后端 (tsk_sTHRFtFoHf-G)
- createTask 消费后端去重后的权威 attachments 列表,按入库数报数并提示去重数
- 附件删除 toast 语义由 warn 调整为 ok(attDeleted 为成功语义)
- TaskDetail 缩略图 isImg 收敛到 KIT_ATT_SAFE_INLINE 硬白名单(png/jpeg/gif/webp),
  与后端 src/api/server.ts SAFE_INLINE 一字不差,svg/html 不走 <img src=服务端URL> inline,
  钉死同源存储型 XSS
- i18n 新增 attUploaded/attDeduped 两个带 {n} 占位的 key,覆盖 zh/en/es/ja/fr

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:42:46 +08:00
maestro 80f257171d merge: maestro/tsk_0VF1URfdoe3d [tsk_0VF1URfdoe3d] 2026-06-30 07:41:26 +08:00
wangjia 33bef7f5fc feat(console): 侧栏项目按优先级显示三类计数徽标
左侧项目状态新增数字提示,按优先级展示:
1. 需人工处理数(紫,attention)
2. agent 运行数(青,executing)
3. 被阻塞数(琥珀,blocked)
仅非零显示,固定「人工 > 运行 > 阻塞」排列;折叠态仍单状态点,
任一计数 > 0 时放大。后端 projectSummary 已就绪,仅透出 blocked
并渲染。i18n 五语言补齐 projAgentsTip / projBlockedTip。

tsk_0VF1URfdoe3d

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:34:47 +08:00
maestro db4e6e2fb4 merge: maestro/tsk_ia4uiOfk6ZdV [tsk_ia4uiOfk6ZdV] 2026-06-30 07:25:41 +08:00
maestro eac3575851 merge: maestro/tsk_fTul9G4eutVn [tsk_fTul9G4eutVn] 2026-06-30 07:25:39 +08:00
wangjia 64b8c912ef chore(console): 验证附件创建面板已上线 + 低风险加固(粘贴/拖拽体量上限 + render 副作用收敛)[tsk_ia4uiOfk6ZdV]
任务功能(统一 files 状态 / 缩略图预览 / 单项删除 / 客户端去重 / 5 语 i18n)
已于 65cd5ad、00e6bc7 实现并合并,后端同源存储型 XSS 防护已于 d9d3de2 落实,
逐条核对全部满足,故不重复实现。仅做两处低风险加固(范围限 design/ui_kits/console/**):

- NewTaskPanel.addFiles 增加单文件 25MB 软上限,超限跳过并内联提示,
  新增 i18n key attTooLarge(zh/en/es/ja/fr 五语齐备)
- 缩略图 objectURL 改为在 addFiles 中预生成(ensureThumb),render 仅读,
  消除 map 渲染期写 urlMapRef 的副作用,避免 React 严格模式下重复创建/泄漏

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:23:12 +08:00
wangjia f1ee537ab7 附件内容去重(hash):上传改为内容寻址 + store 按 hash 去重 [tsk_fTul9G4eutVn]
- types: Attachment 增 hash?/size? 可选字段(向后兼容,老数据零迁移)
- 上传端点 POST .../attachments 改为内容寻址:流式计算 sha256,写临时文件再
  按 <sha256>.<ext> 原子 rename;同 hash 已存在则丢弃临时文件(真去重);
  超限/异常清理临时文件不留垃圾。磁盘名仅由内容 hash 决定,杜绝撞名覆盖丢数据。
- store.addAttachments 去重键改为 hash ?? path(later-wins,老数据回退 path)。
- DELETE 端点补注释:附件按 task 隔离,无需跨任务 refcount。
- 安全保持并固化:GET inline 仅硬白名单图片,其余强制 attachment+octet-stream
  +nosniff+CSP sandbox;路径越界防护;hash 命名不含路径可控字符。
- 测试:新增同内容去重/不同内容同名各留一条/响应含 hash+size 等用例。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:22:15 +08:00
wangjia d9d3de26b8 fix(security): 附件 GET 端点防同源存储型 XSS——非白名单图片(svg/html等)强制下载+octet-stream+nosniff+CSP sandbox
maestro 双复审漏掉的真实 HIGH:上传文件与 API 同源,inline 渲染会在 console 源执行其脚本。只对 png/jpeg/gif/webp 允许 inline 预览,其余强制 attachment 下载并改 content-type 为 octet-stream,加 X-Content-Type-Options:nosniff + CSP sandbox 双重兜底。带 SVG 安全单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:59:34 +08:00
maestro f443b16649 merge: maestro/tsk_dbtI_ffTJll9 [tsk_dbtI_ffTJll9] 2026-06-30 01:53:41 +08:00
wangjia 00e6bc72e7 feat(console): 附件全链路接线(预览/下载/删除 + 已建任务交互)
- app/src/api.js: 新增 attachmentUrl(inline/下载 URL 构造)与 deleteAttachment 封装
- app/src/app.jsx: taskOps 增 delAttachment(删除→toast→局部刷新)
- design/ui_kits/console/TaskTree.jsx: TaskDetail 附件区从纯文本升级为画廊
  (图片缩略图点击新标签预览 + 下载按钮 + 确认删除),URL 内联零依赖、删除走 taskOps
- design/ui_kits/console/i18n.js: 新增 attSection/attDownload/attDeleteConfirm/
  attDeleted/attDeleteFailed(zh/en/es/ja/fr 五语齐全)

tsk_dbtI_ffTJll9

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:49:17 +08:00
maestro b51163ceed merge: maestro/tsk_tqxHX6NRQ3iA [tsk_tqxHX6NRQ3iA] 2026-06-30 01:40:59 +08:00
maestro 65cd5add5c maestro(tsk_tqxHX6NRQ3iA): 前端 NewTaskPanel:剪贴板粘贴图片 + 拖拽上传 + 统一 files 状态 + 缩略图预览 + 单个删除 + 客户端去重 + 5 语 i18n 2026-06-30 01:36:43 +08:00
maestro bf2f7d8155 merge: maestro/tsk_tttdU3VLCy50 [tsk_tttdU3VLCy50] 2026-06-30 01:33:43 +08:00
wangjia 2500e3e779 后端:附件读取/下载端点 + 删除端点 + addAttachments 落库去重 (tsk_tttdU3VLCy50)
- store.addAttachments:按 path 去重(later-wins),消除重传产生的悬空元数据
- store.removeAttachment:按磁盘文件名(basename(path))移除元数据,返回 removed
- GET /api/tasks/:id/attachments/:name:inline 预览,?download=1 触发下载(RFC5987 文件名)
- DELETE /api/tasks/:id/attachments/:name:先删元数据再尽力删磁盘文件
- 只服务元数据登记过的附件 + resolve/startsWith 前缀校验,杜绝目录穿越
- 新增 test/attachments.test.ts(去重/读取/下载/删除/穿越/丢失/端到端上传去重)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:42 +08:00
wangjia 3ffdf411fc docs(CLAUDE.md): 标明 app/+design 为在用 React 控制台、web/ 为遗留——纠正 planner 对前端目标的误判
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:01:24 +08:00
wangjia b48d4d8135 Merge branch 'main' into worktree-tidy-jumping-shell 2026-06-29 23:32:40 +08:00
wangjia 5c68bb9324 docs(task-rules): 新增任务执行规则参考 HTML + 登记 docs/index 知识库分类
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:32:30 +08:00
wangjia af690701bf feat(web): Markdown 渲染器 + 拆解轨迹 + 需你处理区 + 复审报告折叠卡片
- RichDoc(marked+DOMPurify+highlight.js CDN)统一渲染 gate spec/复审报告/任务树 SPEC;- Transcript 组件:拆解评审「拆解轨迹」懒加载 planner run transcript;- 「审批闸」改名「需你处理」并并入 needs_attention(requeue/接管/取消 + 完整复审报告,按代码/安全分段折叠卡片、标裁决徽章);- 子任务显 scopeFiles 文件范围 + frozen「待拆解确认」标。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:32:30 +08:00
wangjia 5a609a8675 feat(retry+decompose): 范围型驳回不重试 + 拆解 prompt 补 scope 铁律
- ingest:复审 reject 时若任务有 scopeFiles 且理由命中越界/范围外/声明范围等标记 → 判为范围型驳回,调 store.failScopeReject 直接转 needs_attention(清退避、不进重试链),因 executor 碰不了范围外文件、重试必然同样被驳回;- store.failScopeReject:补记 failed run 承载原因 + 直转 needs_attention(带单测);- runner 拆解 prompt:加「files 范围铁律」——覆盖全跨层足迹 / 后端能力别塞前端范围 / 宁空勿窄 / 全或空。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:32:10 +08:00
maestro 8b494e92aa merge: maestro/tsk_5dmtoHD_QVA3 [tsk_5dmtoHD_QVA3] 2026-06-29 19:57:46 +08:00
wangjia aca8f1b18b 后端文件名加固:无名/无扩展名粘贴 blob 按 mimetype 生成安全文件名+扩展名 (tsk_5dmtoHD_QVA3)
附件上传接口对剪贴板粘贴等「无名/无扩展名」的 blob 加固落盘命名:
- 新增 extFromMime / safeAttachmentName 纯函数(可单测、已导出)
- 占位名(blob/image/file 等)或空名 → 生成唯一 paste-<ts>-<seq>,
  扩展名优先按 mimetype 推断、其次保留原扩展名,避免多次粘贴同名覆盖
- 正常名缺扩展名 → 按 mimetype 补;路径穿越/非法字符净化,剥前导点
- name 字段在原名缺失时回退到生成名

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:54:58 +08:00
maestro 0569423b7d merge: maestro/tsk_erQHNYdn6i9g [tsk_erQHNYdn6i9g] 2026-06-29 19:15:36 +08:00
wangjia 7884182d2b feat: 编排器跨项目全局并发上限闸(tsk_erQHNYdn6i9g)
新增用户级设置项 globalConcurrency(settings 表,独立于新建项目默认值
来源 concurrency),缺省 0 = 不限,向后兼容无需 DB 迁移。

- store: UserSettings 加 globalConcurrency 字段 + 默认 0;新增
  globalInflightCount()(跨所有项目 started run 总数,与 inflightTaskIds 同口径)
- orchestrator.claimTick: 叠加全局闸,与 per-project 闸串联(都过才领);
  轮初查一次全局在途、轮内手动 ++,达上限即 return 跨所有项目停止领取
- api: PUT /api/settings 支持 globalConcurrency(空/null 归一为 0)
- web: 侧栏「全局设置」入口 + 模态,编辑/回显/校验(≥0 整数)
- test: 新增全局闸达上限跨项目阻止 / 未达上限正常领取 / 边界 / 轮初短路 用例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:11:42 +08:00
33 changed files with 1962 additions and 156 deletions
+5 -1
View File
@@ -53,7 +53,11 @@ Communication between daemon and worker is **file + signal only** (`job.json`, `
| `src/api/server.ts` | Fastify REST + WebSocket server |
| `src/mcp/` | MCP server tools exposed to Claude Code sessions |
| `src/sync/` | One-way sync from legacy `todo.json` files |
| `web/` | Dashboard SPA (multi-project, real-time WS, inline approve/reject) |
| `app/` | **Active console (the dashboard users actually use)** — React SPA on `:4519` (vite dev; proxies `/api`+`/ws` to `:4517`). Data & wiring live here: `src/api.js`, `src/app.jsx`, `src/adapt.js`. |
| `design/ui_kits/console/` | **Active console UI** — React UI components, the single source of truth for the console; `app/` loads them via vite. **Frontend UI changes go here; data/actions go in `app/src/`.** Strings go through `i18n.js` (5 langs). |
| `web/` | **⚠️ Legacy / deprecated** — old vanilla-JS dashboard served directly by the daemon. Superseded by the `app/` + `design/ui_kits/console/` React console. **Do NOT add new features, config entries, or UI here** — build them in `app/` + `design/ui_kits/console/`. |
> **Frontend rule of thumb:** the live console is **`design/ui_kits/console/` (UI) + `app/src/` (data/wiring)** on `:4519`. `web/` is legacy — never target it for new work.
### Task Lifecycle
+5
View File
@@ -4,6 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MAESTRO · 任务调度台</title>
<!-- Markdown 渲染(方案/复审/任务树 SPEC 用,window 全局,先于 module 脚本加载)-->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/styles/github-dark.min.css" integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js" integrity="sha384-/TQbtLCAerC3jgaim+N78RZSDYV7ryeoBCVqTuzRrFec2akfBkHS7ACQ3PQhvMVi" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/highlight.min.js" integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
+15 -3
View File
@@ -25,7 +25,10 @@ export function adaptProject(p) {
return {
id: p.id, name: p.name, path: shortPath(p.repoPath), branch: p.defaultBranch,
autonomy: p.autonomy, concurrency: p.concurrency, hue: hueFor(p.name),
state, pending: s.attention || 0, agents: s.executing || 0,
// agents 徽标 = 实际在途 agent 数(runs.status='started',含 planner/spec/executor 各阶段),
// 与「运行中」青点同源(s.running);不可用 s.executing——那只统计任务恰处 executing 状态,
// 会漏掉 analyzing(planner)/speccing(spec) 阶段正在跑的 agent,导致徽标缺失。
state, pending: s.attention || 0, agents: s.running || 0, blocked: s.blocked || 0,
// 透传给 ConfigPanel 用的原始字段
model: p.model, verifyCmd: p.verifyCmd, maxRetries: p.maxRetries, repoPath: p.repoPath,
logo: p.logo, budgetUsd: p.budgetUsd ?? null, budgetPeriod: p.budgetPeriod ?? 'month',
@@ -54,6 +57,13 @@ export function buildTaskTree(flat) {
if (pid && byId.has(pid)) byId.get(pid).children.push(node);
else roots.push(node);
});
// 冻结标记:父任务尚未批准拆解(plan_review/analyzing)时,子任务虽落在 speccing/ready/blocked
// 实际被编排器闸冻结、不会领取执行。前端据此改显「待拆解确认」,避免误读成「写方案中/可执行」。
byId.forEach((node) => {
const pid = node._raw.parentId;
const ps = pid && byId.has(pid) ? byId.get(pid)._raw.status : null;
node.frozen = ps === 'plan_review' || ps === 'analyzing';
});
// 子节点按优先级(P0 在前)再按创建时间排序
const sortKids = (n) => {
n.children.sort((a, b) => a.prio.localeCompare(b.prio) || a._raw.createdAt.localeCompare(b._raw.createdAt));
@@ -76,13 +86,15 @@ export function buildTaskTree(flat) {
return { active, archived };
}
// ── 审批闸卡片 ─────────────────────────────────────────
// ── 「需你处理」卡片:审批闸(plan/spec/exec) + 需人工(attention) ────────────
export function adaptApproval(tk, projName) {
const gate = tk.status.replace('_review', ''); // plan | spec | exec
// needs_attention 不是审批闸,是卡住态——并入同区但操作不同(requeue/接管/取消,非 accept/reject
const gate = tk.status === 'needs_attention' ? 'attention' : tk.status.replace('_review', ''); // plan | spec | exec | attention
const r = tk.result || {};
let doc;
if (gate === 'plan') doc = tk.plan;
else if (gate === 'spec') doc = tk.spec;
else if (gate === 'attention') doc = tk.lastRunError || tk.spec || tk.plan || '(任务卡住,无错误详情——可能为执行前重评估 / 合并冲突)';
else doc = r.summary || r.diffSummary || tk.operations; // exec:优先 code review 报告(做了什么/怎么做/测试/逐条 review)
const out = {
id: tk.id, gate, taskId: tk.id, title: tk.title,
+7
View File
@@ -52,6 +52,13 @@ export const api = {
}
return res.json();
},
// 附件预览/下载 URL:默认 inline(图片可 <img src> 预览),download=true 触发下载。
// name = basename(att.path)(磁盘安全名,非展示名)。纯字符串构造,GET 走 <img>/<a> 由浏览器处理。
attachmentUrl: (taskId, name, opts = {}) =>
`/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}${opts.download ? '?download=1' : ''}`,
// 删除单个附件(按磁盘文件名)。返回 { attachments } 最新列表。
deleteAttachment: (taskId, name) =>
http('DELETE', `/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}`),
};
// WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。
+19 -6
View File
@@ -300,19 +300,22 @@ export function App() {
const project = projects.find((p) => p.id === currentId) || projects[0];
const projNameOf = (pid) => (projectsRaw.find((p) => p.id === pid) || {}).name;
const { active: tasks, archived } = buildTaskTree(tasksRaw);
// 「需你处理」= 三个审批闸 + needs_attention(卡住待人工)。后者并入同区但操作不同。
const REVIEW = new Set(['plan_review', 'spec_review', 'exec_review']);
const gates = approvalsRaw
.filter((a) => a.projectId === currentId && REVIEW.has(a.status))
.filter((a) => a.projectId === currentId && (REVIEW.has(a.status) || a.status === 'needs_attention'))
.map((a) => {
const g = adaptApproval(a, projNameOf(a.projectId));
// 拆解评审:挂上实际拆出的子任务(标题/复杂度/优先级/依赖/状态),供评审弹层结构化展示
if (g.gate === 'plan') {
g.subtasks = tasksRaw
.filter((tk) => tk.parentId === a.id)
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status }));
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status, scopeFiles: tk.scopeFiles || [] }));
}
return g;
});
})
// 审批闸在前、需人工在后
.sort((x, y) => (x.gate === 'attention' ? 1 : 0) - (y.gate === 'attention' ? 1 : 0));
const agents = adaptActiveAgents(agentsResp, currentId);
const events = eventsRaw.map(adaptEvent);
const quota = adaptQuota(usage);
@@ -347,8 +350,13 @@ export function App() {
try {
const task = await api.createTask(currentId, body);
if (files && files.length) {
try { await api.uploadAttachments(task.id, files); toast('ok', `附件已上传 · ${files.length}`); }
catch (e) { toast('warn', e.message); }
try {
// 消费后端去重后的权威列表:新建任务原本无附件,stored 即本批去重后入库条数
const { attachments } = await api.uploadAttachments(task.id, files);
const stored = attachments.length;
const deduped = files.length - stored; // >0 表示有同内容文件被去重
toast('ok', t.attUploaded.replace('{n}', stored) + (deduped > 0 ? t.attDeduped.replace('{n}', deduped) : ''));
} catch (e) { toast('warn', e.message); }
}
toast('ok', t.toastCreated);
loadProject(currentId); loadGlobal();
@@ -384,6 +392,11 @@ export function App() {
try { await api.deleteTask(taskId); toast('warn', '已删除'); loadProject(currentId); loadGlobal(); }
catch (e) { toast('warn', '删除失败:' + e.message); }
},
// 删除已建任务的单个附件(name = 磁盘文件名 basename(att.path)),成功后局部刷新
delAttachment: async (taskId, name) => {
try { await api.deleteAttachment(taskId, name); toast('ok', t.attDeleted); loadProject(currentId); }
catch (e) { toast('warn', t.attDeleteFailed + e.message); }
},
};
const [showNewProject, setShowNewProject] = React.useState(false);
const createProject = async (body) => {
@@ -423,7 +436,7 @@ export function App() {
onSync={onSync}
onClose={() => setShowConfig(false)} /> : null}
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} onOpenStream={setStreamAgent} />
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
<window.MaestroKitGateSection approvals={gates} onDecide={decide} taskOps={taskOps} onTakeover={takeover} t={t} />
{tasks.length ? (
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} onTakeover={takeover} taskOps={taskOps} t={t} />
) : (
+5
View File
@@ -7,6 +7,11 @@ import '@ds/_ds_bundle.js';
// 文案 i18n(真实数据由 src/api.js + src/adapt.js 提供,不再用 data.js mock
import '@ds/ui_kits/console/i18n.js';
// 共享 Markdown 渲染组件(window.MaestroRichDoc)——须在引用它的 GateSection/TaskTree 之前
import '@ds/ui_kits/console/RichDoc.jsx';
// 共享 transcript 回放组件(window.MaestroTranscript)——拆解评审「拆解轨迹」等用
import '@ds/ui_kits/console/Transcript.jsx';
// 六个整屏 surface(保持 design/ 源文件不动,作为单一真相源)
import '@ds/ui_kits/console/Sidebar.jsx';
import '@ds/ui_kits/console/EventPanel.jsx';
+4 -3
View File
@@ -11,9 +11,10 @@ function ArchiveRow({ item, t, onOpen }) {
borderBottom: '1px dashed var(--line-soft)',
background: hover ? 'var(--panel)' : 'transparent',
}}>
<span style={{ color: 'var(--muted)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.title}<span style={{ color: 'var(--faint)', fontSize: 10.5, marginLeft: 6 }}>{item.id}</span>
<span style={{ flex: '0 1 auto', minWidth: 0, color: 'var(--muted)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.title}
</span>
<span style={{ flex: 'none', color: 'var(--faint)', fontSize: 10.5 }}>{item.id}</span>
{item.subs ? <span style={{ flex: 'none', fontSize: 10.5, color: 'var(--faint)' }}>{t.archiveSubs.replace('{n}', item.subs)}</span> : null}
<span style={{ flex: 1, minWidth: 8 }}></span>
<ComplexityBadge complexity={item.cplx} />
@@ -126,7 +127,7 @@ function ArchiveModal({ item, t, onClose }) {
const d = item.detail || {};
const label = (text) => <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', margin: '14px 0 4px' }}>{text}</div>;
const doc = (text) => (
<pre style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, color: 'var(--ink)' }}>{text}</pre>
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '10px 12px' }}><window.MaestroRichDoc md={text} /></div>
);
const kvGrid = { fontSize: 12, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 12px', margin: 0 };
return (
+135 -57
View File
@@ -1,32 +1,4 @@
// ── 报告渲染:把 markdown(## 章节 + - 列表 + **粗体** + `代码`)渲染成可折叠卡片 ──
function mdInline(text) {
const nodes = []; const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
let last = 0; let m; let key = 0;
while ((m = re.exec(text))) {
if (m.index > last) nodes.push(text.slice(last, m.index));
const tok = m[0];
if (tok.startsWith('**')) nodes.push(<b key={key++} style={{ color: 'var(--ink)' }}>{tok.slice(2, -2)}</b>);
else nodes.push(<code key={key++} style={{ fontFamily: 'var(--mono)', fontSize: '.92em', color: 'var(--cyan)', background: 'var(--panel-2)', borderRadius: 3, padding: '1px 5px' }}>{tok.slice(1, -1)}</code>);
last = m.index + tok.length;
}
if (last < text.length) nodes.push(text.slice(last));
return nodes;
}
function mdBody(body) {
const out = []; let key = 0;
for (const raw of body.split('\n')) {
const t = raw.trim();
if (!t) { out.push(<div key={key++} style={{ height: 6 }} />); continue; }
if (/^[-*]\s/.test(t)) {
out.push(<div key={key++} style={{ display: 'flex', gap: 8, padding: '2px 0 2px 2px' }}><span style={{ flex: 'none', color: 'var(--green)' }}>·</span><span>{mdInline(t.replace(/^[-*]\s/, ''))}</span></div>);
} else if (/^#{1,4}\s/.test(t)) {
out.push(<div key={key++} style={{ fontWeight: 700, color: 'var(--muted)', margin: '6px 0 2px' }}>{mdInline(t.replace(/^#{1,4}\s/, ''))}</div>);
} else {
out.push(<div key={key++} style={{ padding: '1px 0' }}>{mdInline(t)}</div>);
}
}
return out;
}
// ── 报告渲染:把 markdown 按「## 章节」切成可折叠卡片,章节内容交 window.MaestroRichDoc 渲染 ──
function mdSections(md) {
if (!md) return [];
const out = []; const lines = md.split('\n');
@@ -48,7 +20,7 @@ function ReportSectionCard({ title, body, t, defaultOpen }) {
<span style={{ flex: 'none', color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}></span>
<span style={{ flex: 1, fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', letterSpacing: '.02em' }}>{title || '说明'}</span>
</div>
{open ? <div style={{ padding: '11px 14px', fontSize: 12.5, lineHeight: 1.7, color: 'var(--ink)', wordBreak: 'break-word' }}>{mdBody(body)}</div> : null}
{open ? <div style={{ padding: '11px 14px' }}><window.MaestroRichDoc md={body} /></div> : null}
</div>
);
}
@@ -100,6 +72,85 @@ function FileDiffs({ taskId, t }) {
);
}
// 单段复审报告卡片:默认折叠,标题显示复审类型 + 裁决徽章,点击展开看完整报告(markdown)。
function ReviewReportCard({ label, verdict, report, t, first }) {
const [open, setOpen] = React.useState(false);
const vb = verdict === 'reject' ? { c: 'var(--red)', bd: 'var(--red-dim)', txt: '✗ ' + t.vReject }
: verdict === 'approve' ? { c: 'var(--green)', bd: 'var(--green-dim)', txt: '✓ ' + t.vApprove } : null;
return (
<div style={{ marginTop: first ? 0 : 8, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, overflow: 'hidden' }}>
<div onClick={() => setOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', cursor: 'pointer', userSelect: 'none', background: 'var(--panel-2)' }}>
<span style={{ color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}></span>
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.14em', color: 'var(--muted)' }}>{label}</span>
{vb ? <span style={{ fontSize: 10, fontWeight: 700, color: vb.c, border: '1px solid ' + vb.bd, borderRadius: 3, padding: '0 6px' }}>{vb.txt}</span> : null}
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--faint)' }}>{open ? t.gateCollapse : t.gateExpand}</span>
</div>
{open ? <div style={{ padding: '11px 13px' }}><window.MaestroRichDoc md={report} /></div> : null}
</div>
);
}
// 需人工卡片:拉最近的 reviewer / security run 的「完整复审报告」(脱敏 transcript 末块=最终报告),markdown 渲染——
// 替代被截断到 200 字的 lastRunError,把驳回原因罗列清楚。无复审报告(执行崩溃/冲突/重评估等)则回退显示 lastRunError。
function ReviewReports({ taskId, fallback, t }) {
const [st, setSt] = React.useState({ loading: true, reports: null });
React.useEffect(() => {
let alive = true;
(async () => {
try {
const runs = await fetch('/api/tasks/' + taskId + '/runs').then((r) => r.ok ? r.json() : []);
const list = Array.isArray(runs) ? runs : [];
const latest = (kind) => list.filter((r) => r.kind === kind)
.slice().sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')))[0];
const out = [];
for (const [kind, label] of [['reviewer', t.lblCodeReview], ['security', t.lblSecurity]]) {
const run = latest(kind);
if (!run) continue;
const d = await fetch('/api/runs/' + run.id + '/transcript').then((r) => r.ok ? r.json() : null);
const texts = ((d && d.messages) || []).filter((m) => m.type === 'text' && m.text && m.text.trim());
if (texts.length) {
const report = texts[texts.length - 1].text;
const vm = /VERDICT:\s*(approve|reject)/i.exec(report);
out.push({ label, report, verdict: vm ? vm[1].toLowerCase() : null });
}
}
if (alive) setSt({ loading: false, reports: out });
} catch (e) { if (alive) setSt({ loading: false, reports: [] }); }
})();
return () => { alive = false; };
}, [taskId]);
if (st.loading) return <div style={{ fontSize: 11.5, color: 'var(--faint)', padding: '4px 2px' }}></div>;
if (st.reports && st.reports.length) {
return (
<div>
{st.reports.map((r, i) => (
<ReviewReportCard key={i} label={r.label} verdict={r.verdict} report={r.report} t={t} first={i === 0} />
))}
</div>
);
}
return fallback ? <window.MaestroRichDoc md={fallback} /> : null;
}
// 拆解评审「拆解轨迹」:默认折叠,点击展开后懒加载该任务 planner run 的完整 transcript(表格/分析+工具调用)。
function GateTranscript({ taskId, t }) {
const [open, setOpen] = React.useState(false);
return (
<div style={{ marginTop: 18 }}>
<div onClick={() => setOpen((o) => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', padding: '4px 0' }}>
<span style={{ color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}></span>
<span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em' }}>{t.planTranscript}</span>
</div>
{open ? (
<div style={{ marginTop: 8, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, padding: '8px 12px' }}>
<window.MaestroTranscript taskId={taskId} kind="planner" />
</div>
) : null}
</div>
);
}
// 全屏预览:读完整方案 + 就地裁决
function GatePreview({ item, t, onDecide, onClose }) {
const { ComplexityBadge, StatusChip, Button, Textarea } = window.MaestroDesignSystem_a6a290;
@@ -130,7 +181,6 @@ function GatePreview({ item, t, onDecide, onClose }) {
);
};
const secLabel = (txt) => <div style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em', margin: '18px 0 8px' }}>{txt}</div>;
const reportBox = (txt) => <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 4, padding: '12px 16px', fontSize: 12.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{txt}</div>;
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
@@ -150,9 +200,7 @@ function GatePreview({ item, t, onDecide, onClose }) {
</div>
) : null}
{item.docLabel ? <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{item.docLabel}</div> : null}
{/^##\s/m.test(item.doc || '')
? <ReportCards md={item.doc} t={t} />
: <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '12px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{item.doc}</div>}
{item.doc ? <ReportCards md={item.doc} t={t} /> : null}
{item.gate === 'exec' && rv && rv.securitySummary ? (
<React.Fragment>{secLabel(t.lblSecurity)}<ReportCards md={rv.securitySummary} t={t} /></React.Fragment>
) : null}
@@ -179,17 +227,26 @@ function GatePreview({ item, t, onDecide, onClose }) {
<ComplexityBadge complexity={s.complexity} />
<span style={{ flex: 'none', fontSize: 10, fontWeight: 700, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '1px 6px' }}>P{s.priority}</span>
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, color: 'var(--ink)', minWidth: 120 }}>{s.title}</span>
{StatusChip ? <StatusChip status={s.status} label={t.status[s.status]} /> : null}
{StatusChip ? <StatusChip status="paused" label={t.frozenPending} /> : null}
</div>
<div style={{ marginTop: 7, paddingLeft: 31, fontSize: 11.5, color: 'var(--faint)' }}>
{t.dependsOn}: {deps.length ? <span style={{ color: 'var(--amber)', fontWeight: 600 }}>{deps.join('、')}</span> : t.noDeps}
</div>
{s.scopeFiles && s.scopeFiles.length ? (
<div style={{ marginTop: 5, paddingLeft: 31, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<span style={{ flex: 'none', fontSize: 11.5, color: 'var(--faint)' }}>{t.scopeFiles}:</span>
{s.scopeFiles.map((f) => (
<span key={f} style={{ fontSize: 10.5, fontFamily: 'var(--mono)', color: 'var(--cyan)', background: 'var(--panel-2)', border: '1px solid var(--line-soft)', borderRadius: 3, padding: '1px 6px' }}>{f}</span>
))}
</div>
) : null}
</div>
);
})}
</div>
</div>
) : null}
{item.gate === 'plan' ? <GateTranscript taskId={item.taskId} t={t} /> : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 16px', borderTop: '1px solid var(--line)', background: 'var(--panel-2)' }}>
{rejecting ? (
@@ -212,8 +269,8 @@ function GatePreview({ item, t, onDecide, onClose }) {
);
}
// 审批闸区:斜纹条 + GateCard 列表accept/reject + 驳回必填理由)
function GateSection({ approvals, onDecide, t }) {
// 「需你处理」区:审批闸(accept/reject + 驳回必填理由)+ 需人工(requeue/接管/取消)
function GateSection({ approvals, onDecide, taskOps, onTakeover, t }) {
const { GateCard, GateStripe, Button, Textarea } = window.MaestroDesignSystem_a6a290;
const [rejecting, setRejecting] = React.useState(null);
const [reason, setReason] = React.useState('');
@@ -233,32 +290,53 @@ function GateSection({ approvals, onDecide, t }) {
const isFolded = folded.has(a.id);
return (
<div key={a.id} style={{ marginBottom: 12 }}>
<GateCard gate={a.gate} kindLabel={t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={a.doc}
<GateCard gate={a.gate} kindLabel={a.gate === 'attention' ? t.gateNeedsHuman : t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={undefined}
collapsed={isFolded} foldable onToggleFold={() => toggleFold(a.id)}
foldTitle={isFolded ? t.gateExpand : t.gateCollapse}
onHeaderDoubleClick={() => setPreview(a)} headerTitle={t.gateDblTip}
headerExtra={<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
onHeaderDoubleClick={a.gate === 'attention' ? undefined : () => setPreview(a)} headerTitle={a.gate === 'attention' ? null : t.gateDblTip}
headerExtra={a.gate === 'attention' ? null : (<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
style={{ fontFamily: 'var(--mono)', fontSize: 12, lineHeight: 1, cursor: 'pointer', background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 'var(--radius-sm,4px)', padding: '3px 7px' }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--violet)'; e.currentTarget.style.borderColor = 'var(--violet-dim)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}></button>}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}></button>)}
actions={
<React.Fragment>
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
<span style={{ marginLeft: 'auto' }}>
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}> {t.fullRead}</Button>
</span>
</React.Fragment>
a.gate === 'attention' ? (
<React.Fragment>
<Button variant="accept" onClick={() => taskOps && taskOps.requeue(a.taskId)}>{t.gRequeue}</Button>
{onTakeover ? <Button variant="ghost" onClick={() => onTakeover({ id: a.taskId, title: a.title })}>{t.gTakeover}</Button> : null}
<span style={{ marginLeft: 'auto' }}>
<Button variant="reject" size="xs" onClick={() => taskOps && taskOps.cancel(a.taskId)}>{t.gCancelTask}</Button>
</span>
</React.Fragment>
) : (
<React.Fragment>
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
<span style={{ marginLeft: 'auto' }}>
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}> {t.fullRead}</Button>
</span>
</React.Fragment>
)
}>
{rejecting === a.id ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginTop: 10 }}>
<Textarea danger placeholder={t.rejectPlaceholder} minHeight={56} value={reason} onChange={setReason} />
<Button variant="reject" disabled={!reason.trim()}
onClick={() => { onDecide(a, 'reject', reason); setRejecting(null); setReason(''); }}>
{t.confirmReject}
</Button>
</div>
) : null}
{a.gate === 'attention' ? (
<ReviewReports taskId={a.taskId} fallback={a.doc} t={t} />
) : (
<React.Fragment>
{a.doc ? (
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 'var(--radius-sm, 4px)', padding: '10px 12px', maxHeight: 280, overflow: 'auto' }}>
<window.MaestroRichDoc md={a.doc} />
</div>
) : null}
{rejecting === a.id ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginTop: 10 }}>
<Textarea danger placeholder={t.rejectPlaceholder} minHeight={56} value={reason} onChange={setReason} />
<Button variant="reject" disabled={!reason.trim()}
onClick={() => { onDecide(a, 'reject', reason); setRejecting(null); setReason(''); }}>
{t.confirmReject}
</Button>
</div>
) : null}
</React.Fragment>
)}
</GateCard>
</div>
);
+65
View File
@@ -0,0 +1,65 @@
// 共享 Markdown 渲染:marked 解析 + DOMPurify 净化 + highlight.js 代码高亮。
// 挂 window.MaestroRichDoc,供 GateSection / TaskTree 等在 vite app 与 standalone demo 两环境共用。
// 依赖 window.marked / window.DOMPurify(必需,CDN 全局)+ window.hljs(可选,缺则代码块不着色)。
function ensureRichDocCss() {
if (document.getElementById('maestro-richdoc-css')) return;
const s = document.createElement('style');
s.id = 'maestro-richdoc-css';
s.textContent = `
.m-richdoc { font-size: 12.8px; line-height: 1.7; color: var(--ink); word-break: break-word; }
.m-richdoc > :first-child { margin-top: 0; }
.m-richdoc > :last-child { margin-bottom: 0; }
.m-richdoc h1, .m-richdoc h2, .m-richdoc h3, .m-richdoc h4 { font-weight: 700; line-height: 1.3; margin: 16px 0 8px; letter-spacing: .01em; }
.m-richdoc h1 { font-size: 1.32em; color: var(--ink); border-bottom: 1px solid var(--line-soft); padding-bottom: 5px; }
.m-richdoc h2 { font-size: 1.16em; color: var(--green); }
.m-richdoc h3 { font-size: 1.05em; color: var(--cyan); }
.m-richdoc h4 { font-size: 1em; color: var(--muted); }
.m-richdoc p { margin: 7px 0; }
.m-richdoc ul, .m-richdoc ol { margin: 7px 0; padding-left: 22px; }
.m-richdoc li { margin: 3px 0; }
.m-richdoc li::marker { color: var(--green); }
.m-richdoc a { color: var(--cyan); text-decoration: none; border-bottom: 1px solid var(--cyan-dim); }
.m-richdoc a:hover { border-bottom-color: var(--cyan); }
.m-richdoc strong { color: var(--ink); font-weight: 700; }
.m-richdoc em { color: var(--muted); }
.m-richdoc code { font-family: var(--mono); font-size: .9em; color: var(--cyan); background: var(--panel-2); border-radius: 3px; padding: 1.5px 5px; }
.m-richdoc pre { background: var(--bg-deep); border: 1px solid var(--line-soft); border-radius: 5px; padding: 11px 13px; overflow-x: auto; margin: 9px 0; }
.m-richdoc pre code { display: block; font-family: var(--mono); font-size: 11.8px; line-height: 1.55; color: var(--ink); background: none; border: 0; padding: 0; }
.m-richdoc blockquote { margin: 9px 0; padding: 2px 13px; border-left: 3px solid var(--line); color: var(--muted); }
.m-richdoc table { border-collapse: collapse; margin: 9px 0; font-size: .95em; display: block; overflow-x: auto; }
.m-richdoc th, .m-richdoc td { border: 1px solid var(--line); padding: 5px 10px; text-align: left; }
.m-richdoc th { background: var(--panel-2); color: var(--ink); font-weight: 700; }
.m-richdoc hr { border: 0; border-top: 1px solid var(--line-soft); margin: 14px 0; }
.m-richdoc img { max-width: 100%; }
`;
document.head.appendChild(s);
}
function MaestroRichDoc({ md }) {
const ref = React.useRef(null);
ensureRichDocCss();
const ready = typeof window !== 'undefined' && !!window.marked && !!window.DOMPurify;
const html = React.useMemo(() => {
if (!ready || !md) return null;
try {
const raw = window.marked.parse(String(md), { gfm: true, breaks: false });
return window.DOMPurify.sanitize(raw);
} catch (e) {
return null;
}
}, [md, ready]);
React.useEffect(() => {
if (html && ref.current && window.hljs) {
ref.current.querySelectorAll('pre code').forEach((el) => {
try { window.hljs.highlightElement(el); } catch (e) { /* noop */ }
});
}
}, [html]);
// 兜底:库未就绪或解析失败 → 原文 pre-wrap,永不崩
if (html == null) {
return <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, color: 'var(--ink)' }}>{md || ''}</pre>;
}
return <div ref={ref} className="m-richdoc" dangerouslySetInnerHTML={{ __html: html }} />;
}
window.MaestroRichDoc = MaestroRichDoc;
+36 -23
View File
@@ -48,6 +48,17 @@ function projStateMeta(state, t) {
})[state] || { color: 'var(--muted)', pulse: false, label: t.projIdle };
}
// 项目计数徽标:按优先级展示「需人工 / 运行中 / 被阻塞」,仅非零显示
function CountBadge({ value, color, title }) {
return (
<span title={title} style={{
fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 700, lineHeight: '15px',
minWidth: 15, height: 15, textAlign: 'center', padding: '0 4px',
color: 'var(--bg-deep)', background: color, borderRadius: 8,
}}>{value}</span>
);
}
function ProjStatusDot({ meta, size = 7 }) {
return (
<span style={{
@@ -247,16 +258,18 @@ function AgentCard({ global, summary, t, collapsed, projects }) {
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>{t.gAgents.toUpperCase()}
<span style={{ marginLeft: 'auto', fontWeight: 400, letterSpacing: '.04em', color: 'var(--faint)' }}>{t.apWeek}</span>
</div>
{/* 口径说明:本卡仅统计 maestro 任务执行,与账号级额度条不同源 */}
<div style={{ fontSize: 9.5, color: 'var(--faint)', letterSpacing: '.04em', marginBottom: 8, marginTop: -4 }}>{t.apScopeMaestro}</div>
{/* 概览 2×2 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 10 }}>
{[
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'],
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'],
[summary.runsWeek, t.apRuns, 'var(--ink)'],
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)'],
].map(([v, k, c], i) => (
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)', null],
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)', t.apCostEstimateHint],
[summary.runsWeek, t.apRuns, 'var(--ink)', null],
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)', null],
].map(([v, k, c, hint], i) => (
<div key={i} style={{ background: 'var(--bg-deep)', padding: '7px 9px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: c, letterSpacing: '.02em' }}>{v}</div>
<div title={hint || undefined} style={{ fontSize: 15, fontWeight: 700, color: c, letterSpacing: '.02em' }}>{v}</div>
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
</div>
))}
@@ -308,6 +321,7 @@ function AgentDetailModal({ projects, t, onClose }) {
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>
<span style={{ fontSize: 14, fontWeight: 700, letterSpacing: '.04em', color: 'var(--ink)' }}>{t.apDetailTitle}</span>
<span style={{ fontSize: 9.5, color: 'var(--faint)', letterSpacing: '.04em' }}>· {t.apScopeMaestro}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden' }}>
{[['day', t.apByDay], ['week', t.apByWeek], ['month', t.apByMonth]].map(([g, label]) => (
<button key={g} type="button" onClick={() => setGran(g)} style={{
@@ -323,13 +337,13 @@ function AgentDetailModal({ projects, t, onClose }) {
{/* total 概览 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 14 }}>
{[
[fmtUsd(tot.costUsd), t.apCost, 'var(--green)'],
[fmtTokens(tot.tokens), t.apTokens, 'var(--cyan)'],
[tot.runs, t.apRuns, 'var(--ink)'],
[tot.tasks, t.apColTasks, 'var(--ink)'],
].map(([v, k, c], i) => (
[fmtUsd(tot.costUsd), t.apCost, 'var(--green)', t.apCostEstimateHint],
[fmtTokens(tot.tokens), t.apTokens, 'var(--cyan)', null],
[tot.runs, t.apRuns, 'var(--ink)', null],
[tot.tasks, t.apColTasks, 'var(--ink)', null],
].map(([v, k, c, hint], i) => (
<div key={i} style={{ background: 'var(--bg-deep)', padding: '8px 10px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: c }}>{v}</div>
<div title={hint || undefined} style={{ fontSize: 15, fontWeight: 700, color: c }}>{v}</div>
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
</div>
))}
@@ -361,7 +375,7 @@ function AgentDetailModal({ projects, t, onClose }) {
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apTokens}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apCost}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apColTasks}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left' }}>{t.apColModels}</th>
<th title={t.apModelApprox} style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left', cursor: 'help' }}>{t.apColModels} <span style={{ color: 'var(--faint)' }}></span></th>
</tr>
</thead>
<tbody>
@@ -509,7 +523,10 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
const active = p.id === currentId;
const meta = projStateMeta(p.state, t);
return (
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label + (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')}
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label
+ (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')
+ (p.agents ? ' · ' + t.projAgentsTip.replace('{n}', p.agents) : '')
+ (p.blocked ? ' · ' + t.projBlockedTip.replace('{n}', p.blocked) : '')}
draggable={!collapsed}
onDragStart={(e) => { setDragId(p.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { if (dragId) { e.preventDefault(); if (p.id !== dragId && overId !== p.id) setOverId(p.id); } }}
@@ -536,20 +553,16 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
<span style={{ fontWeight: 600, fontSize: 13, color: active ? 'var(--green)' : 'var(--ink)' }}>{p.name}</span>
<span style={{ fontSize: 10.5, color: 'var(--faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.path}</span>
</span>
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
{p.pending > 0 ? (
<span title={t.projPendingTip.replace('{n}', p.pending)} style={{
fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 700, lineHeight: '15px',
minWidth: 15, height: 15, textAlign: 'center', padding: '0 4px',
color: 'var(--bg-deep)', background: 'var(--violet)', borderRadius: 8,
}}>{p.pending}</span>
) : null}
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 5 }}>
{p.pending > 0 ? <CountBadge value={p.pending} color="var(--violet)" title={t.projPendingTip.replace('{n}', p.pending)} /> : null}
{p.agents > 0 ? <CountBadge value={p.agents} color="var(--cyan)" title={t.projAgentsTip.replace('{n}', p.agents)} /> : null}
{p.blocked > 0 ? <CountBadge value={p.blocked} color="var(--amber)" title={t.projBlockedTip.replace('{n}', p.blocked)} /> : null}
<ProjStatusDot meta={meta} />
</span>
</React.Fragment>
) : (
<span style={{ position: 'absolute', top: 7, right: 9, display: 'flex', alignItems: 'center' }}>
<ProjStatusDot meta={meta} size={p.pending > 0 ? 8 : 6} />
<ProjStatusDot meta={meta} size={(p.pending || p.agents || p.blocked) > 0 ? 8 : 6} />
</span>
)}
</li>
+222 -15
View File
@@ -9,6 +9,10 @@ const KIT_STATUS_ORDER = [
];
const KIT_CPLX_OPTS = [{ value: 'hard', label: 'HARD' }, { value: 'medium', label: 'MED' }, { value: 'easy', label: 'EASY' }];
const KIT_PRIO_OPTS = [{ value: 'P0', label: 'P0' }, { value: 'P1', label: 'P1' }, { value: 'P2', label: 'P2' }];
// 与后端 src/api/server.ts 的 SAFE_INLINE 一字不差:只有这些类型后端才 inline 回原图,
// 其余(尤其 image/svg+xml、text/html)强制 attachment+octet-stream 下载。前端缩略图必须对齐同一硬白名单,
// 否则对 svg 用 <img src=服务端URL> 会拿到 octet-stream 出坏图,且策略漂移时埋同源存储型 XSS 隐患。
const KIT_ATT_SAFE_INLINE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
// 主显示区各区块标题通用折叠按钮(▾ 展开 / ▸ 折叠);暴露到 window 供 Agent/审批闸/归档复用
function KitFoldBtn({ folded, onClick, t }) {
@@ -223,9 +227,10 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
}}>
{kids.length ? '▶' : '·'}
</span>
<span style={{ fontWeight: 500, color: ctx ? 'var(--muted)' : 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}<span style={{ color: 'var(--faint)', fontSize: 10.5, marginLeft: 6 }}>{task.id}</span>
<span style={{ flex: '0 1 auto', minWidth: 0, fontWeight: 500, color: ctx ? 'var(--muted)' : 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}
</span>
<span style={{ flex: 'none', color: 'var(--faint)', fontSize: 10.5 }}>{task.id}</span>
<span style={{ flex: 1, minWidth: 8 }}></span>
{task.deps ? (
<span style={{ flex: 'none', fontSize: 10.5, letterSpacing: '.06em', color: 'var(--amber)', border: '1px dashed var(--amber-dim)', borderRadius: 3, padding: '1px 7px', lineHeight: 1.5, whiteSpace: 'nowrap' }}>
@@ -234,7 +239,9 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
) : null}
<span style={{ fontSize: 10.5, color: task.prio === 'P0' ? 'var(--amber)' : 'var(--faint)', flex: 'none' }}>{task.prio}</span>
<CplxPicker task={task} cplx={cplxOf(task)} onChange={onChangeCplx} />
<StatusChip status={task.status} label={t.status[task.status]} />
{task.frozen
? <StatusChip status="paused" label={t.frozenPending} />
: <StatusChip status={task.status} label={t.status[task.status]} />}
</div>
{expanded ? <TaskDetail task={task} t={t} byId={byId} onJump={onJump} onTakeover={onTakeover} taskOps={taskOps} /> : null}
{kids.length && open ? (
@@ -251,6 +258,7 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
}
function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
ensureNewTaskCss();
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
const label = task.doc ? t.specLabel : task.ops ? t.opsLabel : null;
const text = task.doc || task.ops;
@@ -281,8 +289,37 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
{ops.requeue && canRequeue ? <button onClick={() => ops.requeue(task.id)} style={btn('green')}> 重投</button> : null}
{ops.cancel && canCancel ? <button onClick={() => ops.cancel(task.id)} style={btn('amber')}> 取消</button> : null}
{ops.del ? <button onClick={() => { if (confirm('删除任务「' + task.title + '」?子任务一并删除,不可恢复。')) ops.del(task.id); }} style={btn('red')}>🗑 删除</button> : null}
{attachments.length ? <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--faint)' }}>📎 {attachments.map((a) => a.name).join(' · ')}</span> : null}
</div>
{/* 附件画廊:图片缩略图(点击新标签预览)+ 下载 + 删除(确认后调用 taskOps.delAttachment */}
{attachments.length ? (
<div onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{t.attSection} · {attachments.length}</div>
<div className="m-nt-grid">
{attachments.map((a) => {
const dn = attDiskName(a);
const isImg = KIT_ATT_SAFE_INLINE.has(a.type || '');
const url = attUrl(task.id, dn);
return (
<div key={dn || a.name} className="m-nt-chip">
<a className="m-att-thumb-link" href={url} target="_blank" rel="noopener noreferrer" title={a.name}>
<span className="m-nt-thumb">
{isImg ? <img src={url} alt={a.name} /> : <span className="m-nt-ext">{(a.name.includes('.') ? a.name.split('.').pop() : (a.type || 'file').split('/').pop() || 'file').toUpperCase()}</span>}
</span>
</a>
<span className="m-nt-name" title={a.name}>{a.name}</span>
<span className="m-att-act">
<a className="m-att-btn" href={attUrl(task.id, dn, true)} download={a.name}> {t.attDownload}</a>
{ops.delAttachment ? (
<button type="button" className="m-att-btn is-del"
onClick={() => { if (confirm(t.attDeleteConfirm.replace('{name}', a.name))) ops.delAttachment(task.id, dn); }}></button>
) : null}
</span>
</div>
);
})}
</div>
</div>
) : null}
{/* 编辑表单 */}
{editing ? (
<div style={{ display: 'grid', gap: 12, padding: 12, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg-deep)' }} onClick={(e) => e.stopPropagation()}>
@@ -315,7 +352,7 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
{label ? (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{label}</div>
<pre style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 12.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, color: 'var(--ink)', borderRadius: 4 }}>{text}</pre>
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 14px', borderRadius: 4 }}><window.MaestroRichDoc md={text} /></div>
</div>
) : (
<div style={{ padding: '10px 12px', border: '1px dashed var(--line)', borderRadius: 6, color: 'var(--faint)', fontStyle: 'italic', fontSize: 12 }}>
@@ -348,17 +385,164 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
</div>
</div>
) : null}
{/* 审批记录:读 _raw.approvals,按记录列出闸名·通过/驳回 + 审批人 + 时间;驳回突出 reason。
安全:reason/actor/gate 全部走 React 文本节点(默认转义),绝不经 MaestroRichDoc / innerHTML,避免同源存储型 XSS。 */}
{(() => {
const approvals = (task._raw && task._raw.approvals) || [];
if (!approvals.length) return null;
return (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{t.approvalsLabel}</div>
<div style={{ display: 'grid', gap: 6 }}>
{approvals.map((a, i) => {
const rej = a.action === 'reject';
const at = (a.at || '').replace('T', ' ').slice(0, 19);
const gate = (t.gates && t.gates[a.gate]) || a.gate;
return (
<div key={i} style={{
fontSize: 12, padding: '6px 10px', borderRadius: 4,
background: 'var(--bg-deep)', border: '1px solid var(--line-soft)',
borderLeft: '2px solid ' + (rej ? 'var(--red-dim)' : 'var(--green-dim)'),
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ color: rej ? 'var(--red)' : 'var(--green)', fontWeight: 700, flex: 'none' }}>
{gate} · {rej ? t.rejected : t.accepted}
</span>
<span style={{ color: 'var(--faint)', fontSize: 11 }}>{t.approver} {a.actor} · {at}</span>
</div>
{rej && a.reason ? (
<div style={{ marginTop: 5, padding: '5px 9px', borderRadius: 3,
background: 'rgba(240,100,100,.08)', borderLeft: '2px solid var(--red-dim)', color: 'var(--amber)' }}>
<span style={{ color: 'var(--red)', fontWeight: 600, marginRight: 6 }}>{t.rejectReason}</span>{a.reason}
</div>
) : a.reason ? (
<div style={{ marginTop: 4, color: 'var(--muted)' }}> {a.reason}</div>
) : null}
</div>
);
})}
</div>
</div>
);
})()}
</div>
);
}
// 新建任务面板的附件区样式(dropzone 高亮 / 缩略图 chip / 删除角标),一次性注入避免污染全局
function ensureNewTaskCss() {
if (document.getElementById('maestro-kit-newtask-css')) return;
const s = document.createElement('style');
s.id = 'maestro-kit-newtask-css';
s.textContent = `
.m-nt-drop { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px dashed var(--line); border-radius: var(--radius-sm, 4px); background: var(--bg-deep); transition: border-color .12s, background .12s; }
.m-nt-drop.is-over { border-color: var(--green-dim); background: var(--panel-2); }
.m-nt-hint { font-size: 10.5px; color: var(--faint); letter-spacing: .04em; }
.m-nt-hint.is-over { color: var(--green); }
.m-nt-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.m-nt-chip { position: relative; width: 92px; display: flex; flex-direction: column; gap: 3px; }
.m-nt-thumb { width: 92px; height: 64px; border: 1px solid var(--line); border-radius: var(--radius-sm, 4px); background: var(--panel-2); overflow: hidden; display: flex; align-items: center; justify-content: center; }
.m-nt-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.m-nt-ext { font-family: var(--mono); font-size: 12px; color: var(--cyan); text-transform: uppercase; letter-spacing: .06em; }
.m-nt-name { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.m-nt-size { font-size: 9.5px; color: var(--faint); font-family: var(--mono); }
.m-nt-del { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; padding: 0; line-height: 1; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); color: var(--muted); cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; transition: color .12s, border-color .12s, background .12s; }
.m-nt-del:hover { color: var(--red); border-color: var(--red-dim); background: var(--panel-2); }
.m-att-thumb-link { display: block; width: 92px; height: 64px; }
.m-att-act { display: flex; gap: 6px; align-items: center; }
.m-att-btn { font-family: var(--mono); font-size: 10px; line-height: 1; padding: 3px 6px; border: 1px solid var(--line); border-radius: var(--radius-sm, 4px); background: var(--panel); color: var(--muted); cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; transition: color .12s, border-color .12s, background .12s; }
.m-att-btn:hover { color: var(--green); border-color: var(--green-dim); background: var(--panel-2); }
.m-att-btn.is-del:hover { color: var(--red); border-color: var(--red-dim); }
`;
document.head.appendChild(s);
}
// 已建任务附件:磁盘文件名(后端 :name 标识符 = basename(att.path),非展示名 att.name
function attDiskName(a) { return (a.path || '').split('/').pop() || a.name || ''; }
// 附件预览/下载 URL(UI kit 内联,零外部依赖;与 app/src/api.js attachmentUrl 同构)
function attUrl(taskId, name, download) {
return `/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}${download ? '?download=1' : ''}`;
}
// 三来源(选择/粘贴/拖拽)统一去重键:name+size+lastModified
function ntFileKey(f) { return (f.name || '') + '' + f.size + '' + f.lastModified; }
// 由 MIME 推断扩展名(剪贴板图片常无文件名)
function ntExtFromType(type) {
if (!type) return '';
const sub = String(type).split('/')[1] || '';
const m = { jpeg: 'jpg', svg: 'svg', 'svg+xml': 'svg' };
return sub ? '.' + (m[sub] || sub) : '';
}
// 为无名文件(粘贴的截图)补一个稳定文件名
function ntNamed(f, i) {
if (f.name) return f;
const name = 'pasted-' + Date.now() + (i ? '-' + i : '') + ntExtFromType(f.type);
try { return new File([f], name, { type: f.type, lastModified: f.lastModified }); } catch { return f; }
}
// chip 角标显示的扩展名文字
function ntExtLabel(f) {
const fromName = f.name && f.name.includes('.') ? f.name.split('.').pop() : '';
return (fromName || ntExtFromType(f.type).replace(/^\./, '') || 'file').toUpperCase();
}
function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
ensureNewTaskCss();
const { Button, Input, Select, ComplexitySeg } = window.MaestroDesignSystem_a6a290;
const [title, setTitle] = React.useState('');
const [complexity, setComplexity] = React.useState('auto');
const [priority, setPriority] = React.useState('1');
const [parentId, setParentId] = React.useState('');
const [files, setFiles] = React.useState([]);
const [dragOver, setDragOver] = React.useState(false);
// 图片缩略图 objectURL 缓存(按去重键),卸载/移除时显式 revoke 防泄漏
const urlMapRef = React.useRef(new Map());
const [warn, setWarn] = React.useState('');
// 为图片预生成缩略图 objectURL(幂等,按去重键缓存)——只在 addFiles 调用,render 仅读,避免严格模式下重复 render 泄漏
const ensureThumb = (f) => {
if (!f.type || !f.type.startsWith('image/')) return;
const key = ntFileKey(f);
const map = urlMapRef.current;
if (!map.has(key)) map.set(key, URL.createObjectURL(f));
};
const thumbUrl = (f) => (f.type && f.type.startsWith('image/')) ? (urlMapRef.current.get(ntFileKey(f)) || null) : null;
// 组件卸载时释放全部 objectURL
React.useEffect(() => () => {
urlMapRef.current.forEach((u) => URL.revokeObjectURL(u));
urlMapRef.current.clear();
}, []);
// 合并新文件并按复合键去重(剪贴板无名图先补名)
const addFiles = (incoming) => {
if (!incoming || !incoming.length) return;
const MAX_BYTES = 25 * 1024 * 1024; // 单文件软上限,避免超大截图卡住上传
const named = Array.from(incoming).map((f, i) => ntNamed(f, i));
const ok = named.filter((f) => f.size <= MAX_BYTES);
setWarn(ok.length < named.length ? t.attTooLarge : '');
if (!ok.length) return;
ok.forEach(ensureThumb); // 预生成缩略图,render 不再产生副作用
setFiles((prev) => {
const seen = new Set(prev.map(ntFileKey));
const merged = prev.slice();
ok.forEach((f) => { const k = ntFileKey(f); if (!seen.has(k)) { seen.add(k); merged.push(f); } });
return merged;
});
};
const removeFile = (key) => {
setFiles((prev) => prev.filter((f) => ntFileKey(f) !== key));
const map = urlMapRef.current;
if (map.has(key)) { URL.revokeObjectURL(map.get(key)); map.delete(key); }
};
const onPaste = (e) => {
const items = (e.clipboardData && e.clipboardData.items) || [];
const imgs = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length) { e.preventDefault(); addFiles(imgs); }
};
const submit = (e) => {
e.preventDefault();
if (!title.trim()) return;
@@ -370,7 +554,7 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
walk(tasks, '');
return (
<form style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: 14, marginBottom: 14 }}
onSubmit={submit}>
onSubmit={submit} onPaste={onPaste}>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<span style={{ flex: 1, minWidth: 320, display: 'flex' }}><Input label={t.titleLabel} required placeholder={t.titlePlaceholder} value={title} onChange={setTitle} style={{ width: '100%' }} /></span>
</div>
@@ -387,16 +571,39 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
</span>
</div>
<div style={{ marginTop: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.attachLabel || '附件 · 图片/文件(可选)'}</span>
<input type="file" multiple onChange={(e) => setFiles([...e.target.files])}
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
</label>
{files.length ? (
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--faint)' }}>
{files.map((f) => f.name).join(' · ')}
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.attachLabel}</span>
<div className={'m-nt-drop' + (dragOver ? ' is-over' : '')}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragEnter={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}
onDrop={(e) => { e.preventDefault(); addFiles(e.dataTransfer.files); setDragOver(false); }}>
<input type="file" multiple
onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }}
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
<span className={'m-nt-hint' + (dragOver ? ' is-over' : '')}>{dragOver ? t.dropHint : t.pasteHint}</span>
{warn ? <span className="m-nt-hint" style={{ color: 'var(--amber)' }}>{warn}</span> : null}
{files.length ? (
<div className="m-nt-grid">
{files.map((f) => {
const key = ntFileKey(f);
const url = thumbUrl(f);
return (
<div key={key} className="m-nt-chip">
<button type="button" className="m-nt-del" title={t.removeFile}
onClick={() => removeFile(key)}>×</button>
<div className="m-nt-thumb">
{url ? <img src={url} alt={f.name} /> : <span className="m-nt-ext">{ntExtLabel(f)}</span>}
</div>
<span className="m-nt-name" title={f.name}>{f.name}</span>
<span className="m-nt-size">{(f.size / 1024).toFixed(0)}KB</span>
</div>
);
})}
</div>
) : null}
</div>
) : null}
</label>
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
<Button onClick={onCancel}>{t.cancel}</Button>
+2 -1
View File
@@ -164,9 +164,10 @@ function AgentSection({ agents, quota, t, onOpenStream }) {
{!folded ? (<React.Fragment>
{quota ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 22, flexWrap: 'wrap', fontFamily: 'var(--mono)', margin: '0 0 12px' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', letterSpacing: '.08em' }}>{t.quota}</span>
<span title={t.quotaScopeAccount} style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', letterSpacing: '.08em', cursor: 'help' }}>{t.quota}</span>
<QuotaMeter label="5h" pct={quota.five.pct} detail={t.quotaReset.replace('{t}', quota.five.reset)} />
<QuotaMeter label={t.quotaWeek} pct={quota.week.pct} detail={t.quotaReset.replace('{t}', quota.week.reset)} />
<span style={{ fontSize: 9.5, color: 'var(--faint)', letterSpacing: '.04em' }}>{t.quotaScopeAccount}</span>
</div>
) : null}
{agents.length === 0 ? (
+48
View File
@@ -0,0 +1,48 @@
// 历史 transcript 回放:拉某任务指定 kind 的 run 的转录(/api/tasks/:id/runs → /api/runs/:id/transcript),
// 渲染成结构化行(text / tool_use / tool_result / result)。挂 window.MaestroTranscript,供拆解评审「拆解轨迹」等处复用。
// 转录已是脱敏 + 截断的扁平 schema{type, text?, name?, input?, isError?}(见 src/executor/transcript.ts)。
function TcLine({ m }) {
if (m.type === 'tool_use') {
return <div style={{ padding: '3px 0' }}><span style={{ color: 'var(--cyan)', fontWeight: 600 }}> {m.name}</span>{m.input ? <span style={{ color: 'var(--muted)' }}> {m.input}</span> : null}</div>;
}
if (m.type === 'tool_result') {
return <div style={{ padding: '1px 0 5px 16px', color: m.isError ? 'var(--red)' : 'var(--faint)', fontSize: 11.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> {m.text || '(空)'}</div>;
}
if (m.type === 'result') return <div style={{ padding: '3px 0', color: 'var(--green)' }}> {m.text || 'done'}</div>;
return <div style={{ padding: '3px 0', color: 'var(--ink)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{m.text}</div>;
}
function MaestroTranscript({ taskId, kind }) {
const [state, setState] = React.useState({ loading: true, msgs: null, err: false });
React.useEffect(() => {
let alive = true;
setState({ loading: true, msgs: null, err: false });
(async () => {
try {
const runs = await fetch('/api/tasks/' + taskId + '/runs').then((r) => r.ok ? r.json() : Promise.reject(new Error('http')));
const list = Array.isArray(runs) ? runs : [];
const run = (kind ? list.filter((r) => r.kind === kind) : list)
.slice().sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')))[0] || list[0];
if (!run) { if (alive) setState({ loading: false, msgs: [], err: false }); return; }
const d = await fetch('/api/runs/' + run.id + '/transcript').then((r) => r.ok ? r.json() : Promise.reject(new Error('http')));
const msgs = Array.isArray(d) ? d : (d.messages || []);
if (alive) setState({ loading: false, msgs, err: false });
} catch (e) {
if (alive) setState({ loading: false, msgs: null, err: true });
}
})();
return () => { alive = false; };
}, [taskId, kind]);
const hint = (txt) => <div style={{ fontSize: 11.5, color: 'var(--faint)', padding: '8px 2px' }}>{txt}</div>;
if (state.loading) return hint('… 加载轨迹');
if (state.err) return hint('轨迹取不到');
if (!state.msgs || !state.msgs.length) return hint('无轨迹记录');
return (
<div style={{ maxHeight: 440, overflowY: 'auto', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, padding: '4px 2px' }}>
{state.msgs.map((m, i) => <TcLine key={m.seq != null ? m.seq : i} m={m} />)}
</div>
);
}
window.MaestroTranscript = MaestroTranscript;
+3 -3
View File
@@ -1,9 +1,9 @@
// MAESTRO 调度台 · UI kit 假数据(与 src/model 状态机对齐)
window.MAESTRO_MOCK = {
projects: [
{ id: 'p1', name: 'maestro', path: '~/dev/maestro', branch: 'main', autonomy: 'auto-easy', concurrency: 2, hue: 140, state: 'running', pending: 1, agents: 1 },
{ id: 'p2', name: 'blog-engine', path: '~/dev/blog-engine', branch: 'main', autonomy: 'manual', concurrency: 1, hue: 38, state: 'paused', pending: 0, agents: 0 },
{ id: 'p3', name: 'dotfiles', path: '~/.dotfiles', branch: 'master', autonomy: 'auto-approved', concurrency: 1, hue: 265, state: 'blocked', pending: 0, agents: 0 },
{ id: 'p1', name: 'maestro', path: '~/dev/maestro', branch: 'main', autonomy: 'auto-easy', concurrency: 2, hue: 140, state: 'running', pending: 1, agents: 1, blocked: 0 },
{ id: 'p2', name: 'blog-engine', path: '~/dev/blog-engine', branch: 'main', autonomy: 'manual', concurrency: 1, hue: 38, state: 'paused', pending: 0, agents: 0, blocked: 0 },
{ id: 'p3', name: 'dotfiles', path: '~/.dotfiles', branch: 'master', autonomy: 'auto-approved', concurrency: 1, hue: 265, state: 'blocked', pending: 2, agents: 0, blocked: 2 },
],
agents: [
{ id: 'r1', project: 'maestro', title: '把轮询改为 WS 推送', kind: 'EXECUTOR', time: '06:12', meta: 'wt-127 · claude-sonnet' },
+40 -15
View File
@@ -22,9 +22,12 @@ window.MAESTRO_I18N = {
agentSection: 'Agent 执行', agentEmpty: '无运行中的 agent', running: 'RUNNING', since: '起',
projRunning: '运行中', projPaused: '暂停', projBlocked: '阻塞', projIdle: '空闲',
projPendingTip: '{n} 项待你审批',
projAgentsTip: '{n} 个 agent 运行中',
projBlockedTip: '{n} 项被阻塞',
gAgents: '全局 Agent', gAgentsDetail: '{p}/{P} 个项目运行 · {a} 个 agent',
apActive: '运行', apRuns: '次运行', apTokens: 'token', apWeek: '本周', apCost: '花费', apTotal: '所有项目', apPerProj: '按项目', apIdle: '空闲', apMore: '展开其余 {n} 个', apCollapse: '收起',
apDetail: '详情', apDetailTitle: '用量详情', apByDay: '按天', apByWeek: '按周', apByMonth: '按月', apColProject: '项目', apColTasks: '任务数', apColModels: '模型', apEmpty: '暂无用量数据',
apScopeMaestro: '仅统计 maestro 任务执行', quotaScopeAccount: '整个账号(含你自己的 Claude Code 会话)', apCostEstimateHint: '按 API 列表价估算,非订阅实际扣费', apModelApprox: '分模型 token 为近似(按 run 末次模型归集)',
gConfig: '全局配置', gConfigDetail: '并发上限 {n} · daemon {v}',
gUserPlan: '订阅', gUserSignOut: '退出登录', gUserSettings: '账户设置',
gGlobalSettings: '全局设置', gSettingsHint: '以下为新建项目的默认值(按用户保存)', gMaxRetries: '最大重试', gTimeoutMin: '超时(分钟)', gAutoPlan: '拆解自动放行', gAutoExec: '执行自动放行', gBudget: '预算 $(空=不限)', gBudgetPeriod: '预算周期', gPeriodDay: '按天', gPeriodMonth: '按月',
@@ -35,11 +38,13 @@ window.MAESTRO_I18N = {
fgroups: { todo: '待办', doing: '进行中', gate: '待审批', bad: '异常', hold: '挂起', done: '完成' },
fStatus: '状态', fAssignee: '执行者', assignees: { agent: '智能体', human: '人工' },
fullRead: '全屏阅读', depsLabel: 'DEPS · 依赖(全部完成才可执行)', depDone: '完成', depWait: '等待',
gateSection: '审批闸', accept: '✓ 通过', reject: '✕ 驳回', confirmReject: '确认驳回',
rejectPlaceholder: '改进意见(必填)',
gateSection: '需你处理', gateNeedsHuman: '需人工', gRequeue: '↻ 重投', gTakeover: '⌨ 接管', gCancelTask: '⊘ 取消', accept: '✓ 通过', reject: '✕ 驳回', confirmReject: '确认驳回',
rejectPlaceholder: '改进意见(必填)', rejectReason: '驳回原因',
taskSection: '任务树', newTask: '+ 新建任务', titleLabel: '标题', titlePlaceholder: '要做什么',
complexity: '复杂度', cplxAuto: '智能', parentTask: '父任务', topLevel: '(顶层)', priority: '优先级',
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: '创建任务', cancel: '取消',
attachLabel: '附件 · 图片/文件(可选)', dropHint: '拖拽文件到此处,或粘贴截图', removeFile: '移除', pasteHint: '支持 ⌘V 粘贴图片',
attSection: '附件', attDownload: '下载', attDeleteConfirm: '删除附件「{name}」?此操作不可恢复。', attDeleted: '附件已删除', attDeleteFailed: '删除附件失败:', attTooLarge: '单个文件超过 25MB,已跳过', attUploaded: '附件已上传 · {n} 个', attDeduped: '(去重 {n} 个)',
depsWait: '待依赖', specLabel: '改动方案(SPEC', opsLabel: '将执行的操作(OPERATIONS',
pendingDoc: '待 Claude Code 产出(经 MCP 写入并提交评审)',
emptyTasks: '该项目暂无任务 —— 点「+ 新建任务」或经 MCP 创建', empty: '空',
@@ -49,7 +54,7 @@ window.MAESTRO_I18N = {
gatePassed: ' 通过',
status: { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', ready: '可执行', blocked: '被依赖阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', failed: '失败', needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消' },
gates: { plan: '拆解评审', spec: '方案评审', exec: '结果评审' },
gateSubtasks: '拆解出的子任务', noDeps: '无依赖', dependsOn: '依赖',
gateSubtasks: '拆解出的子任务', frozenPending: '待拆解确认', scopeFiles: '文件范围', planTranscript: '拆解轨迹(agent 执行记录)', noDeps: '无依赖', dependsOn: '依赖',
lblCodeReview: '代码复审', lblSecurity: '安全审计', lblChanges: '改动', vApprove: '通过', vReject: '驳回', vNone: '无结论',
eventTypes: { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步' },
},
@@ -72,9 +77,12 @@ window.MAESTRO_I18N = {
agentSection: 'Agent runs', agentEmpty: 'No running agents', running: 'RUNNING', since: '',
projRunning: 'Running', projPaused: 'Paused', projBlocked: 'Blocked', projIdle: 'Idle',
projPendingTip: '{n} awaiting your review',
projAgentsTip: '{n} agents running',
projBlockedTip: '{n} blocked',
gAgents: 'Agents', gAgentsDetail: '{p}/{P} projects · {a} agents',
apActive: 'active', apRuns: 'runs', apTokens: 'tokens', apWeek: 'this week', apCost: 'cost', apTotal: 'All projects', apPerProj: 'BY PROJECT', apIdle: 'idle', apMore: '+{n} more', apCollapse: 'Collapse',
apDetail: 'Details', apDetailTitle: 'Usage detail', apByDay: 'By day', apByWeek: 'By week', apByMonth: 'By month', apColProject: 'Project', apColTasks: 'Tasks', apColModels: 'Models', apEmpty: 'No usage data yet',
apScopeMaestro: 'Maestro tasks only', quotaScopeAccount: 'Whole account (incl. your own Claude Code sessions)', apCostEstimateHint: 'Estimated at API list price, not actual subscription billing', apModelApprox: 'Per-model tokens are approximate (attributed to each runs last model)',
gConfig: 'Settings', gConfigDetail: 'Max concurrency {n} · daemon {v}',
gUserPlan: 'Plan', gUserSignOut: 'Sign out', gUserSettings: 'Account settings',
gGlobalSettings: 'Global settings', gSettingsHint: 'Defaults applied to new projects (saved per user)', gMaxRetries: 'Max retries', gTimeoutMin: 'Timeout (min)', gAutoPlan: 'Auto-approve plan', gAutoExec: 'Auto-approve exec', gBudget: 'Budget $ (empty=∞)', gBudgetPeriod: 'Budget period', gPeriodDay: 'Daily', gPeriodMonth: 'Monthly',
@@ -85,11 +93,13 @@ window.MAESTRO_I18N = {
fgroups: { todo: 'To do', doing: 'In progress', gate: 'Pending review', bad: 'Issues', hold: 'On hold', done: 'Done' },
fStatus: 'Status', fAssignee: 'Assignee', assignees: { agent: 'Agent', human: 'Human' },
fullRead: 'Read fullscreen', depsLabel: 'DEPS (all must complete to run)', depDone: 'done', depWait: 'waiting',
gateSection: 'Approval gates', accept: '✓ Accept', reject: '✕ Reject', confirmReject: 'Confirm reject',
rejectPlaceholder: 'Improvement feedback (required)',
gateSection: 'Needs you', gateNeedsHuman: 'Needs human', gRequeue: '↻ Requeue', gTakeover: '⌨ Take over', gCancelTask: '⊘ Cancel', accept: '✓ Accept', reject: '✕ Reject', confirmReject: 'Confirm reject',
rejectPlaceholder: 'Improvement feedback (required)', rejectReason: 'Reject reason',
taskSection: 'Task tree', newTask: '+ New task', titleLabel: 'Title', titlePlaceholder: 'What needs doing',
complexity: 'Complexity', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(top level)', priority: 'Priority',
p0: 'P0 · high', p1: 'P1 · medium', p2: 'P2 · low', create: 'Create task', cancel: 'Cancel',
attachLabel: 'Attachments · images/files (optional)', dropHint: 'Drop files here, or paste a screenshot', removeFile: 'Remove', pasteHint: '⌘V to paste images',
attSection: 'Attachments', attDownload: 'Download', attDeleteConfirm: 'Delete attachment "{name}"? This cannot be undone.', attDeleted: 'Attachment deleted', attDeleteFailed: 'Failed to delete attachment: ', attTooLarge: 'File exceeds 25MB and was skipped', attUploaded: 'Attachments uploaded · {n}', attDeduped: ' ({n} duplicates skipped)',
depsWait: 'deps', specLabel: 'CHANGE SPEC', opsLabel: 'PLANNED OPERATIONS',
pendingDoc: 'Awaiting Claude Code output (written via MCP, then submitted for review)',
emptyTasks: 'No tasks in this project — hit "+ New task" or create via MCP', empty: 'EMPTY',
@@ -99,7 +109,7 @@ window.MAESTRO_I18N = {
gatePassed: ' approved',
status: { init: 'New', analyzing: 'Analyzing', plan_review: 'Plan review', decomposed: 'Decomposed', speccing: 'Drafting spec', spec_review: 'Spec review', ready: 'Ready', blocked: 'Blocked', queued: 'Queued', executing: 'Executing', exec_review: 'Review & merge', failed: 'Failed', needs_attention: 'Needs attention', done: 'Done', paused: 'Paused', cancelled: 'Cancelled' },
gates: { plan: 'PLAN REVIEW', spec: 'SPEC REVIEW', exec: 'EXEC REVIEW' },
gateSubtasks: 'Decomposed subtasks', noDeps: 'no deps', dependsOn: 'deps',
gateSubtasks: 'Decomposed subtasks', frozenPending: 'awaiting approval', scopeFiles: 'files', planTranscript: 'Decompose trace', noDeps: 'no deps', dependsOn: 'deps',
lblCodeReview: 'Code review', lblSecurity: 'Security', lblChanges: 'Changes', vApprove: 'Approve', vReject: 'Reject', vNone: 'No verdict',
eventTypes: { 'task.created': 'Task created', 'task.updated': 'Task updated', 'status.changed': 'Status changed', 'approval.requested': 'Approval requested', 'approval.granted': 'Approval granted', 'approval.rejected': 'Approval rejected', 'run.started': 'Run started', 'run.finished': 'Run finished', 'project.synced': 'Todo synced' },
},
@@ -122,9 +132,12 @@ window.MAESTRO_I18N = {
agentSection: 'Ejecución de agents', agentEmpty: 'Sin agents en ejecución', running: 'RUNNING', since: '',
projRunning: 'En curso', projPaused: 'Pausado', projBlocked: 'Bloqueado', projIdle: 'Inactivo',
projPendingTip: '{n} esperan tu revisión',
projAgentsTip: '{n} agentes en curso',
projBlockedTip: '{n} bloqueadas',
gAgents: 'Agents globales', gAgentsDetail: '{p}/{P} proyectos · {a} agents',
apActive: 'activos', apRuns: 'ejecuciones', apTokens: 'tokens', apWeek: 'esta semana', apCost: 'coste', apTotal: 'Todos los proyectos', apPerProj: 'POR PROYECTO', apIdle: 'inactivo', apMore: '+{n} más', apCollapse: 'Contraer',
apDetail: 'Detalles', apDetailTitle: 'Detalle de uso', apByDay: 'Por día', apByWeek: 'Por semana', apByMonth: 'Por mes', apColProject: 'Proyecto', apColTasks: 'Tareas', apColModels: 'Modelos', apEmpty: 'Sin datos de uso',
apScopeMaestro: 'Solo tareas de Maestro', quotaScopeAccount: 'Toda la cuenta (incl. tus propias sesiones de Claude Code)', apCostEstimateHint: 'Estimado al precio de lista de la API, no la facturación real de la suscripción', apModelApprox: 'Los tokens por modelo son aproximados (atribuidos al último modelo de cada run)',
gConfig: 'Config global', gConfigDetail: 'Concurrencia máx {n} · daemon {v}',
gUserPlan: 'Plan', gUserSignOut: 'Cerrar sesión', gUserSettings: 'Ajustes de cuenta',
gGlobalSettings: 'Configuración global', gSettingsHint: 'Valores por defecto para proyectos nuevos (por usuario)', gMaxRetries: 'Reintentos máx.', gTimeoutMin: 'Tiempo límite (min)', gAutoPlan: 'Auto-aprobar plan', gAutoExec: 'Auto-aprobar ejec.', gBudget: 'Presupuesto $ (vacío=∞)', gBudgetPeriod: 'Periodo', gPeriodDay: 'Diario', gPeriodMonth: 'Mensual',
@@ -135,11 +148,13 @@ window.MAESTRO_I18N = {
fgroups: { todo: 'Pendientes', doing: 'En curso', gate: 'Por revisar', bad: 'Incidencias', hold: 'En pausa', done: 'Hechas' },
fStatus: 'Estado', fAssignee: 'Ejecutor', assignees: { agent: 'Agente', human: 'Humano' },
fullRead: 'Pantalla completa', depsLabel: 'DEPS (todas deben completarse)', depDone: 'hecha', depWait: 'esperando',
gateSection: 'Puertas de aprobación', accept: '✓ Aceptar', reject: '✕ Rechazar', confirmReject: 'Confirmar rechazo',
rejectPlaceholder: 'Comentario de mejora (obligatorio)',
gateSection: 'Requiere acción', gateNeedsHuman: 'Manual', gRequeue: '↻ Reencolar', gTakeover: '⌨ Tomar', gCancelTask: '⊘ Cancelar', accept: '✓ Aceptar', reject: '✕ Rechazar', confirmReject: 'Confirmar rechazo',
rejectPlaceholder: 'Comentario de mejora (obligatorio)', rejectReason: 'Motivo del rechazo',
taskSection: 'Árbol de tareas', newTask: '+ Nueva tarea', titleLabel: 'Título', titlePlaceholder: 'Qué hay que hacer',
complexity: 'Complejidad', cplxAuto: 'AUTO', parentTask: 'Padre', topLevel: '(raíz)', priority: 'Prioridad',
p0: 'P0 · alta', p1: 'P1 · media', p2: 'P2 · baja', create: 'Crear tarea', cancel: 'Cancelar',
attachLabel: 'Adjuntos · imágenes/archivos (opcional)', dropHint: 'Suelta archivos aquí o pega una captura', removeFile: 'Quitar', pasteHint: '⌘V para pegar imágenes',
attSection: 'Adjuntos', attDownload: 'Descargar', attDeleteConfirm: '¿Eliminar el adjunto «{name}»? Esta acción no se puede deshacer.', attDeleted: 'Adjunto eliminado', attDeleteFailed: 'Error al eliminar el adjunto: ', attTooLarge: 'El archivo supera los 25MB y se omitió', attUploaded: 'Adjuntos subidos · {n}', attDeduped: ' ({n} duplicados omitidos)',
depsWait: 'deps', specLabel: 'SPEC DE CAMBIOS', opsLabel: 'OPERACIONES PREVISTAS',
pendingDoc: 'Esperando salida de Claude Code (vía MCP, luego a revisión)',
emptyTasks: 'Sin tareas — pulsa "+ Nueva tarea" o crea vía MCP', empty: 'VACÍO',
@@ -149,7 +164,7 @@ window.MAESTRO_I18N = {
gatePassed: ' aprobada',
status: { init: 'Nueva', analyzing: 'Analizando', plan_review: 'Revisión de plan', decomposed: 'Descompuesta', speccing: 'Redactando spec', spec_review: 'Revisión de spec', ready: 'Lista', blocked: 'Bloqueada', queued: 'En cola', executing: 'Ejecutando', exec_review: 'Revisar y fusionar', failed: 'Fallida', needs_attention: 'Requiere atención', done: 'Hecha', paused: 'Pausada', cancelled: 'Cancelada' },
gates: { plan: 'REVISIÓN DE PLAN', spec: 'REVISIÓN DE SPEC', exec: 'REVISIÓN DE RESULTADO' },
gateSubtasks: 'Subtareas', noDeps: 'sin deps', dependsOn: 'deps',
gateSubtasks: 'Subtareas', frozenPending: 'pendiente aprob.', scopeFiles: 'archivos', planTranscript: 'Traza de descomposición', noDeps: 'sin deps', dependsOn: 'deps',
lblCodeReview: 'Revisión', lblSecurity: 'Seguridad', lblChanges: 'Cambios', vApprove: 'Aprobar', vReject: 'Rechazar', vNone: 'Sin veredicto',
eventTypes: { 'task.created': 'Tarea creada', 'task.updated': 'Tarea actualizada', 'status.changed': 'Cambio de estado', 'approval.requested': 'Aprobación solicitada', 'approval.granted': 'Aprobación concedida', 'approval.rejected': 'Aprobación rechazada', 'run.started': 'Run iniciado', 'run.finished': 'Run terminado', 'project.synced': 'Todo sincronizado' },
},
@@ -172,9 +187,12 @@ window.MAESTRO_I18N = {
agentSection: 'Agent 実行', agentEmpty: '実行中の agent はありません', running: 'RUNNING', since: '開始',
projRunning: '実行中', projPaused: '一時停止', projBlocked: 'ブロック', projIdle: 'アイドル',
projPendingTip: '{n} 件があなたの承認待ち',
projAgentsTip: '{n} 件のエージェント実行中',
projBlockedTip: '{n} 件ブロック中',
gAgents: 'グローバル Agent', gAgentsDetail: '{p}/{P} プロジェクト · {a} 個の agent',
apActive: '実行中', apRuns: '回実行', apTokens: 'token', apWeek: '今週', apCost: 'コスト', apTotal: '全プロジェクト', apPerProj: 'プロジェクト別', apIdle: 'アイドル', apMore: '他 {n} 件', apCollapse: '折りたたむ',
apDetail: '詳細', apDetailTitle: '使用量の詳細', apByDay: '日別', apByWeek: '週別', apByMonth: '月別', apColProject: 'プロジェクト', apColTasks: 'タスク数', apColModels: 'モデル', apEmpty: '使用量データなし',
apScopeMaestro: 'maestro タスクのみ', quotaScopeAccount: 'アカウント全体(自分の Claude Code セッションを含む)', apCostEstimateHint: 'API 定価による概算で、実際のサブスク請求額ではありません', apModelApprox: 'モデル別 token は概算(各 run の最終モデルに集計)',
gConfig: 'グローバル設定', gConfigDetail: '並列上限 {n} · daemon {v}',
gUserPlan: 'プラン', gUserSignOut: 'ログアウト', gUserSettings: 'アカウント設定',
gGlobalSettings: 'グローバル設定', gSettingsHint: '新規プロジェクトの既定値(ユーザー単位で保存)', gMaxRetries: '最大リトライ', gTimeoutMin: 'タイムアウト(分)', gAutoPlan: '分解を自動承認', gAutoExec: '実行を自動承認', gBudget: '予算 $(空=無制限)', gBudgetPeriod: '予算期間', gPeriodDay: '日次', gPeriodMonth: '月次',
@@ -185,11 +203,13 @@ window.MAESTRO_I18N = {
fgroups: { todo: '未着手', doing: '進行中', gate: '承認待ち', bad: '異常', hold: '保留', done: '完了' },
fStatus: 'ステータス', fAssignee: '担当', assignees: { agent: 'エージェント', human: '人手' },
fullRead: '全画面で読む', depsLabel: 'DEPS · 依存(全完了で実行可)', depDone: '完了', depWait: '待機',
gateSection: '承認ゲート', accept: '✓ 承認', reject: '✕ 却下', confirmReject: '却下を確定',
rejectPlaceholder: '改善フィードバック(必須)',
gateSection: '要対応', gateNeedsHuman: '要人手', gRequeue: '↻ 再投入', gTakeover: '⌨ 引継ぎ', gCancelTask: '⊘ 取消', accept: '✓ 承認', reject: '✕ 却下', confirmReject: '却下を確定',
rejectPlaceholder: '改善フィードバック(必須)', rejectReason: '却下理由',
taskSection: 'タスクツリー', newTask: '+ 新規タスク', titleLabel: 'タイトル', titlePlaceholder: '何をしますか',
complexity: '複雑度', cplxAuto: '智能', parentTask: '親タスク', topLevel: '(トップ)', priority: '優先度',
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: 'タスク作成', cancel: 'キャンセル',
attachLabel: '添付 · 画像/ファイル(任意)', dropHint: 'ここにファイルをドロップ、または貼り付け', removeFile: '削除', pasteHint: '⌘Vで画像を貼り付け',
attSection: '添付', attDownload: 'ダウンロード', attDeleteConfirm: '添付「{name}」を削除しますか?この操作は元に戻せません。', attDeleted: '添付を削除しました', attDeleteFailed: '添付の削除に失敗:', attTooLarge: 'ファイルが25MBを超えたためスキップしました', attUploaded: '添付をアップロード · {n} 件', attDeduped: '(重複 {n} 件を除外)',
depsWait: '依存待ち', specLabel: 'SPEC · 変更案', opsLabel: 'OPERATIONS · 操作',
pendingDoc: 'Claude Code の出力待ち(MCP 経由で書き込み後レビューへ)',
emptyTasks: 'タスクなし — 「+ 新規タスク」または MCP で作成', empty: '空',
@@ -199,7 +219,7 @@ window.MAESTRO_I18N = {
gatePassed: ' 承認',
status: { init: '新規', analyzing: '分析・分解中', plan_review: '分解確認待ち', decomposed: '分解済み', speccing: '方針作成中', spec_review: '方針確認待ち', ready: '実行可能', blocked: '依存ブロック', queued: 'キュー待ち', executing: '実行中', exec_review: 'レビュー/マージ', failed: '失敗', needs_attention: '要対応', done: '完了', paused: '一時停止', cancelled: 'キャンセル' },
gates: { plan: '分解レビュー', spec: '方針レビュー', exec: '結果レビュー' },
gateSubtasks: '分解されたサブタスク', noDeps: '依存なし', dependsOn: '依存',
gateSubtasks: '分解されたサブタスク', frozenPending: '承認待ち', scopeFiles: 'ファイル範囲', planTranscript: '分解トレース', noDeps: '依存なし', dependsOn: '依存',
lblCodeReview: 'コードレビュー', lblSecurity: 'セキュリティ', lblChanges: '変更', vApprove: '承認', vReject: '却下', vNone: '判定なし',
eventTypes: { 'task.created': 'タスク作成', 'task.updated': 'タスク更新', 'status.changed': 'ステータス変更', 'approval.requested': '承認リクエスト', 'approval.granted': '承認', 'approval.rejected': '却下', 'run.started': 'run 開始', 'run.finished': 'run 終了', 'project.synced': 'todo 同期' },
},
@@ -222,9 +242,12 @@ window.MAESTRO_I18N = {
agentSection: 'Exécutions agent', agentEmpty: 'Aucun agent en cours', running: 'RUNNING', since: '',
projRunning: 'En cours', projPaused: 'En pause', projBlocked: 'Bloqué', projIdle: 'Inactif',
projPendingTip: '{n} en attente de votre revue',
projAgentsTip: '{n} agents en cours',
projBlockedTip: '{n} bloquées',
gAgents: 'Agents globaux', gAgentsDetail: '{p}/{P} projets · {a} agents',
apActive: 'actifs', apRuns: 'exécutions', apTokens: 'tokens', apWeek: 'cette semaine', apCost: 'coût', apTotal: 'Tous les projets', apPerProj: 'PAR PROJET', apIdle: 'inactif', apMore: '+{n} de plus', apCollapse: 'Réduire',
apDetail: 'Détails', apDetailTitle: "Détail d'usage", apByDay: 'Par jour', apByWeek: 'Par semaine', apByMonth: 'Par mois', apColProject: 'Projet', apColTasks: 'Tâches', apColModels: 'Modèles', apEmpty: "Aucune donnée d'usage",
apScopeMaestro: 'Tâches Maestro uniquement', quotaScopeAccount: 'Tout le compte (y compris vos propres sessions Claude Code)', apCostEstimateHint: 'Estimé au prix catalogue de lAPI, pas la facturation réelle de labonnement', apModelApprox: 'Les tokens par modèle sont approximatifs (attribués au dernier modèle de chaque run)',
gConfig: 'Config globale', gConfigDetail: 'Concurrence max {n} · daemon {v}',
gUserPlan: 'Forfait', gUserSignOut: 'Se déconnecter', gUserSettings: 'Paramètres du compte',
gGlobalSettings: 'Paramètres globaux', gSettingsHint: 'Valeurs par défaut des nouveaux projets (par utilisateur)', gMaxRetries: 'Essais max', gTimeoutMin: 'Délai (min)', gAutoPlan: 'Auto-approuver plan', gAutoExec: 'Auto-approuver exéc.', gBudget: 'Budget $ (vide=∞)', gBudgetPeriod: 'Période', gPeriodDay: 'Quotidien', gPeriodMonth: 'Mensuel',
@@ -235,11 +258,13 @@ window.MAESTRO_I18N = {
fgroups: { todo: 'À faire', doing: 'En cours', gate: 'À approuver', bad: 'Anomalies', hold: 'En attente', done: 'Terminées' },
fStatus: 'Statut', fAssignee: 'Exécutant', assignees: { agent: 'Agent', human: 'Humain' },
fullRead: 'Plein écran', depsLabel: 'DEPS (toutes requises)', depDone: 'terminée', depWait: 'en attente',
gateSection: "Portes d'approbation", accept: '✓ Accepter', reject: '✕ Rejeter', confirmReject: 'Confirmer le rejet',
rejectPlaceholder: "Retour d'amélioration (obligatoire)",
gateSection: 'À traiter', gateNeedsHuman: 'Manuel', gRequeue: '↻ Relancer', gTakeover: '⌨ Reprendre', gCancelTask: '⊘ Annuler', accept: '✓ Accepter', reject: '✕ Rejeter', confirmReject: 'Confirmer le rejet',
rejectPlaceholder: "Retour d'amélioration (obligatoire)", rejectReason: 'Motif du rejet',
taskSection: 'Arbre des tâches', newTask: '+ Nouvelle tâche', titleLabel: 'Titre', titlePlaceholder: 'Que faut-il faire',
complexity: 'Complexité', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(racine)', priority: 'Priorité',
p0: 'P0 · haute', p1: 'P1 · moyenne', p2: 'P2 · basse', create: 'Créer la tâche', cancel: 'Annuler',
attachLabel: 'Pièces jointes · images/fichiers (facultatif)', dropHint: 'Déposez des fichiers ici ou collez une capture', removeFile: 'Retirer', pasteHint: '⌘V pour coller des images',
attSection: 'Pièces jointes', attDownload: 'Télécharger', attDeleteConfirm: 'Supprimer la pièce jointe « {name} » ? Action irréversible.', attDeleted: 'Pièce jointe supprimée', attDeleteFailed: 'Échec de la suppression : ', attTooLarge: 'Le fichier dépasse 25 Mo et a été ignoré', attUploaded: 'Pièces jointes envoyées · {n}', attDeduped: ' ({n} doublons ignorés)',
depsWait: 'deps', specLabel: 'SPEC DES CHANGEMENTS', opsLabel: 'OPÉRATIONS PRÉVUES',
pendingDoc: 'En attente de la sortie de Claude Code (via MCP, puis revue)',
emptyTasks: 'Aucune tâche — « + Nouvelle tâche » ou via MCP', empty: 'VIDE',
@@ -249,7 +274,7 @@ window.MAESTRO_I18N = {
gatePassed: ' approuvée',
status: { init: 'Nouvelle', analyzing: 'Analyse', plan_review: 'Revue du plan', decomposed: 'Décomposée', speccing: 'Rédaction spec', spec_review: 'Revue de spec', ready: 'Prête', blocked: 'Bloquée', queued: 'En file', executing: 'En exécution', exec_review: 'Revue & fusion', failed: 'Échouée', needs_attention: 'À traiter', done: 'Terminée', paused: 'En pause', cancelled: 'Annulée' },
gates: { plan: 'REVUE DU PLAN', spec: 'REVUE DE SPEC', exec: 'REVUE DU RÉSULTAT' },
gateSubtasks: 'Sous-tâches', noDeps: 'sans deps', dependsOn: 'deps',
gateSubtasks: 'Sous-tâches', frozenPending: 'en attente', scopeFiles: 'fichiers', planTranscript: 'Trace de décomposition', noDeps: 'sans deps', dependsOn: 'deps',
lblCodeReview: 'Revue', lblSecurity: 'Sécurité', lblChanges: 'Modifs', vApprove: 'Approuver', vReject: 'Rejeter', vNone: 'Sans verdict',
eventTypes: { 'task.created': 'Tâche créée', 'task.updated': 'Tâche mise à jour', 'status.changed': "Changement d'état", 'approval.requested': 'Approbation demandée', 'approval.granted': 'Approbation accordée', 'approval.rejected': 'Approbation rejetée', 'run.started': 'Run démarré', 'run.finished': 'Run terminé', 'project.synced': 'Todo synchronisé' },
},
+7
View File
@@ -10,6 +10,11 @@
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<!-- Markdown 渲染(window.MaestroRichDoc 依赖)-->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/styles/github-dark.min.css" integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js" integrity="sha384-/TQbtLCAerC3jgaim+N78RZSDYV7ryeoBCVqTuzRrFec2akfBkHS7ACQ3PQhvMVi" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/highlight.min.js" integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp" crossorigin="anonymous"></script>
<script src="../../_ds_bundle.js"></script>
<script src="data.js"></script>
<script src="i18n.js"></script>
@@ -37,6 +42,8 @@
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="RichDoc.jsx"></script>
<script type="text/babel" src="Transcript.jsx"></script>
<script type="text/babel" src="Sidebar.jsx"></script>
<script type="text/babel" src="EventPanel.jsx"></script>
<script type="text/babel" src="Topbar.jsx"></script>
+13 -2
View File
@@ -86,8 +86,19 @@ body{font-family:'IBM Plex Mono',monospace;background:var(--bg);color:var(--text
<!-- ── 知识库调研 ── -->
<div class="cat">
<div class="cat-hdr"><span class="ico">调研</span> 知识库调研 <span class="cnt">· 0</span></div>
<div class="empty">(暂无)</div>
<div class="cat-hdr"><span class="ico">调研</span> 知识库调研 <span class="cnt">· 2</span></div>
<a class="doc" href="usage-cost-explained.html">
<div class="doc-ttl">用量与成本计算说明 · 数据源口径 + cost 公式 + 时区分桶</div>
<div class="doc-path">docs/usage-cost-explained.html</div>
<div class="doc-desc">讲清全局面板「用量」的<strong>两个互不相交数据源</strong>:① 账号级订阅额度(<code>usage.ts</code> 直连 oauth/usage,含你自己的 Claude Code 交互会话)vs ② maestro 自有 run 的 cost/token<code>cc.ts→ingest→store</code>,仅任务执行)——这就是「只有 maestro 在跑却有其他 token 消费」的真相。含 <strong>cost 公式</strong>(四类 token × 各模型单价 / 1e6,引自 <code>pricing.ts</code> 单价表)、<strong>按天/周/月分桶规则与本地时区口径</strong>(修复 UTC 偏移一天 bug)、<strong>$ 为 API 列表价名义估算非订阅实账单</strong>、分模型 token 近似与未知模型记 0 两项已知近似。</div>
<div class="tags"><span class="tag html">HTML</span><span class="tag design">用量/成本</span></div>
</a>
<a class="doc" href="task-rules.html">
<div class="doc-ttl">任务执行规则参考 · 当前实现</div>
<div class="doc-path">docs/task-rules.html</div>
<div class="doc-desc">后台 agent 处理任务的<strong>当前线上规则</strong>(非提案):复杂度路由(easy/medium/hard)+ 四类执行路径——① 任务拆解(HARD,只读 planner26 子任务·depth≤4·5 字段·三段输出·落库·审批·子任务调度)· ② 方案撰写(MEDIUM)· ③ 任务执行(executorworktree + verify/checks/diff/双复审硬闸 + auto-merge)· ④ 解冲突(conflict,真 git merge + P0 插队)。含<strong>跨类通用规则</strong><strong>模型档位</strong>(默认 opus-4-8 + 项目可配 + 回退链)、<strong>关键状态转移表</strong></div>
<div class="tags"><span class="tag html">HTML</span><span class="tag design">规则参考</span></div>
</a>
</div>
<!-- ── 排障 Runbook ── -->
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maestro · 任务执行规则参考</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root{--bg:#0d0d0d;--bg2:#141414;--bg3:#1a1a1a;--border:#252525;--border2:#333;--text:#e8e8e8;--text2:#a0a0a0;--text3:#555;--cyan:#00d4d4;--cyan2:#009999;--green:#4ec94e;--green2:#2d6e2d;--amber:#f0a500;--amber2:#9c6a00;--violet:#9b7bff;--violet2:#6644cc;--red:#ff5555;--red2:#aa2222;--blue:#5599ff;--blue2:#2255cc;--code-bg:#0a0a0a;--shadow:0 4px 32px rgba(0,0,0,.7)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'IBM Plex Mono',monospace;background:var(--bg);color:var(--text);font-size:13px;line-height:1.7}
.topbar{position:sticky;top:0;z-index:100;background:var(--bg2);border-bottom:1px solid var(--border);height:48px;display:flex;align-items:center;padding:0 24px;gap:12px}
.topbar-brand{font-size:13px;font-weight:600;color:var(--cyan);letter-spacing:.06em}
.topbar-sub{font-size:11px;color:var(--text3)}
.topbar a{margin-left:auto;font-size:11px;color:var(--text3);text-decoration:none}
.topbar a:hover{color:var(--cyan)}
.wrap{max-width:1000px;margin:0 auto;padding:32px 24px 80px}
.lead{font-size:13px;color:var(--text2);margin:0 0 10px;line-height:1.8}
.lead strong{color:var(--text)}
.meta{font-size:11px;color:var(--text3);margin:0 0 26px}
h2{font-size:17px;color:var(--text);margin:36px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
h2 .n{font-size:12px;color:var(--cyan);border:1px solid var(--cyan2);border-radius:4px;padding:1px 8px;font-weight:400}
h3{font-size:14px;color:var(--cyan);margin:22px 0 8px}
p{color:var(--text2);margin:8px 0}
strong{color:var(--text)}
em{color:var(--amber);font-style:normal}
code{font-family:inherit;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:1px 5px;color:var(--cyan);font-size:12px}
ul{margin:8px 0 8px 4px;padding-left:20px;color:var(--text2)}
ol{margin:8px 0 8px 4px;padding-left:22px;color:var(--text2)}
li{margin:4px 0}
li::marker{color:var(--green)}
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px}
th,td{border:1px solid var(--border);padding:7px 10px;text-align:left;vertical-align:top}
th{background:var(--bg3);color:var(--text);font-weight:600}
td{color:var(--text2)}
td code,th code{font-size:11.5px}
.card{background:var(--bg2);border:1px solid var(--border);border-left:3px solid var(--cyan2);border-radius:6px;padding:14px 16px;margin:12px 0}
.card.violet{border-left-color:var(--violet2)}
.card.green{border-left-color:var(--green2)}
.card.amber{border-left-color:var(--amber2)}
.card.red{border-left-color:var(--red2)}
.card-ttl{font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.tag{font-size:10px;padding:1px 7px;border-radius:3px;border:1px solid}
.tag.hard{color:var(--red);border-color:var(--red2);background:rgba(255,85,85,.08)}
.tag.med{color:var(--amber);border-color:var(--amber2);background:rgba(240,165,0,.08)}
.tag.easy{color:var(--green);border-color:var(--green2);background:rgba(78,201,78,.08)}
.tag.ro{color:var(--blue);border-color:var(--blue2);background:rgba(85,153,255,.08)}
.tag.wr{color:var(--violet);border-color:var(--violet2);background:rgba(155,123,255,.08)}
.note{background:rgba(0,212,212,.06);border:1px solid var(--cyan2);border-radius:5px;padding:10px 13px;margin:12px 0;font-size:12px;color:var(--text2)}
.note.warn{background:rgba(240,165,0,.06);border-color:var(--amber2)}
.note strong{color:var(--text)}
.foot{margin-top:40px;font-size:11px;color:var(--text3);border-top:1px solid var(--border);padding-top:14px}
.src{color:var(--text3);font-size:11px}
</style>
</head>
<body>
<div class="topbar">
<span class="topbar-brand">MAESTRO</span>
<span class="topbar-sub">· 任务执行规则参考</span>
<a href="index.html">← 文档索引</a>
</div>
<div class="wrap">
<p class="lead">Maestro 后台 agent 处理任务的<strong>当前实现规则</strong>(非设计提案,是线上真实行为)。按<strong>复杂度路由</strong>分四类执行路径:① 任务拆解(HARD)· ② 方案撰写(MEDIUM)· ③ 任务执行(executor)· ④ 解冲突(conflict),外加跨类通用规则与模型档位。</p>
<p class="meta">真相源:<code>src/executor/runner.ts</code> · <code>src/daemon/orchestrator.ts</code> · <code>src/daemon/ingest.ts</code> · <code>src/executor/pipeline.ts</code> · <code>src/executor/models.ts</code> · <code>src/model/status.ts</code>。如与代码不符,以代码为准。</p>
<!-- ───────── 概览 ───────── -->
<h2><span class="n">0</span> 复杂度 → 生命周期路由</h2>
<p>任务按 <code>complexity</code>(可由 <code>auto</code> 让模型判定)走不同路径;只有产生代码改动的任务才过 <code>exec_review</code>(双复审 + 合并)。</p>
<table>
<thead><tr><th>复杂度</th><th>路径</th><th>人审闸</th><th>谁来做</th></tr></thead>
<tbody>
<tr><td><span class="tag easy">EASY</span></td><td><code>init → ready → queued → executing → exec_review → done</code></td><td>exec_review</td><td>executor(直接执行)</td></tr>
<tr><td><span class="tag med">MEDIUM</span></td><td><code>init → speccing → spec_review → ready → … → exec_review → done</code></td><td>spec_review + exec_review</td><td>planner 写方案 → executor 执行</td></tr>
<tr><td><span class="tag hard">HARD</span></td><td><code>init → analyzing → plan_review → decomposed →(子任务各自跑)→ done</code></td><td>plan_review +(各子任务的闸)</td><td>planner 拆解 → 子任务递归</td></tr>
</tbody>
</table>
<div class="note">三种 run 类型(<code>runs.kind</code>):<strong>planner</strong>(拆解 HARD / 写 MEDIUM 方案,只读)· <strong>executor</strong>worktree 内改代码)· <strong>reviewer / security</strong>(执行后双复审,只读)· <strong>conflict</strong>(解合并冲突,本会话新增)。</div>
<!-- ───────── ① 拆解 ───────── -->
<h2><span class="n"></span> 任务拆解(decompose<span class="tag hard">HARD only</span> <span class="tag ro">只读</span></h2>
<div class="card">
<div class="card-ttl">触发 + 执行环境</div>
<ul>
<li><strong>HARD</strong> 任务拆解;编排器把它领进 <code>analyzing</code> 态,起一个 <strong>planner run</strong></li>
<li><strong>只读</strong>cwd = 主仓(<em>不建 worktree、不改任何文件</em>);工具白名单仅 <code>Read / Glob / Grep</code> + <code>Bash(git log/diff/show)</code></li>
<li>模型:planner 档,<strong>默认 <code>claude-opus-4-8</code></strong>(项目可配);<code>maxTurns=90</code></li>
<li>触发前提:叶子任务 · 不在退避冷却 · 依赖全 <code>done</code> · 项目 <code>autonomy ≠ manual</code></li>
</ul>
</div>
<div class="card">
<div class="card-ttl">拆解约束(prompt 硬性要求)</div>
<ul>
<li>拆成 <strong>26 个</strong>更小、可独立交付的子任务。</li>
<li><strong>层级闸</strong><code>depth ≤ 4</code>(根=1),prompt 注入「剩余可拆层数」;子任务只有在<em>剩余层数 &gt; 0</em> 时才允许再标 <code>hard</code>(防无限拆)。</li>
<li>每个子任务 5 个字段:<code>title</code> · <code>complexity</code>(easy/medium/hard) · <code>priority</code>(0/1/2) · <code>deps</code>(依赖<em>本列表的 0-based 序号</em>) · <code>files</code>(文件范围 glob)。</li>
<li><code>files</code> = 该子任务预计改动的文件范围;执行时改<strong>越界文件会被硬闸拦截</strong>,故须尽量精确,不确定留空数组(不限)。</li>
</ul>
</div>
<div class="card">
<div class="card-ttl">输出格式(三段)</div>
<ol>
<li><strong>Markdown 表格总览</strong>(序号 / 标题 / 复杂度 / 优先级 / 依赖 / 文件范围)——给人审看;</li>
<li>一段<strong>分析与拆解理由</strong>正文;</li>
<li>末尾<strong>唯一一个 fenced JSON</strong> <code>{plan, subtasks:[…]}</code>——给机器解析。解析失败 = planner 失败、退避重试。</li>
</ol>
</div>
<div class="card amber">
<div class="card-ttl">落库(ingest 处理结果)</div>
<ul>
<li>JSON 的 <code>plan</code>(一句话理由)→ 存父任务 <code>plan</code> 字段(即评审界面的「说明」);</li>
<li>每个 subtask 建成子任务:<code>priority</code><code>files→scopeFiles</code> 直接落库;<code>deps</code> 先全建好子任务再做<strong>「序号→taskId」二次映射</strong>写依赖;</li>
<li><strong>⚠ 表格 + 分析正文那两段被丢弃</strong>,只留在该 run 的 <em>transcript</em> 里(评审界面「拆解轨迹」可展开查看)。</li>
</ul>
</div>
<div class="card violet">
<div class="card-ttl">审批 + 之后调度</div>
<ul>
<li>默认 → 转 <code>plan_review</code> <strong>人审</strong></li>
<li><strong>可选自动放行</strong>(默认关):项目 <code>auto-approved</code> + <code>autoApprovePlan</code> 开 + 拆解结果<strong>全 easy</strong> + <strong>子任务数 ≤ 5</strong>,四条全满足才跳过人审直接 <code>decomposed</code></li>
<li>父任务在 <code>plan_review</code> / <code>analyzing</code> 期间,<strong>子任务被冻结</strong>(不可领取,UI 显示「待拆解确认」)。</li>
<li>批准后(plan_review→decomposed),子任务解冻,按 <strong>deps + priority</strong> 调度,<strong>各自独立 worktree</strong>(都从 main 拉出)执行、独立合并回 main。</li>
</ul>
</div>
<div class="card red">
<div class="card-ttl">失败 + 收口</div>
<ul>
<li>解析失败 / run 失败 → <code>failPlanAttempt</code> 退避重试,超 <code>maxRetries</code>(默认 2)→ 转 <code>needs_attention</code> 待人工;</li>
<li>父任务 <code>decomposed</code> 后,<strong>子任务全 done → 父任务自动 done</strong></li>
</ul>
</div>
<!-- ───────── ② 方案 ───────── -->
<h2><span class="n"></span> 方案撰写(spec<span class="tag med">MEDIUM</span> <span class="tag ro">只读</span></h2>
<ul>
<li>MEDIUM 任务进 <code>speccing</code> 态,起 <strong>planner run</strong>(与拆解同为 planner kind,但只读写「方案」不拆子任务)。</li>
<li>只读跑在主仓;工具同拆解(Read/Glob/Grep + git 只读);<code>maxTurns=60</code></li>
<li>输出 = 一份 Markdown 方案正文(改动什么 / 为什么 / 验收),存任务 <code>spec</code> 字段。</li>
<li>→ 转 <code>spec_review</code> <strong>人审</strong>;批准后 → <code>ready</code> 进执行队列。reject 须带意见,回流 <code>speccing</code> 重写。</li>
</ul>
<!-- ───────── ③ 执行 ───────── -->
<h2><span class="n"></span> 任务执行(executor<span class="tag wr">改代码</span></h2>
<div class="card">
<div class="card-ttl">执行环境</div>
<ul>
<li>每个任务独立 <strong>worktree</strong>(分支 <code>maestro/&lt;taskId&gt;</code>,从 <code>defaultBranch</code>(main) 拉出);<code>permissionMode=acceptEdits</code></li>
<li>工具:<code>Read/Edit/Write/Glob/Grep</code> + <code>git</code>(status/diff/log/add/commit<em>无 push</em>) + 测试/构建(npm/go/pytest/make…,仍关在 worktree、不发包);<code>maxTurns=100</code></li>
<li>模型:executor 档(默认 opus-4-8,项目可配;旧 <code>project.model</code> 也覆盖 executor)。</li>
<li><strong>必须 commit</strong>message 带任务 ID<code>ensureCommitted</code> 兜底)。</li>
</ul>
</div>
<div class="card amber">
<div class="card-ttl">approve 前的硬闸(任一不过 = 不进 exec_review</div>
<ol>
<li><strong>执行前同步 main + 分歧重评估(规则13)</strong>:起跑前把最新 <code>origin/main</code> 并进 worktree;若撞上<em>本任务声明范围内</em>的文件改动 → 转 <code>needs_attention</code> 待重评估(不盲目硬解)。</li>
<li><strong>verify 闸</strong>:项目 <code>verifyCmd</code> 在 worktree 内跑(10minexit 0 才过;未配=视为通过)。</li>
<li><strong>分项检查闸 checks</strong>:独立的 lint / typecheck / build(项目可配,分项报告哪项挂)。</li>
<li><strong>diff 闸</strong>:体量超阈值、或改了<em>声明范围外文件</em>task.scopeFiles 非空时)→ 拦截。</li>
<li><strong>双复审硬闸</strong>code review + security audit <em>并行</em>跑(各只读、最强档不被 project.model 降档);<strong>任一 verdict=reject → 退回重执行</strong>(带复审意见),不进 exec_review。</li>
</ol>
</div>
<div class="card violet">
<div class="card-ttl">审批 + 合并</div>
<ul>
<li>全绿 → 转 <code>exec_review</code> <strong>人审</strong>accept 触发 <code>--no-ff</code> 自动合并回 main<code>merge:false</code> 可跳过合并)。</li>
<li><strong>可选自动放行</strong>(默认关):<code>auto-approved</code> + <code>autoApproveExec</code> 开 + 双复审均 approve → 跳过人审自动合并。</li>
<li>合并冲突 → 自动建 <strong>P0 解冲突补救任务</strong>(见 ④),原任务留 exec_review,补救成功后自动收口。</li>
</ul>
</div>
<!-- ───────── ④ 解冲突 ───────── -->
<h2><span class="n"></span> 解冲突(conflict<span class="tag wr">改代码</span></h2>
<ul>
<li>exec_review 合并撞冲突时,自动建一个 <strong>P0 最高优先级</strong>补救任务,以专用 <code>runKind=conflict</code> 跑。</li>
<li>专用管线:worker 在补救 worktree 内<strong>真正发起 <code>git merge &lt;原分支&gt;</code></strong> 触发冲突态,再交 CC 逐个解(白名单额外含 <code>Bash(git merge:*)</code>)。</li>
<li>专用 prompt:保留两边意图、解完跑测试验证、禁丢任一方改动、commit 沿用合并信息。</li>
<li>模型:conflict 角色,项目可配,<strong>默认 opus-4-8</strong></li>
<li>走 exec_review + 双复审;合并成功后<strong>自动收口</strong>原任务。P0 插队但<em>不中断在途任务</em>No-preemption)。</li>
</ul>
<!-- ───────── 通用规则 ───────── -->
<h2><span class="n"></span> 跨类通用规则</h2>
<table>
<thead><tr><th>规则</th><th>说明</th></tr></thead>
<tbody>
<tr><td><strong>执行期锁</strong></td><td>任务一旦有在途 runplanner/executor/reviewer/conflict)→ 锁定只读:禁改 title/spec/complexity/deps、禁再调度;run 结束(finish/fail/reap)解锁。</td></tr>
<tr><td><strong>worktree 隔离</strong></td><td>写类 run 只在自己 worktree 内改动,禁碰 worktree 外文件;planner/reviewer 只读、不建 worktree。</td></tr>
<tr><td><strong>不 push / 不部署</strong></td><td>任何 run 都不得 <code>git push</code> / 对外发布;解冲突的 merge 是受控本地合并。</td></tr>
<tr><td><strong>commit 含 task ID</strong></td><td>所有产出 commit message 必带任务 ID<code>ensureCommitted</code> 兜底。</td></tr>
<tr><td><strong>重试退避 + 升级</strong></td><td>失败按退避重试,超 <code>maxRetries</code>(默认 2)→ <code>needs_attention</code> 转人工(planner 留原态、executor 走 failed)。</td></tr>
<tr><td><strong>自动放行默认关</strong></td><td>所有「跳过人审」(plan/exec)默认关闭,须项目级开关 + <code>auto-approved</code> 才生效。</td></tr>
<tr><td><strong>reject 必带意见</strong></td><td>plan/spec/exec 的驳回必须带 reason,按状态机回流重做。</td></tr>
<tr><td><strong>复审独立性</strong></td><td>复审只读、与执行分离、用最强模型、不被 <code>project.model</code> 降档;verdict=reject 是硬闸。</td></tr>
<tr><td><strong>daemon 唯一 DB 写者</strong></td><td>worker 不碰 DB,只读 job.json、写 outbox.ndjson;状态变更只经 <code>transition()</code> 守卫。</td></tr>
<tr><td><strong>超时分层</strong></td><td>planner/executor 30min(可配)· verify 10min · 复审 15min(并行)· conflict 给足强档时间。</td></tr>
</tbody>
</table>
<!-- ───────── 模型 ───────── -->
<h2><span class="n"></span> 模型档位</h2>
<p>内置默认:<strong>每个角色 × 每个复杂度都是 <code>claude-opus-4-8</code></strong><code>DEFAULT_MODEL</code>)。可<strong>按项目</strong><code>projects.models</code>(JSON) 配置。</p>
<p><strong>解析优先级</strong>(高→低):① <code>project.models[role]</code>per-role,含 reviewer/conflict)→ ② 旧 <code>project.model</code>(仅 executor/planner)→ ③ env<code>MAESTRO_MODEL_*</code> / <code>_REVIEW_*</code> / <code>_PLAN_*</code>)→ ④ <code>DEFAULT_MODEL</code></p>
<p>四角色:<code>executor</code> / <code>planner</code> / <code>reviewer</code> / <code>conflict</code>。不可用回退链:<code>opus-4-8 → sonnet-4-6 → fable-5</code>(单 run 内只重试一次)。</p>
<div class="note warn"><strong></strong><code>claude-fable-5</code> 在部分环境不可用,已移出内置默认;原「按复杂度分档(hard=fable / medium=opus / easy=sonnet)」的设计因此改为<strong>统一 opus-4-8 + 项目可配</strong></div>
<!-- ───────── 状态机 ───────── -->
<h2><span class="n"></span> 关键状态转移</h2>
<table>
<thead><tr><th></th><th></th><th>触发</th></tr></thead>
<tbody>
<tr><td><code>init</code></td><td><code>analyzing</code> / <code>speccing</code> / <code>ready</code></td><td>按 hard / medium / easy 路由</td></tr>
<tr><td><code>analyzing</code></td><td><code>plan_review</code></td><td>拆解完成</td></tr>
<tr><td><code>plan_review</code></td><td><code>decomposed</code> / <code>analyzing</code></td><td>accept / reject(+意见)</td></tr>
<tr><td><code>speccing</code></td><td><code>spec_review</code></td><td>方案完成</td></tr>
<tr><td><code>spec_review</code></td><td><code>ready</code> / <code>speccing</code></td><td>accept / reject(+意见)</td></tr>
<tr><td><code>ready</code></td><td><code>queued</code> / <code>blocked</code></td><td>领取 / 依赖未满足</td></tr>
<tr><td><code>queued</code></td><td><code>executing</code></td><td>worker 起跑</td></tr>
<tr><td><code>executing</code></td><td><code>exec_review</code> / <code>failed</code></td><td>执行完成 / 失败</td></tr>
<tr><td><code>exec_review</code></td><td><code>done</code> / <code>ready</code></td><td>accept(合并) / reject(返工)</td></tr>
<tr><td><code>failed</code></td><td><code>queued</code> / <code>needs_attention</code></td><td>重试 / 超阈值</td></tr>
<tr><td><code>decomposed</code></td><td><code>done</code></td><td>子任务全 done</td></tr>
</tbody>
</table>
<p class="src">完整合法转移表见 <code>src/model/status.ts</code><code>TRANSITIONS</code>;状态机/ER 全图见 <a href="optimization-plan.html" style="color:var(--cyan)">优化方案文档</a> ⑤⑥ 节。</p>
<div class="foot">单一入口见 <a href="index.html" style="color:var(--cyan)">docs/index.html</a> · 本文档描述当前线上规则,随实现演进更新 · 与代码不符以代码为准。</div>
</div>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MAESTRO · 用量与成本计算说明</title>
<style>
:root {
--bg: #0a0d0b; --bg-deep: #070908; --panel: #111610; --panel-2: #161c13;
--line: #1e2a1c; --ink: #c8d4c0; --muted: #7a9070; --faint: #445040;
--green: #4ade80; --green-dim: rgba(74,222,128,.12);
--violet: #a78bfa; --violet-dim: rgba(167,139,250,.1);
--amber: #fbbf24; --amber-dim: rgba(251,191,36,.1);
--red: #f87171; --red-dim: rgba(248,113,113,.1);
--cyan: #22d3ee; --cyan-dim: rgba(34,211,238,.1);
--mono: 'IBM Plex Mono', 'Noto Sans SC', monospace;
--radius-sm: 4px; --radius-md: 6px;
}
[data-theme="light"] {
--bg: #f4f6f2; --bg-deep: #eaede7; --panel: #ffffff; --panel-2: #f0f2ee;
--line: #d0d8cc; --ink: #1a2418; --muted: #4a6045; --faint: #8a9e86;
--green: #16a34a; --green-dim: rgba(22,163,74,.1);
--violet: #7c3aed; --violet-dim: rgba(124,58,237,.1);
--amber: #d97706; --amber-dim: rgba(217,119,6,.1);
--red: #dc2626; --red-dim: rgba(220,38,38,.1);
--cyan: #0891b2; --cyan-dim: rgba(8,145,178,.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--ink);
font-family: var(--mono); font-size: 13px; line-height: 1.7; padding: 32px;
}
body::before {
content: ''; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
repeating-linear-gradient(0deg, rgba(0,0,0,.14) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(0,0,0,.45));
opacity: .45;
}
[data-theme="light"] body::before { opacity: .07; }
.page { position: relative; z-index: 1; max-width: 1000px; margin: 0 auto; }
.header {
display: flex; align-items: baseline; gap: 16px;
border-bottom: 1px solid var(--line); padding-bottom: 20px; margin-bottom: 28px;
}
.logo { font-size: 18px; font-weight: 700; letter-spacing: .25em; color: var(--green); text-shadow: 0 0 16px var(--green); }
.logo span { animation: blink 1.1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.subtitle { color: var(--muted); font-size: 11px; letter-spacing: .2em; }
.theme-btn {
margin-left: auto; background: var(--panel); border: 1px solid var(--line);
color: var(--muted); font-family: var(--mono); font-size: 11px;
padding: 4px 10px; border-radius: var(--radius-sm); cursor: pointer; letter-spacing: .1em;
}
.theme-btn:hover { color: var(--ink); border-color: var(--muted); }
.lead { color: var(--muted); font-size: 13px; line-height: 1.9; margin-bottom: 8px; }
.lead strong { color: var(--ink); }
.sec-head {
display: flex; align-items: center; gap: 8px;
font-size: 10px; font-weight: 600; letter-spacing: .28em;
color: var(--muted); text-transform: uppercase;
margin-bottom: 16px; margin-top: 40px;
}
.sec-mark { width: 3px; height: 14px; border-radius: 2px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 720px) { .grid2 { grid-template-columns: 1fr; } }
.card {
border: 1px solid var(--line); border-radius: var(--radius-md);
background: var(--panel); padding: 16px 18px;
}
.card.src-a { border-color: rgba(34,211,238,.4); background: linear-gradient(135deg, var(--panel), rgba(34,211,238,.04)); }
.card.src-b { border-color: rgba(74,222,128,.4); background: linear-gradient(135deg, var(--panel), rgba(74,222,128,.04)); }
.card h3 { font-size: 12px; font-weight: 700; letter-spacing: .1em; margin-bottom: 4px; }
.card.src-a h3 { color: var(--cyan); }
.card.src-b h3 { color: var(--green); }
.card .tag { font-size: 10px; color: var(--faint); letter-spacing: .12em; display: block; margin-bottom: 10px; }
.card ul { list-style: none; display: flex; flex-direction: column; gap: 7px; }
.card li { font-size: 12px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; line-height: 1.6; }
.card li::before { content: '→'; color: var(--faint); flex-shrink: 0; }
.card li b { color: var(--ink); font-weight: 600; }
code { background: var(--bg-deep); border: 1px solid var(--line); border-radius: 3px; padding: 1px 5px; font-size: 12px; color: var(--amber); }
.callout {
border-left: 3px solid var(--amber); background: var(--amber-dim);
border-radius: var(--radius-sm); padding: 12px 16px; margin: 16px 0;
font-size: 12px; color: var(--ink); line-height: 1.8;
}
.callout.violet { border-left-color: var(--violet); background: var(--violet-dim); }
.callout b { color: var(--ink); }
.formula {
background: var(--bg-deep); border: 1px solid var(--line); border-radius: var(--radius-md);
padding: 16px 18px; font-size: 13px; color: var(--ink); margin: 12px 0; line-height: 1.9;
}
.formula .op { color: var(--cyan); }
.formula small { display: block; color: var(--faint); font-size: 11px; margin-top: 8px; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; }
th, td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: 10px; letter-spacing: .12em; text-transform: uppercase; }
td { color: var(--ink); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:hover { background: var(--panel-2); }
.model-name { color: var(--cyan); font-weight: 600; }
ul.plain { list-style: none; display: flex; flex-direction: column; gap: 8px; margin: 8px 0; }
ul.plain li { font-size: 12px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; line-height: 1.7; }
ul.plain li::before { content: '▍'; color: var(--green); flex-shrink: 0; font-size: 10px; }
ul.plain li b { color: var(--ink); }
.foot { margin-top: 44px; font-size: 11px; color: var(--faint); border-top: 1px solid var(--line); padding-top: 14px; line-height: 1.8; }
.src-ref { color: var(--faint); font-size: 11px; }
a { color: var(--cyan); }
</style>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="page">
<div class="header">
<div class="logo">MAESTRO<span></span></div>
<div class="subtitle">用量与成本计算说明 · USAGE &amp; COST EXPLAINED</div>
<button class="theme-btn" onclick="document.documentElement.dataset.theme=document.documentElement.dataset.theme==='dark'?'light':'dark'">⊙ 切换主题</button>
</div>
<p class="lead">控制台里的「用量」其实来自<strong>两个互不相交的数据源</strong>,口径不同、覆盖范围不同,所以经常看着「对不上」。本文把两者来源、为什么对不上、成本怎么算、按天/周/月怎么分桶讲清楚。</p>
<!-- ── 两个数据源 ── -->
<div class="sec-head"><div class="sec-mark" style="background:var(--cyan)"></div>两个数据源 · TWO SOURCES</div>
<div class="grid2">
<div class="card src-a">
<h3>A · 账号级订阅额度</h3>
<span class="tag">额度条 / QuotaMeter(顶栏)</span>
<ul>
<li>daemon 直连 <code>GET api.anthropic.com/api/oauth/usage</code>,取 <b>five_hour / seven_day</b> 的利用率百分比。</li>
<li>覆盖范围 = <b>整个 Claude Code 账号</b>:你手敲的交互会话、别的项目、别的工具,全部计入。</li>
<li>maestro 只能拿到这个「总量百分比」,<b>拿不到明细</b>,也无法拆出「哪部分是 maestro 烧的」。</li>
<li>来源:<span class="src-ref">src/daemon/usage.ts</span></li>
</ul>
</div>
<div class="card src-b">
<h3>B · maestro 自有 run 的 cost / token</h3>
<span class="tag">AGENT 卡 2×2 + 用量详情弹框</span>
<ul>
<li>每次 maestro worker 跑 CC 调用,从 result 读 <b>四类 token</b> 写进 <code>outbox.ndjson</code>daemon 折算累加进 <code>runs.cost_usd / runs.usage</code></li>
<li>覆盖范围 = <b>仅 maestro 的任务执行</b>executor + 双复审 + 解冲突等),你自己的会话<b>不在内</b></li>
<li>天然比额度条少 —— 它只统计自己发起的 run。</li>
<li>来源:<span class="src-ref">cc.ts → ingest.ts → store.setRunUsage() / costSummary() / usageDetail()</span></li>
</ul>
</div>
</div>
<div class="callout violet">
<b>「最近只有 maestro 在跑,却还有其他 token 消费」的真相:</b><br>
那是你<b>自己用 Claude Code 烧的额度</b>(交互会话 / 其他工具)。它只反映在<b>数据源 A(账号级额度条)</b>里,maestro 采集不到、也算不进数据源 B 的明细,但额度条照样会动。<b>这不是 bug,是两个数据源口径不同。</b> 控制台已分别标注:额度条标「账号级,含交互会话」,AGENT 卡 / 详情标「仅统计 maestro 任务执行」。
</div>
<!-- ── 成本如何计算 ── -->
<div class="sec-head"><div class="sec-mark" style="background:var(--green)"></div>成本如何计算 · COST FORMULA</div>
<p class="lead">数据源 B 的成本是<strong>逐次 CC 调用精确折算后累加</strong>的。公式:</p>
<div class="formula">
成本(USD) = <span class="op">Σ</span>( input·P<sub>in</sub> <span class="op">+</span> output·P<sub>out</sub> <span class="op">+</span> cacheRead·P<sub>cr</sub> <span class="op">+</span> cacheWrite·P<sub>cw</sub> ) <span class="op">/</span> 1,000,000
<small>四类 token 各按其模型单价折算;单价单位 = USD / 每 1M token。一个 run 可能含多次 CC 调用(executor + 双复审 / resume / 回退),每次按各自模型精确折算后求和 → <code>runs.cost_usd</code> 准确。来源:src/model/pricing.ts · store.setRunUsage()</small>
</div>
<table>
<thead>
<tr>
<th>模型</th>
<th class="num">input</th>
<th class="num">output</th>
<th class="num">cacheRead</th>
<th class="num">cacheWrite</th>
</tr>
</thead>
<tbody>
<tr><td class="model-name">claude-opus-4-8</td><td class="num">15</td><td class="num">75</td><td class="num">1.5</td><td class="num">18.75</td></tr>
<tr><td class="model-name">claude-sonnet-4-6</td><td class="num">3</td><td class="num">15</td><td class="num">0.3</td><td class="num">3.75</td></tr>
<tr><td class="model-name">claude-haiku-4-5</td><td class="num">1</td><td class="num">5</td><td class="num">0.1</td><td class="num">1.25</td></tr>
</tbody>
</table>
<p class="lead" style="font-size:11px">单价单位:USD / 1M token。模型名归一:精确命中优先,否则按已知前缀匹配(容忍日期 / 区域后缀,如 <code>claude-opus-4-8-20260101</code>)。源:<span class="src-ref">src/model/pricing.ts · MODEL_PRICES</span></p>
<div class="callout">
<b>$ 是按 API 列表价的名义估算,不是订阅实际扣费。</b> 它衡量「这些 run 若按 API 计价值多少钱」,便于横向比较项目/模型开销;你的实际账单取决于订阅套餐,二者不应直接相等。控制台 <code>$</code> 处标注「按 API 列表价估算,非订阅实际扣费」,并以 <code></code> 前缀提示。
</div>
<!-- ── 分桶与时区 ── -->
<div class="sec-head"><div class="sec-mark" style="background:var(--amber)"></div>按天 / 周 / 月分桶与时区 · BUCKETING</div>
<ul class="plain">
<li><b>按天</b>:近 30 天,桶 = 本地 <code>YYYY-MM-DD</code></li>
<li><b>按周</b>:近 12 周,桶 = 本地<b>周一</b> <code>YYYY-MM-DD</code>(滚动 7 天窗)。</li>
<li><b>按月</b>:近 12 个月,桶 = 本地 <code>YYYY-MM</code></li>
<li>「今天 / 本月」窗口起点(<code>periodStartISO</code>)、预算护栏窗口同样以<b>本地零点</b>为基准。</li>
</ul>
<div class="callout">
<b>本次修复(时区分桶):</b> 此前分桶与窗口起点全用 <b>UTC</b>UTC+8 用户在本地 00:0008:00 跑的 run 会被算进<b>前一天</b>、当天窗口晚 8 小时开始 → 柱状图整体偏移一天、当天偏小。现已统一改为 <b>daemon 本地时区</b>local-first 单机,daemon 时区即用户时区,零配置)。SQL 比较的是绝对时刻,本地零点转 ISO 后比较仍正确。源:<span class="src-ref">store.ts · localDay / localMonth / periodStartISO / weekStartLabel / usageDetail</span>
</div>
<!-- ── 已知近似 ── -->
<div class="sec-head"><div class="sec-mark" style="background:var(--red)"></div>已知近似 · KNOWN APPROXIMATIONS</div>
<ul class="plain">
<li><b>分模型 token 为近似。</b> <code>setRunUsage</code> 把累计 token 连同<b>最后一次</b> CC 调用的 model 一起存(<code>{...usage, model}</code>);总 <code>cost_usd</code> 逐次精确折算 → <b>总花费准</b>,但 byModel 的 token / cost 拆分按「run 末次模型」归集,<b>不精确</b>。控制台模型列以 <code></code> 角标提示。</li>
<li><b>未知模型记 0。</b> 若配置了非 <code>opus-4-8 / sonnet-4-6 / haiku-4-5</code> 前缀的模型,<code>priceOf</code> 命中失败 → <code>computeCost</code> 返回 0(不阻断执行,仅成本记 0)。</li>
<li><b>$ 为名义估算</b>,非订阅实账单(见上)。</li>
</ul>
<div class="foot">
单一事实源:成本计价集中于 <code>src/model/pricing.ts</code>,分桶 / 窗口集中于 <code>src/store/store.ts</code><br>
数据源 A(额度):<code>src/daemon/usage.ts</code>;数据源 B(明细):<code>cc.ts → ingest.ts → store.ts</code><br>
本文随实现变化更新;新增 / 迁移文档请同步登记 <code>docs/index.html</code>
</div>
</div>
</body>
</html>
+155 -8
View File
@@ -4,7 +4,7 @@ import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTa
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 type { Project, Autonomy, Attachment } from '../model/types.js';
import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
import { resolvedExecutorModels } from '../executor/models.js';
import { classifyComplexity, type ClassifierFn } from '../executor/classify.js';
@@ -12,10 +12,11 @@ import { acceptAndMerge } from '../executor/exec-merge.js';
import { createWorktree, git } from '../executor/worktree.js';
import { resolveLogo, LOGO_MIME } from './logo.js';
import { readTranscript, TranscriptError } from '../executor/transcript.js';
import { createReadStream, createWriteStream, mkdirSync, readFileSync, statSync } from 'node:fs';
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
import { createHash, randomUUID } from 'node:crypto';
import { transcriptDir } from '../executor/cc.js';
import { homedir } from 'node:os';
import { join, basename } from 'node:path';
import { join, basename, resolve, sep } from 'node:path';
import { pipeline } from 'node:stream/promises';
import multipart from '@fastify/multipart';
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
@@ -25,6 +26,70 @@ function dataDir(): string {
return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
}
/** 常见 MIME → 落盘扩展名(无前导点)。剪贴板粘贴 blob 常无扩展名,据此补齐。 */
const MIME_EXT: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico',
'image/tiff': 'tiff',
'image/heic': 'heic',
'image/heif': 'heif',
'image/avif': 'avif',
'application/pdf': 'pdf',
'application/zip': 'zip',
'application/json': 'json',
'application/xml': 'xml',
'text/plain': 'txt',
'text/markdown': 'md',
'text/csv': 'csv',
'text/html': 'html',
};
/** MIME 类型 → 扩展名(无点)。未知映射时对 image/<subtype> 兜底取 subtype;其余返回 ''。 */
export function extFromMime(mimetype: string | undefined): string {
if (!mimetype) return '';
const key = mimetype.split(';')[0].trim().toLowerCase();
if (MIME_EXT[key]) return MIME_EXT[key];
const m = key.match(/^image\/([a-z0-9.+-]+)$/);
if (m) return m[1].replace(/[^a-z0-9]/g, '');
return '';
}
/** basename 末尾是否带「看起来像扩展名」的后缀(1-8 位字母数字)。 */
function hasExtension(name: string): boolean {
return /\.[A-Za-z0-9]{1,8}$/.test(name);
}
/**
* 计算附件落盘安全文件名。规则:
* 1. 仅取 basename,净化非法字符(`[^\w.\-]` → `_`),剥掉前导点(防隐藏/空名)。
* 2. 主名为空或为占位名(blob/image/file/unknown/untitled/paste)→ 视为「无名粘贴 blob」,
* 生成唯一名 `paste-<ts>-<seq>`,扩展名优先取 mimetype 推断、其次保留原扩展名。
* 3. 有正常主名但缺扩展名 → 按 mimetype 补扩展名(mimetype 未知则保持原样)。
* 返回值保证非空。
*/
export function safeAttachmentName(rawName: string | undefined, mimetype: string | undefined, seq = 0): string {
const ext = extFromMime(mimetype);
const sanitized = basename((rawName ?? '').trim()).replace(/[^\w.\-]/g, '_').replace(/^\.+/, '');
const withExt = hasExtension(sanitized);
const stem = withExt ? sanitized.replace(/\.[A-Za-z0-9]{1,8}$/, '') : sanitized;
const isPlaceholder = stem === '' || /^(blob|image|file|unknown|untitled|paste)$/i.test(stem);
if (isPlaceholder) {
const origExt = withExt ? sanitized.slice(sanitized.lastIndexOf('.') + 1) : '';
const useExt = ext || origExt;
const base = `paste-${Date.now()}-${seq}`;
return useExt ? `${base}.${useExt}` : base;
}
if (!withExt && ext) return `${sanitized}.${ext}`;
return sanitized;
}
/** Project 出参:附加 hasTodoJson<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
function projectOut(p: Project, store: Store): Project & { hasTodoJson: boolean; summary: ReturnType<Store['projectSummary']> } {
return { ...p, hasTodoJson: hasTodoJson(p.repoPath), summary: store.projectSummary(p.id) };
@@ -137,6 +202,8 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd);
if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month';
if (b.model !== undefined) patch.model = b.model === null || b.model === '' ? null : String(b.model);
if (b.globalConcurrency !== undefined)
patch.globalConcurrency = b.globalConcurrency === null || b.globalConcurrency === '' ? 0 : Number(b.globalConcurrency);
return store.putSettings(patch);
});
@@ -277,17 +344,97 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` });
const dir = join(dataDir(), 'tasks', id, 'attachments');
mkdirSync(dir, { recursive: true });
const saved: Array<{ name: string; type: string; path: string }> = [];
const saved: Attachment[] = [];
for await (const part of req.files()) {
const safe = basename(part.filename).replace(/[^\w.\-]/g, '_') || `file-${Date.now()}`;
await pipeline(part.file, createWriteStream(join(dir, safe)));
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename} 超过 25MB 上限` });
saved.push({ name: part.filename, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
// 内容寻址:先流式写临时文件并同步计算 sha256,再按 hash 重命名为最终磁盘名。
// 磁盘名完全由内容 hash 决定(不再用文件名):相同内容天然单份、不同内容永不撞名,
// 且十六进制 sha256 不含 `/`、`..`、脚本可控字符,从根上杜绝路径穿越/撞名覆盖。
const ext = extFromMime(part.mimetype) || (hasExtension(part.filename ?? '')
? (part.filename as string).slice((part.filename as string).lastIndexOf('.') + 1).replace(/[^A-Za-z0-9]/g, '')
: '');
const tmp = join(dir, `.tmp-${randomUUID()}`);
const hash = createHash('sha256');
let size = 0;
try {
await pipeline(part.file, async function* (src) {
for await (const c of src) { hash.update(c as Buffer); size += (c as Buffer).length; yield c; }
}, createWriteStream(tmp));
} catch (e) {
rmSync(tmp, { force: true });
throw e;
}
// 超限:清掉临时文件,绝不留垃圾;@fastify/multipart 在超过 fileSize 时置 truncated。
if (part.file.truncated) {
rmSync(tmp, { force: true });
return reply.code(400).send({ error: `文件 ${part.filename || 'attachment'} 超过 25MB 上限` });
}
const digest = hash.digest('hex');
const finalName = ext ? `${digest}.${ext}` : digest;
const finalAbs = join(dir, finalName);
// 相同内容已存在 → 丢弃临时文件(真去重);否则原子 rename 同目录就位。
if (existsSync(finalAbs)) rmSync(tmp, { force: true });
else renameSync(tmp, finalAbs);
saved.push({
name: part.filename || finalName, type: part.mimetype,
path: `tasks/${id}/attachments/${finalName}`, hash: digest, size,
});
}
if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' });
return { attachments: store.addAttachments(id, saved) };
});
// 附件读取/预览/下载:默认 inline(图片可 <img> 预览);?download=1 触发下载。
// 标识符为磁盘文件名 :name= basename(att.path)),只服务元数据里登记过的附件。
app.get('/api/tasks/:id/attachments/:name', (req, reply) => {
const { id, name } = req.params as { id: string; name: string };
const task = store.getTask(id);
if (!task) return reply.code(404).send({ error: `任务不存在: ${id}` });
const att = (task.attachments ?? []).find((a) => basename(a.path) === name);
if (!att) return reply.code(404).send({ error: `附件不存在: ${name}` });
const abs = resolve(join(dataDir(), att.path));
// 纵深防御:解析后必须仍在该任务 attachments 目录内
const baseDir = resolve(join(dataDir(), 'tasks', id, 'attachments'));
if (abs !== baseDir && !abs.startsWith(baseDir + sep))
return reply.code(400).send({ error: '附件路径越界' });
let st;
try { st = statSync(abs); } catch { return reply.code(404).send({ error: '附件文件已丢失' }); }
const dl = (req.query as { download?: string }).download;
const fallbackName = att.name || name;
// 安全:上传文件与本 API 同源,inline 渲染会在 console 源里执行其脚本 → 存储型 XSS。
// 故只对【硬白名单图片类型】允许 inline 预览;其余(尤其 image/svg+xml、text/html 等可携带脚本的类型)
// 一律强制 attachment 下载 + content-type 改 octet-stream,并加 nosniff + CSP sandbox 双重兜底。
const SAFE_INLINE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const inline = !dl && SAFE_INLINE.has(att.type ?? '');
reply
.header('cache-control', 'private, max-age=300')
.header('content-length', String(st.size))
.header('x-content-type-options', 'nosniff')
.header('content-security-policy', "sandbox; default-src 'none'")
.header('content-disposition',
`${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(fallbackName)}`)
.type(inline ? (att.type as string) : 'application/octet-stream');
return reply.send(createReadStream(abs));
});
// 删除单个附件:先删元数据(拿到 removed),再尽力删磁盘文件
// 前提:附件目录按 task 隔离(tasks/<id>/attachments/),内容寻址后同一 hash 在一个 task 内
// 去重为一条元数据 → 删元数据即可安全删盘,无需跨任务引用计数。
// ⚠️ 若将来改为跨任务共享存储(同一 <sha256> 文件被多任务引用),必须引入 refcount,本次不做。
app.delete('/api/tasks/:id/attachments/:name', (req, reply) => {
const { id, name } = req.params as { id: string; name: string };
if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` });
const { attachments, removed } = store.removeAttachment(id, name);
if (!removed) return reply.code(404).send({ error: `附件不存在: ${name}` });
const abs = resolve(join(dataDir(), removed.path));
const baseDir = resolve(join(dataDir(), 'tasks', id, 'attachments'));
if (abs === baseDir || abs.startsWith(baseDir + sep)) {
try { rmSync(abs, { force: true }); } catch { /* 磁盘文件已不在,忽略 */ }
}
return { attachments };
});
// 人工接管:准备(或复用)任务 worktree,返回用户在自己终端起交互式 claude 的命令。
// 适用卡住/需人工的任务——人接手手动改。不在 daemon 内起交互会话(无 TTY)。
app.post('/api/tasks/:id/takeover', async (req, reply) => {
+13 -1
View File
@@ -216,7 +216,19 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
transcriptRef: rec.executor.transcriptRef ?? undefined,
claudeSessionId: rec.executor.sessionId ?? undefined,
});
// 退回重执行(带复审意见)
// 范围型驳回检测:任务有非空 scopeFiles 且复审拒因「需求超出声明范围/越界/范围外」——重试 executor 必然无解
// (它碰不了范围外文件,只会同样被驳回),故停止重试、直接转 needs_attention,待人工放宽 scopeFiles 或回 planner 重拆。
const task = store.getTask(taskId);
const scoped = !!(task?.scopeFiles && task.scopeFiles.length);
const fullReview = `${rec.code.summary || ''}\n${rec.security.summary || ''}`;
const scopeReject = scoped
&& /out of scope|越界|范围外|声明范围|文件闸|超出.{0,6}范围|scope[^\n]{0,40}(undone|outside|out of)/i.test(fullReview);
if (scopeReject) {
store.failScopeReject(taskId, `复审因范围不足驳回(停止重试,待放宽 scopeFiles 或重新拆解):${reason}`);
log.info(`task=${taskId} run=${runId} 范围型驳回 → needs_attention(停止重试)`);
return;
}
// 普通复审驳回 → 退回重执行(带复审意见)
store.failTaskAttempt(taskId, null, `自动复审拒绝,退回重执行:${reason}`);
log.info(`task=${taskId} run=${runId} 复审拒绝 → 退回重执行`);
return;
+16 -2
View File
@@ -183,10 +183,15 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
const lastRunError = store.lastRunErrorOf(task.id);
// L3 拆解背景:executor 子任务带父任务意图 + 兄弟状态(planner 跑在父任务上,不需要)
const context = isPlanner ? null : store.taskContextOf(task.id);
// 人工驳回意见:返工角色 → 对应闸。executor 返工读 exec 闸;
// planner-spec 读 spec 闸;planner-decompose 读 plan 闸。
const rejectGate = runKind === 'planner-spec' ? 'spec'
: runKind === 'planner-decompose' ? 'plan' : 'exec';
const lastRejectReason = store.lastRejectReasonOf(task.id, rejectGate);
d.writeJobSpec({
runId: run.id,
task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError, context },
task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError, context, lastRejectReason },
project, worktreeDir: dir, branch, runKind,
});
@@ -209,9 +214,16 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
/**
* 领取轮:对每个 active 且 autonomy≠manual 的项目,并发闸 = inflightTaskIds(有 started run 的任务,
* executor + planner 通用)。在 active<concurrency 时按 claimable 领新任务并 spawn worker。
*
* 全局并发闸(settings.globalConcurrency0/缺省=不限)与 per-project 闸【串联】:两闸都过才领。
* 全局在途总数(globalActive)轮初查一次、轮内手动 ++claimOne 同步起新 started run),命中即 return——
* 达上限后任何项目都不应再领,故 return(跨所有项目停止)而非 break(只停当前项目)。
*/
function claimTick(): void {
try {
const cap = store.getSettings().globalConcurrency; // 0/缺省 = 不限
let globalActive = store.globalInflightCount(); // 轮初的全局在途总数
if (cap > 0 && globalActive >= cap) return; // 全局闸已满 → 整轮跨项目都不领
for (const p of store.listProjects()) {
if (p.status !== 'active' || p.autonomy === 'manual') continue;
const inflight = store.inflightTaskIds(p.id);
@@ -219,9 +231,11 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
if (active >= p.concurrency) continue;
for (const { task, score } of claimable(p, inflight)) {
if (active >= p.concurrency) break;
if (active >= p.concurrency) break; // per-project 闸
if (cap > 0 && globalActive >= cap) return; // 全局闸:达上限即跨所有项目停止领取
claimOne(p, task, score);
active++;
globalActive++; // 本轮内手动累加(claimOne 已起新 started run
}
}
} catch (e) {
+28 -1
View File
@@ -77,6 +77,23 @@ function lastErrorLines(task: Task): string[] {
];
}
/**
* 人工驳回意见注入:返工任务带「上次被人审驳回的改进意见」时,生成一段强提醒;否则空数组。
* 由 daemon 在 claimOne 时把 store.lastRejectReasonOf 填进 task.lastRejectReason 下发。
*/
function lastRejectLines(task: Task): string[] {
const r = task.lastRejectReason?.trim();
if (!r) return [];
return [
'## 上次被人审驳回的意见(这是返工任务,必须针对性修正)',
'上一版产出被人工审核驳回,驳回意见如下。请把它当作硬性整改要求,逐条落实,不要重复同样的问题:',
'```',
r.length > 2000 ? r.slice(0, 2000) + '\n…(已截断)' : r,
'```',
'',
];
}
/** 声明文件范围约束(task.scopeFiles 非空时注入):改动越界文件会被硬闸拦截、任务打回。 */
function scopeConstraintLines(task: Task): string[] {
const scope = task.scopeFiles?.filter((s) => s.trim());
@@ -99,6 +116,7 @@ export function buildPrompt(task: Task, project?: Project, globalRules?: string
body || '(无详细说明,按标题完成)',
'',
...lastErrorLines(task),
...lastRejectLines(task),
'## 执行约束(必须遵守)',
'- 只在当前工作目录(git worktree)内改动文件,不得读写或修改 worktree 之外的任何文件。',
...scopeConstraintLines(task),
@@ -183,11 +201,13 @@ export type PlannerFn = (task: Task, project: Project, kind: PlanKind, runId: st
export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project, globalRules?: string | null): string {
const head = [`# 任务:${task.title}`, `任务 ID${task.id}`, '', ...rulesHeaderLines(project, globalRules)];
const errLines = lastErrorLines(task);
const rejectLines = lastRejectLines(task);
if (kind === 'spec') {
if (task.spec) head.push('## 现有方案草稿(可改进/替换)', task.spec, '');
return [
...head,
...errLines,
...rejectLines,
'## 你的角色:方案作者(只读,不改任何文件)',
'只读这个代码库,为上述任务写一份「具体改动方案」。不要编辑文件、不要执行有副作用的命令。',
'## 输出(markdown 正文)',
@@ -201,6 +221,7 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project
return [
...head, '',
...errLines,
...rejectLines,
'## 你的角色:任务拆解者(只读,不改任何文件)',
`当前任务层级 depth=${task.depth ?? 1}(根=1),最多拆到 depth=4(剩余可拆层数 ${Math.max(0, 4 - (task.depth ?? 1))} 层)。`,
'只读这个代码库,分析上述(复杂)任务,拆成 2–6 个更小、可独立交付的子任务。不要编辑文件、不要执行有副作用的命令。',
@@ -208,7 +229,13 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project
'- complexityeasy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解,仅剩余可拆层数>0时才能用)',
'- priority0=P0最紧急 / 1=P1中 / 2=P2最低',
'- deps:依赖本列表中其他子任务的 0-based 序号(无依赖填 []',
'- files:该子任务预计改动的文件范围(glob/路径,如 ["src/foo/**","lib/a.ts"])。尽量精确——执行时改动越界文件会被硬闸拦截。无法确定就留空数组(不限范围)。',
'- files:该子任务预计改动的文件范围(glob/路径,如 ["src/foo/**","lib/a.ts"])。执行时改动越界文件会被硬闸拦截,所以这个范围是给 executor 的【硬约束】,不是建议。',
'',
'### files 范围铁律(设窄会让任务做不完、卡死成 needs_attention,务必遵守)',
'- **必须覆盖该子任务全部跨层足迹**:把它真正要碰的【所有层】都列进去——前端(design/、app/)、后端(src/)、接线、测试。漏掉任一层,executor 就碰不了那层、只能交半成品被驳回。',
'- **隐含后端的能力别塞进纯前端范围**:删除 / 去重 / 持久化 / 列表查询 等通常需要后端端点(src/api、src/store)。若一个子任务含这类能力,files 必须同时包含后端路径,否则就【拆成两个子任务】(如「后端 CRUD 端点」+「前端 UI」,用 deps 串联)。',
'- **宁可留空,不可设窄**:拿不准完整范围时,**留空数组**(= 不限、不触发越界闸)远好于猜一个偏窄的范围——窄而错会直接卡死任务,空只是放权。',
'- 一句话:要么把范围列【全】,要么【留空】,绝不要列【一半】。',
'',
'## 输出格式(重要,必须严格遵守)',
'**第一步**:先输出一个 Markdown 表格总览:',
+6 -2
View File
@@ -105,6 +105,8 @@ export interface Task {
retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用)
nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领
lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示)
/** 人工驳回意见:daemon 在 claimOne 时从 approvals 取最近一次 reject reason 装配进 job,仅注入 prompt,不持久化 */
lastRejectReason?: string | null;
/** L3 拆解背景:daemon 在 claimOne 时装配进 job(父任务意图 + 兄弟任务状态),仅注入 prompt,不持久化 */
context?: TaskContext | null;
attachments?: Attachment[]; // 随任务提交的图片/文件(存 <data>/tasks/<id>/attachments/
@@ -114,9 +116,11 @@ export interface Task {
/** 任务附件:图片/文件随任务提交,落盘于 <MAESTRO_DATA_DIR>/tasks/<taskId>/attachments/ */
export interface Attachment {
name: string; // 原始文件名
name: string; // 原始文件名(展示用)
type: string; // MIME 类型(如 image/png
path: string; // 相对 data 根的存储路径tasks/<taskId>/attachments/<name>
path: string; // 相对 data 根的存储路径,内容寻址:tasks/<taskId>/attachments/<sha256>.<ext>
hash?: string; // 内容 sha256(hex)——去重键;老数据无此字段,回退按 path 去重
size?: number; // 字节数;老数据可能缺省
}
export type RunKind = 'planner' | 'executor' | 'reviewer' | 'security';
+88 -13
View File
@@ -16,6 +16,7 @@ import { computeCost, addUsage, EMPTY_USAGE, type UsageTokens } from '../model/p
import {
type TaskStatus, type GateKind, canTransition, initialNextStatus, gateOf, STATUS_LABEL,
} from '../model/status.js';
import { basename } from 'node:path';
const now = (): string => new Date().toISOString();
@@ -30,29 +31,45 @@ export interface UserSettings {
budgetUsd: number | null;
budgetPeriod: 'day' | 'month';
model: string | null;
/** 跨所有项目的在途 run 总数上限(全局并发闸);0 = 不限。与 concurrency(新建项目默认值)相互独立。 */
globalConcurrency: number;
}
const SETTINGS_DEFAULT: UserSettings = {
autonomy: 'manual', concurrency: 1, maxRetries: 2, timeoutMs: 1_800_000,
autoApprovePlan: false, autoApproveExec: false,
budgetUsd: null, budgetPeriod: 'month', model: null,
globalConcurrency: 0,
};
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
/** 预算周期起点(UTC):day=今日零点 / month=当月 1 号零点。与存储的 ISO 时间戳同基准比较。 */
const pad2 = (n: number): string => String(n).padStart(2, '0');
/** 按 daemon 本地时区把日期格式化为 YYYY-MM-DD(用作「按天」时间桶标签)。 */
function localDay(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/** 按 daemon 本地时区把日期格式化为 YYYY-MM(用作「按月」时间桶标签)。 */
function localMonth(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
}
/**
* 预算周期起点(daemon 本地时区):day=今日本地零点 / month=当月 1 号本地零点。
* 本地零点经 toISOString() 转回绝对时刻,与存储的 ISO 时间戳同基准比较仍正确。
*/
function periodStartISO(period: 'day' | 'week' | 'month'): string {
const d = new Date();
if (period === 'week') return new Date(Date.now() - 7 * 86400_000).toISOString();
const start = period === 'day'
? Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
return new Date(start).toISOString();
? new Date(d.getFullYear(), d.getMonth(), d.getDate())
: new Date(d.getFullYear(), d.getMonth(), 1);
return start.toISOString();
}
/** 把 ISO 时间戳归一到所属周的周一(UTC),返回 YYYY-MM-DD,作为「按周」时间桶标签。 */
/** 把 ISO 时间戳归一到所属周的周一(daemon 本地时区),返回本地 YYYY-MM-DD,作为「按周」时间桶标签。 */
function weekStartLabel(iso: string): string {
const d = new Date(iso);
const dow = (d.getUTCDay() + 6) % 7; // 周一=0 … 周日=6
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - dow)).toISOString().slice(0, 10);
const dow = (d.getDay() + 6) % 7; // 周一=0 … 周日=6
return localDay(new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow));
}
export class StoreError extends Error {}
@@ -416,12 +433,30 @@ export class Store {
const t = this.getTaskRow(taskId);
if (!t) throw new StoreError(`任务不存在: ${taskId}`);
const cur: Attachment[] = t.attachments ? JSON.parse(t.attachments) as Attachment[] : [];
const next = [...cur, ...items];
// 按内容 hash 去重(later-wins):磁盘文件内容寻址,相同内容=同一份磁盘文件;
// 重传相同内容(即便改名)只保留最新一条元数据(更新展示名/type)。老数据无 hash → 回退按 path 去重。
const keyOf = (a: Attachment) => a.hash ?? a.path;
const byKey = new Map<string, Attachment>();
for (const a of [...cur, ...items]) byKey.set(keyOf(a), a);
const next = [...byKey.values()];
this.db.prepare(`UPDATE tasks SET attachments = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(next), now(), taskId);
this.emit(t.project_id, taskId, 'task.updated', { field: 'attachments', count: next.length });
return next;
}
/** 按磁盘文件名(basename(path))移除一条附件元数据。返回剩余列表与被移除项(不存在则 removed=null)。 */
removeAttachment(taskId: string, name: string): { attachments: Attachment[]; removed: Attachment | null } {
const t = this.getTaskRow(taskId);
if (!t) throw new StoreError(`任务不存在: ${taskId}`);
const cur: Attachment[] = t.attachments ? JSON.parse(t.attachments) as Attachment[] : [];
const removed = cur.find((a) => basename(a.path) === name) ?? null;
if (!removed) return { attachments: cur, removed: null };
const next = cur.filter((a) => a !== removed);
this.db.prepare(`UPDATE tasks SET attachments = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(next), now(), taskId);
this.emit(t.project_id, taskId, 'task.updated', { field: 'attachments', count: next.length });
return { attachments: next, removed };
}
private getTaskRow(taskId: string): TaskRow | undefined {
return this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined;
}
@@ -834,6 +869,24 @@ export class Store {
return this.getTask(taskId)!;
}
/**
* 范围型驳回:复审因「任务需求超出其声明范围(scopeFiles)」而拒——重试 executor 必然无解(它碰不了范围外文件,
* 只会同样被驳回,盲目重试到 needs_attention 纯属浪费)。故不进重试链:补记一条 failed executor run 承载原因
* (供 UI lastRunError 兜底显示),直接转 needs_attention,待人工放宽 scopeFiles 或回 planner 重新拆解。
*/
failScopeReject(taskId: string, reason: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const r = this.startRun(taskId, 'executor');
this.finishRun(r.id, 'failed', { error: reason });
if ((this.getTaskRow(taskId)!.status as TaskStatus) !== 'failed') {
this.transition(taskId, 'failed', { by: 'failScopeReject', error: reason });
}
this.setNextEligibleAt(taskId, null);
this.transition(taskId, 'needs_attention', { by: 'failScopeReject', reason });
return this.getTask(taskId)!;
}
/**
* 通用规则13 执行前分歧重评估:sync main 发现 defaultBranch 改动与任务声明范围(scopeFiles)重叠,
* 原方案可能过时 → 不重试、不计失败次数,直接把任务转 needs_attention 待人工重评估。
@@ -918,6 +971,11 @@ export class Store {
return new Set(rows.map((r) => r.tid));
}
/** 跨所有项目的在途 run 总数(全局并发闸用;与 inflightTaskIds 同口径=started run,覆盖 executor + planner)。 */
globalInflightCount(): number {
return (this.db.prepare(`SELECT COUNT(*) AS n FROM runs WHERE status = 'started'`).get() as { n: number }).n;
}
/** 所有 started run + 其任务(reap / ingest / reconcile 通用,覆盖 executor + planner + 残留复审 run)。 */
liveRunsWithTask(): Array<{ task: Task; run: Run }> {
const runs = this.db.prepare(`SELECT * FROM runs WHERE status = 'started' ORDER BY started_at`).all() as RunRow[];
@@ -1025,6 +1083,23 @@ export class Store {
return row?.error ?? null;
}
/**
* 取某任务最近一次「被人审驳回」的改进意见(approvals 里 action='reject')。
* 供把人工驳回意见喂回返工 agent 的 prompt,避免重复犯同样的问题。
* gate 可选:传入则只取该闸的最近一次 rejectexec/spec/plan),与返工角色对齐;
* 不传则取任意闸最近一次。无 reject 返回 null。
*/
lastRejectReasonOf(taskId: string, gate?: GateKind): string | null {
const row = (gate
? this.db.prepare(
`SELECT reason FROM approvals WHERE task_id = ? AND action = 'reject' AND gate = ? AND reason IS NOT NULL ORDER BY at DESC LIMIT 1`,
).get(taskId, gate)
: this.db.prepare(
`SELECT reason FROM approvals WHERE task_id = ? AND action = 'reject' AND reason IS NOT NULL ORDER BY at DESC LIMIT 1`,
).get(taskId)) as { reason?: string | null } | undefined;
return row?.reason ?? null;
}
/**
* L3 拆解背景:取某任务的父任务拆解意图(parent.plan)+ 同层兄弟任务概览(标题/状态/复杂度)。
* 无 parentId → null。仅做便宜的 DB 读,不做 git/RAG 检索。
@@ -1203,8 +1278,8 @@ export class Store {
}
/**
* 用量详情:时间序列(按天/按周/按月分桶)+ 分项目下钻(token/花费/任务数/模型)。
* day → 近 30 天、桶=YYYY-MM-DDweek → 近 12 周、桶=周一 YYYY-MM-DDmonth → 近 12 个月、桶=YYYY-MM。
* 用量详情:时间序列(按天/按周/按月分桶,均按 daemon 本地时区)+ 分项目下钻(token/花费/任务数/模型)。
* day → 近 30 天、桶=本地 YYYY-MM-DDweek → 近 12 周、桶=本地周一 YYYY-MM-DDmonth → 近 12 个月、桶=本地 YYYY-MM。
* token/model 解析与 costSummary 同款(usage JSON 存累计 token + 最近 modelmodel 维度为近似)。
*/
usageDetail(granularity: 'day' | 'week' | 'month'): UsageDetail {
@@ -1213,7 +1288,7 @@ export class Store {
? new Date(Date.now() - 30 * 86400_000).toISOString()
: granularity === 'week'
? new Date(Date.now() - 12 * 7 * 86400_000).toISOString()
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1)).toISOString();
: new Date(now.getFullYear(), now.getMonth() - 11, 1).toISOString();
const rows = this.db.prepare(
`SELECT t.project_id AS pid, r.task_id AS tid, r.started_at AS at, r.usage AS usage, r.cost_usd AS cost
FROM runs r JOIN tasks t ON t.id = r.task_id
@@ -1228,9 +1303,9 @@ export class Store {
const u = row.usage ? JSON.parse(row.usage) as UsageTokens & { model?: string } : null;
const tokens = u ? u.input + u.output + u.cacheRead + u.cacheWrite : 0;
const model = u?.model ?? 'unknown';
const bucket = granularity === 'day' ? row.at.slice(0, 10)
const bucket = granularity === 'day' ? localDay(new Date(row.at))
: granularity === 'week' ? weekStartLabel(row.at)
: row.at.slice(0, 7);
: localMonth(new Date(row.at));
const s = series.get(bucket) ?? { cost: 0, tokens: 0, runs: 0 };
s.cost += row.cost; s.tokens += tokens; s.runs += 1; series.set(bucket, s);
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// adapt.js 是前端纯函数适配层(app/src),用 node:test 直接校验项目摘要映射。
import { adaptProject } from '../app/src/adapt.js';
const baseProject = {
id: 'p1', name: 'maestro', repoPath: '/Users/x/dev/maestro', defaultBranch: 'main',
autonomy: 'auto-easy', concurrency: 2, status: 'active',
};
test('adaptProject: agents 徽标取 s.running(实际在途 agent),而非 s.executing', () => {
// planner/spec 阶段:有真实在途 runrunning>0)但没有 executing 任务 → 徽标必须仍显示 agent 数
const p = adaptProject({ ...baseProject, summary: { running: 1, executing: 0, blocked: 0, attention: 0 } });
assert.equal(p.agents, 1, 'analyzing/speccing 阶段也应计入 agent 徽标');
assert.equal(p.state, 'running');
});
test('adaptProject: 无在途 run 时 agents 徽标为 0(即使有 executing 任务计数错位)', () => {
const p = adaptProject({ ...baseProject, summary: { running: 0, executing: 0, blocked: 2, attention: 1 } });
assert.equal(p.agents, 0);
assert.equal(p.blocked, 2);
assert.equal(p.pending, 1);
assert.equal(p.state, 'blocked');
});
test('adaptProject: summary 缺省时各计数回退 0', () => {
const p = adaptProject({ ...baseProject });
assert.equal(p.agents, 0);
assert.equal(p.pending, 0);
assert.equal(p.blocked, 0);
});
+307
View File
@@ -0,0 +1,307 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store/index.js';
import { buildServer } from '../src/api/server.js';
import type { Attachment } from '../src/model/types.js';
/** 临时数据目录(充当 MAESTRO_DATA_DIR)。 */
function tmpData(): string {
return mkdtempSync(join(tmpdir(), 'maestro-attach-'));
}
/** 在 store 里建项目 + 任务,返回 {store, taskId}。 */
function freshTask(): { store: Store; taskId: string } {
const store = new Store(':memory:');
const p = store.createProject({ name: 'a', repoPath: '/tmp/a-' + Math.random() });
const t = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
return { store, taskId: t.id };
}
/** 把一份附件文件落盘到 <data>/tasks/<id>/attachments/<name>,并返回元数据。 */
function placeFile(dir: string, taskId: string, name: string, mime: string, body: Buffer | string): Attachment {
const attDir = join(dir, 'tasks', taskId, 'attachments');
mkdirSync(attDir, { recursive: true });
writeFileSync(join(attDir, name), body);
return { name, type: mime, path: `tasks/${taskId}/attachments/${name}` };
}
// ───────────────────────── Store 层 ─────────────────────────
test('addAttachments:按内容 hash 去重(later-wins,同内容不同名只一条)', () => {
const { store, taskId } = freshTask();
const hA = 'a'.repeat(64);
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', hash: hA, size: 3, path: `tasks/${taskId}/attachments/${hA}.png` }]);
// 重传相同内容(hash 相同)但改名 → 元数据只保留一条,且取最新 name/type
const hB = 'b'.repeat(64);
const next = store.addAttachments(taskId, [
{ name: 'a-renamed.png', type: 'image/jpeg', hash: hA, size: 3, path: `tasks/${taskId}/attachments/${hA}.png` },
{ name: 'b.pdf', type: 'application/pdf', hash: hB, size: 7, path: `tasks/${taskId}/attachments/${hB}.pdf` },
]);
assert.equal(next.length, 2);
const a = next.find((x) => x.hash === hA);
assert.equal(a?.name, 'a-renamed.png');
assert.equal(a?.type, 'image/jpeg');
// 插入顺序保持:A 在前、B 在后
assert.deepEqual(next.map((x) => x.hash), [hA, hB]);
store.close();
});
test('addAttachments:不同内容(hash 不同)即便同显示名也各保留一条(不再静默覆盖丢数据)', () => {
const { store, taskId } = freshTask();
const h1 = '1'.repeat(64);
const h2 = '2'.repeat(64);
const next = store.addAttachments(taskId, [
{ name: 'screenshot.png', type: 'image/png', hash: h1, size: 3, path: `tasks/${taskId}/attachments/${h1}.png` },
{ name: 'screenshot.png', type: 'image/png', hash: h2, size: 3, path: `tasks/${taskId}/attachments/${h2}.png` },
]);
assert.equal(next.length, 2, '不同内容同名应各保留一条');
assert.deepEqual(next.map((x) => x.hash), [h1, h2]);
store.close();
});
test('addAttachments:老数据无 hash 时回退按 path 去重', () => {
const { store, taskId } = freshTask();
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', path: `tasks/${taskId}/attachments/a.png` }]);
const next = store.addAttachments(taskId, [
{ name: 'a-renamed.png', type: 'image/jpeg', path: `tasks/${taskId}/attachments/a.png` },
]);
assert.equal(next.length, 1);
assert.equal(next[0].name, 'a-renamed.png');
store.close();
});
test('removeAttachment:按磁盘文件名移除,返回 removed;不存在则 removed=null', () => {
const { store, taskId } = freshTask();
store.addAttachments(taskId, [
{ name: 'keep.png', type: 'image/png', path: `tasks/${taskId}/attachments/keep.png` },
{ name: 'gone.pdf', type: 'application/pdf', path: `tasks/${taskId}/attachments/gone.pdf` },
]);
const r1 = store.removeAttachment(taskId, 'gone.pdf');
assert.equal(r1.removed?.path, `tasks/${taskId}/attachments/gone.pdf`);
assert.deepEqual(r1.attachments.map((x) => x.path.split('/').pop()), ['keep.png']);
// 再删不存在的名字 → removed=null,列表不变
const r2 = store.removeAttachment(taskId, 'nope.png');
assert.equal(r2.removed, null);
assert.equal(r2.attachments.length, 1);
store.close();
});
test('removeAttachment / addAttachments:任务不存在抛错', () => {
const store = new Store(':memory:');
assert.throws(() => store.removeAttachment('tsk_nope', 'x'));
assert.throws(() => store.addAttachments('tsk_nope', []));
store.close();
});
// ───────────────────────── API 层(Fastify inject)─────────────────────────
test('GET 附件:默认 inlinecontent-type/length 正确', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const png = Buffer.from('\x89PNG\r\n\x1a\n-fake-image-bytes', 'binary');
store.addAttachments(taskId, [placeFile(dir, taskId, 'pic.png', 'image/png', png)]);
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/pic.png` });
assert.equal(res.statusCode, 200);
assert.match(res.headers['content-type'] as string, /image\/png/);
assert.equal(res.headers['content-length'], String(png.length));
assert.match(res.headers['content-disposition'] as string, /^inline; filename\*=UTF-8''pic\.png/);
await app.close();
store.close();
});
test('GET 附件【安全】:SVG/HTML 等可执行类型强制下载(attachment + octet-stream + nosniff + CSP sandbox),杜绝同源存储型 XSS', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
store.addAttachments(taskId, [placeFile(dir, taskId, 'evil.svg', 'image/svg+xml', svg)]);
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/evil.svg` });
assert.equal(res.statusCode, 200);
// 非白名单图片类型(含 svg/html):绝不 inline、绝不以原 MIME 回 → 强制下载 + octet-stream + 双重头兜底
assert.match(res.headers['content-disposition'] as string, /^attachment;/, 'SVG 必须强制 attachment 下载');
assert.match(res.headers['content-type'] as string, /application\/octet-stream/, 'content-type 必须改为 octet-stream(不能回 image/svg+xml');
assert.equal(res.headers['x-content-type-options'], 'nosniff');
assert.match(res.headers['content-security-policy'] as string, /sandbox/);
await app.close();
store.close();
});
test('GET 附件:?download=1 → attachment + RFC5987 文件名(中文不乱码)', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
// 磁盘安全名为 ascii,但原始 name 含中文
store.addAttachments(taskId, [{
name: '设计稿.png', type: 'image/png', path: `tasks/${taskId}/attachments/paste-1.png`,
}]);
placeFile(dir, taskId, 'paste-1.png', 'image/png', 'data');
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/paste-1.png?download=1` });
assert.equal(res.statusCode, 200);
const cd = res.headers['content-disposition'] as string;
assert.match(cd, /^attachment; filename\*=UTF-8''/);
assert.match(cd, new RegExp(encodeURIComponent('设计稿.png')));
await app.close();
store.close();
});
test('DELETE 附件:剔除元数据 + 删磁盘文件;之后 GET 404', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
store.addAttachments(taskId, [placeFile(dir, taskId, 'doc.pdf', 'application/pdf', 'pdfdata')]);
const absFile = join(dir, 'tasks', taskId, 'attachments', 'doc.pdf');
assert.ok(existsSync(absFile));
const app = buildServer({ store });
const del = await app.inject({ method: 'DELETE', url: `/api/tasks/${taskId}/attachments/doc.pdf` });
assert.equal(del.statusCode, 200);
assert.deepEqual(del.json().attachments, []);
assert.ok(!existsSync(absFile), '磁盘文件应被删除');
const get = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/doc.pdf` });
assert.equal(get.statusCode, 404);
await app.close();
store.close();
});
test('GET/DELETE:任务不存在 → 404', async () => {
const store = new Store(':memory:');
const app = buildServer({ store });
const g = await app.inject({ method: 'GET', url: `/api/tasks/tsk_nope/attachments/x.png` });
assert.equal(g.statusCode, 404);
const d = await app.inject({ method: 'DELETE', url: `/api/tasks/tsk_nope/attachments/x.png` });
assert.equal(d.statusCode, 404);
await app.close();
store.close();
});
test('GET:未登记的附件名 → 404(不返回目录外/任意文件)', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
placeFile(dir, taskId, 'secret.png', 'image/png', 'x'); // 落盘但不登记元数据
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/secret.png` });
assert.equal(res.statusCode, 404);
await app.close();
store.close();
});
test('GET:元数据在但磁盘文件丢失 → 404「附件文件已丢失」', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
// 只写元数据,不落盘
store.addAttachments(taskId, [{ name: 'ghost.png', type: 'image/png', path: `tasks/${taskId}/attachments/ghost.png` }]);
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/ghost.png` });
assert.equal(res.statusCode, 404);
assert.match(res.json().error, /丢失/);
await app.close();
store.close();
});
test('DELETE:元数据在但磁盘文件已被外部删除 → 仍成功清元数据', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
store.addAttachments(taskId, [{ name: 'ghost.png', type: 'image/png', path: `tasks/${taskId}/attachments/ghost.png` }]);
const app = buildServer({ store });
const res = await app.inject({ method: 'DELETE', url: `/api/tasks/${taskId}/attachments/ghost.png` });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json().attachments, []);
await app.close();
store.close();
});
function multipart(filename: string, content: string): { payload: Buffer; headers: Record<string, string> } {
const boundary = '----maestrotest';
const body =
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="files"; filename="${filename}"\r\n` +
`Content-Type: image/png\r\n\r\n` +
content + `\r\n` +
`--${boundary}--\r\n`;
return { payload: Buffer.from(body), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
}
test('上传→内容去重端到端:同内容不同名连传 → 元数据 1 条、磁盘 1 份 <sha256>.png', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const app = buildServer({ store });
// 同内容('aaa'),但文件名不同 → 内容寻址应去重为一条、磁盘一份
const m1 = multipart('shot.png', 'aaa');
const up1 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers });
assert.equal(up1.statusCode, 200);
assert.equal(up1.json().attachments.length, 1);
const m2 = multipart('shot-renamed.png', 'aaa');
const up2 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m2.payload, headers: m2.headers });
assert.equal(up2.statusCode, 200);
const atts = up2.json().attachments;
assert.equal(atts.length, 1, '同内容重传应去重为一条');
// 磁盘上只有 1 个 <sha256>.png(外加可能的 .tmp-* 已清理)
const attDir = join(dir, 'tasks', taskId, 'attachments');
const files = readdirSync(attDir).filter((f) => !f.startsWith('.tmp-'));
assert.equal(files.length, 1, '磁盘应只有一份内容文件');
assert.match(files[0], /^[0-9a-f]{64}\.png$/, '磁盘名应为 <sha256>.png');
// GET /api/tasks/:id 复核:1 条,且含 hash(64 hex)/size 字段
const t = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}` });
const list = t.json().attachments;
assert.equal(list.length, 1);
assert.match(list[0].hash, /^[0-9a-f]{64}$/);
assert.equal(list[0].size, 3);
await app.close();
store.close();
});
test('上传→内容寻址端到端:不同内容同显示名连传 → 元数据 2 条、磁盘 2 份(旧的覆盖丢数据 bug 已消除)', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const app = buildServer({ store });
const m1 = multipart('screenshot.png', 'aaa');
await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers });
const m2 = multipart('screenshot.png', 'bbb'); // 同名、不同内容
const up2 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m2.payload, headers: m2.headers });
assert.equal(up2.statusCode, 200);
assert.equal(up2.json().attachments.length, 2, '不同内容同名应保留两条');
const attDir = join(dir, 'tasks', taskId, 'attachments');
const files = readdirSync(attDir).filter((f) => !f.startsWith('.tmp-'));
assert.equal(files.length, 2, '磁盘应有两份内容文件,不再静默覆盖');
await app.close();
store.close();
});
test('上传:响应附件含 hash(64 hex) 与 size 字段', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const app = buildServer({ store });
const m = multipart('pic.png', 'hello-bytes');
const up = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m.payload, headers: m.headers });
assert.equal(up.statusCode, 200);
const att = up.json().attachments[0];
assert.match(att.hash, /^[0-9a-f]{64}$/);
assert.equal(att.size, 'hello-bytes'.length);
// 磁盘名 = path 末段 = <hash>.png
assert.equal(att.path, `tasks/${taskId}/attachments/${att.hash}.png`);
await app.close();
store.close();
});
+40
View File
@@ -42,6 +42,46 @@ test('store: setRunUsage 累加成本 + costSummary 聚合', () => {
s.close();
});
test('store: usageDetail/costSummary 按 daemon 本地时区分桶(UTC+8 跨日不再偏移一天)', () => {
const origTZ = process.env.TZ;
process.env.TZ = 'Asia/Shanghai'; // 固定 UTC+8,制造 UTC 日 != 本地日
try {
const s = new Store(':memory:');
const p = s.createProject({ name: 'tz', repoPath: '/tmp/tz-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
// —— 按天分桶:近 2 天内 UTC 18:00 的 run → 本地为次日 02:00,本地日应比 UTC 日晚一天 ——
const r1 = s.startRun(t.id, 'executor');
s.setRunUsage(r1.id, { input: 1_000_000, output: 0, cacheRead: 0, cacheWrite: 0 }, 'claude-opus-4-8');
const base = new Date();
base.setUTCDate(base.getUTCDate() - 2);
base.setUTCHours(18, 0, 0, 0);
const startedAt = base.toISOString();
s.db.prepare('UPDATE runs SET started_at = ? WHERE id = ?').run(startedAt, r1.id);
const utcDay = startedAt.slice(0, 10);
const ld = new Date(startedAt);
const localDay = `${ld.getFullYear()}-${String(ld.getMonth() + 1).padStart(2, '0')}-${String(ld.getDate()).padStart(2, '0')}`;
assert.notEqual(localDay, utcDay, 'UTC+8 下 18:00Z 应跨到本地次日,前提才成立');
const detail = s.usageDetail('day');
assert.ok(detail.series.some((b) => b.bucket === localDay), `应分桶到本地日 ${localDay}`);
assert.equal(detail.series.some((b) => b.bucket === utcDay), false, `不应再用 UTC 日 ${utcDay}`);
// —— 当天窗口:本地今天 02:00(UTC 仍为昨天 18:00)的 run 应计入「今天」 ——
const r2 = s.startRun(t.id, 'executor');
s.setRunUsage(r2.id, { input: 0, output: 1_000_000, cacheRead: 0, cacheWrite: 0 }, 'claude-opus-4-8'); // $75
const today2am = new Date();
today2am.setHours(2, 0, 0, 0); // 本地今天 02:00
s.db.prepare('UPDATE runs SET started_at = ? WHERE id = ?').run(today2am.toISOString(), r2.id);
assert.ok(s.costSummary('day').total >= 75, '本地 00:0008:00 的 run 应计入今天(旧 UTC 口径会漏算)');
s.close();
} finally {
if (origTZ === undefined) delete process.env.TZ; else process.env.TZ = origTZ;
}
});
test('store: enforceBudget 超额 → 暂停项目 + 广播 budget.exceeded', () => {
const s = new Store(':memory:');
const p = s.createProject({ name: 'b', repoPath: '/tmp/b-' + Math.random() });
+61
View File
@@ -113,6 +113,67 @@ test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind', () =
s.close();
});
const REJECT_SECTION = '上次被人审驳回的意见';
test('REJECT: 无 lastRejectReason 时三处 prompt 都不含驳回段', () => {
assert.ok(!buildPrompt(fakeTask()).includes(REJECT_SECTION));
assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'medium' }), 'spec').includes(REJECT_SECTION));
assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'hard' }), 'decompose').includes(REJECT_SECTION));
});
test('REJECT: 有 lastRejectReason 时 buildPrompt/buildPlannerPrompt(spec/decompose) 都注入驳回意见原文', () => {
const reason = '错误地动了 web/ 老仪表盘,请改到 design/ui_kits/console/';
for (const p of [
buildPrompt(fakeTask({ lastRejectReason: reason })),
buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRejectReason: reason }), 'spec'),
buildPlannerPrompt(fakeTask({ complexity: 'hard', lastRejectReason: reason }), 'decompose'),
]) {
assert.ok(p.includes(REJECT_SECTION), '应含驳回段标题');
assert.ok(p.includes(reason), '应含驳回意见原文');
}
});
test('REJECT: buildConflictPrompt 始终不注入驳回段(守边界)', () => {
const reason = '某条驳回意见';
assert.ok(!buildConflictPrompt(fakeTask({ lastRejectReason: reason }), ['a.ts']).includes(REJECT_SECTION));
});
test('REJECT: 超长驳回意见被截断到 2000 字符', () => {
const reason = 'Y'.repeat(5000);
const p = buildPrompt(fakeTask({ lastRejectReason: reason }));
assert.ok(p.includes('已截断'));
assert.ok(!p.includes('Y'.repeat(2100)));
});
test('REJECT: 顺序——驳回段在角色说明之前;与失败段并存时失败段在前', () => {
// planner 中驳回段排在「你的角色」之前
const sp = buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRejectReason: 'RJ' }), 'spec');
assert.ok(sp.indexOf(REJECT_SECTION) < sp.indexOf('你的角色'), '驳回段在角色说明之前');
// 失败段与驳回段并存:失败在前、驳回在后
const both = buildPrompt(fakeTask({ lastRunError: 'ERR', lastRejectReason: 'RJ' }));
assert.ok(both.indexOf(SECTION) < both.indexOf(REJECT_SECTION), '失败段在驳回段之前');
});
test('lastRejectReasonOf 取最近一次 reject 的 reason,支持 gate 过滤,无 reject → null', () => {
const s = new Store(':memory:');
const p = s.createProject({ name: 'rj', repoPath: '/tmp/rj-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' });
assert.equal(s.lastRejectReasonOf(t.id), null, '无 reject → null');
// 推到 exec_review 再 rejectexec reject 落 ready
s.setOperations(t.id, 'do it');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'exec_review');
const reason = '代码动了越界文件,请收敛范围';
s.decide(t.id, 'reject', 'alice', reason);
assert.equal(s.lastRejectReasonOf(t.id), reason, '不带 gate → 取最近一次');
assert.equal(s.lastRejectReasonOf(t.id, 'exec'), reason, 'exec 闸能取到');
assert.equal(s.lastRejectReasonOf(t.id, 'plan'), null, 'plan 闸取不到该 exec reject');
s.close();
});
test('buildPrompttask.scopeFiles 非空 → 注入声明文件范围约束;为空 → 不注入', () => {
const withScope = buildPrompt(fakeTask({ scopeFiles: ['src/foo/**', 'lib/a.ts'] }));
assert.match(withScope, /声明的改动文件范围/);
+63
View File
@@ -443,6 +443,69 @@ test('auto-easy 不做规划:medium(speccing) 不被领取', () => {
store.close();
});
// ───────────────────────── 全局并发闸(跨项目在途总数上限)─────────────────────────
/** 单 store 建两个 active / auto-easy 项目,各塞 n 个 easy 任务,per-project 并发足够大(不成为瓶颈)。 */
function setupTwoProjects(n = 2, concurrency = 5): { store: Store; p1: string; p2: string } {
const store = new Store(':memory:');
const p1 = store.createProject({ name: 'g1', repoPath: '/tmp/g1-' + Math.random(), autonomy: 'auto-easy', concurrency });
const p2 = store.createProject({ name: 'g2', repoPath: '/tmp/g2-' + Math.random(), autonomy: 'auto-easy', concurrency });
for (let i = 0; i < n; i++) {
store.createTask({ projectId: p1.id, title: `p1-${i}`, complexity: 'easy' });
store.createTask({ projectId: p2.id, title: `p2-${i}`, complexity: 'easy' });
}
return { store, p1: p1.id, p2: p2.id };
}
test('全局闸:globalConcurrency=1 达上限 → 跨所有项目只领一个(per-project 闸都没满也阻止)', () => {
const { store } = setupTwoProjects(2, 5);
store.putSettings({ globalConcurrency: 1 });
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 1, '全局闸=1:两项目共 4 个可领任务,本轮只放一个');
store.close();
});
test('全局闸:globalConcurrency=0(缺省)不限 → 两项目任务全领(不误伤)', () => {
const { store, p1, p2 } = setupTwoProjects(2, 5);
// 不设 globalConcurrency(默认 0
assert.equal(store.getSettings().globalConcurrency, 0);
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 4, '不限:两项目各 2 个共 4 个全部领取');
assert.equal(store.listTasks(p1).filter((t) => t.status === 'executing').length, 2);
assert.equal(store.listTasks(p2).filter((t) => t.status === 'executing').length, 2);
store.close();
});
test('全局闸:globalConcurrency=3 两项目共 4 任务 → 一轮恰好领 3(边界)', () => {
const { store } = setupTwoProjects(2, 5);
store.putSettings({ globalConcurrency: 3 });
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 3, '全局闸=3:达上限即跨项目停止,恰好领 3 个');
store.close();
});
test('全局闸:在途已达上限 → 整轮直接不领(轮初短路)', () => {
const { store, p1 } = setupTwoProjects(2, 5);
store.putSettings({ globalConcurrency: 1 });
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
assert.equal(state.spawned.length, 1);
assert.equal(store.globalInflightCount(), 1, '已有 1 个在途 started run');
// 再来一轮:全局在途已=1=cap → 不再领
orch.claimTick();
assert.equal(state.spawned.length, 1, '全局在途已达上限 → 整轮不领');
assert.ok(store.listTasks(p1).length >= 0);
store.close();
});
test('并发闸用 inflight1 个 executing + 1 个 planner 占满 concurrency=2', () => {
const { store, projectId } = setup('auto-approved', 2);
const e = store.createTask({ projectId, title: 'easy', complexity: 'easy' }); // → ready
+19
View File
@@ -163,6 +163,25 @@ test('markReeval(规则13):executing → needs_attention,收尾 run 为
s.close();
});
test('failScopeReject(范围型驳回):直接 needs_attention、不进重试链、清退避,记一条 failed run 承载原因', () => {
const s = freshStore();
const p = s.createProject({ name: 'scoperej', repoPath: '/tmp/scoperej-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: ['design/**'] });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
const after = s.failScopeReject(t.id, '复审因范围不足驳回(停止重试):Scope A entirely undone');
assert.equal(after.status, 'needs_attention', '范围型驳回应直接转 needs_attention(不重试)');
assert.equal(after.nextEligibleAt, null, '应清退避、不进重试链');
// 补记一条 failed executor run 承载原因(供 UI lastRunError 兜底)
const failed = s.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed');
assert.equal(failed.length, 1, '应补记一条 failed executor run');
assert.match(failed[0].error ?? '', /范围不足/);
s.close();
});
test('reconcileInterrupted:执行中被 cancel 的任务仍挂 started run → 仅收尾不崩(绝不非法 cancelled→failed', () => {
const s = freshStore();
const p = s.createProject({ name: 'recon', repoPath: '/tmp/recon-' + Math.random() });
+30
View File
@@ -132,6 +132,10 @@ const I18N = {
toastNameRequired: '名称与仓库路径必填',
toastTaskNotFound: '任务不在当前项目',
toastConcInt: '最大并发必须是 ≥1 的整数',
globalSettings: '全局设置', globalConc: '全局并发上限',
globalConcHint: '跨所有项目的在途任务总数上限;0 = 不限。',
globalConcInt: '全局并发上限必须是 ≥0 的整数(0 = 不限)',
toastSettingsSaved: '全局设置已保存',
toastRejectRequired: '驳回意见不能为空',
reorderSaved: '项目顺序已保存',
cancelConfirm: '确定取消任务「{title}」{hint}?\n取消后任务进入归档,不影响子任务执行。',
@@ -274,6 +278,10 @@ const I18N = {
toastNameRequired: 'Name and repo path are required',
toastTaskNotFound: 'Task not found in current project',
toastConcInt: 'Max concurrency must be an integer ≥1',
globalSettings: 'Settings', globalConc: 'Global concurrency cap',
globalConcHint: 'Max in-flight tasks across all projects; 0 = unlimited.',
globalConcInt: 'Global concurrency cap must be an integer ≥0 (0 = unlimited)',
toastSettingsSaved: 'Settings saved',
toastRejectRequired: 'Rejection feedback is required',
reorderSaved: 'Project order saved',
cancelConfirm: 'Cancel task "{title}"{hint}?\nThis moves the task to archive; subtasks are unaffected.',
@@ -1752,6 +1760,17 @@ document.addEventListener('click', (ev) => {
$('#modalRoot').hidden = true;
break;
case 'open-settings':
api('/api/settings').then((s) => {
$('#setGlobalConc').value = s.globalConcurrency ?? 0;
$('#settingsRoot').hidden = false;
$('#setGlobalConc').focus();
}).catch((e) => toast(e.message));
break;
case 'close-settings':
$('#settingsRoot').hidden = true;
break;
case 'toggle-new-task': {
const p = $('#newTaskPanel');
p.hidden = !p.hidden;
@@ -2234,6 +2253,16 @@ $('#newProjectForm').addEventListener('submit', (ev) => {
}, t('toastProjectCreated', { name }));
});
$('#settingsForm').addEventListener('submit', (ev) => {
ev.preventDefault();
const n = Number($('#setGlobalConc').value);
if (!Number.isInteger(n) || n < 0) { toast(t('globalConcInt')); return; }
act(async () => {
await api('/api/settings', { method: 'PUT', body: JSON.stringify({ globalConcurrency: n }) });
$('#settingsRoot').hidden = true;
}, t('toastSettingsSaved'));
});
$('#newTaskPanel').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = ev.target;
@@ -2268,6 +2297,7 @@ document.addEventListener('keydown', (ev) => {
const am = $('#archiveModalRoot');
if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; }
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
if (!$('#settingsRoot').hidden) { $('#settingsRoot').hidden = true; return; }
$('#modalRoot').hidden = true;
}
});
+18
View File
@@ -28,6 +28,8 @@
<div class="side-foot">
<span id="wsDot" class="ws-dot off"></span>
<span id="wsText" data-i18n="connecting">连接中…</span>
<span class="spacer"></span>
<button class="btn btn-ghost btn-xs" data-action="open-settings" data-i18n-title="globalSettings" title="全局设置"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3" stroke="var(--icon-accent,#5fdd7d)"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg> <span data-i18n="globalSettings">全局设置</span></button>
</div>
</aside>
@@ -261,6 +263,22 @@
</form>
</div>
<!-- 全局设置 模态 -->
<div id="settingsRoot" class="modal-root" hidden>
<div class="modal-mask" data-action="close-settings"></div>
<form id="settingsForm" class="modal panel">
<div class="sec-head"><span class="head-mark"></span><span data-i18n="globalSettings">全局设置</span></div>
<label><span data-i18n="globalConc">全局并发上限</span>
<input id="setGlobalConc" type="number" min="0" step="1" value="0" autocomplete="off">
</label>
<p class="cfg-current" data-i18n="globalConcHint">跨所有项目的在途任务总数上限;0 = 不限。</p>
<div class="form-row form-actions">
<button type="submit" class="btn btn-solid" data-action="save-settings" data-i18n="save">保存</button>
<button type="button" class="btn" data-action="close-settings" data-i18n="cancel">取消</button>
</div>
</form>
</div>
<div id="previewRoot" class="preview-root" hidden></div>
<div id="archiveModalRoot" class="preview-root" hidden></div>
<div id="toastRoot" class="toast-root"></div>