""" 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)