auto-sync: 2026-05-18 11:37:38

This commit is contained in:
cfdaily
2026-05-18 11:37:38 +08:00
parent 59f3f74a95
commit 52b5321248
3 changed files with 0 additions and 967 deletions
-318
View File
@@ -1,318 +0,0 @@
"""API 路由 — Card CRUD + Task 嵌套(v2.7"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from src.blackboard.operations import Blackboard, CardOps
from src.blackboard.models import Task, Card, CARD_VALID_STATUSES, CARD_TYPE_SET
from src.blackboard.queries import Queries
from src.blackboard.db import VALID_STATUSES, VALID_TRANSITIONS, COMMENT_TYPES, OUTPUT_TYPES
from src.utils import get_data_root
router = APIRouter(prefix="/api/projects/{project_id}", tags=["cards"])
def _db_path(project_id: str) -> Path:
return get_data_root() / project_id / "blackboard.db"
def _bb(project_id: str) -> Blackboard:
return Blackboard(_db_path(project_id))
def _card_ops(project_id: str) -> CardOps:
from src.blackboard.db import init_db
db = _db_path(project_id)
init_db(db) # 确保 DB 已迁移
return CardOps(db)
def _q(project_id: str) -> Queries:
return Queries(_db_path(project_id))
# ===================================================================
# Card CRUD
# ===================================================================
@router.get("/cards")
async def list_cards(project_id: str, status: Optional[str] = None):
ops = _card_ops(project_id)
cards = ops.list_cards(status=status)
result = []
for c in cards:
d = _card_to_dict(c)
# 附带进度信息
progress = ops.card_progress(c.id)
d["progress"] = progress
result.append(d)
return {"cards": result}
@router.post("/cards")
async def create_card(project_id: str, body: Dict[str, Any]):
ops = _card_ops(project_id)
card = Card(
id=body["id"],
name=body["name"],
description=body.get("description", ""),
card_type=body.get("card_type", "default"),
stages_json=json.dumps(body.get("stages", [])),
labels_json=json.dumps(body.get("labels", [])),
)
ops.create_card(card)
return {"ok": True, "card_id": card.id}
@router.get("/cards/{card_id}")
async def get_card(project_id: str, card_id: str):
ops = _card_ops(project_id)
card = ops.get_card(card_id)
if not card:
raise HTTPException(404, f"Card not found: {card_id}")
result = _card_to_dict(card)
result["progress"] = ops.card_progress(card_id)
return result
@router.patch("/cards/{card_id}")
async def update_card(project_id: str, card_id: str, body: Dict[str, Any]):
ops = _card_ops(project_id)
updates = {}
if "name" in body:
updates["name"] = body["name"]
if "description" in body:
updates["description"] = body["description"]
if "card_type" in body:
updates["card_type"] = body["card_type"]
if "stages" in body:
updates["stages_json"] = json.dumps(body["stages"])
if "labels" in body:
updates["labels_json"] = json.dumps(body["labels"])
if "status" in body:
updates["status"] = body["status"]
if not ops.update_card(card_id, **updates):
raise HTTPException(404, f"Card not found: {card_id}")
return {"ok": True}
@router.post("/cards/{card_id}/archive")
async def archive_card(project_id: str, card_id: str):
ops = _card_ops(project_id)
if not ops.update_card(card_id, status="archived"):
raise HTTPException(404, f"Card not found: {card_id}")
return {"ok": True}
@router.post("/cards/{card_id}/delete")
async def delete_card(project_id: str, card_id: str):
ops = _card_ops(project_id)
if not ops.delete_card(card_id):
raise HTTPException(404, f"Card not found or not archivable: {card_id}")
return {"ok": True}
@router.post("/cards/{card_id}/fork")
async def fork_card(project_id: str, card_id: str, body: Dict[str, Any]):
"""Fork Card:复制 stages + 元数据,不复制 Task"""
ops = _card_ops(project_id)
new_card = ops.fork_card(
card_id,
new_card_id=body["new_card_id"],
new_name=body["new_name"],
)
if not new_card:
raise HTTPException(404, f"Source card not found: {card_id}")
return {"ok": True, "card_id": new_card.id}
@router.post("/cards/{card_id}/refresh-status")
async def refresh_card_status(project_id: str, card_id: str):
"""手动刷新 Card 状态(从 Task 聚合推导)"""
ops = _card_ops(project_id)
new_status = ops.refresh_card_status(card_id)
return {"ok": True, "status": new_status}
# ===================================================================
# Card 内 Task CRUD
# ===================================================================
@router.get("/cards/{card_id}/tasks")
async def list_card_tasks(project_id: str, card_id: str,
status: Optional[str] = None):
bb = _bb(project_id)
tasks = bb.list_tasks(status=status, card_id=card_id)
return {"tasks": [_task_to_dict(t) for t in tasks]}
@router.post("/cards/{card_id}/tasks")
async def create_card_task(project_id: str, card_id: str,
body: Dict[str, Any]):
bb = _bb(project_id)
task = Task(
id=body["id"],
title=body["title"],
description=body.get("description"),
task_type=body.get("task_type", "coding"),
priority=body.get("priority", 5),
assignee=body.get("assignee"),
assigned_by=body.get("assigned_by", "user"),
depends_on=json.dumps(body["depends_on"]) if "depends_on" in body else None,
risk_level=body.get("risk_level", "standard"),
card_id=card_id,
stage=body.get("stage"),
)
bb.create_task(task)
return {"ok": True, "task_id": task.id}
@router.get("/cards/{card_id}/tasks/{task_id}")
async def get_card_task(project_id: str, card_id: str, task_id: str,
expand: Optional[str] = None):
bb = _bb(project_id)
task = bb.get_task(task_id)
if not task:
raise HTTPException(404, f"Task not found: {task_id}")
if task.card_id != card_id:
raise HTTPException(404, f"Task {task_id} not in card {card_id}")
result = _task_to_dict(task)
if expand == "all":
q = _q(project_id)
detail = q.task_detail(task_id)
if detail:
result["comments_count"] = detail.get("comments_count", 0)
result["outputs_count"] = detail.get("outputs_count", 0)
result["review_status"] = detail.get("review_status")
result["comments"] = [dict(c.__dict__) for c in bb.get_comments(task_id)]
result["outputs"] = [dict(o.__dict__) for o in bb.get_outputs(task_id)]
result["reviews"] = [dict(r.__dict__) for r in bb.get_reviews(task_id)]
return result
@router.post("/cards/{card_id}/tasks/{task_id}/status")
async def update_card_task_status(project_id: str, card_id: str,
task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
task = bb.get_task(task_id)
if not task:
raise HTTPException(404, f"Task not found: {task_id}")
if task.card_id != card_id:
raise HTTPException(404, f"Task {task_id} not in card {card_id}")
new_status = body.get("status")
if not new_status:
raise HTTPException(422, "Missing required field: status")
from src.blackboard.db import VALID_TRANSITIONS
current = task.status
allowed = VALID_TRANSITIONS.get(current, set())
if new_status not in allowed:
raise HTTPException(409, {
"error": "invalid_transition",
"detail": f"Cannot transition from {current} to {new_status}",
"valid_transitions": {current: sorted(allowed)},
})
if not bb.update_task_status(task_id, new_status, agent=body.get("agent")):
raise HTTPException(409, "Status update failed")
# 刷新 Card 状态
ops = _card_ops(project_id)
ops.refresh_card_status(card_id)
# SSE 推送
try:
from src.api.sse_routes import get_broker
broker = get_broker()
broker.publish_sync("task_updated", {
"project_id": project_id,
"card_id": card_id,
"task_id": task_id,
"old_status": current,
"new_status": new_status,
})
except Exception:
pass
return {"ok": True, "old_status": current, "new_status": new_status}
# ===================================================================
# Card 内其他操作(comments, outputs, reviews 等)
# ===================================================================
@router.post("/cards/{card_id}/tasks/{task_id}/outputs")
async def write_card_task_output(project_id: str, card_id: str,
task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
agent = body.get("agent")
if not agent:
raise HTTPException(422, "Missing required field: agent")
output_type = body.get("type") or body.get("content_type")
if not output_type:
raise HTTPException(422, "Missing required field: type")
if output_type not in OUTPUT_TYPES:
raise HTTPException(422, f"Invalid type: {output_type}")
title = body.get("title")
if not title:
raise HTTPException(422, "Missing required field: title")
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}
# ===================================================================
# Helper
# ===================================================================
def _card_to_dict(c: Card) -> Dict[str, Any]:
d = {k: v for k, v in c.__dict__.items() if v is not None}
# 解析 JSON 字段
if "stages_json" in d:
try:
d["stages"] = json.loads(d.pop("stages_json"))
except (json.JSONDecodeError, KeyError):
d["stages"] = []
if "labels_json" in d:
try:
d["labels"] = json.loads(d.pop("labels_json"))
except (json.JSONDecodeError, KeyError):
d["labels"] = []
return d
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
-143
View File
@@ -1,143 +0,0 @@
"""API 路由 — Mail Tabv2.7
Mail 是一个特殊的 Project (_mail),每个 Mail 是一个点对点的双节点 Task。
显示形式为独立 Tab,以列表形式展示。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
from src.blackboard.db import init_db
from src.utils import get_data_root
router = APIRouter(prefix="/api/mail", tags=["mail"])
MAIL_PROJECT_ID = "_mail"
def _db_path() -> Path:
root = get_data_root()
db = root / MAIL_PROJECT_ID / "blackboard.db"
db.parent.mkdir(parents=True, exist_ok=True)
init_db(db)
return db
def _bb() -> Blackboard:
return Blackboard(_db_path())
@router.get("")
async def list_mail(status: Optional[str] = None,
_from: Optional[str] = None,
to: Optional[str] = None,
limit: int = Query(50, le=200)):
"""Mail 列表"""
bb = _bb()
tasks = bb.list_tasks(status=status, assignee=to)
result = []
for t in tasks:
# 解析 Mail 元数据
meta = json.loads(t.must_haves or "{}") if t.must_haves else {}
result.append({
"id": t.id,
"title": t.title,
"from": meta.get("from", t.assigned_by),
"to": t.assignee,
"status": t.status,
"type": meta.get("type", "inform"),
"is_read": meta.get("is_read", False),
"created_at": t.created_at,
"description": t.description,
})
return {"mails": result, "total": len(result)}
@router.get("/{mail_id}")
async def get_mail(mail_id: str):
"""Mail 详情"""
bb = _bb()
task = bb.get_task(mail_id)
if not task:
raise HTTPException(404, f"Mail not found: {mail_id}")
meta = json.loads(task.must_haves or "{}") if task.must_haves else {}
return {
"id": task.id,
"title": task.title,
"from": meta.get("from", task.assigned_by),
"to": task.assignee,
"status": task.status,
"type": meta.get("type", "inform"),
"is_read": meta.get("is_read", False),
"created_at": task.created_at,
"description": task.description,
"outputs": [dict(o.__dict__) for o in bb.get_outputs(mail_id)],
"comments": [dict(c.__dict__) for c in bb.get_comments(mail_id)],
}
@router.patch("/{mail_id}")
async def update_mail(mail_id: str, body: Dict[str, Any]):
"""更新 Mail(标记已读/已执行)"""
bb = _bb()
task = bb.get_task(mail_id)
if not task:
raise HTTPException(404, f"Mail not found: {mail_id}")
# 更新 must_haves 里的 is_read
meta = json.loads(task.must_haves or "{}") if task.must_haves else {}
if "is_read" in body:
meta["is_read"] = body["is_read"]
# 如果标记为已执行,更新状态
if body.get("mark_executed"):
meta["is_read"] = True
if task.status not in ("done", "cancelled"):
bb.update_task_status(mail_id, "done", agent="mail-api")
# 写回 must_haves
conn = bb._conn()
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"UPDATE tasks SET must_haves=? WHERE id=?",
(json.dumps(meta), mail_id),
)
conn.commit()
finally:
conn.close()
return {"ok": True}
@router.post("")
async def send_mail(body: Dict[str, Any]):
"""发送 Mail(创建 Task"""
bb = _bb()
meta = {
"from": body.get("from", "user"),
"type": body.get("type", "inform"),
"is_read": False,
}
task = Task(
id=body["id"],
title=body["title"],
description=body.get("description", ""),
assignee=body.get("to"),
assigned_by=body.get("from", "user"),
must_haves=json.dumps(meta),
card_id="default",
task_type="mail",
)
bb.create_task(task)
return {"ok": True, "mail_id": task.id}