forked from xiaohei/taiji-AI-PAD
== Heicode integration (~41 endpoints across 5 modules) ==
- §2 ResourceBinding (5 endpoints) — resources.py / resource_grants.py
- §4 NewAPI metadata proxy (4 endpoints) — heicode_proxy.py + heicode_client.py
- §5 Agnet platform stub (12 endpoints, in-memory mock) — agnet_stub.py
- §6 Task orchestration (5 endpoints + 3 extension endpoints) — heicode_tasks.py
6.1-6.5: intent / list / get / answer / messages
6.6-6.8: execution / delivery / audit?tab=... (Slice 8/9/10)
- §7 SSE single channel + approvals (4 endpoints + 5 event types) —
heicode_events.py + event_bus.py
- §7.8.1 internal billing-provider PUT endpoint — auth.py (routes)
== Schema changes ==
- migrations/026 heicode_tasks (orchestration state)
- migrations/027 users.billing_provider (litellm | newapi switch)
- migrations/028 heicode_approvals (high-risk approval queue)
== Register transaction hardening (P0 + P1 + P2) ==
routes/auth.py register():
- Pre-existing P0: failed register returned IntegrityError str verbatim
(leaking SQL params + ~50 plaintext LiteLLM keys per attempt).
Now logs exc_info, returns {code: REGISTER_FAILED, message: ...}.
- Pre-existing P0: model dedupe — two ModelProvider rows with overlapping
supported_models (e.g. taiji/gpt-4o-mini in both taiji and azure providers)
collide on uq_tenant_model. seen_models set deduplicates within the loop.
- New P1: track created_litellm_keys; on any failure call delete_key() for
each — prevents remote orphan keys when DB rollback fires.
- New P1: replace verify_code with peek_verification_code at the start;
only call verify_code (which consumes) after commit succeeds. Failed
registrations no longer burn the user's one-shot code.
- New P2: narrow inner `except (LiteLLMClientError, Exception)` to just
LiteLLMClientError so SQLAlchemy errors bubble to the outer rollback
instead of being silently swallowed into a half-allocated 200 response.
- New P2: same narrowing on outer `except (AgentManagerError, Exception)`.
== Auth middleware ==
- app/auth.py: allow /api/auth/internal/billing-provider and
/api/auth/internal/approvals to bypass user JWT (service-token auth
via HEICODE_INTERNAL_SERVICE_TOKEN, validated in-route).
== Docs ==
- Heicode-接口契约文档.md v2.2 (41 endpoints + SSE schema + 6.6-6.8)
- Heicode-对接进度与待办.md (through §7.14 SSE + 7.8.2 delivery回执)
- Heicode-完整调用流程图.md (sequence + routing diagrams)
- Agent-Manager-Heicode对接需求文档.md
- HEICODE_API_INTEGRATION.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
706 lines
26 KiB
Python
706 lines
26 KiB
Python
"""
|
||
Heicode 任务编排 API(cc-haha 切片 7/8/9/10 任务驾驶舱配套)
|
||
|
||
8 个端点:
|
||
POST /api/user/tasks/intent 创建任务(用户输入想法 → 第一轮 followups)
|
||
GET /api/user/tasks 用户任务列表
|
||
GET /api/user/tasks/{id} 任务详情
|
||
POST /api/user/tasks/{id}/answer 提交一个 followup 选项 → 触发下一轮 followups 或 TaskCard
|
||
POST /api/user/tasks/{id}/messages 用户继续追加要求
|
||
GET /api/user/tasks/{id}/execution §7.8.2 执行反馈(Slice 8)
|
||
GET /api/user/tasks/{id}/delivery §7.8.2 交付结果(Slice 9)
|
||
GET /api/user/tasks/{id}/audit?tab=... §7.8.2 任务详情审计(Slice 10)
|
||
|
||
字段形态严格对齐 cc-haha/desktop/src/stores/heicodeTaskStore.ts。
|
||
|
||
MVP 编排器(无 LLM 调用,确定性):
|
||
- intent → 2 个 followups(scope + tech)
|
||
- 全部 followups 答完 → 生成 TaskCard
|
||
- 后续 messages → 追加到 thread 不重新追问
|
||
|
||
后续可在 _orchestrator 模块加 LLM 增强(用 LiteLLM 生成更智能的 followups),
|
||
对前端 0 影响(字段形态完全一致)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
import uuid
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from database import get_db
|
||
from models import HeicodeApproval, HeicodeTask, User
|
||
from app.auth import require_auth
|
||
from app.event_bus import get_event_bus
|
||
from app.schemas import SuccessResponse
|
||
|
||
|
||
router = APIRouter(prefix="/api/user/tasks", tags=["Heicode 任务编排"])
|
||
|
||
|
||
# ==================== 数据模型(Pydantic) ====================
|
||
|
||
class IntentRequest(BaseModel):
|
||
intent: str = Field(..., min_length=2, max_length=4000)
|
||
name: Optional[str] = None # 可选,若不传从 intent 抽取
|
||
|
||
|
||
class AnswerRequest(BaseModel):
|
||
question_id: str
|
||
option_id: str
|
||
|
||
|
||
class MessageRequest(BaseModel):
|
||
text: str = Field(..., min_length=1, max_length=4000)
|
||
|
||
|
||
# ==================== 编排器(确定性 MVP) ====================
|
||
|
||
# 初始 followups 模板 — cc-haha wireframe §3 风格
|
||
INITIAL_FOLLOWUPS: List[Dict[str, Any]] = [
|
||
{
|
||
"id": "scope",
|
||
"question": "你希望第一阶段交付到什么程度?",
|
||
"options": [
|
||
{"id": "mvp", "label": "MVP:能跑通主流程"},
|
||
{"id": "polish", "label": "完整功能 + UI 细节打磨"},
|
||
{"id": "prod", "label": "直接上生产", "risk": "high-risk"},
|
||
],
|
||
},
|
||
{
|
||
"id": "tech",
|
||
"question": "技术栈倾向?",
|
||
"options": [
|
||
{"id": "modern_web", "label": "现代 Web(React + Node/Python)"},
|
||
{"id": "py_backend", "label": "Python 后端为主"},
|
||
{"id": "let_heicode", "label": "让 Heicode 决定"},
|
||
],
|
||
},
|
||
]
|
||
|
||
|
||
def _now_ms() -> int:
|
||
return int(time.time() * 1000)
|
||
|
||
|
||
def _new_question_id() -> str:
|
||
return f"q_{uuid.uuid4().hex[:8]}"
|
||
|
||
|
||
def _intent_to_name(intent: str) -> str:
|
||
"""从 intent 抽取一个简短任务名(首句或前 30 字)"""
|
||
s = intent.strip().split("\n")[0].strip()
|
||
# 取到第一个标点符号
|
||
for sep in ["。", "!", "?", ".", "!", "?", ",", ","]:
|
||
idx = s.find(sep)
|
||
if idx != -1 and idx > 5:
|
||
s = s[:idx]
|
||
break
|
||
if len(s) > 30:
|
||
s = s[:28] + "…"
|
||
return s or "新任务"
|
||
|
||
|
||
def _build_initial_thread(intent: str) -> List[Dict[str, Any]]:
|
||
"""根据用户的 intent 构造初始 thread:
|
||
[user 消息, heicode 第一轮追问]"""
|
||
now = _now_ms()
|
||
return [
|
||
{"kind": "user", "text": intent, "at": now},
|
||
{
|
||
"kind": "heicode",
|
||
"text": "好的。我先问你两个问题,方便我组织接下来的工作。",
|
||
"at": now + 1,
|
||
"followups": INITIAL_FOLLOWUPS,
|
||
},
|
||
]
|
||
|
||
|
||
def _all_followups_answered(thread: List[Dict[str, Any]]) -> bool:
|
||
"""检查 thread 中是否所有 followup 都已答完"""
|
||
for turn in thread:
|
||
if turn.get("kind") == "heicode":
|
||
for f in (turn.get("followups") or []):
|
||
if not f.get("answer"):
|
||
return False
|
||
# 至少有一个 heicode turn 才算
|
||
return any(t.get("kind") == "heicode" and t.get("followups") for t in thread)
|
||
|
||
|
||
def _collect_answers(thread: List[Dict[str, Any]]) -> Dict[str, str]:
|
||
"""从 thread 抽出 {question_id: option_id} 字典"""
|
||
out: Dict[str, str] = {}
|
||
for turn in thread:
|
||
for f in (turn.get("followups") or []):
|
||
if f.get("answer"):
|
||
out[f["id"]] = f["answer"]
|
||
return out
|
||
|
||
|
||
def _generate_task_card(intent: str, answers: Dict[str, str]) -> Dict[str, Any]:
|
||
"""根据已收集的 answers + intent 生成 TaskCard。
|
||
MVP:基于模板 + answers 简单填空。"""
|
||
scope_answer = answers.get("scope", "mvp")
|
||
tech_answer = answers.get("tech", "let_heicode")
|
||
|
||
scope_map = {
|
||
"mvp": ["MVP 范围:核心功能跑通", "技术债务可后续清理", "暂不做高级 UI/性能优化"],
|
||
"polish": ["完整功能交付", "UI 细节打磨", "覆盖核心异常场景"],
|
||
"prod": ["直接生产部署(高风险)", "完整测试覆盖", "灰度 / 回滚预案", "生产监控接入"],
|
||
}
|
||
tech_map = {
|
||
"modern_web": "React + Node/Python 后端",
|
||
"py_backend": "Python 后端为主",
|
||
"let_heicode": "由 Heicode 团队按场景决定",
|
||
}
|
||
|
||
return {
|
||
"goal": _intent_to_name(intent),
|
||
"scope": scope_map.get(scope_answer, scope_map["mvp"]),
|
||
"generated_artifacts": [
|
||
"产品说明",
|
||
"原型描述",
|
||
"技术方案",
|
||
f"代码骨架({tech_map.get(tech_answer, '待定')})",
|
||
],
|
||
"manager_actions": [
|
||
{
|
||
"label": "去 Manager 准备资源",
|
||
"deeplink": "/manager/resources?from=task",
|
||
},
|
||
{
|
||
"label": "查看团队建议",
|
||
"deeplink": "/manager/team?from=task",
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
# ==================== 共享工具 ====================
|
||
|
||
def _current_user_id(principal: dict) -> uuid.UUID:
|
||
uid = principal.get("user_id") or (principal.get("claims") or {}).get("sub")
|
||
if not uid:
|
||
raise HTTPException(status_code=401, detail="未登录")
|
||
try:
|
||
return uuid.UUID(str(uid))
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="user_id 格式无效")
|
||
|
||
|
||
def _to_dict(t: HeicodeTask) -> Dict[str, Any]:
|
||
return {
|
||
"id": str(t.id),
|
||
"user_id": str(t.user_id),
|
||
"name": t.name,
|
||
"status": t.status,
|
||
"status_caption": t.status_caption,
|
||
"intent": t.intent,
|
||
"thread": t.thread or [],
|
||
"card": t.card,
|
||
"created_at": int(t.created_at.timestamp() * 1000) if t.created_at else None,
|
||
"updated_at": int(t.updated_at.timestamp() * 1000) if t.updated_at else None,
|
||
}
|
||
|
||
|
||
async def _load_owned(db: AsyncSession, task_id: str, user_id: uuid.UUID) -> HeicodeTask:
|
||
try:
|
||
tid = uuid.UUID(task_id)
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="task_id 格式无效")
|
||
task = await db.get(HeicodeTask, tid)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail={"code": "NOT_FOUND", "message": "任务不存在"})
|
||
if task.user_id != user_id:
|
||
raise HTTPException(
|
||
status_code=403, detail={"code": "FORBIDDEN_SCOPE", "message": "无权访问该任务"}
|
||
)
|
||
return task
|
||
|
||
|
||
def _mark_dirty(task: HeicodeTask) -> None:
|
||
"""SQLAlchemy 对 JSON 字段的修改不会自动检测;强制重新赋值确保持久化"""
|
||
task.thread = list(task.thread or [])
|
||
if task.card is not None:
|
||
task.card = dict(task.card)
|
||
|
||
|
||
# ==================== 1. POST /tasks/intent ====================
|
||
|
||
@router.post("/intent", response_model=SuccessResponse)
|
||
async def create_task_from_intent(
|
||
payload: IntentRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""用户输入想法,创建新任务并返回第一轮 followups"""
|
||
user_id = _current_user_id(principal)
|
||
name = (payload.name or _intent_to_name(payload.intent))[:255]
|
||
|
||
task = HeicodeTask(
|
||
user_id=user_id,
|
||
name=name,
|
||
status="configuring",
|
||
status_caption="等待你回答几个问题",
|
||
intent=payload.intent,
|
||
thread=_build_initial_thread(payload.intent),
|
||
card=None,
|
||
)
|
||
db.add(task)
|
||
await db.commit()
|
||
await db.refresh(task)
|
||
return SuccessResponse(data=_to_dict(task))
|
||
|
||
|
||
# ==================== 2. GET /tasks ====================
|
||
|
||
@router.get("", response_model=SuccessResponse)
|
||
async def list_tasks(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
status_filter: Optional[str] = Query(None, alias="status"),
|
||
limit: int = Query(50, ge=1, le=200),
|
||
offset: int = Query(0, ge=0),
|
||
):
|
||
user_id = _current_user_id(principal)
|
||
stmt = select(HeicodeTask).where(HeicodeTask.user_id == user_id)
|
||
if status_filter:
|
||
stmt = stmt.where(HeicodeTask.status == status_filter)
|
||
stmt = stmt.order_by(HeicodeTask.updated_at.desc()).offset(offset).limit(limit)
|
||
result = await db.execute(stmt)
|
||
items = [_to_dict(t) for t in result.scalars().all()]
|
||
return SuccessResponse(data={
|
||
"items": items, "total": len(items),
|
||
"offset": offset, "limit": limit,
|
||
})
|
||
|
||
|
||
# ==================== 3. GET /tasks/{id} ====================
|
||
|
||
@router.get("/{task_id}", response_model=SuccessResponse)
|
||
async def get_task(
|
||
task_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user_id = _current_user_id(principal)
|
||
task = await _load_owned(db, task_id, user_id)
|
||
return SuccessResponse(data=_to_dict(task))
|
||
|
||
|
||
# ==================== 4. POST /tasks/{id}/answer ====================
|
||
|
||
@router.post("/{task_id}/answer", response_model=SuccessResponse)
|
||
async def answer_followup(
|
||
task_id: str,
|
||
payload: AnswerRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""用户选了某个 followup 选项;如全部答完,生成 TaskCard 并把 status 推到 running"""
|
||
user_id = _current_user_id(principal)
|
||
task = await _load_owned(db, task_id, user_id)
|
||
if task.status not in ("draft", "configuring"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"code": "TASK_STATE_INVALID",
|
||
"message": f"任务当前状态 {task.status},不能再答 followup"},
|
||
)
|
||
|
||
thread = list(task.thread or [])
|
||
matched = False
|
||
valid_option_ids: List[str] = []
|
||
for turn in thread:
|
||
if turn.get("kind") != "heicode":
|
||
continue
|
||
for f in (turn.get("followups") or []):
|
||
if f.get("id") == payload.question_id:
|
||
valid_option_ids = [o["id"] for o in (f.get("options") or [])]
|
||
if payload.option_id not in valid_option_ids:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"code": "INVALID_OPTION",
|
||
"message": f"option_id '{payload.option_id}' 不在该 followup 的允许选项内 {valid_option_ids}",
|
||
},
|
||
)
|
||
f["answer"] = payload.option_id
|
||
matched = True
|
||
break
|
||
if matched:
|
||
break
|
||
|
||
if not matched:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail={"code": "QUESTION_NOT_FOUND",
|
||
"message": f"找不到 question_id '{payload.question_id}'"},
|
||
)
|
||
|
||
# 追加用户消息(让 thread 反映用户做了选择)
|
||
answered_label = next(
|
||
(o["label"] for f in (turn.get("followups") or [])
|
||
for o in (f.get("options") or [])
|
||
if f["id"] == payload.question_id and o["id"] == payload.option_id),
|
||
payload.option_id,
|
||
)
|
||
thread.append({
|
||
"kind": "user",
|
||
"text": f"我选了:{answered_label}",
|
||
"at": _now_ms(),
|
||
})
|
||
|
||
# 全部答完?生成 TaskCard
|
||
old_status = task.status
|
||
status_changed_to: Optional[str] = None
|
||
if _all_followups_answered(thread):
|
||
answers = _collect_answers(thread)
|
||
card = _generate_task_card(task.intent, answers)
|
||
thread.append({
|
||
"kind": "heicode",
|
||
"text": "好,按你的选择,我整理出这张任务卡。你可以去 Manager 准备资源 / 查看团队建议。",
|
||
"at": _now_ms(),
|
||
})
|
||
task.card = card
|
||
task.status = "running"
|
||
task.status_caption = "任务卡已生成,等待 Manager 准备资源"
|
||
status_changed_to = "running"
|
||
|
||
task.thread = thread
|
||
task.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(task)
|
||
|
||
# SSE 广播 task.status_changed(§7.8.3)
|
||
if status_changed_to and status_changed_to != old_status:
|
||
await get_event_bus().emit(
|
||
str(user_id),
|
||
"task.status_changed",
|
||
{
|
||
"task_id": str(task.id),
|
||
"old_status": old_status,
|
||
"new_status": status_changed_to,
|
||
"status_caption": task.status_caption,
|
||
"at": datetime.utcnow().isoformat() + "Z",
|
||
},
|
||
)
|
||
|
||
return SuccessResponse(data=_to_dict(task))
|
||
|
||
|
||
# ==================== 5. POST /tasks/{id}/messages ====================
|
||
|
||
@router.post("/{task_id}/messages", response_model=SuccessResponse)
|
||
async def post_message(
|
||
task_id: str,
|
||
payload: MessageRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""用户继续追加要求;MVP 仅追加到 thread,不再生成新 followups"""
|
||
user_id = _current_user_id(principal)
|
||
task = await _load_owned(db, task_id, user_id)
|
||
thread = list(task.thread or [])
|
||
thread.append({
|
||
"kind": "user",
|
||
"text": payload.text,
|
||
"at": _now_ms(),
|
||
})
|
||
# 简单 ack
|
||
thread.append({
|
||
"kind": "heicode",
|
||
"text": "收到。我会把这条要求纳入任务上下文。",
|
||
"at": _now_ms() + 1,
|
||
})
|
||
task.thread = thread
|
||
task.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(task)
|
||
return SuccessResponse(data=_to_dict(task))
|
||
|
||
|
||
# ==================== 6. GET /tasks/{id}/execution (§7.8.2 Slice 8) ====================
|
||
|
||
def _iso_z(dt: Optional[datetime]) -> Optional[str]:
|
||
if dt is None:
|
||
return None
|
||
return dt.isoformat() + ("Z" if dt.tzinfo is None else "")
|
||
|
||
|
||
def _seed_execution(task: HeicodeTask) -> Dict[str, Any]:
|
||
"""根据 task 状态 + intent,确定性生成 ExecutionState(mock 数据,字段对齐 cc-haha
|
||
heicodeTaskStore.ts: ExecutionState)。
|
||
|
||
生命周期:task.status ∈ {running, awaiting_approval, completed} 才有意义;
|
||
其他状态返回空 sub_steps。
|
||
"""
|
||
base_at = int((task.created_at.timestamp() if task.created_at else time.time()) * 1000)
|
||
finished = task.status == "completed"
|
||
|
||
# 5 个 sub_step(与 cc-haha seed 对齐)
|
||
steps = [
|
||
("step_1", "需求分解", "done"),
|
||
("step_2", "技术方案设计", "done"),
|
||
("step_3", "代码骨架生成", "done" if finished else "running"),
|
||
("step_4", "测试 & 联调", "done" if finished else "waiting"),
|
||
("step_5", "部署 & 验证", "done" if finished else "waiting"),
|
||
]
|
||
sub_steps = [
|
||
{
|
||
"id": sid,
|
||
"title": title,
|
||
"status": status_,
|
||
"caption": "已完成" if status_ == "done" else (
|
||
"进行中" if status_ == "running" else "排队中"
|
||
),
|
||
"at": base_at + i * 1000,
|
||
}
|
||
for i, (sid, title, status_) in enumerate(steps)
|
||
]
|
||
|
||
sk_tool_calls = [
|
||
{
|
||
"id": "tc_1",
|
||
"name": "read_codebase",
|
||
"status": "done",
|
||
"summary": "扫描了 23 个文件,识别出主入口与核心依赖",
|
||
"at": base_at + 200,
|
||
},
|
||
{
|
||
"id": "tc_2",
|
||
"name": "draft_spec",
|
||
"status": "done",
|
||
"summary": "草拟了 PRD(含 4 个 user story)",
|
||
"at": base_at + 1200,
|
||
},
|
||
{
|
||
"id": "tc_3",
|
||
"name": "scaffold_repo",
|
||
"status": "done" if finished else "running",
|
||
"summary": "生成 React + FastAPI 骨架",
|
||
"at": base_at + 2200,
|
||
},
|
||
]
|
||
|
||
events = [
|
||
{"id": "ev_1", "level": "info", "message": "任务启动,分配资源池 default-pool",
|
||
"at": base_at + 100},
|
||
{"id": "ev_2", "level": "info", "message": "需求分解完成,生成 5 个 sub_step",
|
||
"at": base_at + 1100},
|
||
{"id": "ev_3",
|
||
"level": "warn" if not finished else "info",
|
||
"message": ("当前在 step_3:代码骨架生成中" if not finished else "已完成全部 sub_step"),
|
||
"at": base_at + 2100},
|
||
]
|
||
|
||
artifacts = [
|
||
{"id": "art_1", "kind": "doc", "label": "产品说明(PRD)",
|
||
"url": f"/manager/artifacts/{task.id}/prd.md"},
|
||
{"id": "art_2", "kind": "doc", "label": "原型描述",
|
||
"url": f"/manager/artifacts/{task.id}/proto.md"},
|
||
{"id": "art_3", "kind": "api", "label": "API 设计稿",
|
||
"url": f"/manager/artifacts/{task.id}/api.yaml"},
|
||
{"id": "art_4", "kind": "diff", "label": "代码骨架 diff",
|
||
"url": f"/manager/artifacts/{task.id}/scaffold.diff"},
|
||
]
|
||
|
||
return {
|
||
"sub_steps": sub_steps,
|
||
"sk_tool_calls": sk_tool_calls,
|
||
"events": events,
|
||
"artifacts": artifacts,
|
||
"spend_today": "¥12.30",
|
||
}
|
||
|
||
|
||
@router.get("/{task_id}/execution", response_model=SuccessResponse)
|
||
async def get_task_execution(
|
||
task_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""§7.8.2 Slice 8:执行反馈面板。任务在 running/awaiting_approval/completed
|
||
时填充 sub_steps / sk_tool_calls / events / artifacts / spend_today。"""
|
||
user_id = _current_user_id(principal)
|
||
task = await _load_owned(db, task_id, user_id)
|
||
if task.status not in ("running", "awaiting_approval", "completed"):
|
||
# 未启动的任务返回空 ExecutionState(前端会显示「等待启动」占位)
|
||
return SuccessResponse(data={
|
||
"sub_steps": [], "sk_tool_calls": [], "events": [],
|
||
"artifacts": [], "spend_today": None,
|
||
})
|
||
return SuccessResponse(data=_seed_execution(task))
|
||
|
||
|
||
# ==================== 7. GET /tasks/{id}/delivery (§7.8.2 Slice 9) ====================
|
||
|
||
def _seed_delivery(task: HeicodeTask) -> Dict[str, Any]:
|
||
"""task.status == completed 时生成 DeliveryResult。"""
|
||
return {
|
||
"summary": f"任务「{task.name}」已交付。包含产品说明、代码骨架、测试报告 3 大类产物,可直接推进到部署阶段。",
|
||
"deliverables": [
|
||
{
|
||
"id": "deliv_1",
|
||
"kind": "product-spec",
|
||
"title": "产品说明 PRD v1",
|
||
"primary_action": {"label": "查看文档",
|
||
"deeplink": f"/manager/artifacts/{task.id}/prd.md"},
|
||
},
|
||
{
|
||
"id": "deliv_2",
|
||
"kind": "code-diff",
|
||
"title": "代码骨架(React + FastAPI)",
|
||
"primary_action": {"label": "查看 diff",
|
||
"deeplink": f"/manager/artifacts/{task.id}/scaffold.diff"},
|
||
"secondary_action": {"label": "去 Manager 拉分支",
|
||
"deeplink": f"/manager/git?task={task.id}"},
|
||
},
|
||
{
|
||
"id": "deliv_3",
|
||
"kind": "test-env",
|
||
"title": "测试环境 staging-001",
|
||
"primary_action": {"label": "打开测试环境",
|
||
"deeplink": f"/manager/envs/staging-001?from=task"},
|
||
},
|
||
{
|
||
"id": "deliv_4",
|
||
"kind": "prod-env",
|
||
"title": "生产部署预案",
|
||
"primary_action": {"label": "查看预案",
|
||
"deeplink": f"/manager/deploy/plan?task={task.id}"},
|
||
},
|
||
],
|
||
"quality": [
|
||
{"id": "q_1", "label": "单元测试覆盖率", "status": "pass", "detail": "82%"},
|
||
{"id": "q_2", "label": "Lint / 静态扫描", "status": "pass", "detail": "0 个高危"},
|
||
{"id": "q_3", "label": "安全扫描", "status": "pass", "detail": "通过"},
|
||
{"id": "q_4", "label": "性能基线", "status": "warn", "detail": "p95 略高于阈值,建议复测"},
|
||
],
|
||
"next_actions": [
|
||
{"label": "去 Manager 准备资源", "intent": "manager.resources"},
|
||
{"label": "进入测试联调", "intent": "manager.test"},
|
||
{"label": "提交灰度发布申请", "intent": "manager.deploy"},
|
||
],
|
||
}
|
||
|
||
|
||
@router.get("/{task_id}/delivery", response_model=SuccessResponse)
|
||
async def get_task_delivery(
|
||
task_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""§7.8.2 Slice 9:交付结果面板。仅 task.status == completed 时返回完整数据;
|
||
其他状态返回空 DeliveryResult(前端显示「任务尚未完成」占位)。"""
|
||
user_id = _current_user_id(principal)
|
||
task = await _load_owned(db, task_id, user_id)
|
||
if task.status != "completed":
|
||
return SuccessResponse(data={
|
||
"summary": "", "deliverables": [], "quality": [], "next_actions": [],
|
||
})
|
||
return SuccessResponse(data=_seed_delivery(task))
|
||
|
||
|
||
# ==================== 8. GET /tasks/{id}/audit?tab=... (§7.8.2 Slice 10) ====================
|
||
|
||
_AUDIT_TABS = {"usage", "resources", "approvals", "security"}
|
||
|
||
|
||
def _audit_seed_usage(task: HeicodeTask) -> List[Dict[str, Any]]:
|
||
"""模型用量(mock)"""
|
||
return [
|
||
{"id": "u_1", "model": "gpt-4o-mini", "input_tokens": 12_400,
|
||
"output_tokens": 3_200, "cost": "¥4.20", "at_iso": _iso_z(task.created_at)},
|
||
{"id": "u_2", "model": "claude-sonnet-4", "input_tokens": 8_100,
|
||
"output_tokens": 2_600, "cost": "¥6.80", "at_iso": _iso_z(task.created_at)},
|
||
{"id": "u_3", "model": "gpt-4o", "input_tokens": 2_300,
|
||
"output_tokens": 1_100, "cost": "¥1.30", "at_iso": _iso_z(task.updated_at)},
|
||
]
|
||
|
||
|
||
def _audit_seed_resources(task: HeicodeTask) -> List[Dict[str, Any]]:
|
||
"""资源访问(mock)"""
|
||
return [
|
||
{"id": "r_1", "resource": "git/heicode-frontend", "scope": "read",
|
||
"last_used_iso": _iso_z(task.updated_at)},
|
||
{"id": "r_2", "resource": "k8s/staging-001", "scope": "deploy",
|
||
"last_used_iso": _iso_z(task.updated_at)},
|
||
{"id": "r_3", "resource": "secret/db-readonly", "scope": "read",
|
||
"last_used_iso": _iso_z(task.updated_at)},
|
||
]
|
||
|
||
|
||
async def _audit_real_approvals(
|
||
db: AsyncSession, task: HeicodeTask
|
||
) -> List[Dict[str, Any]]:
|
||
"""approvals tab:从 heicode_approvals 真表拉对应 task 的审批历史"""
|
||
result = await db.execute(
|
||
select(HeicodeApproval)
|
||
.where(HeicodeApproval.task_id == task.id)
|
||
.order_by(HeicodeApproval.enqueued_at.desc())
|
||
.limit(50)
|
||
)
|
||
out: List[Dict[str, Any]] = []
|
||
for a in result.scalars().all():
|
||
out.append({
|
||
"id": str(a.id),
|
||
"operation": a.operation,
|
||
"target_resource": a.target_resource,
|
||
"risk_level": a.risk_level,
|
||
"decision": a.decision,
|
||
"enqueued_iso": _iso_z(a.enqueued_at),
|
||
"resolved_iso": _iso_z(a.resolved_at),
|
||
})
|
||
return out
|
||
|
||
|
||
def _audit_seed_security(task: HeicodeTask) -> List[Dict[str, Any]]:
|
||
"""安全事件(mock)"""
|
||
return [
|
||
{"id": "s_1", "event": "task_created", "level": "info",
|
||
"at_iso": _iso_z(task.created_at)},
|
||
{"id": "s_2", "event": "resource_grant_used", "level": "info",
|
||
"at_iso": _iso_z(task.updated_at)},
|
||
]
|
||
|
||
|
||
@router.get("/{task_id}/audit", response_model=SuccessResponse)
|
||
async def get_task_audit(
|
||
task_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db),
|
||
tab: Optional[str] = Query(None, description="usage|resources|approvals|security;不传则返回 4 tab 全量"),
|
||
):
|
||
"""§7.8.2 Slice 10:任务详情抽屉。
|
||
- 默认(不传 tab)返回 `{usage, resources, approvals, security}` 4 tab 全量;
|
||
- 传 tab 则只返回该 tab 字段;其他 tab 字段返回空数组(保持 schema 稳定)。
|
||
|
||
其中 `approvals` 是真实查 heicode_approvals 表,其他 3 tab 是 deterministic mock。
|
||
"""
|
||
user_id = _current_user_id(principal)
|
||
task = await _load_owned(db, task_id, user_id)
|
||
if tab is not None and tab not in _AUDIT_TABS:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"code": "INVALID_TAB",
|
||
"message": f"tab 必须是 {sorted(_AUDIT_TABS)} 之一"},
|
||
)
|
||
|
||
out: Dict[str, Any] = {
|
||
"usage": [], "resources": [], "approvals": [], "security": [],
|
||
}
|
||
if tab is None or tab == "usage":
|
||
out["usage"] = _audit_seed_usage(task)
|
||
if tab is None or tab == "resources":
|
||
out["resources"] = _audit_seed_resources(task)
|
||
if tab is None or tab == "approvals":
|
||
out["approvals"] = await _audit_real_approvals(db, task)
|
||
if tab is None or tab == "security":
|
||
out["security"] = _audit_seed_security(task)
|
||
return SuccessResponse(data=out)
|