auto-sync: 2026-05-18 00:27:49
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user