auto-sync: 2026-05-18 00:27:49

This commit is contained in:
cfdaily
2026-05-18 00:27:49 +08:00
parent 3843eb1c0a
commit 2edc8b29a9
+315
View File
@@ -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