From 2df36b11e2f961d0d05e79518126b96b55d4d338 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 7 May 2026 10:01:58 +0800 Subject: [PATCH] fix(awooop): record approval decisions in run timeline --- .../src/services/platform_operator_service.py | 119 +++++++++++++++++- .../[locale]/awooop/runs/[run_id]/page.tsx | 1 + 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index e38d6929..ec73918d 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -9,12 +9,13 @@ ADR-106(AwoooP Agent Platform) from __future__ import annotations import uuid +from datetime import UTC, datetime from typing import Any from uuid import UUID import structlog from fastapi import HTTPException, status -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy import or_ as sa_or from src.db.awooop_models import ( @@ -38,6 +39,7 @@ _DEFAULT_PER_PAGE = 50 _MAX_PER_PAGE = 200 _MAX_EVENTS = 100 _MAX_TIMELINE_ITEMS = 100 +_MAX_STEP_SUMMARY_CHARS = 128 # ============================================================================= # Tenants @@ -174,6 +176,30 @@ def _timeline_item( } +def _utc_now_naive() -> datetime: + """回傳與 AwoooP timestamp-without-timezone 欄位相容的 UTC 時間。""" + return datetime.now(UTC).replace(tzinfo=None) + + +def _truncate_step_summary(value: str | None) -> str | None: + """壓縮 Step summary,避免超過 DB 欄位與前端 timeline 需要的短摘要。""" + if not value: + return None + compact = " ".join(str(value).split()) + if len(compact) <= _MAX_STEP_SUMMARY_CHARS: + return compact + return f"{compact[: _MAX_STEP_SUMMARY_CHARS - 1]}…" + + +def _approval_step_title(tool_name: str, step_seq: int) -> str: + """將 operator_console.* step 轉成人能一眼理解的 timeline 標題。""" + if tool_name == "operator_console.approve": + return f"人工審批 {step_seq}: 核准" + if tool_name == "operator_console.reject": + return f"人工審批 {step_seq}: 拒絕" + return f"Step {step_seq}: {tool_name}" + + async def get_run_detail( run_id: str, project_id: str | None = None, @@ -219,7 +245,7 @@ async def get_run_detail( ) inbound_result = await db.execute( select(AwoooPConversationEvent) - .where(sa_or_(*inbound_where)) + .where(sa_or(*inbound_where)) .order_by(AwoooPConversationEvent.received_at.asc()) .limit(_MAX_TIMELINE_ITEMS) ) @@ -352,11 +378,12 @@ async def get_run_detail( ) ) for row in steps: + is_approval_step = row.tool_name.startswith("operator_console.") timeline.append( _timeline_item( ts=row.completed_at or row.created_at, - kind="step", - title=f"Step {row.step_seq}: {row.tool_name}", + kind="approval" if is_approval_step else "step", + title=_approval_step_title(row.tool_name, row.step_seq), status=row.result_status, summary=row.block_reason or row.error_code, metadata={ @@ -586,6 +613,13 @@ async def decide_approval( await transition(run_uuid, project_id, "running") new_state = "running" + await _record_approval_decision_step( + run_id=run_uuid, + project_id=project_id, + decision=decision, + approver_id=approver_id, + reason=reason, + ) import base64 import json as _json @@ -608,6 +642,13 @@ async def decide_approval( error_detail=f"operator 拒絕: approver={approver_id!r}, reason={reason!r}", ) new_state = "cancelled" + await _record_approval_decision_step( + run_id=run_uuid, + project_id=project_id, + decision=decision, + approver_id=approver_id, + reason=reason, + ) try: await write_audit( @@ -621,6 +662,7 @@ async def decide_approval( "reason": reason, "new_state": new_state, }, + run_id=run_id, ) except Exception as exc: logger.warning("approval_audit_write_failed", run_id=run_id, error=str(exc)) @@ -631,3 +673,72 @@ async def decide_approval( "new_state": new_state, "approval_token_jti": approval_token_jti, } + + +async def _record_approval_decision_step( + *, + run_id: UUID, + project_id: str, + decision: str, + approver_id: str, + reason: str | None, +) -> None: + """把 Operator Console 的人工審批決策寫進 Run Step Journal。 + + 這是治理與可觀測節點,不是執行閘門本身;寫入失敗不可反向阻擋 + 已完成的 approve / reject,否則會讓人工決策狀態機產生二次故障。 + """ + tool_name = ( + "operator_console.approve" + if decision == "approve" + else "operator_console.reject" + ) + summary = _truncate_step_summary( + f"approver={approver_id}; decision={decision}; reason={reason or '-'}" + ) + + try: + async with get_db_context(project_id) as db: + max_result = await db.execute( + select(func.coalesce(func.max(AwoooPRunStepJournal.step_seq), 0)).where( + AwoooPRunStepJournal.run_id == run_id, + AwoooPRunStepJournal.project_id == project_id, + ) + ) + step_seq = int(max_result.scalar_one()) + 1 + + db.add( + AwoooPRunStepJournal( + run_id=run_id, + project_id=project_id, + step_seq=step_seq, + tool_name=tool_name, + result_status="success", + block_reason=summary, + completed_at=_utc_now_naive(), + ) + ) + await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == run_id, + AwoooPRunState.project_id == project_id, + ) + .values(step_count=AwoooPRunState.step_count + 1) + ) + + logger.info( + "approval_decision_step_recorded", + run_id=str(run_id), + project_id=project_id, + decision=decision, + approver_id=approver_id, + ) + except Exception as exc: + logger.warning( + "approval_decision_step_record_failed", + run_id=str(run_id), + project_id=project_id, + decision=decision, + error=str(exc), + ) diff --git a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx index 082724a1..5dc9f091 100644 --- a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx @@ -125,6 +125,7 @@ function statusClass(status: string) { } function itemIcon(kind: string) { + if (kind === "approval") return ShieldCheck; if (kind === "inbound") return MessageSquareText; if (kind === "outbound") return Send; if (kind === "step") return Wrench;