fix(awooop): record approval decisions in run timeline
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 58s
CD Pipeline / build-and-deploy (push) Successful in 3m28s
CD Pipeline / post-deploy-checks (push) Successful in 1m21s

This commit is contained in:
Your Name
2026-05-07 10:01:58 +08:00
parent 1b7f46f02c
commit 2df36b11e2
2 changed files with 116 additions and 4 deletions

View File

@@ -9,12 +9,13 @@ ADR-106AwoooP 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),
)

View File

@@ -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;