From 2edc8b29a96d32d0bc2ebb245313674b5f062d6b Mon Sep 17 00:00:00 2001 From: cfdaily Date: Mon, 18 May 2026 00:27:49 +0800 Subject: [PATCH] auto-sync: 2026-05-18 00:27:49 --- src/api/card_routes.py | 315 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 src/api/card_routes.py diff --git a/src/api/card_routes.py b/src/api/card_routes.py new file mode 100644 index 0000000..5577caa --- /dev/null +++ b/src/api/card_routes.py @@ -0,0 +1,315 @@ +"""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: + return CardOps(_db_path(project_id)) + + +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