Compare commits

...

21 Commits

Author SHA1 Message Date
cfdaily d6cb854f68 fix: mention 重复投递 + mail 失败通知竞态保护 + §14 设计文档同步
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 31s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
Bug 1: spawn_full_agent use_main_session 返回 None 导致 mention 重复投递
- 根因: use_main_session=True 时 session_id=None, return None 被 ticker
  _process_posts 误判为 spawn 失败, 每次 tick 都重试
- 修复: 引入 effective_sid = session_id or 'main', 统一用于
  _register_session / _monitor_process / return value

Bug 2: _mark_task failed 时未检查已完成状态导致误发投递失败通知
- 根因: spawner 标 failed 和 handler 标 done 竞态条件下, 已完成的
  mail task 被误发投递失败通知
- 修复: notify_mail_failed 调用前加防御性检查, 若 task 已 done 则跳过

设计文档: §13 三个 handler sections 列表同步 DeliveryChecklistSection
  及 GiteaConventionSection / WikiGuideSection, 更新 section 复用分析表
  及文件结构 section 计数
2026-06-15 09:48:09 +08:00
pangtong-fujunshi 1f373d5cb5 Merge PR #79
Deploy / ci (push) Failing after 8s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-15 00:06:56 +00:00
cfdaily a8c9d25857 [moz] feat(prompt): L0~L2 prompt improvements
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 13s
CI / notify-on-failure (pull_request) Successful in 0s
- L0 wiki-rule: 扩充检索路径(practices/concepts/docs/design/)+ 检索方式(index→summary→grep→full)
- L1 SOUL.md: 同步测试 + PR 审查(代码改动检查设计文档+测试脚本,PR/CI/CD 三重把关)
- L1 AGENTS.md: 新增测试规范段(生产隔离/残留清理/测试开发分离)
- L2 prompt_composer: 新增 DeliveryChecklistSection(executor/mail/toolchain handler 注册)
- 456 passed, 0 failed
2026-06-15 08:04:42 +08:00
pangtong-fujunshi 660ac4b659 Merge PR #78: [moz] feat(frontend): 工具链面板加 from/to 显示 + 筛选 + 修复事件类型未知
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Successful in 12s
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 1s
2026-06-14 09:13:55 +00:00
cfdaily 91685ebfdd [moz] feat(frontend): 工具链面板加 from/to 显示 + 筛选 + 修复事件类型未知
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 11s
CI / notify-on-failure (pull_request) Successful in 0s
- 前端:列表项加 from → to 标签(Agent 中文名)
- 前端:加「全部 / 未处理」筛选按钮
- 前端:详情区也显示 from → to
- 后端:ToolchainContextSection 修复事件类型 fallback 为中文标签
- 后端:加来源/指派信息到 prompt 消息体
2026-06-14 17:12:11 +08:00
pangtong-fujunshi 65910f5417 Merge PR #77: [moz] fix(api): list_tasks 默认排序改为 DESC
Deploy / ci (push) Successful in 10s
Deploy / deploy (push) Successful in 13s
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 1s
2026-06-14 08:55:14 +00:00
cfdaily 17b87290c8 [moz] fix(api): list_tasks 默认排序改为 created_at DESC(最新在前)
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 10s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 16:53:35 +08:00
pangtong-fujunshi bd5735f970 Merge PR #76: [moz] refactor(frontend): 工具链 Tab 移入系统设置子页签
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Successful in 13s
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 1s
2026-06-14 08:37:16 +00:00
cfdaily 05f9112fab [moz] refactor(frontend): 工具链 Tab 移入系统设置子页签
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 28s
CI / frontend (pull_request) Successful in 11s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 16:36:34 +08:00
jiangwei-infra b926b35703 Merge PR #75: [moz] fix(ci): 修复 deploy push trigger 不触发问题
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Successful in 13s
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-14 08:31:35 +00:00
jiangwei-infra 8df1d4a83c Merge branch 'main' into fix/cd-push-trigger-yaml
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 27s
CI / frontend (pull_request) Successful in 11s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 08:30:20 +00:00
cfdaily aad5a6b317 [moz] fix(ci): 修复 deploy push trigger 不触发问题
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Successful in 0s
根因:deploy.yml notify-deploy-success job 中 python3 -c 使用多行字符串,
Python 代码零缩进(column 0)破坏了 YAML literal block scalar (run: |),
导致 Gitea YAML 解析器报错 'line 114: could not find expected :',
在 DetectWorkflows 阶段被静默丢弃,push 事件无法触发 deploy。

Gitea 日志证据:
  ignore invalid workflow "deploy.yml": yaml: line 114: could not find expected ':'

修复:将多行 python3 -c 改为单行,避免零缩进代码行破坏 YAML 块结构。

