fix(awooop): record approval decisions in run timeline
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user