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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy import or_ as sa_or
|
from sqlalchemy import or_ as sa_or
|
||||||
|
|
||||||
from src.db.awooop_models import (
|
from src.db.awooop_models import (
|
||||||
@@ -38,6 +39,7 @@ _DEFAULT_PER_PAGE = 50
|
|||||||
_MAX_PER_PAGE = 200
|
_MAX_PER_PAGE = 200
|
||||||
_MAX_EVENTS = 100
|
_MAX_EVENTS = 100
|
||||||
_MAX_TIMELINE_ITEMS = 100
|
_MAX_TIMELINE_ITEMS = 100
|
||||||
|
_MAX_STEP_SUMMARY_CHARS = 128
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Tenants
|
# 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(
|
async def get_run_detail(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
project_id: str | None = None,
|
project_id: str | None = None,
|
||||||
@@ -219,7 +245,7 @@ async def get_run_detail(
|
|||||||
)
|
)
|
||||||
inbound_result = await db.execute(
|
inbound_result = await db.execute(
|
||||||
select(AwoooPConversationEvent)
|
select(AwoooPConversationEvent)
|
||||||
.where(sa_or_(*inbound_where))
|
.where(sa_or(*inbound_where))
|
||||||
.order_by(AwoooPConversationEvent.received_at.asc())
|
.order_by(AwoooPConversationEvent.received_at.asc())
|
||||||
.limit(_MAX_TIMELINE_ITEMS)
|
.limit(_MAX_TIMELINE_ITEMS)
|
||||||
)
|
)
|
||||||
@@ -352,11 +378,12 @@ async def get_run_detail(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
for row in steps:
|
for row in steps:
|
||||||
|
is_approval_step = row.tool_name.startswith("operator_console.")
|
||||||
timeline.append(
|
timeline.append(
|
||||||
_timeline_item(
|
_timeline_item(
|
||||||
ts=row.completed_at or row.created_at,
|
ts=row.completed_at or row.created_at,
|
||||||
kind="step",
|
kind="approval" if is_approval_step else "step",
|
||||||
title=f"Step {row.step_seq}: {row.tool_name}",
|
title=_approval_step_title(row.tool_name, row.step_seq),
|
||||||
status=row.result_status,
|
status=row.result_status,
|
||||||
summary=row.block_reason or row.error_code,
|
summary=row.block_reason or row.error_code,
|
||||||
metadata={
|
metadata={
|
||||||
@@ -586,6 +613,13 @@ async def decide_approval(
|
|||||||
|
|
||||||
await transition(run_uuid, project_id, "running")
|
await transition(run_uuid, project_id, "running")
|
||||||
new_state = "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 base64
|
||||||
import json as _json
|
import json as _json
|
||||||
@@ -608,6 +642,13 @@ async def decide_approval(
|
|||||||
error_detail=f"operator 拒絕: approver={approver_id!r}, reason={reason!r}",
|
error_detail=f"operator 拒絕: approver={approver_id!r}, reason={reason!r}",
|
||||||
)
|
)
|
||||||
new_state = "cancelled"
|
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:
|
try:
|
||||||
await write_audit(
|
await write_audit(
|
||||||
@@ -621,6 +662,7 @@ async def decide_approval(
|
|||||||
"reason": reason,
|
"reason": reason,
|
||||||
"new_state": new_state,
|
"new_state": new_state,
|
||||||
},
|
},
|
||||||
|
run_id=run_id,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("approval_audit_write_failed", run_id=run_id, error=str(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,
|
"new_state": new_state,
|
||||||
"approval_token_jti": approval_token_jti,
|
"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) {
|
function itemIcon(kind: string) {
|
||||||
|
if (kind === "approval") return ShieldCheck;
|
||||||
if (kind === "inbound") return MessageSquareText;
|
if (kind === "inbound") return MessageSquareText;
|
||||||
if (kind === "outbound") return Send;
|
if (kind === "outbound") return Send;
|
||||||
if (kind === "step") return Wrench;
|
if (kind === "step") return Wrench;
|
||||||
|
|||||||
Reference in New Issue
Block a user