影响范围:仅 deploy.yml,不影响 ci.yml 和 e2e.yml
验证方式:YAML 解析已通过,合并后观察 push 事件是否触发 Actions
2026-06-14 16:28:41 +08:00
pangtong-fujunshi ad34750075 Merge PR #74: [moz] ci: CI 管道新增 frontend build job 2026-06-14 08:14:15 +00:00
cfdaily cd7e24cd3c [moz] ci: CI 管道新增 frontend build job(tsc + vite build)
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / frontend (pull_request) Successful in 40s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 16:12:05 +08:00
pangtong-fujunshi 0521b7b6f0 Merge PR #73: [moz] feat(frontend): 工具链 Tab 2026-06-14 07:24:24 +00:00
cfdaily fc30f91183 [moz] feat(frontend): 新增工具链 Tab — 列表+详情+搜索栏
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 15:22:34 +08:00
pangtong-fujunshi 8c72ff0565 Merge PR #72: [moz] refactor(api): API 拆分 + expand 聚合 + 搜索 2026-06-14 06:55:08 +00:00
cfdaily cc2e5aa64c [moz] fix(api): Review M1 修复 — expand=all 保持旧格式 + _toolchain 加入 _VIRTUAL_PROJECTS
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 30s
CI / notify-on-failure (pull_request) Successful in 0s
- M1: expand=all 保持旧 list 格式(向后兼容 TaskModal .map()/.length)
- 细粒度 expand=comments,events 用新 {items,total_count,limit} 格式
- S1(PR#73): _toolchain 加入 _VIRTUAL_PROJECTS
- S1(PR#72): 移除 _validate_project 未使用 import
2026-06-14 14:22:14 +08:00
cfdaily d09fd4a173 [moz] fix(api): flake8 lint 修复 — 移除未使用 import
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Successful in 1s
2026-06-14 14:20:33 +08:00
cfdaily 5db4c89fe7 [moz] refactor(api): 拆分 blackboard_routes → task_routes + task_relation_routes + shared + expand 细粒度聚合
CI / lint (pull_request) Failing after 9s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 2s
2026-06-14 14:02:59 +08:00
pangtong-fujunshi e70816a69f Merge PR #71: [moz] docs: §18 API 聚合重构 + 工具链 Tab 设计 2026-06-14 05:57:21 +00:00
15 changed files with 772 additions and 330 deletions
+23 -3
View File
@@ -62,12 +62,30 @@ jobs:
(echo '=== RETRY WITH VERBOSE ===' && \
PYTHONPATH=$(pwd) /tmp/ci-venv-test/bin/pytest tests/ -m "not e2e" -x -v 2>&1 | tail -30)
# ── Job 3: CI 失败通知 ───────────────────────────────
# ── Job 3: Frontend Build ───────────────────────────
frontend:
runs-on: macos-arm64
needs: lint
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install & Build
run: |
cd src/frontend
npm ci || npm install
npm run build
# ── Job 4: CI 失败通知 ───────────────────────────────
# 使用 needs.<job>.result 直接判断,不查询 commit status API
# 根因:notify 自身的 pending status 会污染 commit status 查询结果(竞态条件)
notify-on-failure:
runs-on: macos-arm64
needs: [lint, test]
needs: [lint, test, frontend]
if: always()
steps:
- name: Check results and notify
@@ -75,12 +93,13 @@ jobs:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
LINT_RESULT: ${{ needs.lint.result }}
TEST_RESULT: ${{ needs.test.result }}
FRONTEND_RESULT: ${{ needs.frontend.result }}
run: |
echo "Lint result: $LINT_RESULT"
echo "Test result: $TEST_RESULT"
# 只有 lint 或 test 明确失败时才发通知
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ]; then
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ] || [ "$FRONTEND_RESULT" = "failure" ]; then
echo "CI has failures, sending notification..."
# 如果是 PR 事件,写评论通知
@@ -90,6 +109,7 @@ jobs:
FAILED_JOBS=""
[ "$LINT_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}lint "
[ "$TEST_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}test "
[ "$FRONTEND_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}frontend "
curl -sf -X POST \
-H "Authorization: token $GITEA_TOKEN" \
+1 -9
View File
@@ -110,15 +110,7 @@ jobs:
PR_AUTHOR=$(curl --max-time 5 -sf \
-H "Authorization: token $GITEA_TOKEN" \
"$API_URL/repos/$REPO/pulls?state=closed&sort=updated&order=desc&limit=10" | \
python3 -c "
import json, sys
sha = '$COMMIT_SHA'
for pr in json.load(sys.stdin):
merge_sha = pr.get('merge_commit_sha', '') or ''
if merge_sha.startswith(sha) or sha.startswith(merge_sha):
print(pr['user']['login'])
break
" 2>/dev/null || echo "")
python3 -c "import json,sys; sha='$COMMIT_SHA'; matches=[pr['user']['login'] for pr in json.load(sys.stdin) if (pr.get('merge_commit_sha','') or '').startswith(sha) or sha.startswith(pr.get('merge_commit_sha','') or '')]; print(matches[0] if matches else '')" 2>/dev/null || echo "")
# 确定通知对象
if [ -n "$PR_AUTHOR" ]; then
+36 -3
View File
@@ -585,6 +585,18 @@ class PromptComposer:
| 50-59 | 硬约束 | 安全红线、禁止行为 |
| 60-69 | 扩展段 | 保留给未来使用 |
## 共性 Section(三 handler 共享)
以下三个 Section 在 `prompt_composer.py` 中统一定义,被 Task/Mail/Toolchain 三个 handler 共同注入:
| Section | priority | 用途 |
|---------|----------|------|
| `GiteaConventionSection` | 55 | Gitea Issue/PR 标题规范、分支命名、提交格式 |
| `DeliveryChecklistSection` | 55 | 交付前检查清单(产出格式、验证项、必读文档) |
| `WikiGuideSection` | 60 | Wiki 知识库检索指引(检索路径、优先级、知识缺口记录) |
设计意图:将跨 handler 的共性约束从各 handler 的 ConstraintsSection 中抽离,避免重复维护。
---
# §13 三个 Handler 的 Section 注册
@@ -601,6 +613,9 @@ def get_sections(self) -> list[PromptSection]:
RoleSkillSection(priority=30), # BootstrapBuilder 段 3Skill 全文)
TaskApiSection(priority=40), # API 操作指令,success_status="review"
TaskConstraintsSection(priority=50), # 硬约束
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
]
```
@@ -611,6 +626,9 @@ def get_sections(self) -> list[PromptSection]:
| RoleSkillSection | BootstrapBuilder 段 3 | 个性:只有 task 读 Skill 全文 |
| TaskApiSection | spawner `_build_api_section` | **共性基础 + 个性参数**success_status |
| TaskConstraintsSection | BootstrapBuilder 段 4 | 个性:每种 task 约束不同 |
| GiteaConventionSection | prompt_composer.py | **共性**Gitea Issue/PR 规范 |
| WikiGuideSection | prompt_composer.py | **共性**Wiki 检索指引 |
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
## MailHandler sections
@@ -620,6 +638,9 @@ def get_sections(self) -> list[PromptSection]:
MailContextSection(priority=10), # from/to/title/text,区分 inform/request
MailApiSection(priority=40), # API 操作指令,success_status="done"
MailConstraintsSection(priority=50), # 硬约束(禁止状态转换命令等)
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
]
```
@@ -628,6 +649,9 @@ def get_sections(self) -> list[PromptSection]:
| MailContextSection | MAIL_INFORM_TEMPLATE / MAIL_REQUEST_TEMPLATE | 个性:邮件格式 |
| MailApiSection | spawner `_build_api_section` 变体 | **共性基础 + 个性参数**success_status="done",含 Mail API 指令) |
| MailConstraintsSection | 模板中的 ⚠️ 约束 | 个性 |
| GiteaConventionSection | prompt_composer.py | **共性**Gitea Issue/PR 规范 |
| WikiGuideSection | prompt_composer.py | **共性**Wiki 检索指引 |
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
## ToolchainHandler sections
@@ -637,6 +661,9 @@ def get_sections(self) -> list[PromptSection]:
ToolchainContextSection(priority=10), # 事件类型 + 事件详情
ToolchainApiSection(priority=40), # API 操作指令,success_status="done"
ToolchainConstraintsSection(priority=50), # 硬约束
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
]
```
@@ -645,6 +672,9 @@ def get_sections(self) -> list[PromptSection]:
| ToolchainContextSection | toolchain_templates.py + md 文件 | 个性:事件格式 |
| ToolchainApiSection | spawner `_build_api_section` 变体 | **共性基础 + 个性参数** |
| ToolchainConstraintsSection | 新增 | 个性 |
| GiteaConventionSection | prompt_composer.py | **共性**Gitea Issue/PR 规范 |
| WikiGuideSection | prompt_composer.py | **共性**Wiki 检索指引 |
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
## Section 复用分析
@@ -655,6 +685,9 @@ def get_sections(self) -> list[PromptSection]:
| *ConstraintsSection | ✅ | ✅ | ✅ | ❌ 约束内容不同,各自实现 |
| PriorOutputsSection | ✅ | ❌ | ❌ | 仅 task |
| RoleSkillSection | ✅ | ❌ | ❌ | 仅 task |
| GiteaConventionSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
| WikiGuideSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
| DeliveryChecklistSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
**结论**ApiSection 可以抽一个 BaseApiSectioncurl 模板 + success_status 参数),其余 section 各自实现。
@@ -667,9 +700,9 @@ src/daemon/
├── task_type_registry.py # §3 + §4Protocol + Registry
├── prompt_composer.py # §12 PromptSection + PromptContext + PromptComposer
├── base_task_handler.py # §16 BaseTaskHandler 基类
├── task_handler.py # §13 TaskHandler(继承 BaseTaskHandler+ 5 sections
├── mail_handler.py # §13 MailHandler(继承 BaseTaskHandler+ 3 sections
├── toolchain_handler.py # §13 ToolchainHandler(继承 BaseTaskHandler+ 3 sections
├── task_handler.py # §13 TaskHandler(继承 BaseTaskHandler+ 8 sections
├── mail_handler.py # §13 MailHandler(继承 BaseTaskHandler+ 6 sections
├── toolchain_handler.py # §13 ToolchainHandler(继承 BaseTaskHandler+ 6 sections
├── dispatcher.py # §6 改动
├── spawner.py # §6 改动
├── ticker.py # §6 改动
+73
View File
@@ -0,0 +1,73 @@
"""共享 helper 和常量"""
from typing import Any, Dict
from fastapi import HTTPException
from src.blackboard.operations import Blackboard
from src.blackboard.queries import Queries
from src.blackboard.models import Task
from src.blackboard.registry import ProjectRegistry
from src.utils import get_data_root
# 虚拟项目白名单
_VIRTUAL_PROJECTS = frozenset({"_general", "_mail", "_toolchain"})
def _validate_project(project_id: str) -> str:
"""校验 project_id"""
if project_id in _VIRTUAL_PROJECTS:
return project_id
reg = ProjectRegistry(get_data_root())
if reg.get_project(project_id):
return project_id
raise HTTPException(400, {
"ok": False,
"error": "project_not_found",
"detail": f"Project '{project_id}' is not registered.",
"suggestions": [
f"Register first: POST /api/projects with id='{project_id}'",
"Or use '_general' for tasks without a specific project",
],
})
def _bb(project_id: str) -> Blackboard:
_validate_project(project_id)
return Blackboard(get_data_root() / project_id / "blackboard.db")
def _q(project_id: str) -> Queries:
_validate_project(project_id)
return Queries(get_data_root() / project_id / "blackboard.db")
def _task_to_dict(t: Task) -> Dict[str, Any]:
d = {k: v for k, v in t.__dict__.items() if v is not None}
return d
_KNOWN_AGENT_IDS: list = []
def _init_agent_ids():
"""从配置文件加载 Agent ID 列表"""
global _KNOWN_AGENT_IDS
if _KNOWN_AGENT_IDS:
return
try:
import yaml
import os
cfg_path = os.path.join(os.path.dirname(__file__), "..", "..", "config", "default.yaml")
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
_KNOWN_AGENT_IDS = list(cfg.get("daemon", {}).get("agent_profiles", {}).keys())
except Exception:
_KNOWN_AGENT_IDS = []
def _extract_mentions(text: str) -> list:
"""从文本中自动提取 @agent-id 格式的 mention"""
import re
_init_agent_ids()
candidates = set(re.findall(r'@([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)', text))
return [a for a in candidates if a in _KNOWN_AGENT_IDS]
+240
View File
@@ -0,0 +1,240 @@
"""Task 关联路由 — comments / outputs / decisions / observations / reviews / events / experiences / summary"""
from __future__ import annotations
import json
import os
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query
from src.blackboard.models import Review
from src.blackboard.db import OUTPUT_TYPES
from src.api.shared import (
_bb,
_q,
_extract_mentions,
)
router = APIRouter(prefix="/api/projects/{project_id}", tags=["blackboard"])
# --------------------------------------------------------------------------- #
# Comments
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/comments")
async def get_comments(project_id: str, task_id: str,
comment_type: Optional[str] = None):
bb = _bb(project_id)
comments = bb.get_comments(task_id, comment_type=comment_type)
return {"comments": [dict(c.__dict__) for c in comments]}
@router.post("/tasks/{task_id}/comments")
async def add_comment(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
mentions_raw = body.get("mentions")
comment_body = body["body"]
# #04: 自动从 body 提取 @mention,与显式传的 mentions 取并集
auto_mentions = _extract_mentions(comment_body)
if isinstance(mentions_raw, str):
try:
explicit_mentions = json.loads(mentions_raw)
except Exception:
explicit_mentions = []
elif isinstance(mentions_raw, list):
explicit_mentions = mentions_raw
else:
explicit_mentions = []
merged_mentions = list(set(explicit_mentions + auto_mentions))
cid = bb.add_comment(task_id, body["author"], comment_body,
comment_type=body.get("comment_type", "general"),
mentions=merged_mentions)
if merged_mentions:
bb.record_mentions(cid, task_id, merged_mentions)
# #10: SSE 通知前端黑板有新 comment
try:
from src.api.sse_routes import get_broker
broker = get_broker()
broker.publish_sync("comment_added", {
"project_id": project_id,
"task_id": task_id,
"comment_id": cid,
"author": body["author"],
})
except Exception:
pass
return {"ok": True, "comment_id": cid, "mentions": merged_mentions}
# --------------------------------------------------------------------------- #
# Outputs
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/outputs")
async def get_outputs(project_id: str, task_id: str):
bb = _bb(project_id)
return {"outputs": [dict(o.__dict__) for o in bb.get_outputs(task_id)]}
@router.post("/tasks/{task_id}/outputs")
async def write_output(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
# 字段校验 + Agent-friendly 错误
agent = body.get("agent")
if not agent:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: agent",
"hint": "Provide your agent ID, e.g. 'zhangfei-dev'",
})
# type 字段:接受 type 或 content_type(别名兼容)
output_type = body.get("type") or body.get("content_type")
valid_types = sorted(OUTPUT_TYPES)
if not output_type:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: type",
"valid_values": {"type": valid_types},
"hint": "Use 'type' field. Also accepts 'content_type' as alias.",
})
if output_type not in OUTPUT_TYPES:
raise HTTPException(422, {
"error": "validation_failed",
"detail": f"Invalid type: '{output_type}'",
"valid_values": {"type": valid_types},
})
title = body.get("title")
if not title:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: title",
"hint": "Provide a brief title describing this output",
})
# 内容模式:content(直传)或 content_path(引用)
content = body.get("content")
content_path = body.get("content_path") or body.get("path")
if content and not content_path:
# 内容直传模式:自动写文件
artifacts_dir = os.path.join(
os.path.dirname(bb.db_path), "artifacts", task_id
)
os.makedirs(artifacts_dir, exist_ok=True)
# 安全文件名
safe_name = "".join(
c if c.isalnum() or c in "._-" else "_" for c in title)
if not safe_name:
safe_name = "output"
file_path = os.path.join(artifacts_dir, safe_name)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
content_path = file_path
oid = bb.write_output(
task_id, agent, output_type, title,
content_path=content_path,
summary=body.get("summary"),
metadata=body.get("metadata"),
)
return {"ok": True, "output_id": oid}
# --------------------------------------------------------------------------- #
# Decisions
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/decisions")
async def get_decisions(project_id: str, task_id: str):
bb = _bb(project_id)
return {"decisions": [dict(d.__dict__) for d in bb.get_decisions(task_id)]}
@router.post("/tasks/{task_id}/decisions")
async def add_decision(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
did = bb.add_decision(task_id, body["decider"], body["decision"],
body["rationale"],
alternatives=body.get("alternatives"))
return {"ok": True, "decision_id": did}
# --------------------------------------------------------------------------- #
# Observations
# --------------------------------------------------------------------------- #
@router.post("/tasks/{task_id}/observations")
async def add_observation(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
oid = bb.add_observation(task_id, body["observer"], body["body"],
severity=body.get("severity", "info"))
return {"ok": True, "observation_id": oid}
# --------------------------------------------------------------------------- #
# Reviews
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/reviews")
async def get_reviews(project_id: str, task_id: str):
bb = _bb(project_id)
return {"reviews": [dict(r.__dict__) for r in bb.get_reviews(task_id)]}
@router.post("/tasks/{task_id}/reviews")
async def add_review(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
review = Review(
id=body["id"], task_id=task_id, reviewer=body["reviewer"],
review_type=body["review_type"], verdict=body["verdict"],
summary=body["summary"], confidence=body.get("confidence"),
round=body.get("round", 1), max_rounds=body.get("max_rounds", 3),
)
bb.add_review(review)
return {"ok": True, "review_id": review.id}
# --------------------------------------------------------------------------- #
# Per-task Events & Experiences
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/events")
async def get_task_events(project_id: str, task_id: str,
limit: int = Query(50, le=200)):
q = _q(project_id)
return {"events": q.task_events(task_id, limit)}
@router.get("/tasks/{task_id}/experiences")
async def get_task_experiences(project_id: str, task_id: str):
q = _q(project_id)
return {"experiences": q.task_experiences(task_id)}
# --------------------------------------------------------------------------- #
# Global Events
# --------------------------------------------------------------------------- #
@router.get("/events")
async def get_events(project_id: str, limit: int = Query(50, le=200)):
q = _q(project_id)
return {"events": q.recent_events(limit)}
# --------------------------------------------------------------------------- #
# Summary
# --------------------------------------------------------------------------- #
@router.get("/summary")
async def task_summary(project_id: str):
q = _q(project_id)
return {"summary": q.task_summary()}
@@ -1,68 +1,45 @@
"""API 路由 — 黑板 CRUD"""
"""Task 核心路由 — CRUD、状态、归档"""
from __future__ import annotations
import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task, Review
from src.blackboard.queries import Queries
from src.blackboard.db import VALID_STATUSES, OUTPUT_TYPES
from src.blackboard.registry import ProjectRegistry
from fastapi import APIRouter, HTTPException
from src.blackboard.models import Task
from src.blackboard.db import VALID_STATUSES, VALID_TRANSITIONS
from src.utils import get_data_root
from src.api.shared import (
_bb,
_q,
_task_to_dict,
_extract_mentions,
)
router = APIRouter(prefix="/api/projects/{project_id}", tags=["blackboard"])
# 虚拟项目白名单(不需要在 registry 注册)
_VIRTUAL_PROJECTS = frozenset({"_general", "_mail"})
def _validate_project(project_id: str) -> str:
"""校验 project_id,已知项目/虚拟项目放行,未知项目返回 400"""
if project_id in _VIRTUAL_PROJECTS:
return project_id
reg = ProjectRegistry(get_data_root())
if reg.get_project(project_id):
return project_id
raise HTTPException(400, {
"ok": False,
"error": "project_not_found",
"detail": f"Project '{project_id}' is not registered.",
"suggestions": [
f"Register first: POST /api/projects with id='{project_id}'",
"Or use '_general' for tasks without a specific project",
],
})
def _bb(project_id: str) -> Blackboard:
_validate_project(project_id)
return Blackboard(get_data_root() / project_id / "blackboard.db")
def _q(project_id: str) -> Queries:
_validate_project(project_id)
return Queries(get_data_root() / project_id / "blackboard.db")
# --- Tasks ---
# --------------------------------------------------------------------------- #
# Tasks
# --------------------------------------------------------------------------- #
@router.get("/tasks")
async def list_tasks(project_id: str,
status: Optional[str] = None,
assignee: Optional[str] = None,
parent_task: Optional[str] = None):
parent_task: Optional[str] = None,
q: Optional[str] = None):
bb = _bb(project_id)
tasks = bb.list_tasks(
status=status,
assignee=assignee,
parent_task=parent_task)
tasks = bb.list_tasks(status=status, assignee=assignee, parent_task=parent_task)
if q:
q_lower = q.lower()
tasks = [t for t in tasks if q_lower in (t.title or "").lower()]
return {"tasks": [_task_to_dict(t) for t in tasks]}
@@ -74,6 +51,11 @@ async def get_task(project_id: str, task_id: str,
if not task:
raise HTTPException(404, f"Task not found: {task_id}")
result = _task_to_dict(task)
if not expand:
return result
# expand=all: 保持旧格式(list + 聚合字段),向后兼容前端 TaskModal
if expand == "all":
q = _q(project_id)
detail = q.task_detail(task_id)
@@ -90,6 +72,37 @@ async def get_task(project_id: str, task_id: str,
for d in bb.get_decisions(task_id)]
result["events"] = q.task_events(task_id)
result["experiences"] = q.task_experiences(task_id)
return result
# 细粒度 expand: 新格式(comments/events 带 limit + total_count
expand_list = expand.split(",")
q = _q(project_id)
if "comments" in expand_list:
all_comments = bb.get_comments(task_id)
result["comments"] = {
"items": [dict(c.__dict__) for c in all_comments[-20:]],
"total_count": len(all_comments),
"limit": 20,
}
if "events" in expand_list:
all_events = q.task_events(task_id, limit=99999)
result["events"] = {
"items": all_events[-30:],
"total_count": len(all_events),
"limit": 30,
}
if "outputs" in expand_list:
result["outputs"] = [dict(o.__dict__) for o in bb.get_outputs(task_id)]
if "reviews" in expand_list:
result["reviews"] = [dict(r.__dict__) for r in bb.get_reviews(task_id)]
if "decisions" in expand_list:
result["decisions"] = [dict(d.__dict__) for d in bb.get_decisions(task_id)]
return result
@@ -100,11 +113,9 @@ async def create_task(project_id: str, body: Dict[str, Any]):
task_id = body.get("id")
if not task_id:
import re
from datetime import datetime
prefix = re.sub(r'[^a-z0-9]', '-', project_id.lower()).strip('-')[:20]
date_str = datetime.now().strftime('%Y%m%d')
# seq: 查当前项目最大 seq
import sqlite3
db_path = get_data_root() / project_id / "blackboard.db"
try:
conn = sqlite3.connect(str(db_path), timeout=5)
@@ -237,7 +248,6 @@ async def update_status(project_id: str, task_id: str, body: Dict[str, Any]):
})
# 检查转换是否合法
from src.blackboard.db import VALID_TRANSITIONS
current = old_task.status
allowed = VALID_TRANSITIONS.get(current, set())
if new_status not in allowed:
@@ -271,220 +281,6 @@ async def update_status(project_id: str, task_id: str, body: Dict[str, Any]):
return {"ok": True, "old_status": current, "new_status": new_status}
# --- @mention 自动提取(#04 ---
_KNOWN_AGENT_IDS: list = []
def _init_agent_ids():
"""从配置文件加载 Agent ID 列表"""
global _KNOWN_AGENT_IDS
if _KNOWN_AGENT_IDS:
return
try:
import yaml
cfg_path = os.path.join(
os.path.dirname(__file__),
"..",
"..",
"config",
"default.yaml")
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
_KNOWN_AGENT_IDS = list(
cfg.get(
"daemon",
{}).get(
"agent_profiles",
{}).keys())
except Exception:
_KNOWN_AGENT_IDS = []
def _extract_mentions(text: str) -> list:
"""从文本中自动提取 @agent-id 格式的 mention"""
import re
_init_agent_ids()
candidates = set(
re.findall(
r'@([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)',
text))
return [a for a in candidates if a in _KNOWN_AGENT_IDS]
# --- Comments ---
@router.get("/tasks/{task_id}/comments")
async def get_comments(project_id: str, task_id: str,
comment_type: Optional[str] = None):
bb = _bb(project_id)
comments = bb.get_comments(task_id, comment_type=comment_type)
return {"comments": [dict(c.__dict__) for c in comments]}
@router.post("/tasks/{task_id}/comments")
async def add_comment(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
mentions_raw = body.get("mentions")
comment_body = body["body"]
# #04: 自动从 body 提取 @mention,与显式传的 mentions 取并集
auto_mentions = _extract_mentions(comment_body)
if isinstance(mentions_raw, str):
try:
explicit_mentions = json.loads(mentions_raw)
except Exception:
explicit_mentions = []
elif isinstance(mentions_raw, list):
explicit_mentions = mentions_raw
else:
explicit_mentions = []
merged_mentions = list(set(explicit_mentions + auto_mentions))
cid = bb.add_comment(task_id, body["author"], comment_body,
comment_type=body.get("comment_type", "general"),
mentions=merged_mentions)
if merged_mentions:
bb.record_mentions(cid, task_id, merged_mentions)
# #10: SSE 通知前端黑板有新 comment
try:
from src.api.sse_routes import get_broker
broker = get_broker()
broker.publish_sync("comment_added", {
"project_id": project_id,
"task_id": task_id,
"comment_id": cid,
"author": body["author"],
})
except Exception:
pass
return {"ok": True, "comment_id": cid, "mentions": merged_mentions}
# --- Outputs ---
@router.get("/tasks/{task_id}/outputs")
async def get_outputs(project_id: str, task_id: str):
bb = _bb(project_id)
return {"outputs": [dict(o.__dict__) for o in bb.get_outputs(task_id)]}
@router.post("/tasks/{task_id}/outputs")
async def write_output(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
# 字段校验 + Agent-friendly 错误
agent = body.get("agent")
if not agent:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: agent",
"hint": "Provide your agent ID, e.g. 'zhangfei-dev'",
})
# type 字段:接受 type 或 content_type(别名兼容)
output_type = body.get("type") or body.get("content_type")
valid_types = sorted(OUTPUT_TYPES)
if not output_type:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: type",
"valid_values": {"type": valid_types},
"hint": "Use 'type' field. Also accepts 'content_type' as alias.",
})
if output_type not in OUTPUT_TYPES:
raise HTTPException(422, {
"error": "validation_failed",
"detail": f"Invalid type: '{output_type}'",
"valid_values": {"type": valid_types},
})
title = body.get("title")
if not title:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: title",
"hint": "Provide a brief title describing this output",
})
# 内容模式:content(直传)或 content_path(引用)
content = body.get("content")
content_path = body.get("content_path") or body.get("path")
if content and not content_path:
# 内容直传模式:自动写文件
import os
artifacts_dir = os.path.join(
os.path.dirname(bb.db_path), "artifacts", task_id
)
os.makedirs(artifacts_dir, exist_ok=True)
# 安全文件名
safe_name = "".join(
c if c.isalnum() or c in "._-" else "_" for c in title)
if not safe_name:
safe_name = "output"
file_path = os.path.join(artifacts_dir, safe_name)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
content_path = file_path
oid = bb.write_output(
task_id, agent, output_type, title,
content_path=content_path,
summary=body.get("summary"),
metadata=body.get("metadata"),
)
return {"ok": True, "output_id": oid}
# --- Decisions ---
@router.get("/tasks/{task_id}/decisions")
async def get_decisions(project_id: str, task_id: str):
bb = _bb(project_id)
return {"decisions": [dict(d.__dict__) for d in bb.get_decisions(task_id)]}
@router.post("/tasks/{task_id}/decisions")
async def add_decision(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
did = bb.add_decision(task_id, body["decider"], body["decision"],
body["rationale"],
alternatives=body.get("alternatives"))
return {"ok": True, "decision_id": did}
# --- Observations ---
@router.post("/tasks/{task_id}/observations")
async def add_observation(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
oid = bb.add_observation(task_id, body["observer"], body["body"],
severity=body.get("severity", "info"))
return {"ok": True, "observation_id": oid}
# --- Reviews ---
@router.get("/tasks/{task_id}/reviews")
async def get_reviews(project_id: str, task_id: str):
bb = _bb(project_id)
return {"reviews": [dict(r.__dict__) for r in bb.get_reviews(task_id)]}
@router.post("/tasks/{task_id}/reviews")
async def add_review(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
review = Review(
id=body["id"], task_id=task_id, reviewer=body["reviewer"],
review_type=body["review_type"], verdict=body["verdict"],
summary=body["summary"], confidence=body.get("confidence"),
round=body.get("round", 1), max_rounds=body.get("max_rounds", 3),
)
bb.add_review(review)
return {"ok": True, "review_id": review.id}
@router.patch("/tasks/{task_id}")
async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
"""更新任务元数据(归档、标题等)"""
@@ -497,7 +293,6 @@ async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
if not updates:
return {"ok": True}
# 直接用 SQL 更新
import sqlite3
conn = sqlite3.connect(str(bb.db_path), timeout=5)
try:
set_clause = ", ".join(f"{k}=?" for k in updates)
@@ -509,38 +304,9 @@ async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
return {"ok": True}
# --- Per-task Events & Experiences ---
@router.get("/tasks/{task_id}/events")
async def get_task_events(project_id: str, task_id: str,
limit: int = Query(50, le=200)):
q = _q(project_id)
return {"events": q.task_events(task_id, limit)}
@router.get("/tasks/{task_id}/experiences")
async def get_task_experiences(project_id: str, task_id: str):
q = _q(project_id)
return {"experiences": q.task_experiences(task_id)}
# --- Global Events ---
@router.get("/events")
async def get_events(project_id: str, limit: int = Query(50, le=200)):
q = _q(project_id)
return {"events": q.recent_events(limit)}
# --- Summary ---
@router.get("/summary")
async def task_summary(project_id: str):
q = _q(project_id)
return {"summary": q.task_summary()}
# --- Archive (v2.8) ---
# --------------------------------------------------------------------------- #
# Archive (v2.8)
# --------------------------------------------------------------------------- #
@router.post("/tasks/{task_id}/archive")
async def archive_task(project_id: str, task_id: str,
@@ -563,10 +329,3 @@ async def archive_done_tasks(project_id: str):
bb = _bb(project_id)
count = bb.archive_done_tasks()
return {"ok": True, "archived_count": count}
# --- Helper ---
def _task_to_dict(t: Task) -> Dict[str, Any]:
d = {k: v for k, v in t.__dict__.items() if v is not None}
return d
+1 -1
View File
@@ -208,7 +208,7 @@ class Blackboard:
params.append(parent_task)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY priority ASC, created_at ASC"
query += " ORDER BY priority ASC, created_at DESC"
rows = conn.execute(query, params).fetchall()
return [Task.from_row(r) for r in rows]
finally:
+2 -2
View File
@@ -9,7 +9,7 @@ import logging
from pathlib import Path
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
from src.blackboard.db import get_connection
logger = logging.getLogger("moziplus-v2.handler.mail")
@@ -36,7 +36,7 @@ class MailHandler(BaseTaskHandler):
return composer.compose(context)
def get_sections(self) -> list:
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection()]
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection(), DeliveryChecklistSection()]
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
"""Mail 完成验证:区分 inform/request。
+24
View File
@@ -174,3 +174,27 @@ class WikiGuideSection:
def should_include(self, context: "PromptContext") -> bool:
return True
# ---------------------------------------------------------------------------
# DeliveryChecklistSection — 交付检查清单
# ---------------------------------------------------------------------------
class DeliveryChecklistSection:
"""交付检查清单 — 提醒 Agent 完成前同步关联成果物。"""
name: str = "delivery_checklist"
priority: int = 55 # CONSTRAINTS(50) 和 EXTENSION(60) 之间
CHECKLIST_TEXT = (
"## 交付检查\n"
"完成代码改动前确认:\n"
"- 改了实现 → docs/design/ 对应设计文档是否需要更新\n"
"- 改了实现 → tests/ 是否有对应测试脚本需要更新\n"
"- 所有成果物变更通过 PR 流程:PR review 把关设计合理性,CI 把关代码质量,CD 把关部署正确性\n"
)
def render(self, context: "PromptContext") -> str:
return self.CHECKLIST_TEXT
def should_include(self, context: "PromptContext") -> bool:
return True
+22 -4
View File
@@ -625,19 +625,24 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._register_session(session_id, agent_id, task_id, proc.pid,
# use_main_session=True 时 session_id 为 None,但 _register_session 和
# _monitor_process 需要一个非 None 的 key;同时 ticker 等调用方用
# `result is not None` 判断 spawn 是否成功,返回 None 会被误判为失败。
# 统一用 "main" 作为占位标识。
effective_sid = session_id or "main"
self._register_session(effective_sid, agent_id, task_id, proc.pid,
broadcast_task_ids=broadcast_task_ids)
logger.info("Spawned agent %s (session=%s, pid=%d)",
agent_id, session_id, proc.pid)
agent_id, effective_sid, proc.pid)
# Schedule monitor(传 wrapped_on_complete)
asyncio.create_task(
self._monitor_process(session_id, proc, agent_id, task_id,
self._monitor_process(effective_sid, proc, agent_id, task_id,
on_complete=_wrapped_on_complete,
db_path=task_db_path or self.db_path)
)
return session_id
return effective_sid
except Exception as e:
# spawn 失败也要 release counter
@@ -1949,6 +1954,19 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
try:
from src.daemon.mail_notify import _is_mail_project, notify_mail_failed
if _is_mail_project(db_path):
# 防御性检查:如果 task 已经 done,不触发失败通知(竞态保护)
# 场景:spawner 标 failed 和 handler 标 done 同时发生
try:
conn2 = get_connection(db_path)
current_status = conn2.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn2.close()
if current_status and current_status["status"] == "done":
logger.info("Task %s already done, skipping mail failure notification", task_id)
return
except Exception:
pass
# Mail 失败:通知发件人,不 @pangtong
notify_mail_failed(db_path, task_id, reason, detail)
else:
+2 -1
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Dict, Optional
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
from src.blackboard.db import get_connection
logger = logging.getLogger("moziplus-v2.handler")
@@ -315,6 +315,7 @@ class TaskHandler(BaseTaskHandler):
TaskConstraintsSection(),
GiteaConventionSection(),
WikiGuideSection(),
DeliveryChecklistSection(),
]
def build_prompt(self, context: PromptContext) -> str:
+27 -2
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from typing import Dict, List
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
from src.daemon.toolchain_templates import render_template, _TEMPLATE_MAP
from src.blackboard.db import get_connection
@@ -51,17 +51,41 @@ class ToolchainContextSection:
name: str = "toolchain_context"
priority: int = 10
EVENT_LABELS_ZH: Dict[str, str] = {
"review_request": "Review 请求",
"review_result": "Review 结果",
"review_merged": "PR 合并",
"review_comment": "Review 评论",
"review_updated": "Review 更新",
"ci_failure": "CI 失败",
"deploy_failure": "部署失败",
"issue_assigned": "Issue 指派",
"mention": "@提及",
}
def render(self, context: PromptContext) -> str:
event_type = context.event_type
event_data: Dict = context.event_data or {}
# 事件类型中文标签
event_label = self.EVENT_LABELS_ZH.get(event_type, event_type or '未知')
# from / to 信息
to_agent = context.agent_id or ''
from_agent = 'system'
# Part 1: 事件信息(现有模板引擎)
if event_type in _TEMPLATE_MAP:
variables = {k: str(v) for k, v in event_data.items()}
event_text = render_template(event_type, variables)
# 补充事件类型中文标签 + from/to
header = f"- **事件类型**: {event_label}\n- **来源**: {from_agent}\n- **指派**: {to_agent}\n"
event_text = header + "\n" + event_text
else:
lines = ["## 工具链事件", ""]
lines.append(f"- **事件类型**: {event_type or '未知'}")
lines.append(f"- **事件类型**: {event_label}")
lines.append(f"- **来源**: {from_agent}")
lines.append(f"- **指派**: {to_agent}")
if event_data:
lines.append("- **事件详情**:")
for key, value in event_data.items():
@@ -228,6 +252,7 @@ class ToolchainHandler(BaseTaskHandler):
ToolchainConstraintsSection(),
GiteaConventionSection(),
WikiGuideSection(),
DeliveryChecklistSection(),
]
def build_prompt(self, context: PromptContext) -> str:
@@ -5,6 +5,7 @@
import { useState, useCallback } from 'react';
import { api, AgentsStatusData } from '../api';
import ToolchainPanel from './ToolchainPanel';
interface ServiceCheckResult {
name: string;
@@ -15,7 +16,7 @@ interface ServiceCheckResult {
}
export default function SettingsPanel() {
const [tab, setTab] = useState<'connections' | 'security' | 'version' | 'logs'>('connections');
const [tab, setTab] = useState<'connections' | 'security' | 'version' | 'logs' | 'toolchain'>('connections');
// 接线状态巡检
const [checking, setChecking] = useState(false);
@@ -95,6 +96,7 @@ export default function SettingsPanel() {
{ key: 'security' as const, label: '🛡️ 安全防务' },
{ key: 'version' as const, label: '📦 版本更新' },
{ key: 'logs' as const, label: '📋 城防日志' },
{ key: 'toolchain' as const, label: '⛓️ 工具链' },
].map((t) => (
<button key={t.key} className={`btn ${tab === t.key ? 'btn-primary' : ''}`} onClick={() => setTab(t.key)}>
{t.label}
@@ -288,6 +290,9 @@ export default function SettingsPanel() {
</div>
</div>
)}
{/* ========== 工具链 ========== */}
{tab === 'toolchain' && <ToolchainPanel />}
</div>
);
}
@@ -0,0 +1,250 @@
/**
* ToolchainPanel
* _toolchain tasksCI/PR//Review
*/
import { useEffect, useState } from 'react';
const AGENT_NAMES: Record<string, string> = {
'pangtong-fujunshi': '庞统',
'simayi-challenger': '司马懿',
'zhangfei-dev': '张飞',
'guanyu-dev': '关羽',
'zhaoyun-data': '赵云',
'jiangwei-infra': '姜维',
'system': '系统',
};
const EVENT_LABELS: Record<string, string> = {
'review_request': 'Review 请求',
'review_result': 'Review 结果',
'review_merged': 'PR 合并',
'review_comment': 'Review 评论',
'review_updated': 'Review 更新',
'ci_failure': 'CI 失败',
'deploy_failure': '部署失败',
'issue_assigned': 'Issue 指派',
'mention': '@提及',
};
const STATUS_COLORS: Record<string, string> = {
pending: '#f59e0b22', claimed: '#6a9eff22', working: '#6a9eff22',
review: '#818cf822', done: '#2ecc8a22', failed: '#ef444422',
cancelled: '#6b728022', blocked: '#ef444422',
};
const STATUS_LABELS: Record<string, string> = {
pending: '待处理', claimed: '已认领', working: '处理中',
review: '审查中', done: '已完成', failed: '失败',
cancelled: '已取消', blocked: '已拦截',
};
function fmtTime(iso: string): string {
try {
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
const now = Date.now();
const diff = now - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins}分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}小时前`;
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
} catch { return iso; }
}
export default function ToolchainPanel() {
const [tasks, setTasks] = useState<any[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<any>(null);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [filterMode, setFilterMode] = useState<'all' | 'pending'>('all');
const loadTasks = async (q?: string) => {
setLoading(true);
try {
const url = q
? `/api/projects/_toolchain/tasks?q=${encodeURIComponent(q)}`
: `/api/projects/_toolchain/tasks`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
setTasks(data.tasks || []);
}
} catch { /* */ }
setLoading(false);
};
const displayed = filterMode === 'pending'
? tasks.filter(t => !['done', 'failed', 'cancelled'].includes(t.status))
: tasks;
useEffect(() => { loadTasks(); }, []);
// 搜索防抖 300ms
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery !== undefined) loadTasks(searchQuery || undefined);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
useEffect(() => {
if (!selectedId) { setDetail(null); return; }
(async () => {
try {
const res = await fetch(
`/api/projects/_toolchain/tasks/${selectedId}?expand=comments`
);
if (res.ok) setDetail(await res.json());
} catch { /* */ }
})();
}, [selectedId]);
// 渲染评论列表(兼容 expand 和裸 list 格式)
const renderComments = (comments: any[]) => {
if (!comments || comments.length === 0) return null;
return (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8, fontWeight: 600 }}>
📋 ({comments.length})
</div>
{comments.map((c: any, i: number) => (
<div key={c.id || i} style={{
padding: '8px 12px', background: 'var(--panel2)', borderRadius: 6, marginBottom: 6,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 600 }}>
{c.author || 'system'}
</span>
<span style={{ fontSize: 9, color: 'var(--muted)' }}>{fmtTime(c.created_at)}</span>
</div>
<div style={{ fontSize: 12, color: '#a0aec0', lineHeight: 1.5 }}>{c.body}</div>
</div>
))}
</div>
);
};
return (
<div style={{ display: 'flex', gap: 0, height: '100%', minHeight: 500 }}>
{/* 左侧列表 */}
<div style={{ width: 380, borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
{/* 搜索栏 + 刷新 */}
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--line)', display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="text"
placeholder="搜索工具链事件..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
flex: 1, padding: '4px 8px', borderRadius: 4, fontSize: 11,
border: '1px solid #2a3550', background: '#161b2e', color: '#dde4f8',
outline: 'none',
}}
/>
<button onClick={() => loadTasks(searchQuery || undefined)} style={{
padding: '3px 8px', borderRadius: 4, fontSize: 10,
border: '1px solid #2a3550', background: '#161b2e', color: '#8899aa', cursor: 'pointer',
}}>🔄</button>
<button onClick={() => setFilterMode('all')} style={{
padding: '3px 8px', borderRadius: 4, fontSize: 10,
border: `1px solid ${filterMode === 'all' ? 'var(--acc)' : '#2a3550'}`,
background: filterMode === 'all' ? 'var(--acc)22' : '#161b2e',
color: filterMode === 'all' ? 'var(--acc)' : '#8899aa', cursor: 'pointer',
}}></button>
<button onClick={() => setFilterMode('pending')} style={{
padding: '3px 8px', borderRadius: 4, fontSize: 10,
border: `1px solid ${filterMode === 'pending' ? 'var(--acc)' : '#2a3550'}`,
background: filterMode === 'pending' ? 'var(--acc)22' : '#161b2e',
color: filterMode === 'pending' ? 'var(--acc)' : '#8899aa', cursor: 'pointer',
}}></button>
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{filterMode === 'pending' ? displayed.length : tasks.length} </span>
</div>
{/* 事件列表 */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{tasks.length === 0 && (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--muted)', fontSize: 12 }}>
{loading ? '加载中...' : '暂无工具链事件'}
</div>
)}
{displayed.map((t: any) => (
<div key={t.id} onClick={() => setSelectedId(t.id)} style={{
padding: '10px 14px', borderBottom: '1px solid var(--line)',
cursor: 'pointer', transition: 'background .15s',
background: selectedId === t.id ? 'var(--panel2)' : 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--panel2)'}
onMouseLeave={e => e.currentTarget.style.background = selectedId === t.id ? 'var(--panel2)' : 'transparent'}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
background: STATUS_COLORS[t.status] || '#2a3550',
color: '#dde4f8',
}}>{STATUS_LABELS[t.status] || t.status}</span>
<span style={{ fontSize: 9, color: 'var(--muted)' }}>{fmtTime(t.created_at)}</span>
</div>
<div style={{
fontSize: 12, fontWeight: 500, color: '#dde4f8',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{t.title}</div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
{AGENT_NAMES['system'] || '系统'} {AGENT_NAMES[t.assignee] || t.assignee || '?'}
</div>
</div>
))}
</div>
</div>
{/* 右侧详情 */}
<div style={{ flex: 1, padding: '16px 20px', overflowY: 'auto' }}>
{!detail ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--muted)' }}>
<div style={{ fontSize: 36, marginBottom: 12 }}></div>
<div style={{ fontSize: 13 }}></div>
</div>
) : (
<>
{/* 头部 */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 10, padding: '2px 6px', borderRadius: 4, background: STATUS_COLORS[detail.status] || '#2a3550', color: '#dde4f8' }}>
{STATUS_LABELS[detail.status] || detail.status}
</span>
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{detail.id}</span>
</div>
<div style={{ fontSize: 18, fontWeight: 700, lineHeight: 1.3 }}>{detail.title}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
{AGENT_NAMES['system'] || '系统'} {AGENT_NAMES[detail.assignee] || detail.assignee || '?'}
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>
{fmtTime(detail.created_at)}
</div>
</div>
{/* 正文 */}
{detail.description && (
<div style={{
padding: '14px 16px', background: 'var(--panel2)', borderRadius: 10,
fontSize: 13, color: '#a0aec0', lineHeight: 1.7, whiteSpace: 'pre-wrap',
}}>
{detail.description}
</div>
)}
{/* action_report 评论 — expand 格式 {items, total_count} */}
{detail.comments && detail.comments.items && detail.comments.items.length > 0 &&
renderComments(detail.comments.items)
}
{/* 兼容裸 list 格式 */}
{detail.comments && Array.isArray(detail.comments) && detail.comments.length > 0 &&
renderComments(detail.comments)
}
</>
)}
</div>
</div>
);
}
+4 -2
View File
@@ -7,7 +7,8 @@ from src.api.sse_routes import router as sse_router
from src.api.project_routes import router as project_router
from src.api.daemon_routes import router as daemon_router
from src.api.checkpoint_routes import router as checkpoint_router
from src.api.blackboard_routes import router as blackboard_router
from src.api.task_routes import router as task_router
from src.api.task_relation_routes import router as task_relation_router
import logging
from contextlib import asynccontextmanager
@@ -273,7 +274,8 @@ app.add_middleware(
# ---------------------------------------------------------------------------
app.include_router(blackboard_router)
app.include_router(task_router)
app.include_router(task_relation_router)
app.include_router(checkpoint_router)
app.include_router(daemon_router)
app.include_router(project_router)