feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -74,6 +74,25 @@ KEYCLOAK_ALGORITHM=RS256
|
|||||||
KEYCLOAK_AUDIENCE="account"
|
KEYCLOAK_AUDIENCE="account"
|
||||||
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
|
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 工作台事件同步
|
||||||
|
# ============================================
|
||||||
|
# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。
|
||||||
|
CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173"
|
||||||
|
WORKBENCH_EVENTS_ENABLED=true
|
||||||
|
WORKBENCH_EVENT_RETENTION_DAYS=7
|
||||||
|
|
||||||
|
# 每个项目启动一个 worker 实例。长期运行优先使用 OAuth 客户端凭据。
|
||||||
|
OPERATIONAL_WORKER_PROJECT_ID=""
|
||||||
|
OPERATIONAL_WORKER_SERVER_URL="http://127.0.0.1:8000"
|
||||||
|
OPERATIONAL_WORKER_AGENT_URL="http://127.0.0.1:8787"
|
||||||
|
OPERATIONAL_WORKER_RECONCILE_SECONDS=30
|
||||||
|
OPERATIONAL_WORKER_MAX_CONCURRENCY=4
|
||||||
|
OPERATIONAL_WORKER_TOKEN_URL=""
|
||||||
|
OPERATIONAL_WORKER_CLIENT_ID=""
|
||||||
|
OPERATIONAL_WORKER_CLIENT_SECRET=""
|
||||||
|
OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS=300
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Bocha Web Search API
|
# Bocha Web Search API
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class ProblemDetails(BaseModel):
|
|||||||
code: str
|
code: str
|
||||||
trace_id: str
|
trace_id: str
|
||||||
errors: list[dict[str, Any]] = Field(default_factory=list)
|
errors: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
retryable: bool = False
|
||||||
|
related_refs: list[Any] = Field(default_factory=list)
|
||||||
|
recovery_action: str = "none"
|
||||||
|
retry_after_seconds: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _trace_id(request: Request) -> str:
|
def _trace_id(request: Request) -> str:
|
||||||
@@ -36,6 +40,10 @@ def _problem_response(
|
|||||||
detail: str,
|
detail: str,
|
||||||
code: str,
|
code: str,
|
||||||
errors: list[dict[str, Any]] | None = None,
|
errors: list[dict[str, Any]] | None = None,
|
||||||
|
retryable: bool = False,
|
||||||
|
related_refs: list[Any] | None = None,
|
||||||
|
recovery_action: str = "none",
|
||||||
|
retry_after_seconds: int | None = None,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
problem = ProblemDetails(
|
problem = ProblemDetails(
|
||||||
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
|
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
|
||||||
@@ -46,6 +54,10 @@ def _problem_response(
|
|||||||
code=code,
|
code=code,
|
||||||
trace_id=_trace_id(request),
|
trace_id=_trace_id(request),
|
||||||
errors=errors or [],
|
errors=errors or [],
|
||||||
|
retryable=retryable,
|
||||||
|
related_refs=related_refs or [],
|
||||||
|
recovery_action=recovery_action,
|
||||||
|
retry_after_seconds=retry_after_seconds,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
@@ -89,7 +101,6 @@ def install_problem_details_handlers(app: FastAPI) -> None:
|
|||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||||
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
|
||||||
code_by_status = {
|
code_by_status = {
|
||||||
401: "unauthenticated",
|
401: "unauthenticated",
|
||||||
403: "forbidden",
|
403: "forbidden",
|
||||||
@@ -98,12 +109,43 @@ def install_problem_details_handlers(app: FastAPI) -> None:
|
|||||||
422: "validation_error",
|
422: "validation_error",
|
||||||
503: "dependency_unavailable",
|
503: "dependency_unavailable",
|
||||||
}
|
}
|
||||||
|
fallback_code = code_by_status.get(exc.status_code, "request_error")
|
||||||
|
detail_payload = exc.detail if isinstance(exc.detail, dict) else None
|
||||||
|
code = (
|
||||||
|
detail_payload.get("code")
|
||||||
|
if detail_payload and isinstance(detail_payload.get("code"), str)
|
||||||
|
else fallback_code
|
||||||
|
)
|
||||||
|
detail = exc.detail if isinstance(exc.detail, str) else None
|
||||||
|
if detail_payload:
|
||||||
|
candidate = detail_payload.get("message") or detail_payload.get("detail")
|
||||||
|
if isinstance(candidate, str):
|
||||||
|
detail = candidate
|
||||||
|
if detail is None:
|
||||||
|
detail = code.replace("_", " ").title()
|
||||||
|
related_refs = detail_payload.get("related_refs", []) if detail_payload else []
|
||||||
|
if not isinstance(related_refs, list):
|
||||||
|
related_refs = []
|
||||||
|
recovery_action = (
|
||||||
|
detail_payload.get("recovery_action", "none") if detail_payload else "none"
|
||||||
|
)
|
||||||
|
if not isinstance(recovery_action, str):
|
||||||
|
recovery_action = "none"
|
||||||
|
retry_after_seconds = (
|
||||||
|
detail_payload.get("retry_after_seconds") if detail_payload else None
|
||||||
|
)
|
||||||
|
if not isinstance(retry_after_seconds, int):
|
||||||
|
retry_after_seconds = None
|
||||||
return _problem_response(
|
return _problem_response(
|
||||||
request,
|
request,
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
title=code_by_status.get(exc.status_code, "request_error")
|
title=fallback_code.replace("_", " ").title(),
|
||||||
.replace("_", " ")
|
|
||||||
.title(),
|
|
||||||
detail=detail,
|
detail=detail,
|
||||||
code=code_by_status.get(exc.status_code, "request_error"),
|
code=code,
|
||||||
|
retryable=(
|
||||||
|
bool(detail_payload.get("retryable", False)) if detail_payload else False
|
||||||
|
),
|
||||||
|
related_refs=related_refs,
|
||||||
|
recovery_action=recovery_action,
|
||||||
|
retry_after_seconds=retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
"""Project-scoped APIs for operational tasks, evidence and approved actions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
from app.auth.permissions import (
|
||||||
|
OPERATIONAL_TASK_EXECUTE,
|
||||||
|
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||||
|
OPERATIONAL_TASK_VIEW,
|
||||||
|
OPERATIONAL_TASK_WRITE,
|
||||||
|
permissions_for_context,
|
||||||
|
require_permission,
|
||||||
|
)
|
||||||
|
from app.auth.project_dependencies import (
|
||||||
|
ProjectContext,
|
||||||
|
get_project_context,
|
||||||
|
get_project_pg_connection,
|
||||||
|
)
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
ActionDecisionCommand,
|
||||||
|
ActionExecuteCommand,
|
||||||
|
ActionProposalCommand,
|
||||||
|
ActionReconcileCommand,
|
||||||
|
AgentArtifactsCommand,
|
||||||
|
AgentArtifactsResult,
|
||||||
|
AgentRunCancelCommand,
|
||||||
|
AgentRunCommand,
|
||||||
|
AgentRunDispatchFailureCommand,
|
||||||
|
AgentRunFailureCommand,
|
||||||
|
AgentTaskContext,
|
||||||
|
DispatchDueSchedulesCommand,
|
||||||
|
DispatchDueSchedulesResult,
|
||||||
|
EvidenceRecord,
|
||||||
|
ExternalFeedbackCommand,
|
||||||
|
ExternalFeedbackResult,
|
||||||
|
HumanApprovalAction,
|
||||||
|
HumanEvidenceCommand,
|
||||||
|
NotificationAcknowledgeCommand,
|
||||||
|
OperationalTaskCreate,
|
||||||
|
OperationalTaskDetail,
|
||||||
|
OperationalTaskNotification,
|
||||||
|
OperationalTaskNotificationPage,
|
||||||
|
OperationalTaskPage,
|
||||||
|
OperationalTaskRetryCommand,
|
||||||
|
OperationalTaskRun,
|
||||||
|
OperationalTaskRunPage,
|
||||||
|
OperationalTaskSchedule,
|
||||||
|
OperationalTaskScheduleCreate,
|
||||||
|
OperationalTaskSchedulePage,
|
||||||
|
OperationalTaskScheduleUpdate,
|
||||||
|
RecoverExpiredLeasesCommand,
|
||||||
|
RecoverExpiredLeasesResult,
|
||||||
|
TaskStatus,
|
||||||
|
TaskDataQueryResult,
|
||||||
|
TaskType,
|
||||||
|
)
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
from app.infra.integrations.operational_actions import (
|
||||||
|
OperationalActionDispatcher,
|
||||||
|
get_operational_action_dispatcher,
|
||||||
|
)
|
||||||
|
from app.infra.integrations.operational_data import TaskDataError, read_task_data
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService:
|
||||||
|
return OperationalTaskService(
|
||||||
|
OperationalTaskRepository(conn, ctx.project_id),
|
||||||
|
ctx,
|
||||||
|
permissions_for_context(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_domain(exc: DomainError):
|
||||||
|
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks",
|
||||||
|
response_model=OperationalTaskDetail,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_operational_task(
|
||||||
|
command: OperationalTaskCreate,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).create(command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operational-tasks", response_model=OperationalTaskPage)
|
||||||
|
async def list_operational_tasks(
|
||||||
|
task_type: TaskType | None = None,
|
||||||
|
task_status: TaskStatus | None = Query(default=None, alias="status"),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
items, next_offset = await _service(ctx, conn).list(
|
||||||
|
task_type=task_type, status=task_status, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return OperationalTaskPage(
|
||||||
|
items=items, limit=limit, offset=offset, next_offset=next_offset
|
||||||
|
)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operational-task-runs", response_model=OperationalTaskRunPage)
|
||||||
|
async def list_operational_task_runs(
|
||||||
|
run_status: Literal[
|
||||||
|
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
|
||||||
|
] | None = Query(default=None, alias="status"),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
cursor: str | None = None,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
cursor_created_at = None
|
||||||
|
cursor_run_id = None
|
||||||
|
if cursor:
|
||||||
|
try:
|
||||||
|
timestamp, run_id = cursor.rsplit("|", 1)
|
||||||
|
cursor_created_at = datetime.fromisoformat(timestamp)
|
||||||
|
cursor_run_id = UUID(run_id)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid run cursor") from exc
|
||||||
|
try:
|
||||||
|
items, next_cursor = await _service(ctx, conn).list_runs(
|
||||||
|
status=run_status,
|
||||||
|
limit=limit,
|
||||||
|
cursor_created_at=cursor_created_at,
|
||||||
|
cursor_run_id=cursor_run_id,
|
||||||
|
)
|
||||||
|
return OperationalTaskRunPage(items=items, next_cursor=next_cursor)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/schedules",
|
||||||
|
response_model=OperationalTaskSchedule,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_operational_schedule(
|
||||||
|
command: OperationalTaskScheduleCreate,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).create_schedule(command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/operational-tasks/schedules", response_model=OperationalTaskSchedulePage
|
||||||
|
)
|
||||||
|
async def list_operational_schedules(
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return OperationalTaskSchedulePage(items=await _service(ctx, conn).list_schedules())
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/operational-tasks/schedules/{schedule_id}",
|
||||||
|
response_model=OperationalTaskSchedule,
|
||||||
|
)
|
||||||
|
async def update_operational_schedule(
|
||||||
|
schedule_id: UUID,
|
||||||
|
command: OperationalTaskScheduleUpdate,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).update_schedule(schedule_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/schedules/dispatch-due",
|
||||||
|
response_model=DispatchDueSchedulesResult,
|
||||||
|
)
|
||||||
|
async def dispatch_due_operational_schedules(
|
||||||
|
command: DispatchDueSchedulesCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).dispatch_due_schedules(command.limit)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/leases/recover",
|
||||||
|
response_model=RecoverExpiredLeasesResult,
|
||||||
|
)
|
||||||
|
async def recover_operational_task_leases(
|
||||||
|
command: RecoverExpiredLeasesCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).recover_expired_leases(command.limit)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/operational-task-notifications",
|
||||||
|
response_model=OperationalTaskNotificationPage,
|
||||||
|
)
|
||||||
|
async def list_operational_task_notifications(
|
||||||
|
unread_only: bool = False,
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
items, next_offset = await _service(ctx, conn).list_notifications(
|
||||||
|
unread_only=unread_only, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return OperationalTaskNotificationPage(
|
||||||
|
items=items, limit=limit, offset=offset, next_offset=next_offset
|
||||||
|
)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-task-notifications/{notification_id}/acknowledge",
|
||||||
|
response_model=OperationalTaskNotification,
|
||||||
|
)
|
||||||
|
async def acknowledge_operational_task_notification(
|
||||||
|
notification_id: UUID,
|
||||||
|
command: NotificationAcknowledgeCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).acknowledge_notification(notification_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operational-tasks/{task_id}", response_model=OperationalTaskDetail)
|
||||||
|
async def get_operational_task(
|
||||||
|
task_id: UUID,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).detail(task_id)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/operational-tasks/{task_id}/retry", response_model=OperationalTaskDetail)
|
||||||
|
async def retry_operational_task(
|
||||||
|
task_id: UUID,
|
||||||
|
command: OperationalTaskRetryCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).retry_task(task_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/runs/{run_id}/claim",
|
||||||
|
response_model=OperationalTaskRun,
|
||||||
|
)
|
||||||
|
async def claim_operational_task_run(
|
||||||
|
task_id: UUID,
|
||||||
|
run_id: UUID,
|
||||||
|
command: AgentRunCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).claim_run(task_id, run_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/runs/{run_id}/heartbeat",
|
||||||
|
response_model=OperationalTaskRun,
|
||||||
|
)
|
||||||
|
async def heartbeat_operational_task_run(
|
||||||
|
task_id: UUID,
|
||||||
|
run_id: UUID,
|
||||||
|
command: AgentRunCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).heartbeat_run(task_id, run_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/runs/{run_id}/complete",
|
||||||
|
response_model=OperationalTaskDetail,
|
||||||
|
)
|
||||||
|
async def complete_operational_task_run(
|
||||||
|
task_id: UUID,
|
||||||
|
run_id: UUID,
|
||||||
|
command: AgentRunCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).complete_run(task_id, run_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/runs/{run_id}/fail",
|
||||||
|
response_model=OperationalTaskDetail,
|
||||||
|
)
|
||||||
|
async def fail_operational_task_run(
|
||||||
|
task_id: UUID,
|
||||||
|
run_id: UUID,
|
||||||
|
command: AgentRunFailureCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).fail_run(task_id, run_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/runs/{run_id}/cancel",
|
||||||
|
response_model=OperationalTaskDetail,
|
||||||
|
)
|
||||||
|
async def cancel_operational_task_run(
|
||||||
|
task_id: UUID,
|
||||||
|
run_id: UUID,
|
||||||
|
command: AgentRunCancelCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).cancel_run(task_id, run_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/runs/{run_id}/dispatch-failure",
|
||||||
|
response_model=OperationalTaskRun,
|
||||||
|
)
|
||||||
|
async def record_operational_task_run_dispatch_failure(
|
||||||
|
task_id: UUID,
|
||||||
|
run_id: UUID,
|
||||||
|
command: AgentRunDispatchFailureCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).record_dispatch_failure(task_id, run_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/operational-tasks/{task_id}/agent-context", response_model=AgentTaskContext
|
||||||
|
)
|
||||||
|
async def get_operational_task_agent_context(
|
||||||
|
task_id: UUID,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).agent_context(task_id)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/agent-artifacts",
|
||||||
|
response_model=AgentArtifactsResult,
|
||||||
|
)
|
||||||
|
async def publish_operational_task_artifacts(
|
||||||
|
task_id: UUID,
|
||||||
|
command: AgentArtifactsCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).publish_artifacts(task_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult
|
||||||
|
)
|
||||||
|
async def query_operational_task_data(
|
||||||
|
task_id: UUID,
|
||||||
|
query: TaskDataQuery,
|
||||||
|
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
service = _service(ctx, conn)
|
||||||
|
try:
|
||||||
|
await service.detail(task_id)
|
||||||
|
snapshot = await read_task_data(
|
||||||
|
context=ctx, query=query, credential=authorization
|
||||||
|
)
|
||||||
|
evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot)
|
||||||
|
return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot)
|
||||||
|
except TaskDataError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/human-evidence",
|
||||||
|
response_model=OperationalTaskDetail,
|
||||||
|
)
|
||||||
|
async def add_operational_task_human_evidence(
|
||||||
|
task_id: UUID,
|
||||||
|
command: HumanEvidenceCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).add_human_evidence(task_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/operational-tasks/{task_id}/evidence/{evidence_id}/snapshot",
|
||||||
|
response_model=TaskDataSnapshot,
|
||||||
|
)
|
||||||
|
async def get_operational_task_data_snapshot(
|
||||||
|
task_id: UUID,
|
||||||
|
evidence_id: str,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).data_snapshot(task_id, evidence_id)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/actions",
|
||||||
|
response_model=HumanApprovalAction,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def propose_operational_task_action(
|
||||||
|
task_id: UUID,
|
||||||
|
command: ActionProposalCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).propose_action(task_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/actions/{action_id}/decision",
|
||||||
|
response_model=HumanApprovalAction,
|
||||||
|
)
|
||||||
|
async def decide_operational_task_action(
|
||||||
|
task_id: UUID,
|
||||||
|
action_id: UUID,
|
||||||
|
command: ActionDecisionCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).decide_action(task_id, action_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/actions/{action_id}/execute",
|
||||||
|
response_model=HumanApprovalAction,
|
||||||
|
)
|
||||||
|
async def execute_operational_task_action(
|
||||||
|
task_id: UUID,
|
||||||
|
action_id: UUID,
|
||||||
|
command: ActionExecuteCommand,
|
||||||
|
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).submit_action(
|
||||||
|
task_id, action_id, command, dispatcher, authorization
|
||||||
|
)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/actions/{action_id}/reconcile",
|
||||||
|
response_model=HumanApprovalAction,
|
||||||
|
)
|
||||||
|
async def reconcile_operational_task_action(
|
||||||
|
task_id: UUID,
|
||||||
|
action_id: UUID,
|
||||||
|
command: ActionReconcileCommand,
|
||||||
|
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).reconcile_action(
|
||||||
|
task_id, action_id, command, dispatcher, authorization
|
||||||
|
)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/operational-tasks/{task_id}/external-feedback",
|
||||||
|
response_model=ExternalFeedbackResult,
|
||||||
|
)
|
||||||
|
async def synchronize_operational_task_feedback(
|
||||||
|
task_id: UUID,
|
||||||
|
command: ExternalFeedbackCommand,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).external_feedback(task_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Authenticated workbench snapshot and resumable Server-Sent Events."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from psycopg.conninfo import conninfo_to_dict
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.workbench_runtime import workbench_runtime
|
||||||
|
from app.auth.permissions import OPERATIONAL_TASK_VIEW, permissions_for_context, require_permission
|
||||||
|
from app.auth.project_dependencies import (
|
||||||
|
ProjectContext,
|
||||||
|
get_metadata_repository,
|
||||||
|
get_project_business_routing,
|
||||||
|
get_project_pg_connection,
|
||||||
|
)
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.domain.schemas.workbench_events import WorkbenchEvent, WorkbenchSyncSnapshot
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
from app.infra.db.project_routing import ActiveProjectRouting
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService:
|
||||||
|
return OperationalTaskService(
|
||||||
|
OperationalTaskRepository(conn, ctx.project_id),
|
||||||
|
ctx,
|
||||||
|
permissions_for_context(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workbench/sync-snapshot", response_model=WorkbenchSyncSnapshot)
|
||||||
|
async def workbench_sync_snapshot(
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
async with conn.transaction(isolation_level="repeatable read", read_only=True):
|
||||||
|
cursor = await WorkbenchEventRepository(conn, ctx.project_id).current_cursor()
|
||||||
|
runtime = await workbench_runtime(ctx=ctx, conn=conn)
|
||||||
|
tasks, _ = await _service(ctx, conn).list(
|
||||||
|
task_type=None, status=None, limit=50, offset=0
|
||||||
|
)
|
||||||
|
notifications, _ = await _service(ctx, conn).list_notifications(
|
||||||
|
unread_only=False, limit=100, offset=0
|
||||||
|
)
|
||||||
|
return WorkbenchSyncSnapshot(
|
||||||
|
schema="workbench-sync-snapshot@1",
|
||||||
|
event_cursor=cursor,
|
||||||
|
runtime=runtime,
|
||||||
|
tasks=tasks,
|
||||||
|
notifications=notifications,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workbench/events")
|
||||||
|
async def workbench_events(
|
||||||
|
after: int | None = Query(default=None, ge=0),
|
||||||
|
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
routing: ActiveProjectRouting = Depends(get_project_business_routing),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
):
|
||||||
|
if not settings.WORKBENCH_EVENTS_ENABLED:
|
||||||
|
raise HTTPException(status_code=503, detail="Workbench events are disabled")
|
||||||
|
cursor = after
|
||||||
|
if cursor is None and last_event_id:
|
||||||
|
try:
|
||||||
|
cursor = int(last_event_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid Last-Event-ID") from exc
|
||||||
|
cursor = cursor or 0
|
||||||
|
current_identity = _database_identity(routing.business_dsn)
|
||||||
|
try:
|
||||||
|
active_routings = await metadata_repo.list_active_project_db_routings("biz_data")
|
||||||
|
finally:
|
||||||
|
# FastAPI keeps yield dependencies alive for a StreamingResponse. The
|
||||||
|
# stream only needs immutable routing values, so release the shared
|
||||||
|
# metadata checkout before the five-minute SSE body begins.
|
||||||
|
await metadata_repo.session.close()
|
||||||
|
if any(
|
||||||
|
item.project_id != ctx.project_id
|
||||||
|
and _database_identity(item.dsn) == current_identity
|
||||||
|
for item in active_routings
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Workbench events require one dedicated business database per project",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream():
|
||||||
|
started = asyncio.get_running_loop().time()
|
||||||
|
last_keepalive = started
|
||||||
|
async with workbench_event_hubs.subscribe(ctx.project_id, routing.business_dsn) as hub:
|
||||||
|
oldest = await hub.oldest_cursor()
|
||||||
|
latest = await hub.current_cursor()
|
||||||
|
if cursor > 0 and (
|
||||||
|
oldest is None or cursor < oldest - 1 or cursor > latest
|
||||||
|
):
|
||||||
|
payload = {"schema": "workbench-event@1", "cursor": cursor}
|
||||||
|
yield f"event: reset_required\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n"
|
||||||
|
return
|
||||||
|
current = cursor
|
||||||
|
wake_generation = hub.generation
|
||||||
|
while asyncio.get_running_loop().time() - started < 300:
|
||||||
|
rows = await hub.read_after(current)
|
||||||
|
for row in rows:
|
||||||
|
event = WorkbenchEvent(
|
||||||
|
schema="workbench-event@1",
|
||||||
|
**row,
|
||||||
|
)
|
||||||
|
current = event.event_seq
|
||||||
|
data = event.model_dump_json(by_alias=True)
|
||||||
|
yield f"id: {current}\nevent: workbench_event\ndata: {data}\n\n"
|
||||||
|
if rows:
|
||||||
|
continue
|
||||||
|
now = asyncio.get_running_loop().time()
|
||||||
|
if now - last_keepalive >= 15:
|
||||||
|
yield f": keepalive {int(now)}\n\n"
|
||||||
|
last_keepalive = now
|
||||||
|
# LISTEN is only a wake-up path. The timeout also reconciles
|
||||||
|
# against the durable cursor if a notification was missed.
|
||||||
|
wake_generation = await hub.wait_for_wake(wake_generation, timeout=1)
|
||||||
|
yield "event: reconnect\ndata: {}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Content-Encoding": "identity",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _database_identity(dsn: str) -> tuple[str, str, str]:
|
||||||
|
values = conninfo_to_dict(dsn)
|
||||||
|
return (
|
||||||
|
values.get("host") or values.get("hostaddr") or "localhost",
|
||||||
|
values.get("port") or "5432",
|
||||||
|
values.get("dbname") or values.get("user") or "",
|
||||||
|
)
|
||||||
@@ -11,6 +11,7 @@ from app.api.v1.endpoints import (
|
|||||||
leakage,
|
leakage,
|
||||||
meta,
|
meta,
|
||||||
model_import,
|
model_import,
|
||||||
|
operational_tasks,
|
||||||
project,
|
project,
|
||||||
project_data,
|
project_data,
|
||||||
scada,
|
scada,
|
||||||
@@ -18,6 +19,7 @@ from app.api.v1.endpoints import (
|
|||||||
simulation,
|
simulation,
|
||||||
web_search,
|
web_search,
|
||||||
workbench_runtime,
|
workbench_runtime,
|
||||||
|
workbench_events,
|
||||||
)
|
)
|
||||||
from app.api.v1.endpoints.components import (
|
from app.api.v1.endpoints.components import (
|
||||||
controls,
|
controls,
|
||||||
@@ -91,6 +93,14 @@ api_router.include_router(
|
|||||||
tags=["Workbench Runtime"],
|
tags=["Workbench Runtime"],
|
||||||
dependencies=[simulation_access],
|
dependencies=[simulation_access],
|
||||||
)
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
workbench_events.router,
|
||||||
|
tags=["Workbench Events"],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
operational_tasks.router,
|
||||||
|
tags=["Operational Tasks"],
|
||||||
|
)
|
||||||
api_router.include_router(access.router, tags=["Access Control"])
|
api_router.include_router(access.router, tags=["Access Control"])
|
||||||
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ MODEL_IMPORT = "model.import"
|
|||||||
AUDIT_VIEW = "audit.view"
|
AUDIT_VIEW = "audit.view"
|
||||||
ENVIRONMENT_MANAGE = "environment.manage"
|
ENVIRONMENT_MANAGE = "environment.manage"
|
||||||
MEMBERSHIP_MANAGE = "membership.manage"
|
MEMBERSHIP_MANAGE = "membership.manage"
|
||||||
|
OPERATIONAL_TASK_VIEW = "operational_task.view"
|
||||||
|
OPERATIONAL_TASK_WRITE = "operational_task.write"
|
||||||
|
OPERATIONAL_TASK_EXECUTE = "operational_task.execute"
|
||||||
|
OPERATIONAL_TASK_EXTERNAL_SYNC = "operational_task.external_sync"
|
||||||
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE = "operational_action.work_order.approve"
|
||||||
|
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE = "operational_action.work_order.execute"
|
||||||
|
OPERATIONAL_ACTION_COMMAND_APPROVE = "operational_action.command.approve"
|
||||||
|
OPERATIONAL_ACTION_COMMAND_EXECUTE = "operational_action.command.execute"
|
||||||
|
|
||||||
PROJECT_MEMBER_PERMISSIONS = frozenset(
|
PROJECT_MEMBER_PERMISSIONS = frozenset(
|
||||||
{
|
{
|
||||||
@@ -36,6 +44,10 @@ PROJECT_MEMBER_PERMISSIONS = frozenset(
|
|||||||
RISK_RUN,
|
RISK_RUN,
|
||||||
OPTIMIZATION_VIEW,
|
OPTIMIZATION_VIEW,
|
||||||
OPTIMIZATION_RUN,
|
OPTIMIZATION_RUN,
|
||||||
|
OPERATIONAL_TASK_VIEW,
|
||||||
|
OPERATIONAL_TASK_WRITE,
|
||||||
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
||||||
|
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +56,7 @@ PROJECT_VIEWER_PERMISSIONS = frozenset(
|
|||||||
WEBGIS_VIEW,
|
WEBGIS_VIEW,
|
||||||
SCADA_VIEW,
|
SCADA_VIEW,
|
||||||
SIMULATION_VIEW,
|
SIMULATION_VIEW,
|
||||||
|
OPERATIONAL_TASK_VIEW,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +66,15 @@ SYSTEM_ADMIN_PERMISSIONS = frozenset(
|
|||||||
AUDIT_VIEW,
|
AUDIT_VIEW,
|
||||||
ENVIRONMENT_MANAGE,
|
ENVIRONMENT_MANAGE,
|
||||||
MEMBERSHIP_MANAGE,
|
MEMBERSHIP_MANAGE,
|
||||||
|
OPERATIONAL_TASK_EXECUTE,
|
||||||
|
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SYSTEM_SERVICE_PERMISSIONS = frozenset(
|
||||||
|
{
|
||||||
|
OPERATIONAL_TASK_EXECUTE,
|
||||||
|
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +93,15 @@ def resolve_permissions(
|
|||||||
permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset()))
|
permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset()))
|
||||||
if is_superuser or system_role == "admin":
|
if is_superuser or system_role == "admin":
|
||||||
permissions.update(SYSTEM_ADMIN_PERMISSIONS)
|
permissions.update(SYSTEM_ADMIN_PERMISSIONS)
|
||||||
|
if project_role == "member":
|
||||||
|
permissions.update(
|
||||||
|
{
|
||||||
|
OPERATIONAL_ACTION_COMMAND_APPROVE,
|
||||||
|
OPERATIONAL_ACTION_COMMAND_EXECUTE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif system_role == "service" and project_role == "member":
|
||||||
|
permissions.update(SYSTEM_SERVICE_PERMISSIONS)
|
||||||
return frozenset(permissions)
|
return frozenset(permissions)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ async def resolve_project_business_routing(
|
|||||||
return ActiveProjectRouting(
|
return ActiveProjectRouting(
|
||||||
project_code=ctx.project_code,
|
project_code=ctx.project_code,
|
||||||
business_dsn=business.dsn,
|
business_dsn=business.dsn,
|
||||||
|
project_id=ctx.project_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +162,7 @@ async def resolve_project_simulation_routing(
|
|||||||
project_code=ctx.project_code,
|
project_code=ctx.project_code,
|
||||||
business_dsn=business.dsn,
|
business_dsn=business.dsn,
|
||||||
timescale_dsn=timescale.dsn,
|
timescale_dsn=timescale.dsn,
|
||||||
|
project_id=ctx.project_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,21 @@ class Settings(BaseSettings):
|
|||||||
KEYCLOAK_AUDIENCE: str = ""
|
KEYCLOAK_AUDIENCE: str = ""
|
||||||
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900
|
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900
|
||||||
|
|
||||||
|
# Authenticated workbench synchronization.
|
||||||
|
WORKBENCH_EVENTS_ENABLED: bool = True
|
||||||
|
WORKBENCH_EVENT_RETENTION_DAYS: int = 7
|
||||||
|
CORS_ALLOWED_ORIGINS: str = (
|
||||||
|
"http://127.0.0.1:5173,http://localhost:5173,"
|
||||||
|
"http://127.0.0.1:15173,http://localhost:15173"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_allowed_origins(self) -> list[str]:
|
||||||
|
origins = [value.strip() for value in self.CORS_ALLOWED_ORIGINS.split(",") if value.strip()]
|
||||||
|
if "*" in origins:
|
||||||
|
raise ValueError("CORS_ALLOWED_ORIGINS must list explicit origins")
|
||||||
|
return origins
|
||||||
|
|
||||||
# Bocha Web Search API
|
# Bocha Web Search API
|
||||||
BOCHA_API_KEY: str = ""
|
BOCHA_API_KEY: str = ""
|
||||||
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
BusinessRole = Literal["admin", "user"]
|
BusinessRole = Literal["admin", "service", "user"]
|
||||||
ProjectRole = Literal["member", "viewer"]
|
ProjectRole = Literal["member", "viewer"]
|
||||||
ProjectStatus = Literal["active", "inactive", "archived"]
|
ProjectStatus = Literal["active", "inactive", "archived"]
|
||||||
ProjectDbRole = Literal["biz_data", "iot_data"]
|
ProjectDbRole = Literal["biz_data", "iot_data"]
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Finite read scopes available to operational investigations."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field, JsonValue, model_validator
|
||||||
|
|
||||||
|
from app.domain.schemas.shared import NonEmptyStr, StrictModel
|
||||||
|
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
TaskDataSource = Literal[
|
||||||
|
"workbench_runtime", "scada_devices", "scada_readings", "peripheral_points",
|
||||||
|
"peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDataQuery(StrictModel):
|
||||||
|
source: TaskDataSource
|
||||||
|
device_ids: list[NonEmptyStr] = Field(default_factory=list, max_length=20)
|
||||||
|
point_id: NonEmptyStr | None = None
|
||||||
|
start: WorkbenchDateTime | None = None
|
||||||
|
end: WorkbenchDateTime | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_window(self):
|
||||||
|
if self.source in {"scada_readings", "peripheral_observations"}:
|
||||||
|
if self.start is None or self.end is None:
|
||||||
|
raise ValueError("time series queries require start and end")
|
||||||
|
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
|
||||||
|
raise ValueError("query window must be positive and at most 24 hours")
|
||||||
|
if self.source == "scada_readings" and not self.device_ids:
|
||||||
|
raise ValueError("SCADA queries require device_ids")
|
||||||
|
if self.source == "peripheral_observations" and not self.point_id:
|
||||||
|
raise ValueError("peripheral queries require point_id")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDataSnapshot(StrictModel):
|
||||||
|
source: TaskDataSource
|
||||||
|
source_ref: NonEmptyStr
|
||||||
|
queried_at: WorkbenchDateTime
|
||||||
|
origin: Literal["system_data", "synthetic"]
|
||||||
|
data: JsonValue
|
||||||
|
truncated: bool
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
"""Contracts for project-scoped operational tasks and human-approved actions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import Field, JsonValue, model_validator
|
||||||
|
|
||||||
|
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||||
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||||
|
from app.domain.schemas.workbench_runtime import (
|
||||||
|
WorkbenchDateTime,
|
||||||
|
WorkbenchRuntimeWorkOrderSubtask,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TaskType = Literal["scheduled_flow", "external_work_order", "event_investigation"]
|
||||||
|
TaskStatus = Literal[
|
||||||
|
"queued", "running", "waiting_human", "completed", "failed", "cancelled"
|
||||||
|
]
|
||||||
|
TaskLifecycleStatus = Literal[
|
||||||
|
"open", "waiting_human", "completed", "failed", "cancelled"
|
||||||
|
]
|
||||||
|
OperationalTaskRunStatus = Literal[
|
||||||
|
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
|
||||||
|
]
|
||||||
|
TaskPriority = Literal["normal", "urgent"]
|
||||||
|
TaskSourceKind = Literal["schedule", "external_system", "operator", "agent"]
|
||||||
|
TriggerKind = Literal["schedule", "external_sync", "event", "manual"]
|
||||||
|
EvidenceKind = Literal["system_data", "external_feedback", "human_input", "synthetic"]
|
||||||
|
PlanNodeStatus = Literal[
|
||||||
|
"pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"
|
||||||
|
]
|
||||||
|
ActionKind = Literal["create_work_order", "issue_command"]
|
||||||
|
ApprovalStatus = Literal["pending", "approved", "rejected"]
|
||||||
|
ExecutionStatus = Literal[
|
||||||
|
"not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSource(StrictModel):
|
||||||
|
kind: TaskSourceKind
|
||||||
|
external_id: NonEmptyStr | None = None
|
||||||
|
external_revision: PositiveInt | None = None
|
||||||
|
event_kind: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskTrigger(StrictModel):
|
||||||
|
kind: TriggerKind
|
||||||
|
occurred_at: WorkbenchDateTime
|
||||||
|
detail: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskCreate(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
task_type: TaskType
|
||||||
|
workflow: NonEmptyStr
|
||||||
|
title: NonEmptyStr
|
||||||
|
summary: NonEmptyStr
|
||||||
|
priority: TaskPriority = "normal"
|
||||||
|
source: TaskSource
|
||||||
|
trigger: TaskTrigger
|
||||||
|
scheduled_for: WorkbenchDateTime | None = None
|
||||||
|
agent_session_id: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanNode(StrictModel):
|
||||||
|
id: NonEmptyStr
|
||||||
|
title: NonEmptyStr
|
||||||
|
owner: Literal["agent", "human"]
|
||||||
|
status: PlanNodeStatus
|
||||||
|
detail: NonEmptyStr
|
||||||
|
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
|
action_refs: list[UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanEdge(StrictModel):
|
||||||
|
from_node: NonEmptyStr = Field(alias="from")
|
||||||
|
to_node: NonEmptyStr = Field(alias="to")
|
||||||
|
condition: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanInput(StrictModel):
|
||||||
|
schema_name: Literal["execution-plan@1"] = Field(alias="schema")
|
||||||
|
title: NonEmptyStr
|
||||||
|
reason: NonEmptyStr
|
||||||
|
nodes: Annotated[list[ExecutionPlanNode], Field(min_length=1, max_length=100)]
|
||||||
|
edges: Annotated[list[ExecutionPlanEdge], Field(max_length=300)] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_graph(self):
|
||||||
|
node_ids = [node.id for node in self.nodes]
|
||||||
|
if len(set(node_ids)) != len(node_ids):
|
||||||
|
raise ValueError("execution plan node ids must be unique")
|
||||||
|
known = set(node_ids)
|
||||||
|
graph = {node_id: [] for node_id in node_ids}
|
||||||
|
indegree = {node_id: 0 for node_id in node_ids}
|
||||||
|
seen_edges: set[tuple[str, str]] = set()
|
||||||
|
for edge in self.edges:
|
||||||
|
pair = (edge.from_node, edge.to_node)
|
||||||
|
if edge.from_node not in known or edge.to_node not in known:
|
||||||
|
raise ValueError("execution plan edges must reference known nodes")
|
||||||
|
if edge.from_node == edge.to_node or pair in seen_edges:
|
||||||
|
raise ValueError("execution plan edges must be unique and non-reflexive")
|
||||||
|
seen_edges.add(pair)
|
||||||
|
graph[edge.from_node].append(edge.to_node)
|
||||||
|
indegree[edge.to_node] += 1
|
||||||
|
pending = [node_id for node_id, value in indegree.items() if value == 0]
|
||||||
|
visited = 0
|
||||||
|
while pending:
|
||||||
|
current = pending.pop()
|
||||||
|
visited += 1
|
||||||
|
for target in graph[current]:
|
||||||
|
indegree[target] -= 1
|
||||||
|
if indegree[target] == 0:
|
||||||
|
pending.append(target)
|
||||||
|
if visited != len(node_ids):
|
||||||
|
raise ValueError("execution plan must be acyclic")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceInput(StrictModel):
|
||||||
|
evidence_id: NonEmptyStr
|
||||||
|
kind: EvidenceKind
|
||||||
|
summary: NonEmptyStr
|
||||||
|
source_label: NonEmptyStr
|
||||||
|
source_ref: NonEmptyStr | None = None
|
||||||
|
observed_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceRecord(EvidenceInput):
|
||||||
|
recorded_at: WorkbenchDateTime
|
||||||
|
recorded_by: NonEmptyStr
|
||||||
|
has_snapshot: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class StoredEvidenceRecord(EvidenceRecord):
|
||||||
|
data_snapshot: TaskDataSnapshot | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationReportInput(StrictModel):
|
||||||
|
title: NonEmptyStr
|
||||||
|
current_conclusion: NonEmptyStr
|
||||||
|
conclusion_boundary: NonEmptyStr
|
||||||
|
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
|
recommendations: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
|
pending_items: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanSnapshot(StrictModel):
|
||||||
|
revision: PositiveInt
|
||||||
|
execution_generation: PositiveInt | None = None
|
||||||
|
source_session_id: NonEmptyStr
|
||||||
|
published_at: WorkbenchDateTime
|
||||||
|
published_by: NonEmptyStr
|
||||||
|
plan: ExecutionPlanInput
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationReportRevision(StrictModel):
|
||||||
|
revision: PositiveInt
|
||||||
|
execution_generation: PositiveInt | None = None
|
||||||
|
source_session_id: NonEmptyStr
|
||||||
|
published_at: WorkbenchDateTime
|
||||||
|
published_by: NonEmptyStr
|
||||||
|
report: InvestigationReportInput
|
||||||
|
|
||||||
|
|
||||||
|
class AgentArtifactsCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
source_session_id: NonEmptyStr
|
||||||
|
run_id: UUID | None = None
|
||||||
|
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
|
||||||
|
plan: ExecutionPlanInput | None = None
|
||||||
|
report: InvestigationReportInput | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_artifact(self):
|
||||||
|
if not self.evidence and self.plan is None and self.report is None:
|
||||||
|
raise ValueError("at least one artifact must be supplied")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWorkOrderPayload(StrictModel):
|
||||||
|
title: NonEmptyStr
|
||||||
|
description: NonEmptyStr
|
||||||
|
work_order_type: NonEmptyStr
|
||||||
|
priority: TaskPriority = "normal"
|
||||||
|
scheduled_at: WorkbenchDateTime | None = None
|
||||||
|
location: NonEmptyStr | None = None
|
||||||
|
assignee: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommandTarget(StrictModel):
|
||||||
|
kind: NonEmptyStr
|
||||||
|
id: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class IssueCommandPayload(StrictModel):
|
||||||
|
command_type: NonEmptyStr
|
||||||
|
target: CommandTarget
|
||||||
|
parameters: dict[str, JsonValue]
|
||||||
|
impact_summary: NonEmptyStr
|
||||||
|
preconditions: list[NonEmptyStr] = Field(min_length=1)
|
||||||
|
expires_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
ActionPayload = CreateWorkOrderPayload | IssueCommandPayload
|
||||||
|
|
||||||
|
|
||||||
|
class ActionProposalCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
kind: ActionKind
|
||||||
|
reason: NonEmptyStr
|
||||||
|
payload: ActionPayload
|
||||||
|
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
|
node_id: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_payload_kind(self):
|
||||||
|
expected = CreateWorkOrderPayload if self.kind == "create_work_order" else IssueCommandPayload
|
||||||
|
if not isinstance(self.payload, expected):
|
||||||
|
raise ValueError("action payload does not match its kind")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ActionDecisionCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_version: PositiveInt
|
||||||
|
decision: Literal["approved", "rejected"]
|
||||||
|
comment: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActionExecuteCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_version: PositiveInt
|
||||||
|
|
||||||
|
|
||||||
|
class ActionReconcileCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_version: PositiveInt
|
||||||
|
|
||||||
|
|
||||||
|
class ActionReceipt(StrictModel):
|
||||||
|
adapter: NonEmptyStr
|
||||||
|
receipt_id: NonEmptyStr
|
||||||
|
submitted_at: WorkbenchDateTime
|
||||||
|
completed_at: WorkbenchDateTime | None = None
|
||||||
|
external_status: NonEmptyStr
|
||||||
|
detail: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class HumanApprovalAction(StrictModel):
|
||||||
|
action_id: UUID
|
||||||
|
task_id: UUID
|
||||||
|
version: PositiveInt
|
||||||
|
kind: ActionKind
|
||||||
|
reason: NonEmptyStr
|
||||||
|
payload: ActionPayload
|
||||||
|
evidence_refs: list[NonEmptyStr]
|
||||||
|
node_id: NonEmptyStr | None = None
|
||||||
|
required_approval_permission: NonEmptyStr
|
||||||
|
required_execution_permission: NonEmptyStr
|
||||||
|
approval_status: ApprovalStatus
|
||||||
|
execution_status: ExecutionStatus
|
||||||
|
execution_request_id: UUID | None = None
|
||||||
|
proposed_by: NonEmptyStr
|
||||||
|
proposed_at: WorkbenchDateTime
|
||||||
|
decided_by: NonEmptyStr | None = None
|
||||||
|
decided_at: WorkbenchDateTime | None = None
|
||||||
|
decision_comment: NonEmptyStr | None = None
|
||||||
|
executed_by: NonEmptyStr | None = None
|
||||||
|
executed_at: WorkbenchDateTime | None = None
|
||||||
|
receipt: ActionReceipt | None = None
|
||||||
|
execution_detail: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalWorkOrderSnapshot(StrictModel):
|
||||||
|
work_order_id: UUID
|
||||||
|
revision: PositiveInt
|
||||||
|
title: NonEmptyStr
|
||||||
|
summary: NonEmptyStr
|
||||||
|
status: Literal[
|
||||||
|
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
||||||
|
]
|
||||||
|
priority: Literal["low", "normal", "high", "urgent"]
|
||||||
|
assignee: NonEmptyStr | None = None
|
||||||
|
scheduled_at: WorkbenchDateTime | None = None
|
||||||
|
work_order_type: Literal["general", "water_quality_complaint", "burst_report"] = "general"
|
||||||
|
location: NonEmptyStr | None = None
|
||||||
|
scenario_code: NonEmptyStr | None = None
|
||||||
|
subtasks: Annotated[
|
||||||
|
list[WorkbenchRuntimeWorkOrderSubtask], Field(min_length=1, max_length=50)
|
||||||
|
] | None = None
|
||||||
|
origin: Literal["synthetic"]
|
||||||
|
updated_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def unique_subtasks(self):
|
||||||
|
if self.subtasks is not None and len(
|
||||||
|
{item.id for item in self.subtasks}
|
||||||
|
) != len(self.subtasks):
|
||||||
|
raise ValueError("work order subtask ids must be unique")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskRun(StrictModel):
|
||||||
|
run_id: UUID
|
||||||
|
task_id: UUID
|
||||||
|
generation: PositiveInt
|
||||||
|
execution_request_id: UUID
|
||||||
|
status: OperationalTaskRunStatus
|
||||||
|
revision: PositiveInt
|
||||||
|
agent_session_id: NonEmptyStr | None = None
|
||||||
|
lease_expires_at: WorkbenchDateTime | None = None
|
||||||
|
heartbeat_at: WorkbenchDateTime | None = None
|
||||||
|
started_at: WorkbenchDateTime | None = None
|
||||||
|
finished_at: WorkbenchDateTime | None = None
|
||||||
|
failure_code: NonEmptyStr | None = None
|
||||||
|
failure_detail: NonEmptyStr | None = None
|
||||||
|
dispatch_attempts: int = Field(default=0, ge=0)
|
||||||
|
next_dispatch_at: WorkbenchDateTime
|
||||||
|
created_at: WorkbenchDateTime
|
||||||
|
updated_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskSummary(StrictModel):
|
||||||
|
project_id: UUID
|
||||||
|
task_id: UUID
|
||||||
|
task_type: TaskType
|
||||||
|
workflow: NonEmptyStr
|
||||||
|
lifecycle_status: TaskLifecycleStatus = "open"
|
||||||
|
active_run: OperationalTaskRun | None = None
|
||||||
|
# Compatibility fields for the staged 014 -> 015 rollout. New consumers
|
||||||
|
# must use lifecycle_status and active_run.
|
||||||
|
status: TaskStatus
|
||||||
|
priority: TaskPriority
|
||||||
|
revision: PositiveInt
|
||||||
|
execution_generation: PositiveInt
|
||||||
|
execution_request_id: UUID
|
||||||
|
title: NonEmptyStr
|
||||||
|
summary: NonEmptyStr
|
||||||
|
source: TaskSource
|
||||||
|
trigger: TaskTrigger
|
||||||
|
scheduled_for: WorkbenchDateTime | None
|
||||||
|
agent_session_id: NonEmptyStr | None
|
||||||
|
agent_lease_expires_at: WorkbenchDateTime | None
|
||||||
|
current_plan_revision: PositiveInt | None
|
||||||
|
current_report_revision: PositiveInt | None
|
||||||
|
created_at: WorkbenchDateTime
|
||||||
|
updated_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskPermissions(StrictModel):
|
||||||
|
can_publish_artifacts: bool
|
||||||
|
can_propose_actions: bool
|
||||||
|
can_sync_external_feedback: bool
|
||||||
|
can_decide_work_orders: bool
|
||||||
|
can_execute_work_orders: bool
|
||||||
|
can_decide_commands: bool
|
||||||
|
can_execute_commands: bool
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskDetail(OperationalTaskSummary):
|
||||||
|
latest_plan: ExecutionPlanSnapshot | None
|
||||||
|
latest_report: InvestigationReportRevision | None
|
||||||
|
evidence: list[EvidenceRecord]
|
||||||
|
actions: list[HumanApprovalAction]
|
||||||
|
permissions: OperationalTaskPermissions
|
||||||
|
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskPage(StrictModel):
|
||||||
|
items: list[OperationalTaskSummary]
|
||||||
|
limit: PositiveInt
|
||||||
|
offset: int = Field(ge=0)
|
||||||
|
next_offset: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskRunPage(StrictModel):
|
||||||
|
items: list[OperationalTaskRun]
|
||||||
|
next_cursor: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTaskContext(StrictModel):
|
||||||
|
task: OperationalTaskDetail
|
||||||
|
approved_data_scopes: list[NonEmptyStr]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_revision: PositiveInt
|
||||||
|
source_session_id: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunFailureCommand(AgentRunCommand):
|
||||||
|
failure_code: NonEmptyStr
|
||||||
|
failure_detail: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunCancelCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_revision: PositiveInt
|
||||||
|
reason: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunDispatchFailureCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_revision: PositiveInt
|
||||||
|
failure_code: NonEmptyStr
|
||||||
|
failure_detail: NonEmptyStr
|
||||||
|
retryable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskRetryCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
reason: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class RecoverExpiredLeasesCommand(StrictModel):
|
||||||
|
limit: int = Field(default=20, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class RecoverExpiredLeasesResult(StrictModel):
|
||||||
|
task_ids: list[UUID]
|
||||||
|
recovered_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class AgentArtifactsResult(StrictModel):
|
||||||
|
task: OperationalTaskDetail
|
||||||
|
plan_revision: PositiveInt | None
|
||||||
|
report_revision: PositiveInt | None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDataQueryResult(StrictModel):
|
||||||
|
evidence: EvidenceRecord
|
||||||
|
snapshot: TaskDataSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalFeedbackEvidence(StrictModel):
|
||||||
|
evidence_id: NonEmptyStr
|
||||||
|
kind: Literal["external_feedback", "synthetic"] = "external_feedback"
|
||||||
|
summary: NonEmptyStr
|
||||||
|
source_label: NonEmptyStr
|
||||||
|
source_ref: NonEmptyStr | None = None
|
||||||
|
observed_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalFeedbackCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
source_event_id: NonEmptyStr
|
||||||
|
source_revision: PositiveInt
|
||||||
|
status: Literal[
|
||||||
|
"scheduled", "assigned", "in_progress", "awaiting_feedback",
|
||||||
|
"completed", "cancelled", "failed"
|
||||||
|
]
|
||||||
|
occurred_at: WorkbenchDateTime
|
||||||
|
detail: NonEmptyStr
|
||||||
|
evidence: ExternalFeedbackEvidence | None = None
|
||||||
|
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def snapshot_matches_feedback_revision(self):
|
||||||
|
if self.work_order_snapshot is not None:
|
||||||
|
work_order_id = str(self.work_order_snapshot.work_order_id)
|
||||||
|
if self.source_event_id not in {
|
||||||
|
work_order_id, f"peripheral_sample:{work_order_id}"
|
||||||
|
}:
|
||||||
|
raise ValueError("work_order_snapshot id must match source_event_id")
|
||||||
|
if self.work_order_snapshot.revision != self.source_revision:
|
||||||
|
raise ValueError("work_order_snapshot revision must match source_revision")
|
||||||
|
expected_status = {
|
||||||
|
"draft": "scheduled",
|
||||||
|
"open": "assigned",
|
||||||
|
"in_progress": "in_progress",
|
||||||
|
"awaiting_reply": "awaiting_feedback",
|
||||||
|
"completed": "completed",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
}[self.work_order_snapshot.status]
|
||||||
|
if self.status != expected_status:
|
||||||
|
raise ValueError("work_order_snapshot status must match feedback status")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalFeedbackResult(StrictModel):
|
||||||
|
task: OperationalTaskDetail
|
||||||
|
source_event_id: NonEmptyStr
|
||||||
|
source_revision: PositiveInt
|
||||||
|
accepted_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class HumanEvidenceCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
evidence: EvidenceInput
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskNotification(StrictModel):
|
||||||
|
notification_id: UUID
|
||||||
|
task_id: UUID
|
||||||
|
kind: Literal[
|
||||||
|
"task_created", "action_pending", "report_updated", "task_completed",
|
||||||
|
"task_failed", "run_delivery_degraded"
|
||||||
|
]
|
||||||
|
title: NonEmptyStr
|
||||||
|
description: NonEmptyStr
|
||||||
|
required_permission: NonEmptyStr | None = None
|
||||||
|
status: Literal["unread", "read"]
|
||||||
|
version: PositiveInt
|
||||||
|
created_at: WorkbenchDateTime
|
||||||
|
read_at: WorkbenchDateTime | None = None
|
||||||
|
read_by: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskNotificationPage(StrictModel):
|
||||||
|
items: list[OperationalTaskNotification]
|
||||||
|
limit: PositiveInt
|
||||||
|
offset: int = Field(ge=0)
|
||||||
|
next_offset: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationAcknowledgeCommand(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_version: PositiveInt
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskScheduleCreate(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
schedule_key: NonEmptyStr
|
||||||
|
title: NonEmptyStr
|
||||||
|
summary: NonEmptyStr
|
||||||
|
interval_seconds: int = Field(ge=60, le=31_536_000)
|
||||||
|
next_run_at: WorkbenchDateTime
|
||||||
|
priority: TaskPriority = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskSchedule(StrictModel):
|
||||||
|
schedule_id: UUID
|
||||||
|
schedule_key: NonEmptyStr
|
||||||
|
title: NonEmptyStr
|
||||||
|
summary: NonEmptyStr
|
||||||
|
interval_seconds: int = Field(ge=60, le=31_536_000)
|
||||||
|
next_run_at: WorkbenchDateTime
|
||||||
|
priority: TaskPriority
|
||||||
|
enabled: bool
|
||||||
|
version: PositiveInt
|
||||||
|
created_at: WorkbenchDateTime
|
||||||
|
updated_at: WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskSchedulePage(StrictModel):
|
||||||
|
items: list[OperationalTaskSchedule]
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskScheduleUpdate(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_version: PositiveInt
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchDueSchedulesCommand(StrictModel):
|
||||||
|
limit: int = Field(default=20, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchDueSchedulesResult(StrictModel):
|
||||||
|
task_ids: list[UUID]
|
||||||
|
dispatched_at: WorkbenchDateTime
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Stable workbench event-stream and synchronization contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import Field, JsonValue
|
||||||
|
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
OperationalTaskNotification,
|
||||||
|
OperationalTaskSummary,
|
||||||
|
)
|
||||||
|
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||||
|
from app.domain.schemas.workbench_runtime import WorkbenchDateTime, WorkbenchRuntimeSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
WorkbenchEventTopic = Literal[
|
||||||
|
"operational.task.changed",
|
||||||
|
"operational.run.queued",
|
||||||
|
"operational.run.changed",
|
||||||
|
"operational.report.published",
|
||||||
|
"operational.action.changed",
|
||||||
|
"operational.notification.changed",
|
||||||
|
"workbench.alert.changed",
|
||||||
|
"workbench.work_order.changed",
|
||||||
|
"analysis.run.changed",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkbenchEvent(StrictModel):
|
||||||
|
schema_name: Literal["workbench-event@1"] = Field(alias="schema")
|
||||||
|
event_seq: PositiveInt
|
||||||
|
event_id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
topic: WorkbenchEventTopic
|
||||||
|
aggregate_type: NonEmptyStr
|
||||||
|
aggregate_id: NonEmptyStr
|
||||||
|
aggregate_revision: PositiveInt
|
||||||
|
occurred_at: WorkbenchDateTime
|
||||||
|
metadata: dict[str, JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkbenchSyncSnapshot(StrictModel):
|
||||||
|
schema_name: Literal["workbench-sync-snapshot@1"] = Field(alias="schema")
|
||||||
|
event_cursor: int = Field(ge=0)
|
||||||
|
runtime: WorkbenchRuntimeSnapshot
|
||||||
|
tasks: list[OperationalTaskSummary]
|
||||||
|
notifications: list[OperationalTaskNotification]
|
||||||
|
generated_at: WorkbenchDateTime
|
||||||
@@ -525,6 +525,39 @@ class MetadataRepository:
|
|||||||
pool_max_size=record.pool_max_size,
|
pool_max_size=record.pool_max_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def list_active_project_db_routings(
|
||||||
|
self, db_role: str
|
||||||
|
) -> List[ProjectDbRouting]:
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(models.ProjectDatabase)
|
||||||
|
.join(models.Project, models.Project.id == models.ProjectDatabase.project_id)
|
||||||
|
.where(
|
||||||
|
models.ProjectDatabase.db_role == db_role,
|
||||||
|
models.Project.status == "active",
|
||||||
|
)
|
||||||
|
.order_by(models.ProjectDatabase.project_id)
|
||||||
|
)
|
||||||
|
if not is_database_encryption_configured():
|
||||||
|
raise ValueError("DATABASE_ENCRYPTION_KEY is not configured")
|
||||||
|
encryptor = get_database_encryptor()
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
ProjectDbRouting(
|
||||||
|
project_id=record.project_id,
|
||||||
|
db_role=record.db_role,
|
||||||
|
db_type=record.db_type,
|
||||||
|
dsn=_normalize_postgres_dsn(encryptor.decrypt(record.dsn_encrypted)),
|
||||||
|
pool_min_size=record.pool_min_size,
|
||||||
|
pool_max_size=record.pool_max_size,
|
||||||
|
)
|
||||||
|
for record in result.scalars().all()
|
||||||
|
]
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"Failed to decrypt project DB DSN: DATABASE_ENCRYPTION_KEY mismatch "
|
||||||
|
"or invalid dsn_encrypted value"
|
||||||
|
) from exc
|
||||||
|
|
||||||
async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]:
|
async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(models.Project, models.UserProjectMembership.project_role)
|
select(models.Project, models.UserProjectMembership.project_role)
|
||||||
|
|||||||
@@ -53,18 +53,52 @@ class AnalysisRepository:
|
|||||||
status: str,
|
status: str,
|
||||||
created_by: str,
|
created_by: str,
|
||||||
parameters: dict[str, Any],
|
parameters: dict[str, Any],
|
||||||
) -> None:
|
) -> int:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE analysis.runs
|
UPDATE analysis.runs
|
||||||
SET created_by = %s, status = %s, parameters = %s
|
SET created_by = %s, status = %s, parameters = %s,
|
||||||
|
revision = revision + 1
|
||||||
WHERE run_id = %s
|
WHERE run_id = %s
|
||||||
|
RETURNING revision
|
||||||
""",
|
""",
|
||||||
(created_by, status, Jsonb(parameters), run_id),
|
(created_by, status, Jsonb(parameters), run_id),
|
||||||
)
|
)
|
||||||
if cur.rowcount != 1:
|
row = cur.fetchone()
|
||||||
|
if row is None:
|
||||||
raise LookupError(f"analysis run {run_id} does not exist")
|
raise LookupError(f"analysis run {run_id} does not exist")
|
||||||
|
return int(row["revision"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def append_run_changed_event_sync(
|
||||||
|
conn: Connection,
|
||||||
|
*,
|
||||||
|
project_id: UUID,
|
||||||
|
run_id: UUID,
|
||||||
|
revision: int,
|
||||||
|
status: str,
|
||||||
|
occurred_at: datetime,
|
||||||
|
results_committed: bool,
|
||||||
|
) -> None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO workbench.domain_event_log
|
||||||
|
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||||
|
aggregate_revision,occurred_at,metadata)
|
||||||
|
VALUES (%s,%s,'analysis.run.changed','analysis_run',%s,%s,%s,%s)
|
||||||
|
RETURNING event_seq""",
|
||||||
|
(
|
||||||
|
project_id, uuid4(), str(run_id), revision, occurred_at,
|
||||||
|
Jsonb({"run_id": str(run_id), "status": status,
|
||||||
|
"results_committed": results_committed}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT pg_notify('workbench_events', %s)",
|
||||||
|
(f'{project_id}:{row["event_seq"]}',),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def insert_result_sync(
|
def insert_result_sync(
|
||||||
|
|||||||
@@ -0,0 +1,589 @@
|
|||||||
|
"""PostgreSQL persistence for durable operational tasks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from psycopg.errors import UndefinedTable
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalTaskRepository:
|
||||||
|
def __init__(self, connection: AsyncConnection, project_id):
|
||||||
|
self.connection = connection
|
||||||
|
self.project_id = str(project_id)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self):
|
||||||
|
try:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
yield self
|
||||||
|
except UndefinedTable as exc:
|
||||||
|
raise DomainError(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"Project operational-task storage is not provisioned",
|
||||||
|
status=503,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def lock(self, key: str) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
|
||||||
|
(f"tjwater-operational:{self.project_id}:{key}",),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_request(self, scope: str, request_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT request_digest,response
|
||||||
|
FROM workbench.operational_task_requests
|
||||||
|
WHERE project_id=%s AND scope=%s AND request_id=%s""",
|
||||||
|
(self.project_id, scope, request_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def save_request(
|
||||||
|
self, scope: str, request_id: str, request_digest: str, response: dict
|
||||||
|
) -> None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_requests
|
||||||
|
(project_id,scope,request_id,request_digest,response)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT(project_id,scope,request_id) DO UPDATE SET
|
||||||
|
response=excluded.response
|
||||||
|
WHERE operational_task_requests.request_digest=excluded.request_digest""",
|
||||||
|
(self.project_id, scope, request_id, request_digest, Jsonb(response)),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise DomainError(
|
||||||
|
"IDEMPOTENCY_CONFLICT",
|
||||||
|
"request_id was used with different content",
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_task(self, task_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
|
WHERE project_id=%s AND task_id=%s""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def get_active_run(self, task_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
|
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
|
created_at,updated_at
|
||||||
|
FROM workbench.operational_task_runs
|
||||||
|
WHERE project_id=%s AND task_id=%s
|
||||||
|
AND status IN ('queued','claimed','running')
|
||||||
|
ORDER BY generation DESC LIMIT 1""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def get_run(self, run_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
|
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
|
created_at,updated_at
|
||||||
|
FROM workbench.operational_task_runs
|
||||||
|
WHERE project_id=%s AND run_id=%s""",
|
||||||
|
(self.project_id, run_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def insert_run(self, run: dict) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_runs
|
||||||
|
(project_id,run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
|
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
|
created_at,updated_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||||
|
(
|
||||||
|
self.project_id, run["run_id"], run["task_id"], run["generation"],
|
||||||
|
run["execution_request_id"], run["status"], run["revision"],
|
||||||
|
run.get("agent_session_id"), run.get("lease_expires_at"),
|
||||||
|
run.get("heartbeat_at"), run.get("started_at"), run.get("finished_at"),
|
||||||
|
run.get("failure_code"), run.get("failure_detail"),
|
||||||
|
run.get("dispatch_attempts", 0), run["next_dispatch_at"],
|
||||||
|
run["created_at"], run["updated_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_run(self, run: dict, expected_revision: int) -> None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.operational_task_runs SET
|
||||||
|
status=%s,revision=%s,agent_session_id=%s,lease_expires_at=%s,
|
||||||
|
heartbeat_at=%s,started_at=%s,finished_at=%s,failure_code=%s,
|
||||||
|
failure_detail=%s,dispatch_attempts=%s,next_dispatch_at=%s,updated_at=%s
|
||||||
|
WHERE project_id=%s AND run_id=%s AND revision=%s""",
|
||||||
|
(
|
||||||
|
run["status"], run["revision"], run.get("agent_session_id"),
|
||||||
|
run.get("lease_expires_at"), run.get("heartbeat_at"),
|
||||||
|
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
|
||||||
|
run.get("failure_detail"), run.get("dispatch_attempts", 0),
|
||||||
|
run["next_dispatch_at"], run["updated_at"], self.project_id,
|
||||||
|
run["run_id"], expected_revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise DomainError("VERSION_CONFLICT", "Run revision changed", status=409)
|
||||||
|
|
||||||
|
async def list_runs(
|
||||||
|
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None
|
||||||
|
) -> list[dict]:
|
||||||
|
result = await self.connection.execute(
|
||||||
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
|
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
|
created_at,updated_at
|
||||||
|
FROM workbench.operational_task_runs
|
||||||
|
WHERE project_id=%s
|
||||||
|
AND (%s::text IS NULL OR status=%s::text)
|
||||||
|
AND (%s::timestamptz IS NULL OR (created_at,run_id)>(%s::timestamptz,%s::uuid))
|
||||||
|
AND (status<>'queued' OR next_dispatch_at<=now())
|
||||||
|
ORDER BY created_at,run_id LIMIT %s""",
|
||||||
|
(
|
||||||
|
self.project_id, status, status, cursor_created_at,
|
||||||
|
cursor_created_at, cursor_run_id, limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await result.fetchall()]
|
||||||
|
|
||||||
|
async def claim_expired_runs(self, limit: int) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
|
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
|
created_at,updated_at
|
||||||
|
FROM workbench.operational_task_runs
|
||||||
|
WHERE project_id=%s AND status IN ('claimed','running')
|
||||||
|
AND lease_expires_at IS NOT NULL AND lease_expires_at<=now()
|
||||||
|
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
||||||
|
(self.project_id, limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def consecutive_timed_out_runs(self, task_id: str) -> int:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT status FROM workbench.operational_task_runs
|
||||||
|
WHERE project_id=%s AND task_id=%s ORDER BY generation DESC""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
count = 0
|
||||||
|
for row in await cursor.fetchall():
|
||||||
|
if row["status"] != "timed_out":
|
||||||
|
break
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def append_event(
|
||||||
|
self, *, topic: str, aggregate_type: str, aggregate_id: str,
|
||||||
|
aggregate_revision: int, occurred_at, metadata: dict | None = None,
|
||||||
|
) -> int:
|
||||||
|
event_id = uuid4()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.domain_event_log
|
||||||
|
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||||
|
aggregate_revision,occurred_at,metadata)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING event_seq""",
|
||||||
|
(
|
||||||
|
self.project_id, event_id, topic, aggregate_type, aggregate_id,
|
||||||
|
aggregate_revision, occurred_at, Jsonb(metadata or {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
event_seq = int(row["event_seq"])
|
||||||
|
await self.connection.execute(
|
||||||
|
"SELECT pg_notify('workbench_events', %s)",
|
||||||
|
(f'{self.project_id}:{event_seq}',),
|
||||||
|
)
|
||||||
|
return event_seq
|
||||||
|
|
||||||
|
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
|
WHERE project_id=%s AND create_request_id=%s""",
|
||||||
|
(self.project_id, request_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def get_task_by_external_source(
|
||||||
|
self, source_kind: str, external_id: str
|
||||||
|
) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
|
WHERE project_id=%s
|
||||||
|
AND document#>>'{source,kind}'=%s
|
||||||
|
AND document#>>'{source,external_id}'=%s""",
|
||||||
|
(self.project_id, source_kind, external_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def list_tasks(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
task_type: str | None,
|
||||||
|
status: str | None,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT task.document,
|
||||||
|
CASE WHEN active_run.run_id IS NULL THEN NULL
|
||||||
|
ELSE to_jsonb(active_run) - 'project_id' END AS active_run
|
||||||
|
FROM workbench.operational_tasks AS task
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT run.* FROM workbench.operational_task_runs AS run
|
||||||
|
WHERE run.project_id=task.project_id AND run.task_id=task.task_id
|
||||||
|
AND run.status IN ('queued','claimed','running')
|
||||||
|
ORDER BY run.generation DESC LIMIT 1
|
||||||
|
) AS active_run ON true
|
||||||
|
WHERE task.project_id=%s
|
||||||
|
AND (%s::text IS NULL OR task.task_type=%s::text)
|
||||||
|
AND (%s::text IS NULL OR task.status=%s::text)
|
||||||
|
ORDER BY task.updated_at DESC,task.task_id
|
||||||
|
LIMIT %s OFFSET %s""",
|
||||||
|
(
|
||||||
|
self.project_id,
|
||||||
|
task_type,
|
||||||
|
task_type,
|
||||||
|
status,
|
||||||
|
status,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{**row["document"], "active_run": row["active_run"]}
|
||||||
|
for row in await cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def insert_task(self, task: dict, create_request_id: str) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_tasks
|
||||||
|
(project_id,task_id,create_request_id,task_type,workflow,status,lifecycle_status,priority,
|
||||||
|
revision,execution_generation,execution_request_id,agent_session_id,
|
||||||
|
agent_lease_expires_at,current_plan_revision,current_report_revision,
|
||||||
|
scheduled_for,created_at,updated_at,document)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||||
|
self._task_params(task, create_request_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_task(self, task: dict) -> None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.operational_tasks SET
|
||||||
|
status=%s,lifecycle_status=%s,revision=%s,execution_generation=%s,execution_request_id=%s,
|
||||||
|
agent_session_id=%s,agent_lease_expires_at=%s,current_plan_revision=%s,
|
||||||
|
current_report_revision=%s,updated_at=%s,document=%s
|
||||||
|
WHERE project_id=%s AND task_id=%s AND revision=%s""",
|
||||||
|
(
|
||||||
|
task["status"], task["lifecycle_status"], task["revision"], task["execution_generation"],
|
||||||
|
task["execution_request_id"], task.get("agent_session_id"),
|
||||||
|
task.get("agent_lease_expires_at"), task.get("current_plan_revision"),
|
||||||
|
task.get("current_report_revision"), task["updated_at"], Jsonb(task),
|
||||||
|
self.project_id, task["task_id"], task["revision"] - 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise DomainError("VERSION_CONFLICT", "Task revision changed", status=409)
|
||||||
|
|
||||||
|
def _task_params(self, task: dict, create_request_id: str) -> tuple:
|
||||||
|
return (
|
||||||
|
self.project_id, task["task_id"], create_request_id, task["task_type"],
|
||||||
|
task["workflow"], task["status"], task["lifecycle_status"], task["priority"], task["revision"],
|
||||||
|
task["execution_generation"], task["execution_request_id"],
|
||||||
|
task.get("agent_session_id"), task.get("agent_lease_expires_at"),
|
||||||
|
task.get("current_plan_revision"), task.get("current_report_revision"),
|
||||||
|
task.get("scheduled_for"), task["created_at"], task["updated_at"], Jsonb(task),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_evidence(self, task_id: str, evidence_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_evidence
|
||||||
|
WHERE project_id=%s AND task_id=%s AND evidence_id=%s""",
|
||||||
|
(self.project_id, task_id, evidence_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def list_evidence(self, task_id: str) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_evidence
|
||||||
|
WHERE project_id=%s AND task_id=%s ORDER BY created_at,evidence_id""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
return [row["document"] for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def insert_evidence(self, task_id: str, evidence: dict) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_evidence
|
||||||
|
(project_id,task_id,evidence_id,document,created_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)""",
|
||||||
|
(self.project_id, task_id, evidence["evidence_id"], Jsonb(evidence),
|
||||||
|
evidence["recorded_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_latest_plan(self, task_id: str) -> dict | None:
|
||||||
|
return await self._latest_artifact("operational_task_plans", task_id)
|
||||||
|
|
||||||
|
async def get_latest_report(self, task_id: str) -> dict | None:
|
||||||
|
return await self._latest_artifact("operational_task_reports", task_id)
|
||||||
|
|
||||||
|
async def _latest_artifact(self, table: str, task_id: str) -> dict | None:
|
||||||
|
if table not in {"operational_task_plans", "operational_task_reports"}:
|
||||||
|
raise ValueError("unsupported artifact table")
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"""SELECT document FROM workbench.{table}
|
||||||
|
WHERE project_id=%s AND task_id=%s ORDER BY revision DESC LIMIT 1""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def insert_plan(self, task_id: str, snapshot: dict) -> None:
|
||||||
|
await self._insert_artifact("operational_task_plans", task_id, snapshot)
|
||||||
|
|
||||||
|
async def insert_report(self, task_id: str, snapshot: dict) -> None:
|
||||||
|
await self._insert_artifact("operational_task_reports", task_id, snapshot)
|
||||||
|
|
||||||
|
async def _insert_artifact(self, table: str, task_id: str, snapshot: dict) -> None:
|
||||||
|
if table not in {"operational_task_plans", "operational_task_reports"}:
|
||||||
|
raise ValueError("unsupported artifact table")
|
||||||
|
await self.connection.execute(
|
||||||
|
f"""INSERT INTO workbench.{table}
|
||||||
|
(project_id,task_id,revision,document,created_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)""",
|
||||||
|
(self.project_id, task_id, snapshot["revision"], Jsonb(snapshot),
|
||||||
|
snapshot["published_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_action(self, task_id: str, action_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_actions
|
||||||
|
WHERE project_id=%s AND task_id=%s AND action_id=%s""",
|
||||||
|
(self.project_id, task_id, action_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def list_actions(self, task_id: str) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_actions
|
||||||
|
WHERE project_id=%s AND task_id=%s ORDER BY created_at,action_id""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
return [row["document"] for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def insert_action(self, task_id: str, action: dict) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_actions
|
||||||
|
(project_id,task_id,action_id,version,approval_status,execution_status,
|
||||||
|
document,created_at,updated_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||||
|
(self.project_id, task_id, action["action_id"], action["version"],
|
||||||
|
action["approval_status"], action["execution_status"], Jsonb(action),
|
||||||
|
action["proposed_at"], action["proposed_at"]),
|
||||||
|
)
|
||||||
|
await self._insert_action_revision(task_id, action)
|
||||||
|
|
||||||
|
async def update_action(self, task_id: str, action: dict) -> None:
|
||||||
|
await self._insert_action_revision(task_id, action)
|
||||||
|
await self.connection.execute(
|
||||||
|
"""UPDATE workbench.operational_task_actions SET
|
||||||
|
version=%s,approval_status=%s,execution_status=%s,document=%s,updated_at=%s
|
||||||
|
WHERE project_id=%s AND task_id=%s AND action_id=%s""",
|
||||||
|
(action["version"], action["approval_status"], action["execution_status"],
|
||||||
|
Jsonb(action), action.get("executed_at") or action.get("decided_at") or
|
||||||
|
action["proposed_at"], self.project_id, task_id, action["action_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _insert_action_revision(self, task_id: str, action: dict) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_action_revisions
|
||||||
|
(project_id,task_id,action_id,version,document,recorded_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s)""",
|
||||||
|
(
|
||||||
|
self.project_id, task_id, action["action_id"], action["version"],
|
||||||
|
Jsonb(action), action.get("executed_at") or action.get("decided_at")
|
||||||
|
or action["proposed_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_feedback(self, task_id: str, source_event_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT source_revision,document,accepted_at
|
||||||
|
FROM workbench.operational_task_external_feedback
|
||||||
|
WHERE project_id=%s AND task_id=%s AND source_event_id=%s""",
|
||||||
|
(self.project_id, task_id, source_event_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def upsert_feedback(self, task_id: str, feedback: dict, accepted_at) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_external_feedback
|
||||||
|
(project_id,task_id,source_event_id,source_revision,document,accepted_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT(project_id,task_id,source_event_id) DO UPDATE SET
|
||||||
|
source_revision=excluded.source_revision,document=excluded.document,
|
||||||
|
accepted_at=excluded.accepted_at""",
|
||||||
|
(self.project_id, task_id, feedback["source_event_id"],
|
||||||
|
feedback["source_revision"], Jsonb(feedback), accepted_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def insert_notification(self, notification: dict) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_notifications
|
||||||
|
(project_id,notification_id,task_id,created_at,document)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)""",
|
||||||
|
(self.project_id, notification["notification_id"], notification["task_id"],
|
||||||
|
notification["created_at"], Jsonb(notification)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_notification(self, notification_id: str, user_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT n.document || jsonb_build_object(
|
||||||
|
'status',CASE WHEN r.read_at IS NULL THEN 'unread' ELSE 'read' END,
|
||||||
|
'version',COALESCE(r.version,1),
|
||||||
|
'read_at',r.read_at,'read_by',
|
||||||
|
CASE WHEN r.user_id IS NULL THEN NULL ELSE 'principal:' || r.user_id::text END
|
||||||
|
) AS document
|
||||||
|
FROM workbench.operational_task_notifications n
|
||||||
|
LEFT JOIN workbench.operational_task_notification_reads r
|
||||||
|
ON r.project_id=n.project_id AND r.notification_id=n.notification_id
|
||||||
|
AND r.user_id=%s
|
||||||
|
WHERE n.project_id=%s AND n.notification_id=%s""",
|
||||||
|
(user_id, self.project_id, notification_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def mark_notification_read(
|
||||||
|
self, notification_id: str, user_id: str, version: int, read_at
|
||||||
|
) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_notification_reads
|
||||||
|
(project_id,notification_id,user_id,version,read_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT(project_id,notification_id,user_id) DO UPDATE SET
|
||||||
|
version=excluded.version,read_at=excluded.read_at""",
|
||||||
|
(self.project_id, notification_id, user_id, version, read_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_notifications(
|
||||||
|
self, *, user_id: str, allowed_permissions: list[str],
|
||||||
|
unread_only: bool, limit: int, offset: int
|
||||||
|
) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT n.document || jsonb_build_object(
|
||||||
|
'status',CASE WHEN r.read_at IS NULL THEN 'unread' ELSE 'read' END,
|
||||||
|
'version',COALESCE(r.version,1),
|
||||||
|
'read_at',r.read_at,'read_by',
|
||||||
|
CASE WHEN r.user_id IS NULL THEN NULL ELSE 'principal:' || r.user_id::text END
|
||||||
|
) AS document
|
||||||
|
FROM workbench.operational_task_notifications n
|
||||||
|
LEFT JOIN workbench.operational_task_notification_reads r
|
||||||
|
ON r.project_id=n.project_id AND r.notification_id=n.notification_id
|
||||||
|
AND r.user_id=%s
|
||||||
|
WHERE n.project_id=%s
|
||||||
|
AND (%s=false OR r.read_at IS NULL)
|
||||||
|
AND (n.document->>'required_permission' IS NULL
|
||||||
|
OR n.document->>'required_permission'=ANY(%s))
|
||||||
|
ORDER BY n.created_at DESC,n.notification_id LIMIT %s OFFSET %s""",
|
||||||
|
(user_id, self.project_id, unread_only, allowed_permissions, limit, offset),
|
||||||
|
)
|
||||||
|
return [row["document"] for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def get_schedule_by_request(self, request_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_schedules
|
||||||
|
WHERE project_id=%s AND create_request_id=%s""",
|
||||||
|
(self.project_id, request_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def insert_schedule(self, schedule: dict, request_id: str) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_schedules
|
||||||
|
(project_id,schedule_id,create_request_id,schedule_key,next_run_at,
|
||||||
|
interval_seconds,enabled,version,document,created_at,updated_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||||
|
(self.project_id, schedule["schedule_id"], request_id,
|
||||||
|
schedule["schedule_key"], schedule["next_run_at"],
|
||||||
|
schedule["interval_seconds"], schedule["enabled"], schedule["version"],
|
||||||
|
Jsonb(schedule), schedule["created_at"], schedule["updated_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_schedules(self) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_schedules
|
||||||
|
WHERE project_id=%s ORDER BY next_run_at,schedule_id""",
|
||||||
|
(self.project_id,),
|
||||||
|
)
|
||||||
|
return [row["document"] for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def get_schedule(self, schedule_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_schedules
|
||||||
|
WHERE project_id=%s AND schedule_id=%s""",
|
||||||
|
(self.project_id, schedule_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def update_schedule(self, schedule: dict) -> None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.operational_task_schedules SET
|
||||||
|
enabled=%s,version=%s,updated_at=%s,document=%s
|
||||||
|
WHERE project_id=%s AND schedule_id=%s AND version=%s""",
|
||||||
|
(
|
||||||
|
schedule["enabled"], schedule["version"], schedule["updated_at"],
|
||||||
|
Jsonb(schedule), self.project_id, schedule["schedule_id"],
|
||||||
|
schedule["version"] - 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise DomainError("VERSION_CONFLICT", "Schedule version changed", status=409)
|
||||||
|
|
||||||
|
async def claim_due_schedules(self, limit: int) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_schedules
|
||||||
|
WHERE project_id=%s AND enabled=true AND next_run_at <= now()
|
||||||
|
ORDER BY next_run_at,schedule_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
||||||
|
(self.project_id, limit),
|
||||||
|
)
|
||||||
|
return [row["document"] for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def advance_schedule(self, schedule: dict) -> None:
|
||||||
|
await self.connection.execute(
|
||||||
|
"""UPDATE workbench.operational_task_schedules SET
|
||||||
|
next_run_at=%s,version=%s,updated_at=%s,document=%s
|
||||||
|
WHERE project_id=%s AND schedule_id=%s""",
|
||||||
|
(schedule["next_run_at"], schedule["version"], schedule["updated_at"],
|
||||||
|
Jsonb(schedule), self.project_id, schedule["schedule_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def insert_occurrence(self, schedule_id: str, due_at, task_id: str, created_at) -> bool:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_schedule_occurrences
|
||||||
|
(project_id,schedule_id,due_at,task_id,created_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT(project_id,schedule_id,due_at) DO NOTHING
|
||||||
|
RETURNING task_id""",
|
||||||
|
(self.project_id, schedule_id, due_at, task_id, created_at),
|
||||||
|
)
|
||||||
|
return await cursor.fetchone() is not None
|
||||||
@@ -6,6 +6,7 @@ from contextlib import asynccontextmanager
|
|||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from psycopg.errors import UndefinedTable
|
from psycopg.errors import UndefinedTable
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.domain.domain_errors import DomainError
|
from app.domain.domain_errors import DomainError
|
||||||
|
|
||||||
@@ -86,3 +87,21 @@ class TaskAlertRepository:
|
|||||||
Jsonb({key: value for key, value in state.items() if key != "revision"}),
|
Jsonb({key: value for key, value in state.items() if key != "revision"}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def append_changed_event(self, state: dict) -> None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.domain_event_log
|
||||||
|
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||||
|
aggregate_revision,occurred_at,metadata)
|
||||||
|
VALUES (%s,%s,'workbench.alert.changed','workbench_alert',%s,%s,%s,%s)
|
||||||
|
RETURNING event_seq""",
|
||||||
|
(
|
||||||
|
self.project_id, uuid4(), state["alert_id"], state["revision"],
|
||||||
|
state["updated_at"], Jsonb({"alert_id": state["alert_id"]}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
await self.connection.execute(
|
||||||
|
"SELECT pg_notify('workbench_events', %s)",
|
||||||
|
(f'{self.project_id}:{row["event_seq"]}',),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Per-process, project-scoped PostgreSQL LISTEN hubs for workbench SSE."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from psycopg_pool import AsyncConnectionPool
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkbenchEventHub:
|
||||||
|
"""Own one LISTEN connection and fan wake-ups out to SSE subscribers."""
|
||||||
|
|
||||||
|
def __init__(self, project_id: UUID, dsn: str) -> None:
|
||||||
|
self.project_id = project_id
|
||||||
|
self.dsn = dsn
|
||||||
|
self.subscribers = 0
|
||||||
|
self._connection: AsyncConnection | None = None
|
||||||
|
self._pool: AsyncConnectionPool | None = None
|
||||||
|
self._connection_lock = asyncio.Lock()
|
||||||
|
self._condition = asyncio.Condition()
|
||||||
|
self._generation = 0
|
||||||
|
self._ready = asyncio.Event()
|
||||||
|
self._closed = False
|
||||||
|
self._listener_task: asyncio.Task | None = None
|
||||||
|
self._idle_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._listener_task is None or self._listener_task.done():
|
||||||
|
self._closed = False
|
||||||
|
self._ready.clear()
|
||||||
|
self._listener_task = asyncio.create_task(
|
||||||
|
self._listen_loop(), name=f"workbench-events:{self.project_id}"
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(self._ready.wait(), timeout=10)
|
||||||
|
|
||||||
|
async def _connect(self) -> AsyncConnection:
|
||||||
|
if self._pool is None:
|
||||||
|
self._pool = AsyncConnectionPool(
|
||||||
|
conninfo=self.dsn,
|
||||||
|
min_size=0,
|
||||||
|
max_size=1,
|
||||||
|
open=False,
|
||||||
|
kwargs={"autocommit": True, "row_factory": dict_row},
|
||||||
|
)
|
||||||
|
await self._pool.open()
|
||||||
|
connection = await self._pool.getconn()
|
||||||
|
await connection.execute("LISTEN workbench_events")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
async def _listen_loop(self) -> None:
|
||||||
|
retry_seconds = 1
|
||||||
|
while not self._closed:
|
||||||
|
try:
|
||||||
|
if self._connection is None or self._connection.closed:
|
||||||
|
async with self._connection_lock:
|
||||||
|
if self._connection is None or self._connection.closed:
|
||||||
|
self._connection = await self._connect()
|
||||||
|
self._ready.set()
|
||||||
|
retry_seconds = 1
|
||||||
|
notified = False
|
||||||
|
async with self._connection_lock:
|
||||||
|
connection = self._connection
|
||||||
|
if connection is None:
|
||||||
|
continue
|
||||||
|
async for notification in connection.notifies(timeout=1, stop_after=1):
|
||||||
|
if notification.payload.startswith(f"{self.project_id}:"):
|
||||||
|
notified = True
|
||||||
|
break
|
||||||
|
if notified:
|
||||||
|
await self._wake()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Workbench event listener failed for project %s", self.project_id)
|
||||||
|
await self._drop_connection()
|
||||||
|
await self._wake()
|
||||||
|
await asyncio.sleep(retry_seconds)
|
||||||
|
retry_seconds = min(retry_seconds * 2, 30)
|
||||||
|
|
||||||
|
async def _wake(self) -> None:
|
||||||
|
async with self._condition:
|
||||||
|
self._generation += 1
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
|
async def wait_for_wake(self, generation: int, timeout: float) -> int:
|
||||||
|
async with self._condition:
|
||||||
|
if self._generation != generation:
|
||||||
|
return self._generation
|
||||||
|
with contextlib.suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._condition.wait_for(lambda: self._generation != generation),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return self._generation
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generation(self) -> int:
|
||||||
|
return self._generation
|
||||||
|
|
||||||
|
async def oldest_cursor(self) -> int | None:
|
||||||
|
return await self._read(lambda repository: repository.oldest_cursor())
|
||||||
|
|
||||||
|
async def current_cursor(self) -> int:
|
||||||
|
return await self._read(lambda repository: repository.current_cursor())
|
||||||
|
|
||||||
|
async def read_after(self, after: int) -> list[dict]:
|
||||||
|
return await self._read(lambda repository: repository.read_after(after))
|
||||||
|
|
||||||
|
async def _read(self, operation):
|
||||||
|
await self.start()
|
||||||
|
async with self._connection_lock:
|
||||||
|
if self._connection is None or self._connection.closed:
|
||||||
|
self._connection = await self._connect()
|
||||||
|
self._ready.set()
|
||||||
|
repository = WorkbenchEventRepository(self._connection, self.project_id)
|
||||||
|
return await operation(repository)
|
||||||
|
|
||||||
|
async def _drop_connection(self) -> None:
|
||||||
|
async with self._connection_lock:
|
||||||
|
connection, self._connection = self._connection, None
|
||||||
|
if connection is not None and self._pool is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self._pool.putconn(connection)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
if self._idle_task is not None:
|
||||||
|
self._idle_task.cancel()
|
||||||
|
self._idle_task = None
|
||||||
|
if self._listener_task is not None:
|
||||||
|
self._listener_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._listener_task
|
||||||
|
self._listener_task = None
|
||||||
|
await self._drop_connection()
|
||||||
|
if self._pool is not None:
|
||||||
|
await self._pool.close()
|
||||||
|
self._pool = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkbenchEventHubManager:
|
||||||
|
def __init__(self, idle_seconds: float = 60) -> None:
|
||||||
|
self.idle_seconds = idle_seconds
|
||||||
|
self._hubs: dict[tuple[UUID, str], WorkbenchEventHub] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def subscribe(self, project_id: UUID, dsn: str):
|
||||||
|
key = (project_id, dsn)
|
||||||
|
async with self._lock:
|
||||||
|
hub = self._hubs.get(key)
|
||||||
|
if hub is None:
|
||||||
|
hub = WorkbenchEventHub(project_id, dsn)
|
||||||
|
self._hubs[key] = hub
|
||||||
|
if hub._idle_task is not None:
|
||||||
|
hub._idle_task.cancel()
|
||||||
|
hub._idle_task = None
|
||||||
|
hub.subscribers += 1
|
||||||
|
try:
|
||||||
|
await hub.start()
|
||||||
|
yield hub
|
||||||
|
finally:
|
||||||
|
async with self._lock:
|
||||||
|
hub.subscribers = max(0, hub.subscribers - 1)
|
||||||
|
if hub.subscribers == 0 and hub._idle_task is None:
|
||||||
|
hub._idle_task = asyncio.create_task(self._close_when_idle(key, hub))
|
||||||
|
|
||||||
|
async def _close_when_idle(
|
||||||
|
self, key: tuple[UUID, str], hub: WorkbenchEventHub
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.idle_seconds)
|
||||||
|
async with self._lock:
|
||||||
|
if hub.subscribers or self._hubs.get(key) is not hub:
|
||||||
|
return
|
||||||
|
self._hubs.pop(key, None)
|
||||||
|
hub._idle_task = None
|
||||||
|
await hub.close()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def close_all(self) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
hubs = list(self._hubs.values())
|
||||||
|
self._hubs.clear()
|
||||||
|
await asyncio.gather(*(hub.close() for hub in hubs), return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
workbench_event_hubs = WorkbenchEventHubManager()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Cursor reads for the durable project-scoped workbench event log."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
|
||||||
|
|
||||||
|
class WorkbenchEventRepository:
|
||||||
|
def __init__(self, connection: AsyncConnection, project_id):
|
||||||
|
self.connection = connection
|
||||||
|
self.project_id = str(project_id)
|
||||||
|
|
||||||
|
async def current_cursor(self) -> int:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"SELECT COALESCE(max(event_seq),0) AS cursor FROM workbench.domain_event_log WHERE project_id=%s",
|
||||||
|
(self.project_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return int(row["cursor"])
|
||||||
|
|
||||||
|
async def oldest_cursor(self) -> int | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"SELECT min(event_seq) AS cursor FROM workbench.domain_event_log WHERE project_id=%s",
|
||||||
|
(self.project_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return int(row["cursor"]) if row and row["cursor"] is not None else None
|
||||||
|
|
||||||
|
async def read_after(self, after: int, limit: int = 200) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT event_seq,event_id,project_id,topic,aggregate_type,aggregate_id,
|
||||||
|
aggregate_revision,occurred_at,metadata
|
||||||
|
FROM workbench.domain_event_log
|
||||||
|
WHERE project_id=%s AND event_seq>%s
|
||||||
|
ORDER BY event_seq LIMIT %s""",
|
||||||
|
(self.project_id, after, limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
@@ -4,6 +4,7 @@ from contextlib import contextmanager
|
|||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar, Token
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ class ActiveProjectRouting:
|
|||||||
project_code: str
|
project_code: str
|
||||||
business_dsn: str
|
business_dsn: str
|
||||||
timescale_dsn: str | None = None
|
timescale_dsn: str | None = None
|
||||||
|
project_id: UUID | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def business_database_name(self) -> str:
|
def business_database_name(self) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Typed external action adapters. Credentials are request-scoped, never persisted."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal, Protocol
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
ActionReceipt, CreateWorkOrderPayload, HumanApprovalAction,
|
||||||
|
IssueCommandPayload, OperationalTaskDetail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActionDispatchOutcome:
|
||||||
|
status: Literal["submitted", "succeeded", "failed", "unsupported", "unknown"]
|
||||||
|
detail: str
|
||||||
|
receipt: ActionReceipt | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalActionDispatcher(Protocol):
|
||||||
|
async def dispatch(
|
||||||
|
self, *, context: ProjectContext, task: OperationalTaskDetail,
|
||||||
|
action: HumanApprovalAction, credential: str | None,
|
||||||
|
) -> ActionDispatchOutcome: ...
|
||||||
|
|
||||||
|
async def reconcile(
|
||||||
|
self, *, context: ProjectContext, task: OperationalTaskDetail,
|
||||||
|
action: HumanApprovalAction, credential: str | None,
|
||||||
|
) -> ActionDispatchOutcome: ...
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalActionSettings(BaseSettings):
|
||||||
|
peripheral_url: str = ""
|
||||||
|
command_gateway_url: str = ""
|
||||||
|
timeout_seconds: float = Field(default=10, ge=1, le=60)
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="OPERATIONAL_ACTION_", extra="ignore",
|
||||||
|
env_file=Path(__file__).resolve().parents[3] / ".env",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SampleWorkOrderReceipt(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
work_order_id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
origin: Literal["synthetic"]
|
||||||
|
status: Literal[
|
||||||
|
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CommandGatewayReceipt(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
request_id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
receipt_id: str = Field(min_length=1)
|
||||||
|
status: Literal["accepted", "running", "completed", "failed"]
|
||||||
|
submitted_at: datetime
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
detail: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class HttpOperationalActionDispatcher:
|
||||||
|
def __init__(
|
||||||
|
self, settings: OperationalActionSettings | None = None,
|
||||||
|
*, transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
):
|
||||||
|
self.settings = settings or OperationalActionSettings()
|
||||||
|
self.transport = transport
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, *, context: ProjectContext, task: OperationalTaskDetail,
|
||||||
|
action: HumanApprovalAction, credential: str | None,
|
||||||
|
) -> ActionDispatchOutcome:
|
||||||
|
base = (self.settings.peripheral_url if action.kind == "create_work_order"
|
||||||
|
else self.settings.command_gateway_url)
|
||||||
|
if not base:
|
||||||
|
return ActionDispatchOutcome("unsupported", "尚未配置该动作的外部执行适配器")
|
||||||
|
if not credential or not credential.lower().startswith("bearer "):
|
||||||
|
return ActionDispatchOutcome("failed", "缺少当前用户的产品身份,未提交外部动作")
|
||||||
|
if action.task_id != task.task_id or task.project_id != context.project_id:
|
||||||
|
return ActionDispatchOutcome("failed", "动作与当前项目任务不匹配,未提交")
|
||||||
|
if action.approval_status != "approved":
|
||||||
|
return ActionDispatchOutcome("failed", "动作尚未通过人工确认,未提交")
|
||||||
|
request_id = action.execution_request_id
|
||||||
|
if request_id is None:
|
||||||
|
return ActionDispatchOutcome("failed", "缺少持久化执行编号,未提交")
|
||||||
|
headers = {"Authorization": credential, "X-Project-Id": str(context.project_id)}
|
||||||
|
try:
|
||||||
|
if action.kind == "create_work_order":
|
||||||
|
payload = CreateWorkOrderPayload.model_validate(action.payload)
|
||||||
|
body = {
|
||||||
|
"request_id": str(request_id), "title": payload.title,
|
||||||
|
"summary": payload.description, "status": "open",
|
||||||
|
"priority": payload.priority, "assignee": payload.assignee,
|
||||||
|
"scheduled_at": payload.scheduled_at.isoformat() if payload.scheduled_at else None,
|
||||||
|
"location": payload.location,
|
||||||
|
# An approved action creates a child execution order, not a
|
||||||
|
# new inbound complaint/burst event for the sync loop.
|
||||||
|
"work_order_type": "general",
|
||||||
|
}
|
||||||
|
path = "/api/sample/v1/work-orders"
|
||||||
|
recovery_path = f"/api/sample/v1/requests/{request_id}"
|
||||||
|
else:
|
||||||
|
payload = IssueCommandPayload.model_validate(action.payload)
|
||||||
|
if payload.expires_at <= datetime.now(timezone.utc):
|
||||||
|
return ActionDispatchOutcome("failed", "命令确认窗口已过期,未提交")
|
||||||
|
body = {
|
||||||
|
"request_id": str(request_id), "project_id": str(context.project_id),
|
||||||
|
"task_id": str(task.task_id), "action_id": str(action.action_id),
|
||||||
|
"action_version": action.version,
|
||||||
|
"approved_by": action.decided_by,
|
||||||
|
"approved_at": action.decided_at.isoformat() if action.decided_at else None,
|
||||||
|
"command": payload.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
path = "/api/v1/commands"
|
||||||
|
recovery_path = f"/api/v1/commands/requests/{request_id}"
|
||||||
|
except (ValidationError, TypeError, ValueError):
|
||||||
|
return ActionDispatchOutcome("failed", "动作参数不符合执行契约,未提交")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=base.rstrip("/"), headers=headers, timeout=self.settings.timeout_seconds,
|
||||||
|
follow_redirects=False, trust_env=False, transport=self.transport,
|
||||||
|
) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(path, json=body)
|
||||||
|
if response.status_code in {400, 401, 403, 404, 409, 422}:
|
||||||
|
# Sample responses explicitly state whether any write occurred.
|
||||||
|
# An untyped rejection is not proof that no effect took place.
|
||||||
|
error = response.json()
|
||||||
|
if isinstance(error, dict) and error.get("effect") == "none":
|
||||||
|
return ActionDispatchOutcome("failed", "外部系统拒绝了动作,未生效")
|
||||||
|
response.raise_for_status()
|
||||||
|
return self._receipt(action.kind, response.json(), request_id, context.project_id)
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
|
||||||
|
# A lost response can follow a successful write. Recover using the
|
||||||
|
# same idempotency key, never manufacture success or blindly retry.
|
||||||
|
try:
|
||||||
|
recovered = await client.get(recovery_path)
|
||||||
|
recovered.raise_for_status()
|
||||||
|
value = recovered.json()
|
||||||
|
if action.kind == "create_work_order":
|
||||||
|
if not isinstance(value, dict) or value.get("request_id") != str(request_id):
|
||||||
|
raise ValueError("request receipt mismatch")
|
||||||
|
value = value["result"]
|
||||||
|
return self._receipt(action.kind, value, request_id, context.project_id)
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
|
||||||
|
return ActionDispatchOutcome("unknown", "外部执行结果待核实,请通过回执核对,勿重复下发")
|
||||||
|
|
||||||
|
async def reconcile(
|
||||||
|
self, *, context: ProjectContext, task: OperationalTaskDetail,
|
||||||
|
action: HumanApprovalAction, credential: str | None,
|
||||||
|
) -> ActionDispatchOutcome:
|
||||||
|
request_id = action.execution_request_id
|
||||||
|
if not credential or request_id is None or action.task_id != task.task_id or task.project_id != context.project_id:
|
||||||
|
return ActionDispatchOutcome("unknown", "无法核实当前动作的身份或执行编号")
|
||||||
|
base = self.settings.peripheral_url if action.kind == "create_work_order" else self.settings.command_gateway_url
|
||||||
|
if not base:
|
||||||
|
return ActionDispatchOutcome("unknown", "回执查询服务尚未配置")
|
||||||
|
path = (f"/api/sample/v1/requests/{request_id}" if action.kind == "create_work_order"
|
||||||
|
else f"/api/v1/commands/requests/{request_id}")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=base.rstrip("/"), timeout=self.settings.timeout_seconds,
|
||||||
|
follow_redirects=False, trust_env=False, transport=self.transport,
|
||||||
|
) as client:
|
||||||
|
response = await client.get(path, headers={
|
||||||
|
"Authorization": credential, "X-Project-Id": str(context.project_id),
|
||||||
|
})
|
||||||
|
response.raise_for_status()
|
||||||
|
value = response.json()
|
||||||
|
if action.kind == "create_work_order":
|
||||||
|
if not isinstance(value, dict) or value.get("request_id") != str(request_id):
|
||||||
|
raise ValueError("request receipt mismatch")
|
||||||
|
value = value["result"]
|
||||||
|
return self._receipt(action.kind, value, request_id, context.project_id)
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
|
||||||
|
return ActionDispatchOutcome("unknown", "尚未取得可核实的执行回执")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _receipt(kind: str, value: object, request_id: UUID, project_id: UUID) -> ActionDispatchOutcome:
|
||||||
|
if kind == "create_work_order":
|
||||||
|
order = SampleWorkOrderReceipt.model_validate(value)
|
||||||
|
if order.project_id != project_id or order.created_at.tzinfo is None:
|
||||||
|
raise ValueError("invalid external project or timestamp")
|
||||||
|
receipt = ActionReceipt(
|
||||||
|
adapter="peripheral_sample", receipt_id=str(order.work_order_id),
|
||||||
|
submitted_at=order.created_at, completed_at=order.created_at,
|
||||||
|
external_status=order.status, detail="合成外围工单已创建,现场工作进度由外围系统继续同步",
|
||||||
|
)
|
||||||
|
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
|
||||||
|
result = CommandGatewayReceipt.model_validate(value)
|
||||||
|
if result.project_id != project_id or result.request_id != request_id:
|
||||||
|
raise ValueError("command receipt does not match submission")
|
||||||
|
if result.submitted_at.tzinfo is None or (result.completed_at and result.completed_at.tzinfo is None):
|
||||||
|
raise ValueError("command receipt timestamps must include timezone")
|
||||||
|
if result.status == "completed" and result.completed_at is None:
|
||||||
|
raise ValueError("command has no completion receipt")
|
||||||
|
receipt = ActionReceipt(
|
||||||
|
adapter="command_gateway", receipt_id=result.receipt_id,
|
||||||
|
submitted_at=result.submitted_at, completed_at=result.completed_at,
|
||||||
|
external_status=result.status, detail=result.detail,
|
||||||
|
)
|
||||||
|
status = "succeeded" if result.status == "completed" else "failed" if result.status == "failed" else "submitted"
|
||||||
|
return ActionDispatchOutcome(status, result.detail, receipt)
|
||||||
|
|
||||||
|
|
||||||
|
def get_operational_action_dispatcher() -> OperationalActionDispatcher:
|
||||||
|
return HttpOperationalActionDispatcher()
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Read-only source adapters with bounded queries and explicit provenance."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from app.auth.permissions import SCADA_VIEW, SIMULATION_VIEW, permissions_for_context
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDataSettings(BaseSettings):
|
||||||
|
server_url: str = "http://127.0.0.1:8000"
|
||||||
|
peripheral_url: str = ""
|
||||||
|
timeout_seconds: float = Field(default=15, ge=1, le=60)
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="OPERATIONAL_DATA_", extra="ignore",
|
||||||
|
env_file=Path(__file__).resolve().parents[3] / ".env",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDataError(Exception):
|
||||||
|
def __init__(self, status: int, detail: str):
|
||||||
|
super().__init__(detail)
|
||||||
|
self.status = status
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_project(value: object, project_id: str) -> bool:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return all(_matches_project(item, project_id) for item in value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if "project_id" in value and value["project_id"] != project_id:
|
||||||
|
return False
|
||||||
|
return all(_matches_project(item, project_id) for item in value.values())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _select_demo_observations(
|
||||||
|
payload: object, query: TaskDataQuery, project_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Project a bounded DEMO-* observation query from the stored demo dataset."""
|
||||||
|
if not isinstance(payload, dict) or not _matches_project(payload, project_id):
|
||||||
|
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
||||||
|
points = payload.get("points")
|
||||||
|
observations = payload.get("observations")
|
||||||
|
if not isinstance(points, list) or not isinstance(observations, list):
|
||||||
|
raise TaskDataError(502, "外围合成样本格式无效")
|
||||||
|
if not any(isinstance(point, dict) and point.get("point_id") == query.point_id for point in points):
|
||||||
|
raise TaskDataError(404, "合成样本测点不存在")
|
||||||
|
assert query.start is not None and query.end is not None
|
||||||
|
items: list[object] = []
|
||||||
|
try:
|
||||||
|
for item in observations:
|
||||||
|
if not isinstance(item, dict) or item.get("point_id") != query.point_id:
|
||||||
|
continue
|
||||||
|
observed_at = datetime.fromisoformat(str(item.get("observed_at")))
|
||||||
|
if query.start <= observed_at < query.end:
|
||||||
|
items.append(item)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise TaskDataError(502, "外围合成样本格式无效") from exc
|
||||||
|
return {
|
||||||
|
"origin": "synthetic",
|
||||||
|
"source_id": payload.get("source_id"),
|
||||||
|
"source_revision": payload.get("source_revision"),
|
||||||
|
"point_id": query.point_id,
|
||||||
|
"window": {
|
||||||
|
"start": query.start.isoformat(),
|
||||||
|
"end": query.end.isoformat(),
|
||||||
|
},
|
||||||
|
"items": items,
|
||||||
|
"truncated": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def read_task_data(
|
||||||
|
*, context: ProjectContext, query: TaskDataQuery, credential: str | None,
|
||||||
|
settings: TaskDataSettings | None = None,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> TaskDataSnapshot:
|
||||||
|
config = settings or TaskDataSettings()
|
||||||
|
permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
|
||||||
|
if permission not in permissions_for_context(context):
|
||||||
|
raise TaskDataError(403, "当前身份没有该数据源的读取权限")
|
||||||
|
if not credential or not credential.lower().startswith("bearer "):
|
||||||
|
raise TaskDataError(401, "数据查询需要当前产品身份")
|
||||||
|
peripheral = query.source.startswith("peripheral_")
|
||||||
|
base = config.peripheral_url if peripheral else config.server_url
|
||||||
|
if not base:
|
||||||
|
raise TaskDataError(503, "调查数据源尚未配置")
|
||||||
|
paths = {
|
||||||
|
"workbench_runtime": "/api/v1/workbench/runtime",
|
||||||
|
"scada_devices": "/api/v1/scada-devices",
|
||||||
|
"scada_readings": "/api/v1/timeseries/scada-readings",
|
||||||
|
"peripheral_points": "/api/sample/v1/monitoring/points",
|
||||||
|
"peripheral_work_orders": "/api/sample/v1/work-orders",
|
||||||
|
"peripheral_observations": "/api/sample/v1/monitoring/observations",
|
||||||
|
"peripheral_test_data": "/api/sample/v1/test-data",
|
||||||
|
}
|
||||||
|
demo_observations = (
|
||||||
|
query.source == "peripheral_observations"
|
||||||
|
and query.point_id is not None
|
||||||
|
and query.point_id.startswith("DEMO-")
|
||||||
|
)
|
||||||
|
path = "/api/sample/v1/demo-data" if demo_observations else paths[query.source]
|
||||||
|
params: dict[str, str | int] = {"limit": 100}
|
||||||
|
if query.source == "scada_readings":
|
||||||
|
params.update(device_ids=",".join(query.device_ids), start_time=query.start.isoformat(), end_time=query.end.isoformat())
|
||||||
|
elif query.source == "peripheral_observations" and not demo_observations:
|
||||||
|
params = {"point_id": query.point_id, "start": query.start.isoformat(), "end": query.end.isoformat()}
|
||||||
|
elif demo_observations:
|
||||||
|
params = {}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=base.rstrip("/"), timeout=config.timeout_seconds, follow_redirects=False,
|
||||||
|
trust_env=False, transport=transport,
|
||||||
|
) as client:
|
||||||
|
async with client.stream("GET", path, params=params, headers={
|
||||||
|
"Authorization": credential, "X-Project-Id": str(context.project_id),
|
||||||
|
}) as response:
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise TaskDataError(response.status_code, "数据源拒绝当前身份或项目权限")
|
||||||
|
response.raise_for_status()
|
||||||
|
body = bytearray()
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
body.extend(chunk)
|
||||||
|
if len(body) > 2_000_000:
|
||||||
|
raise TaskDataError(422, "查询结果过大,请缩短时间范围或减少设备数量")
|
||||||
|
data = json.loads(body)
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
raise TaskDataError(502, "调查数据查询失败,不能据此形成已核实结论") from exc
|
||||||
|
if demo_observations:
|
||||||
|
data = _select_demo_observations(data, query, str(context.project_id))
|
||||||
|
elif not _matches_project(data, str(context.project_id)):
|
||||||
|
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
||||||
|
origin = "synthetic" if peripheral else "system_data"
|
||||||
|
historical_replay = False
|
||||||
|
if query.source in {"peripheral_points", "peripheral_observations"}:
|
||||||
|
source_origin = data.get("origin") if isinstance(data, dict) else None
|
||||||
|
if source_origin not in {"historical_replay", "synthetic"}:
|
||||||
|
raise TaskDataError(502, "监测数据缺少明确来源,不能作为已核实证据")
|
||||||
|
historical_replay = source_origin == "historical_replay"
|
||||||
|
origin = "system_data" if historical_replay else "synthetic"
|
||||||
|
truncated = isinstance(data, dict) and bool(data.get("truncated") or data.get("next_cursor") or data.get("next_offset"))
|
||||||
|
if isinstance(data, list) and len(data) > 1000:
|
||||||
|
data, truncated = data[:1000], True
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("items"), list):
|
||||||
|
if len(data["items"]) > 1000:
|
||||||
|
data = {**data, "items": data["items"][:1000]}
|
||||||
|
truncated = True
|
||||||
|
if isinstance(data.get("total"), int) and data["total"] > len(data["items"]):
|
||||||
|
truncated = True
|
||||||
|
# The digest identifies the returned snapshot, not merely a mutable URL.
|
||||||
|
digest = hashlib.sha256(json.dumps(data, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
||||||
|
if demo_observations:
|
||||||
|
assert query.start is not None and query.end is not None
|
||||||
|
source_ref = (
|
||||||
|
f"peripheral_demo_data:{query.point_id}:"
|
||||||
|
f"{query.start.isoformat()}:{query.end.isoformat()}:sha256:{digest}"
|
||||||
|
)
|
||||||
|
elif historical_replay:
|
||||||
|
# Use a distinct identity: old snapshots may have incorrectly labeled
|
||||||
|
# this same historical response synthetic. Immutable evidence stays intact.
|
||||||
|
source_ref = f"peripheral_historical_replay:{query.source}:sha256:{digest}"
|
||||||
|
else:
|
||||||
|
source_ref = f"{query.source}:sha256:{digest}"
|
||||||
|
return TaskDataSnapshot(
|
||||||
|
source=query.source, source_ref=source_ref,
|
||||||
|
queried_at=datetime.now(timezone.utc), origin=origin,
|
||||||
|
data=data, truncated=truncated,
|
||||||
|
)
|
||||||
+3
-1
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
from app.api.problem_details import install_problem_details_handlers
|
from app.api.problem_details import install_problem_details_handlers
|
||||||
from app.api.v1.rest_router import api_router
|
from app.api.v1.rest_router import api_router
|
||||||
from app.infra.db.dynamic_manager import project_connection_manager
|
from app.infra.db.dynamic_manager import project_connection_manager
|
||||||
|
from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs
|
||||||
from app.infra.db.metadb.database import close_metadata_engine
|
from app.infra.db.metadb.database import close_metadata_engine
|
||||||
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
|
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
|
||||||
from app.native.wndb.core.connection import close_all_project_pools
|
from app.native.wndb.core.connection import close_all_project_pools
|
||||||
@@ -31,6 +32,7 @@ async def lifespan(app: FastAPI):
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
await workbench_event_hubs.close_all()
|
||||||
await project_connection_manager.close_all()
|
await project_connection_manager.close_all()
|
||||||
close_all_timescale_pools()
|
close_all_timescale_pools()
|
||||||
close_all_project_pools()
|
close_all_project_pools()
|
||||||
@@ -67,7 +69,7 @@ app.add_middleware(AuditMiddleware)
|
|||||||
# 确保这是你最后一个添加的 app.add_middleware
|
# 确保这是你最后一个添加的 app.add_middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # 允许所有来源
|
allow_origins=settings.cors_allowed_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||||
from app.native.wndb.core.connection import project_connection, project_transaction
|
from app.native.wndb.core.connection import project_connection, project_transaction
|
||||||
|
from app.infra.db.project_routing import get_active_project_routing
|
||||||
from app.domain.time import parse_utc_time
|
from app.domain.time import parse_utc_time
|
||||||
|
|
||||||
|
|
||||||
@@ -40,16 +41,21 @@ def create_analysis_run(
|
|||||||
started_at = parse_utc_time(scheme_start_time, field_name="scheme_start_time")
|
started_at = parse_utc_time(scheme_start_time, field_name="scheme_start_time")
|
||||||
run_id = uuid4()
|
run_id = uuid4()
|
||||||
with project_connection(name) as conn:
|
with project_connection(name) as conn:
|
||||||
AnalysisRepository.create_run_sync(
|
with conn.transaction():
|
||||||
conn,
|
AnalysisRepository.create_run_sync(
|
||||||
run_id=run_id,
|
conn,
|
||||||
name=scheme_name,
|
run_id=run_id,
|
||||||
run_type=scheme_type,
|
name=scheme_name,
|
||||||
created_by=username,
|
run_type=scheme_type,
|
||||||
started_at=started_at,
|
created_by=username,
|
||||||
status=status,
|
started_at=started_at,
|
||||||
parameters=scheme_detail,
|
status=status,
|
||||||
)
|
parameters=scheme_detail,
|
||||||
|
)
|
||||||
|
_append_run_event(
|
||||||
|
conn, run_id, revision=1, status=status,
|
||||||
|
occurred_at=started_at, results_committed=False,
|
||||||
|
)
|
||||||
return run_id
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +90,10 @@ def store_analysis_run_with_result(
|
|||||||
result_type=result_type,
|
result_type=result_type,
|
||||||
payload=result_payload,
|
payload=result_payload,
|
||||||
)
|
)
|
||||||
|
_append_run_event(
|
||||||
|
conn, run_id, revision=1, status="completed",
|
||||||
|
occurred_at=started_at, results_committed=True,
|
||||||
|
)
|
||||||
return run_id
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
@@ -96,13 +106,37 @@ def update_analysis_run(
|
|||||||
scheme_detail: dict[str, Any],
|
scheme_detail: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
with project_connection(name) as conn:
|
with project_connection(name) as conn:
|
||||||
AnalysisRepository.update_run_sync(
|
with conn.transaction():
|
||||||
conn,
|
revision = AnalysisRepository.update_run_sync(
|
||||||
run_id,
|
conn,
|
||||||
status=status,
|
run_id,
|
||||||
created_by=username,
|
status=status,
|
||||||
parameters=scheme_detail,
|
created_by=username,
|
||||||
)
|
parameters=scheme_detail,
|
||||||
|
)
|
||||||
|
_append_run_event(
|
||||||
|
conn, run_id, revision=revision, status=status,
|
||||||
|
occurred_at=datetime.now(timezone.utc),
|
||||||
|
results_committed=status == "completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_run_event(
|
||||||
|
conn, run_id: UUID, *, revision: int, status: str,
|
||||||
|
occurred_at: datetime, results_committed: bool,
|
||||||
|
) -> None:
|
||||||
|
routing = get_active_project_routing()
|
||||||
|
if routing is None or routing.project_id is None:
|
||||||
|
return
|
||||||
|
AnalysisRepository.append_run_changed_event_sync(
|
||||||
|
conn,
|
||||||
|
project_id=routing.project_id,
|
||||||
|
run_id=run_id,
|
||||||
|
revision=revision,
|
||||||
|
status=status,
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
results_committed=results_committed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_analysis_run(name: str, run_id: UUID) -> dict[str, Any]:
|
def get_analysis_run(name: str, run_id: UUID) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class TaskAlertService:
|
|||||||
state_document = state.model_dump(mode="json")
|
state_document = state.model_dump(mode="json")
|
||||||
await self.repo.save_event(event, state_document)
|
await self.repo.save_event(event, state_document)
|
||||||
await self.repo.save_alert(state_document)
|
await self.repo.save_alert(state_document)
|
||||||
|
append_changed_event = getattr(self.repo, "append_changed_event", None)
|
||||||
|
if append_changed_event is not None:
|
||||||
|
await append_changed_event(state_document)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def _transition(
|
def _transition(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "de342f10ac2949eb337513b1d1cb6b595083be2fad2860c9ccd92eebe2758e6b"
|
"sha256": "3669e1b9ba31dad83f9c53028fd974b8bacac0f9d3069f644e49254baf6bb82f"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,19 @@
|
|||||||
],
|
],
|
||||||
"title": "Assignee"
|
"title": "Assignee"
|
||||||
},
|
},
|
||||||
|
"location": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 200,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Location"
|
||||||
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"default": "normal",
|
"default": "normal",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -267,6 +280,19 @@
|
|||||||
"title": "Request Id",
|
"title": "Request Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"scenario_code": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Scenario Code"
|
||||||
|
},
|
||||||
"scheduled_at": {
|
"scheduled_at": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -285,12 +311,29 @@
|
|||||||
"draft",
|
"draft",
|
||||||
"open",
|
"open",
|
||||||
"in_progress",
|
"in_progress",
|
||||||
|
"awaiting_reply",
|
||||||
"completed",
|
"completed",
|
||||||
"cancelled"
|
"cancelled"
|
||||||
],
|
],
|
||||||
"title": "Status",
|
"title": "Status",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subtasks": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
|
||||||
|
},
|
||||||
|
"maxItems": 50,
|
||||||
|
"minItems": 1,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Subtasks"
|
||||||
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"maxLength": 4000,
|
"maxLength": 4000,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
@@ -302,6 +345,16 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"work_order_type": {
|
||||||
|
"default": "general",
|
||||||
|
"enum": [
|
||||||
|
"general",
|
||||||
|
"water_quality_complaint",
|
||||||
|
"burst_report"
|
||||||
|
],
|
||||||
|
"title": "Work Order Type",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1586,16 +1639,44 @@
|
|||||||
"title": "Clear Assignee",
|
"title": "Clear Assignee",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"clear_location": {
|
||||||
|
"default": false,
|
||||||
|
"title": "Clear Location",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"clear_scenario_code": {
|
||||||
|
"default": false,
|
||||||
|
"title": "Clear Scenario Code",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"clear_scheduled_at": {
|
"clear_scheduled_at": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"title": "Clear Scheduled At",
|
"title": "Clear Scheduled At",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"clear_subtasks": {
|
||||||
|
"default": false,
|
||||||
|
"title": "Clear Subtasks",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"expected_revision": {
|
"expected_revision": {
|
||||||
"minimum": 1.0,
|
"minimum": 1.0,
|
||||||
"title": "Expected Revision",
|
"title": "Expected Revision",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"location": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 200,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Location"
|
||||||
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1618,6 +1699,19 @@
|
|||||||
"title": "Request Id",
|
"title": "Request Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"scenario_code": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Scenario Code"
|
||||||
|
},
|
||||||
"scheduled_at": {
|
"scheduled_at": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1637,6 +1731,7 @@
|
|||||||
"draft",
|
"draft",
|
||||||
"open",
|
"open",
|
||||||
"in_progress",
|
"in_progress",
|
||||||
|
"awaiting_reply",
|
||||||
"completed",
|
"completed",
|
||||||
"cancelled"
|
"cancelled"
|
||||||
],
|
],
|
||||||
@@ -1648,6 +1743,22 @@
|
|||||||
],
|
],
|
||||||
"title": "Status"
|
"title": "Status"
|
||||||
},
|
},
|
||||||
|
"subtasks": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
|
||||||
|
},
|
||||||
|
"maxItems": 50,
|
||||||
|
"minItems": 1,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Subtasks"
|
||||||
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1705,6 +1816,17 @@
|
|||||||
"title": "Created By",
|
"title": "Created By",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"location": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Location"
|
||||||
|
},
|
||||||
"origin": {
|
"origin": {
|
||||||
"const": "synthetic",
|
"const": "synthetic",
|
||||||
"title": "Origin",
|
"title": "Origin",
|
||||||
@@ -1730,6 +1852,17 @@
|
|||||||
"title": "Revision",
|
"title": "Revision",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"scenario_code": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Scenario Code"
|
||||||
|
},
|
||||||
"scheduled_at": {
|
"scheduled_at": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1747,12 +1880,29 @@
|
|||||||
"draft",
|
"draft",
|
||||||
"open",
|
"open",
|
||||||
"in_progress",
|
"in_progress",
|
||||||
|
"awaiting_reply",
|
||||||
"completed",
|
"completed",
|
||||||
"cancelled"
|
"cancelled"
|
||||||
],
|
],
|
||||||
"title": "Status",
|
"title": "Status",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subtasks": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask"
|
||||||
|
},
|
||||||
|
"maxItems": 50,
|
||||||
|
"minItems": 1,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Subtasks"
|
||||||
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Summary",
|
"title": "Summary",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1774,6 +1924,16 @@
|
|||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Work Order Id",
|
"title": "Work Order Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"work_order_type": {
|
||||||
|
"default": "general",
|
||||||
|
"enum": [
|
||||||
|
"general",
|
||||||
|
"water_quality_complaint",
|
||||||
|
"burst_report"
|
||||||
|
],
|
||||||
|
"title": "Work Order Type",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1824,6 +1984,149 @@
|
|||||||
],
|
],
|
||||||
"title": "WorkOrderPage",
|
"title": "WorkOrderPage",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
},
|
||||||
|
"WorkbenchRuntimeWorkOrderStage": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Description",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Owner",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"minimum": 1.0,
|
||||||
|
"title": "Sequence",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"done",
|
||||||
|
"current",
|
||||||
|
"pending",
|
||||||
|
"blocked",
|
||||||
|
"skipped"
|
||||||
|
],
|
||||||
|
"title": "Status",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"time_label": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Time Label",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Title",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"sequence",
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"owner",
|
||||||
|
"time_label",
|
||||||
|
"description"
|
||||||
|
],
|
||||||
|
"title": "WorkbenchRuntimeWorkOrderStage",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"WorkbenchRuntimeWorkOrderSubtask": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"assignee": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Assignee",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration_minutes": {
|
||||||
|
"minimum": 1.0,
|
||||||
|
"title": "Duration Minutes",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Location",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reply_requirements": {
|
||||||
|
"items": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": "Reply Requirements",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"reply_window_minutes": {
|
||||||
|
"minimum": 1.0,
|
||||||
|
"title": "Reply Window Minutes",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"scheduled_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Scheduled At",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"stages": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderStage"
|
||||||
|
},
|
||||||
|
"title": "Stages",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"blocked",
|
||||||
|
"completed",
|
||||||
|
"cancelled"
|
||||||
|
],
|
||||||
|
"title": "Status",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Summary",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Title",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"summary",
|
||||||
|
"status",
|
||||||
|
"scheduled_at",
|
||||||
|
"location",
|
||||||
|
"assignee",
|
||||||
|
"duration_minutes",
|
||||||
|
"reply_window_minutes",
|
||||||
|
"stages",
|
||||||
|
"reply_requirements"
|
||||||
|
],
|
||||||
|
"title": "WorkbenchRuntimeWorkOrderSubtask",
|
||||||
|
"type": "object"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
# 15173 联调 UI Keycloak 客户端
|
||||||
|
|
||||||
|
2026-09-23 已在 `tjwater` realm 验证独立 public client
|
||||||
|
`tjwater-integration-ui`。该客户端只用于本机 15173 联调页面,不替代或修改
|
||||||
|
现有 `next-tjwater` 客户端。
|
||||||
|
|
||||||
|
已验证配置:
|
||||||
|
|
||||||
|
- Standard Authorization Code Flow:开启。
|
||||||
|
- PKCE:强制 `S256`。
|
||||||
|
- Implicit Flow、Direct Access Grants、Service Accounts:关闭。
|
||||||
|
- Public client:开启,不保存客户端密钥。
|
||||||
|
- Full Scope Allowed:关闭;未授予 realm-management 或系统管理员角色。
|
||||||
|
- Redirect URI:`http://127.0.0.1:15173/*`、`http://localhost:15173/*`。
|
||||||
|
- Web Origin:`http://127.0.0.1:15173`、`http://localhost:15173`。
|
||||||
|
|
||||||
|
可使用以下幂等脚本复核或恢复同一配置;管理员密码仅从隐藏提示或进程环境
|
||||||
|
读取,不会写入文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/provision_integration_ui_client.py
|
||||||
|
```
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 工况工作进程与外部动作适配
|
||||||
|
|
||||||
|
工况执行不依赖浏览器保活。API 保存任务及审批事实,工作进程扫描持久化队列,再向 Agent 提交后端分配的执行请求编号。相同请求编号重试复用同一执行,不能根据轮询时间生成新的编号。
|
||||||
|
|
||||||
|
## 调查任务队列
|
||||||
|
|
||||||
|
当前产品分为定时任务、调查任务、预约任务。定时任务保留前端 mock 数据,不调用后端计划创建或到期调度;调查任务读取数据库并进入本工作队列;预约任务读取数据库中的外围工单投影,以外部同步进度为准。已有后端 schedule API 暂不在当前产品主路径使用。
|
||||||
|
|
||||||
|
从后端仓库运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/run_operational_worker.py --once
|
||||||
|
conda run -n server python scripts/run_operational_worker.py
|
||||||
|
```
|
||||||
|
|
||||||
|
部署环境提供以下变量,不将实际值写入仓库。工作进程也读取后端仓库已忽略的 `.env`,已存在的进程环境变量优先:
|
||||||
|
|
||||||
|
| 变量 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `OPERATIONAL_WORKER_PROJECT_ID` | 唯一授权项目 UUID |
|
||||||
|
| `OPERATIONAL_WORKER_SERVER_URL` | 主后端地址,默认本机 8000 |
|
||||||
|
| `OPERATIONAL_WORKER_AGENT_URL` | Agent 地址,默认本机 8787 |
|
||||||
|
| `OPERATIONAL_WORKER_RECONCILE_SECONDS` | SSE 中断或漏通知时的对账间隔,默认 30 秒 |
|
||||||
|
| `OPERATIONAL_WORKER_MAX_CONCURRENCY` | 同时投递的 run 上限,默认 4,可设置 1–32 |
|
||||||
|
| `OPERATIONAL_WORKER_BEARER` | 已具有该项目权限的产品身份,仅用于受控开发或短期运行 |
|
||||||
|
| `OPERATIONAL_WORKER_TOKEN_URL` | 可选 OAuth 客户端凭据取令牌地址 |
|
||||||
|
| `OPERATIONAL_WORKER_CLIENT_ID`、`OPERATIONAL_WORKER_CLIENT_SECRET` | 长期运行身份;不配置固定 Bearer 时使用,并按有效期刷新令牌 |
|
||||||
|
| `OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS` | OAuth 缓存上限,默认 300 秒,可设置 30–600 秒;同时受令牌 expires_in 限制,应短于后端允许的最大令牌年龄 |
|
||||||
|
|
||||||
|
工作身份必须能通过现有产品身份与项目成员权限校验。配置令牌获取不等于自动创建产品用户、项目成员或授权。权限错误会保留任务用于后续重试,不会绕过认证。
|
||||||
|
|
||||||
|
长期 OAuth 身份由身份管理员创建支持 `client_credentials` 的专用机密客户端;服务账号令牌应具有后端配置要求的 audience、UUID `sub` 和 `preferred_username`。再由产品管理员把该 `sub` 绑定到启用的产品用户及目标项目成员关系。只赋予本次调查所需项目权限,不授予系统管理员或设备命令权限。当前项目成员策略同时包含工单审批/执行权限,不能将其误称为已经细分的专用最小权限服务角色。
|
||||||
|
|
||||||
|
本机联调配置示意(密钥由管理员通过安全配置填入,禁止粘贴到聊天):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
OPERATIONAL_WORKER_PROJECT_ID=324ff7ee-c2d4-5184-a84b-48fb14a2933e
|
||||||
|
OPERATIONAL_WORKER_SERVER_URL=http://127.0.0.1:8000
|
||||||
|
OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:18787
|
||||||
|
OPERATIONAL_WORKER_TOKEN_URL=https://<identity-host>/realms/<realm>/protocol/openid-connect/token
|
||||||
|
OPERATIONAL_WORKER_CLIENT_ID=<dedicated-service-client>
|
||||||
|
OPERATIONAL_WORKER_CLIENT_SECRET=<inject-securely>
|
||||||
|
OPERATIONAL_ACTION_PERIPHERAL_URL=http://127.0.0.1:8011
|
||||||
|
OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8011
|
||||||
|
```
|
||||||
|
|
||||||
|
选择 OAuth 时不要同时配置 `OPERATIONAL_WORKER_BEARER`,否则固定 Bearer 优先。不要配置设备命令网关进行本次合成联调。
|
||||||
|
|
||||||
|
工作进程先恢复过期执行租约,再使用 keyset 游标扫描持久化 run 队列;`operational.run.queued` 事件会立即唤醒扫描,连接中断时每 30 秒对账。Agent 正在执行时每 20 秒续租,租约为 60 秒;进程中断后由后端分配新的执行代次,旧 run 和旧会话不能继续覆盖新一轮结果。网络错误及 5xx 从 5 秒指数退避到 5 分钟,确定性 4xx 终止该 run;连续 10 次投递失败产生运维通知。
|
||||||
|
|
||||||
|
Agent 正常结束必须调用 run `complete`。同一执行代次缺少最终计划或报告时,Server 将 run 以 `ARTIFACT_INCOMPLETE` 明确失败,不依赖租约超时猜测完成状态。心跳只增加 run revision,不改变任务业务 revision。
|
||||||
|
|
||||||
|
## 事件日志与清理
|
||||||
|
|
||||||
|
`resources/sql/015_workbench_events_and_task_runs.sql` 在每个项目 BizDB 中增加 `domain_event_log` 和 `operational_task_runs`。事件与业务投影在同一事务提交,`LISTEN/NOTIFY` 只负责唤醒;浏览器与 worker 始终按持久游标补读。每个 API 进程对活跃项目最多保留一条 LISTEN 连接,无订阅者 60 秒后关闭。
|
||||||
|
|
||||||
|
事件默认保留 7 天。定时任务先执行 dry-run,再执行清理;脚本从 Metadata DB 解析所有活跃项目路由,并在发现多个项目共享同一 BizDB 时拒绝运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/cleanup_workbench_events.py --dry-run
|
||||||
|
conda run -n server python scripts/cleanup_workbench_events.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源。
|
||||||
|
|
||||||
|
## 外围样板工单同步
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/sync_peripheral_work_orders.py --once
|
||||||
|
conda run -n server python scripts/sync_peripheral_work_orders.py
|
||||||
|
```
|
||||||
|
|
||||||
|
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器读取分页工单,将外围版本投影到 `external_work_order` 任务。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。
|
||||||
|
|
||||||
|
水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。
|
||||||
|
|
||||||
|
## 旧调查迁移
|
||||||
|
|
||||||
|
`scripts/import_agent_investigation.py <session_id> --dry-run` 只检查旧合成水质调查能否转换。去掉 `--dry-run` 后使用上述产品工作身份导入后端任务、流程、证据及阶段报告;不会修改原 Agent 文件,也不会覆盖已存在的任务计划。应在启用队列工作进程前导入,避免旧调查被当作新的待执行任务领取。
|
||||||
|
|
||||||
|
## 调查数据读取
|
||||||
|
|
||||||
|
`OPERATIONAL_DATA_SERVER_URL` 默认本机 8000;外围样板数据读取需配置 `OPERATIONAL_DATA_PERIPHERAL_URL`。查询只允许预定义的数据源、最多 20 个设备和 24 小时时间范围,响应有大小限制。每次查询保存内容摘要对应的数据快照;缺失、拒绝、超限或未配置都返回明确错误,不生成正常数据结论。截断结果必须保留不完整标记。
|
||||||
|
|
||||||
|
## 人工确认后的动作
|
||||||
|
|
||||||
|
- `OPERATIONAL_ACTION_PERIPHERAL_URL`:使用既有 `/api/sample/v1/work-orders` 创建合成外围工单。成功回执说明工单已创建,不表示现场工作已经完成。
|
||||||
|
- `OPERATIONAL_ACTION_COMMAND_GATEWAY_URL`:可选设备命令网关。未配置时明确返回不支持执行,不能生成成功回执。
|
||||||
|
- `OPERATIONAL_ACTION_TIMEOUT_SECONDS`:外部请求超时,默认 10 秒。
|
||||||
|
|
||||||
|
命令网关契约:`POST /api/v1/commands` 接收 `request_id`、`project_id`、`task_id`、`action_id`、`action_version`、`approved_by`、`approved_at` 与类型化 `command`。网关必须按 `request_id` 幂等处理,并在执行前验证目标设备当前状态、前置条件和命令有效期。
|
||||||
|
|
||||||
|
回执包含 `request_id`、`project_id`、`receipt_id`、`status`(`accepted` / `running` / `completed` / `failed`)、`submitted_at`、可空 `completed_at` 和 `detail`。`completed` 必须有完成时间。网关回执查询路径为 `GET /api/v1/commands/requests/{request_id}`。
|
||||||
|
|
||||||
|
请求和回执时间必须包含时区。网关地址只来自服务端配置,Agent 或用户动作内容不能指定任意目的地址。产品 Bearer 仅在当前请求内传递,不进入任务、会话、提示词和日志。没有回执的网络故障保留为 `unknown`,不能当作失败后直接重新下发。
|
||||||
|
|
||||||
|
## 验证边界
|
||||||
|
|
||||||
|
适配器和工作进程测试使用隔离 HTTP transport 验证权限前提、幂等身份、丢响应恢复、项目隔离、接受与完成的区别。它们不等于生产设备网关或现场执行验收;接入具体网关后还需以其真实回执验证执行链。
|
||||||
|
|
||||||
|
可从后端仓库复现聚焦验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python -m pytest tests/unit/test_operational_action_adapters.py tests/unit/test_operational_worker.py tests/unit/test_peripheral_task_sync.py tests/unit/test_operational_data.py tests/unit/test_import_agent_investigation.py -q
|
||||||
|
OPERATIONAL_TASKS_STORAGE_TEST=1 conda run -n server python -m pytest tests/integration/test_operational_tasks_storage.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
第二条需要当前配置的 PostgreSQL 可连接及测试 DDL 权限。测试在强制回滚事务中创建表结构和隔离项目数据,结束后不保留这些修改;不代替正式迁移。外部工单调用仍使用隔离 HTTP transport,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 工况任务后端契约
|
||||||
|
|
||||||
|
`resources/sql/014_operational_tasks.sql` 提供项目隔离的统一工况任务存储。所有接口均位于 `/api/v1`,使用产品 Bearer 身份和 `X-Project-Id`;任务正文不会接受客户端提供的项目 ID。
|
||||||
|
|
||||||
|
## 三类任务
|
||||||
|
|
||||||
|
- 定时任务:当前产品继续使用前端 mock。`scheduled_flow` 及后端计划接口为已有保留能力,不接当前前端、不由当前 worker 调度或执行。
|
||||||
|
- `external_work_order`:外围预约、维修等工单的只读任务投影。`source.kind + source.external_id` 在项目内唯一,进度以外部版本反馈为准。
|
||||||
|
- `event_investigation`:投诉、爆管上报或人工发起的调查。Agent 查询受控数据,发布执行图、证据引用和报告;现场反馈会创建新的执行 generation,让 Agent 重新分析。
|
||||||
|
|
||||||
|
创建请求使用 `request_id` 幂等。任务执行使用独立的 `run_id + execution_request_id`:Agent 必须领取当前 run,并通过 run 级心跳维持 60 秒租约。正常结束必须显式 complete;缺少同代最终计划或报告会以 `ARTIFACT_INCOMPLETE` 失败。`POST /operational-tasks/leases/recover` 会处理过期执行,连续三代超时后停止自动重试。
|
||||||
|
|
||||||
|
## 主要接口
|
||||||
|
|
||||||
|
| 方法和路径 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /operational-tasks` | 幂等创建任务 |
|
||||||
|
| `GET /operational-tasks` | 按类型、状态分页读取任务 |
|
||||||
|
| `GET /operational-tasks/{task_id}` | 读取当前计划、报告、证据摘要、审批动作和当前用户权限 |
|
||||||
|
| `GET /operational-tasks/{task_id}/agent-context` | 读取 Agent 任务上下文及当前身份允许的数据源 |
|
||||||
|
| `POST /operational-tasks/{task_id}/runs/{run_id}/claim` | 使用当前执行请求号领取 queued run |
|
||||||
|
| `POST /operational-tasks/{task_id}/runs/{run_id}/heartbeat` | 续期当前 Agent run,不递增任务业务 revision |
|
||||||
|
| `POST /operational-tasks/{task_id}/runs/{run_id}/complete` | 校验同代最终产物并显式结束 run |
|
||||||
|
| `POST /operational-tasks/{task_id}/runs/{run_id}/fail` | 使用稳定失败码显式终止 run |
|
||||||
|
| `POST /operational-tasks/{task_id}/data-query` | 查询固定白名单数据源,同时保存有界原始快照并返回证据 ID |
|
||||||
|
| `POST /operational-tasks/{task_id}/agent-artifacts` | 追加证据、计划快照和报告修订 |
|
||||||
|
| `POST /operational-tasks/{task_id}/human-evidence` | 保存用户补充信息并重新排队分析 |
|
||||||
|
| `POST /operational-tasks/{task_id}/external-feedback` | 幂等同步稳定外部事件流的单调版本 |
|
||||||
|
| `POST /operational-tasks/{task_id}/actions` | 提议需要人工确认的动作 |
|
||||||
|
| `POST .../actions/{action_id}/decision` | 有对应审批权限的用户确认或拒绝 |
|
||||||
|
| `POST .../actions/{action_id}/execute` | 重新校验执行权限后通过类型化适配器提交 |
|
||||||
|
| `POST .../actions/{action_id}/reconcile` | 仅使用固定执行请求号查询不确定或已提交动作的回执 |
|
||||||
|
| `GET /operational-task-notifications` | 返回当前用户可见、按用户区分已读状态的通知 |
|
||||||
|
|
||||||
|
报告采用不可变修订,正文只保存当前结论、结论边界、建议、证据引用和待确认事项。详细的 Agent 工具轨迹和模型内部推理不进入报告。系统数据只能通过受控 `data-query` 形成证据,外围反馈和人工补充也有各自入口;Agent 不能自行声明这三类证据。合成演练证据始终标记为 `synthetic`。
|
||||||
|
|
||||||
|
## 人工确认与执行真实性
|
||||||
|
|
||||||
|
动作分为 `create_work_order` 和 `issue_command`,审批状态与执行状态分别保存。每次状态变化都会写入不可变动作版本;首次提交生成固定 `execution_request_id`,后续回执核对不改变它。
|
||||||
|
|
||||||
|
工单审批和执行可授予项目成员。设备命令审批、执行只授予具有系统管理身份且同时能进入项目的人员。普通查看者只能看任务及其 `permissions`,不能从前端角色名称推断或绕过服务端权限。
|
||||||
|
|
||||||
|
`approved` 只表示允许提交。`submitted` 需要外部接收回执,`succeeded` 还需要完成时间和完成回执。未配置适配器返回 `unsupported`,响应丢失返回 `unknown`;这两种情况都不会被记录为执行成功。外部适配器及 worker 的部署方式见 `docs/operational-task-workers.md`。
|
||||||
|
|
||||||
|
## 数据库部署
|
||||||
|
|
||||||
|
对每个项目 BizDB 应用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/014_operational_tasks.sql
|
||||||
|
psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/015_workbench_events_and_task_runs.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移不会创建或修改外围系统的业务表,也不授予 `PUBLIC` 权限。生产运行前需按部署身份显式授予新表权限。
|
||||||
@@ -13,3 +13,9 @@
|
|||||||
- `/api/sample/v1/demo-data`
|
- `/api/sample/v1/demo-data`
|
||||||
|
|
||||||
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
|
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
|
||||||
|
|
||||||
|
## 水质投诉 Agent 演示
|
||||||
|
|
||||||
|
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的合成投诉事件,或 `work_order_type=burst_report` 的合成爆管上报。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。
|
||||||
|
|
||||||
|
样板服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST /
|
|||||||
- 每个子任务包含一个或多个有序 `stages`,`sequence` 从 1 连续递增,阶段编号在子任务内唯一,最多只有一个 `current` 阶段。
|
- 每个子任务包含一个或多个有序 `stages`,`sequence` 从 1 连续递增,阶段编号在子任务内唯一,最多只有一个 `current` 阶段。
|
||||||
- 阶段名称不写死为固定枚举。演示数据使用派发、执行、复令,正式工单适配器可以按业务定义其他阶段。
|
- 阶段名称不写死为固定枚举。演示数据使用派发、执行、复令,正式工单适配器可以按业务定义其他阶段。
|
||||||
|
|
||||||
演示数据可以先运行 `scripts/seed_peripheral_sample.py`,再运行 `scripts/seed_workbench_work_orders.py` 投影。工作台会显示一张主工单,三项外围核查分别作为子任务展示。
|
预约任务演示数据通过 `scripts/seed_workbench_work_orders.py` 直接写入。工作台会显示三张原有 mock 工单,分别处于派发、执行和复令阶段;每张工单都保留自己的子任务与有序阶段。脚本可重复执行,并会先清理旧的外围合成工单投影。外围种子脚本仍会恢复被软删除的固定演示资源。
|
||||||
|
|
||||||
工作台运行账号对工单表只需 `SELECT`,同步账号使用独立受控写权限;对告警事件表需要 `SELECT, INSERT`,对告警状态表需要 `SELECT, INSERT, UPDATE`。
|
工作台运行账号对工单表只需 `SELECT`,同步账号使用独立受控写权限;对告警事件表需要 `SELECT, INSERT`,对告警状态表需要 `SELECT, INSERT, UPDATE`。
|
||||||
|
|||||||
@@ -7,4 +7,4 @@
|
|||||||
- `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。
|
- `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。
|
||||||
- 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。
|
- 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。
|
||||||
|
|
||||||
演示工单由 `scripts/seed_peripheral_sample.py` 写入 `peripheral_sample`,再由 `scripts/seed_workbench_work_orders.py` 将三项外围核查组合成一张主工单和三个子任务,写入 `workbench.work_orders`。当前固定演示日期为 2026-09-17。
|
预约任务演示数据由 `scripts/seed_workbench_work_orders.py` 直接写入 `workbench.work_orders`,与外围样板的 synthetic 测试数据相互独立。种子集 `workbench-demo-orders@1` 恢复前端原有的三张 mock 工单,分别覆盖派发、执行和复令状态;脚本会在同一事务中删除旧的“外围合成数据核查”投影并幂等写入新记录。当前固定演示日期为 2026-09-17。外围种子脚本仍可重复执行,并会恢复曾被软删除的固定演示工单和测试数据。
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
-- Durable project-scoped operational tasks, immutable artifacts and approvals.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS workbench;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_tasks (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
create_request_id uuid NOT NULL,
|
||||||
|
task_type text NOT NULL CHECK (task_type IN
|
||||||
|
('scheduled_flow','external_work_order','event_investigation')),
|
||||||
|
workflow text NOT NULL CHECK (length(workflow) > 0),
|
||||||
|
status text NOT NULL CHECK (status IN
|
||||||
|
('queued','running','waiting_human','completed','failed','cancelled')),
|
||||||
|
priority text NOT NULL CHECK (priority IN ('normal','urgent')),
|
||||||
|
revision bigint NOT NULL CHECK (revision >= 1),
|
||||||
|
execution_generation bigint NOT NULL CHECK (execution_generation >= 1),
|
||||||
|
execution_request_id uuid NOT NULL,
|
||||||
|
agent_session_id text,
|
||||||
|
agent_lease_expires_at timestamptz,
|
||||||
|
current_plan_revision bigint,
|
||||||
|
current_report_revision bigint,
|
||||||
|
scheduled_for timestamptz,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, task_id),
|
||||||
|
UNIQUE (project_id, create_request_id),
|
||||||
|
UNIQUE (project_id, execution_request_id),
|
||||||
|
CHECK ((jsonb_typeof(document)='object'
|
||||||
|
AND document->>'task_id'=task_id::text
|
||||||
|
AND document->>'task_type'=task_type
|
||||||
|
AND document->>'workflow'=workflow
|
||||||
|
AND document->>'status'=status
|
||||||
|
AND (document->>'revision')::bigint=revision) IS TRUE)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS operational_tasks_status_idx
|
||||||
|
ON workbench.operational_tasks(project_id,status,updated_at DESC,task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS operational_tasks_lease_idx
|
||||||
|
ON workbench.operational_tasks(project_id,agent_lease_expires_at)
|
||||||
|
WHERE status='running' AND agent_lease_expires_at IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS operational_tasks_external_source_idx
|
||||||
|
ON workbench.operational_tasks(
|
||||||
|
project_id,
|
||||||
|
(document#>>'{source,kind}'),
|
||||||
|
(document#>>'{source,external_id}')
|
||||||
|
) WHERE document#>>'{source,external_id}' IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_requests (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
scope text NOT NULL CHECK (length(scope) > 0),
|
||||||
|
request_id uuid NOT NULL,
|
||||||
|
request_digest text NOT NULL CHECK (length(request_digest) > 0),
|
||||||
|
response jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (project_id,scope,request_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_evidence (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
evidence_id text NOT NULL CHECK (length(evidence_id) > 0),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,task_id,evidence_id),
|
||||||
|
FOREIGN KEY (project_id,task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_plans (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
revision bigint NOT NULL CHECK (revision >= 1),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,task_id,revision),
|
||||||
|
FOREIGN KEY (project_id,task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_reports (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
revision bigint NOT NULL CHECK (revision >= 1),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,task_id,revision),
|
||||||
|
FOREIGN KEY (project_id,task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_actions (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
action_id uuid NOT NULL,
|
||||||
|
version bigint NOT NULL CHECK (version >= 1),
|
||||||
|
approval_status text NOT NULL CHECK (approval_status IN
|
||||||
|
('pending','approved','rejected')),
|
||||||
|
execution_status text NOT NULL CHECK (execution_status IN
|
||||||
|
('not_submitted','submitted','succeeded','failed','unsupported','unknown')),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,task_id,action_id),
|
||||||
|
FOREIGN KEY (project_id,task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE,
|
||||||
|
CHECK ((document->>'action_id'=action_id::text
|
||||||
|
AND (document->>'version')::bigint=version
|
||||||
|
AND document->>'approval_status'=approval_status
|
||||||
|
AND document->>'execution_status'=execution_status) IS TRUE)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_action_revisions (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
action_id uuid NOT NULL,
|
||||||
|
version bigint NOT NULL CHECK (version >= 1),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
recorded_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,task_id,action_id,version),
|
||||||
|
FOREIGN KEY (project_id,task_id,action_id)
|
||||||
|
REFERENCES workbench.operational_task_actions(project_id,task_id,action_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CHECK ((document->>'action_id'=action_id::text
|
||||||
|
AND (document->>'version')::bigint=version) IS TRUE)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_external_feedback (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
source_event_id text NOT NULL CHECK (length(source_event_id) > 0),
|
||||||
|
source_revision bigint NOT NULL CHECK (source_revision >= 1),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
accepted_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,task_id,source_event_id),
|
||||||
|
FOREIGN KEY (project_id,task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_notifications (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
notification_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,notification_id),
|
||||||
|
FOREIGN KEY (project_id,task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS operational_task_notifications_unread_idx
|
||||||
|
ON workbench.operational_task_notifications(project_id,created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_notification_reads (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
notification_id uuid NOT NULL,
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
version bigint NOT NULL CHECK (version >= 2),
|
||||||
|
read_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,notification_id,user_id),
|
||||||
|
FOREIGN KEY (project_id,notification_id)
|
||||||
|
REFERENCES workbench.operational_task_notifications(project_id,notification_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_schedules (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
schedule_id uuid NOT NULL,
|
||||||
|
create_request_id uuid NOT NULL,
|
||||||
|
schedule_key text NOT NULL CHECK (length(schedule_key) > 0),
|
||||||
|
next_run_at timestamptz NOT NULL,
|
||||||
|
interval_seconds integer NOT NULL CHECK (interval_seconds >= 60),
|
||||||
|
enabled boolean NOT NULL,
|
||||||
|
version bigint NOT NULL CHECK (version >= 1),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,schedule_id),
|
||||||
|
UNIQUE (project_id,create_request_id),
|
||||||
|
UNIQUE (project_id,schedule_key),
|
||||||
|
CHECK ((document->>'schedule_id'=schedule_id::text
|
||||||
|
AND document->>'schedule_key'=schedule_key
|
||||||
|
AND (document->>'version')::bigint=version) IS TRUE)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_schedule_occurrences (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
schedule_id uuid NOT NULL,
|
||||||
|
due_at timestamptz NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,schedule_id,due_at),
|
||||||
|
UNIQUE (project_id,task_id),
|
||||||
|
FOREIGN KEY (project_id,schedule_id)
|
||||||
|
REFERENCES workbench.operational_task_schedules(project_id,schedule_id),
|
||||||
|
FOREIGN KEY (project_id,task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id,task_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
-- Durable workbench event log and independent Agent execution runs.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS workbench;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.domain_event_log (
|
||||||
|
event_seq bigserial PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
event_id uuid NOT NULL,
|
||||||
|
topic text NOT NULL CHECK (topic IN (
|
||||||
|
'operational.task.changed',
|
||||||
|
'operational.run.queued',
|
||||||
|
'operational.run.changed',
|
||||||
|
'operational.report.published',
|
||||||
|
'operational.action.changed',
|
||||||
|
'operational.notification.changed',
|
||||||
|
'workbench.alert.changed',
|
||||||
|
'workbench.work_order.changed',
|
||||||
|
'analysis.run.changed'
|
||||||
|
)),
|
||||||
|
aggregate_type text NOT NULL CHECK (length(aggregate_type) > 0),
|
||||||
|
aggregate_id text NOT NULL CHECK (length(aggregate_id) > 0),
|
||||||
|
aggregate_revision bigint NOT NULL CHECK (aggregate_revision >= 1),
|
||||||
|
schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1),
|
||||||
|
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (project_id,event_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS domain_event_log_project_cursor_idx
|
||||||
|
ON workbench.domain_event_log(project_id,event_seq);
|
||||||
|
CREATE INDEX IF NOT EXISTS domain_event_log_retention_idx
|
||||||
|
ON workbench.domain_event_log(occurred_at,event_seq);
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS analysis.runs
|
||||||
|
ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1
|
||||||
|
CHECK (revision >= 1);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_runs (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
run_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
generation bigint NOT NULL CHECK (generation >= 1),
|
||||||
|
execution_request_id uuid NOT NULL,
|
||||||
|
status text NOT NULL CHECK (status IN (
|
||||||
|
'queued','claimed','running','succeeded','failed','timed_out','cancelled'
|
||||||
|
)),
|
||||||
|
revision bigint NOT NULL CHECK (revision >= 1),
|
||||||
|
agent_session_id text,
|
||||||
|
lease_expires_at timestamptz,
|
||||||
|
heartbeat_at timestamptz,
|
||||||
|
started_at timestamptz,
|
||||||
|
finished_at timestamptz,
|
||||||
|
failure_code text,
|
||||||
|
failure_detail text,
|
||||||
|
dispatch_attempts integer NOT NULL DEFAULT 0 CHECK (dispatch_attempts >= 0),
|
||||||
|
next_dispatch_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (project_id,run_id),
|
||||||
|
UNIQUE (project_id,task_id,generation),
|
||||||
|
UNIQUE (project_id,execution_request_id),
|
||||||
|
FOREIGN KEY (project_id,task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS operational_task_runs_one_active_idx
|
||||||
|
ON workbench.operational_task_runs(project_id,task_id)
|
||||||
|
WHERE status IN ('queued','claimed','running');
|
||||||
|
CREATE INDEX IF NOT EXISTS operational_task_runs_queue_idx
|
||||||
|
ON workbench.operational_task_runs(
|
||||||
|
project_id,next_dispatch_at,created_at,run_id
|
||||||
|
) WHERE status='queued';
|
||||||
|
CREATE INDEX IF NOT EXISTS operational_task_runs_lease_idx
|
||||||
|
ON workbench.operational_task_runs(project_id,lease_expires_at,run_id)
|
||||||
|
WHERE status IN ('claimed','running') AND lease_expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE workbench.operational_tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS lifecycle_status text;
|
||||||
|
|
||||||
|
UPDATE workbench.operational_tasks
|
||||||
|
SET lifecycle_status = CASE status
|
||||||
|
WHEN 'waiting_human' THEN 'waiting_human'
|
||||||
|
WHEN 'completed' THEN 'completed'
|
||||||
|
WHEN 'failed' THEN 'failed'
|
||||||
|
WHEN 'cancelled' THEN 'cancelled'
|
||||||
|
ELSE 'open'
|
||||||
|
END
|
||||||
|
WHERE lifecycle_status IS NULL;
|
||||||
|
|
||||||
|
UPDATE workbench.operational_tasks
|
||||||
|
SET document = document || jsonb_build_object('lifecycle_status', lifecycle_status)
|
||||||
|
WHERE document->>'lifecycle_status' IS DISTINCT FROM lifecycle_status;
|
||||||
|
|
||||||
|
ALTER TABLE workbench.operational_tasks
|
||||||
|
ALTER COLUMN lifecycle_status SET NOT NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid='workbench.operational_tasks'::regclass
|
||||||
|
AND conname='operational_tasks_lifecycle_status_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE workbench.operational_tasks
|
||||||
|
ADD CONSTRAINT operational_tasks_lifecycle_status_check
|
||||||
|
CHECK (lifecycle_status IN
|
||||||
|
('open','waiting_human','completed','failed','cancelled'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Deterministically preserve the currently active or last known execution as
|
||||||
|
-- generation history. uuid values are derived without requiring an extension.
|
||||||
|
INSERT INTO workbench.operational_task_runs (
|
||||||
|
project_id,run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
|
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,created_at,updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
(substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),1,8)
|
||||||
|
|| '-' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),9,4)
|
||||||
|
|| '-5' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),14,3)
|
||||||
|
|| '-a' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),18,3)
|
||||||
|
|| '-' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),21,12))::uuid,
|
||||||
|
task_id,
|
||||||
|
execution_generation,
|
||||||
|
execution_request_id,
|
||||||
|
CASE status
|
||||||
|
WHEN 'queued' THEN 'queued'
|
||||||
|
WHEN 'running' THEN 'running'
|
||||||
|
WHEN 'waiting_human' THEN 'succeeded'
|
||||||
|
WHEN 'completed' THEN 'succeeded'
|
||||||
|
WHEN 'failed' THEN 'failed'
|
||||||
|
WHEN 'cancelled' THEN 'cancelled'
|
||||||
|
END,
|
||||||
|
GREATEST(revision,1),
|
||||||
|
agent_session_id,
|
||||||
|
agent_lease_expires_at,
|
||||||
|
CASE WHEN status='running' THEN updated_at ELSE NULL END,
|
||||||
|
CASE WHEN status IN ('running','waiting_human','completed','failed','cancelled')
|
||||||
|
THEN created_at ELSE NULL END,
|
||||||
|
CASE WHEN status IN ('waiting_human','completed','failed','cancelled')
|
||||||
|
THEN updated_at ELSE NULL END,
|
||||||
|
CASE WHEN status='failed' THEN 'MIGRATED_FAILURE' ELSE NULL END,
|
||||||
|
NULL,
|
||||||
|
0,
|
||||||
|
created_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM workbench.operational_tasks
|
||||||
|
ON CONFLICT (project_id,task_id,generation) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Distinguish non-interactive OAuth identities from human product users.
|
||||||
|
-- This migration is intentionally re-runnable.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_role_check
|
||||||
|
CHECK (role IN ('admin', 'service', 'user'));
|
||||||
@@ -5,6 +5,7 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator
|
||||||
|
|
||||||
from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime
|
from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime
|
||||||
|
from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrderSubtask
|
||||||
|
|
||||||
Text = Annotated[str, Field(min_length=1, max_length=4000)]
|
Text = Annotated[str, Field(min_length=1, max_length=4000)]
|
||||||
PositiveInt = Annotated[StrictInt, Field(ge=1)]
|
PositiveInt = Annotated[StrictInt, Field(ge=1)]
|
||||||
@@ -116,8 +117,21 @@ class TaskPage(Model):
|
|||||||
next_cursor: UUID | None
|
next_cursor: UUID | None
|
||||||
|
|
||||||
|
|
||||||
WorkOrderStatus = Literal["draft", "open", "in_progress", "completed", "cancelled"]
|
WorkOrderStatus = Literal[
|
||||||
|
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
||||||
|
]
|
||||||
Priority = Literal["low", "normal", "high", "urgent"]
|
Priority = Literal["low", "normal", "high", "urgent"]
|
||||||
|
WorkOrderType = Literal["general", "water_quality_complaint", "burst_report"]
|
||||||
|
WorkOrderSubtasks = Annotated[
|
||||||
|
list[WorkbenchRuntimeWorkOrderSubtask], Field(min_length=1, max_length=50)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_unique_subtasks(
|
||||||
|
subtasks: list[WorkbenchRuntimeWorkOrderSubtask] | None,
|
||||||
|
) -> None:
|
||||||
|
if subtasks is not None and len({item.id for item in subtasks}) != len(subtasks):
|
||||||
|
raise ValueError("work order subtask ids must be unique")
|
||||||
|
|
||||||
|
|
||||||
class CreateWorkOrder(Model):
|
class CreateWorkOrder(Model):
|
||||||
@@ -128,11 +142,16 @@ class CreateWorkOrder(Model):
|
|||||||
priority: Priority = "normal"
|
priority: Priority = "normal"
|
||||||
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
||||||
scheduled_at: WireDateTime | None = None
|
scheduled_at: WireDateTime | None = None
|
||||||
|
work_order_type: WorkOrderType = "general"
|
||||||
|
location: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
||||||
|
scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None
|
||||||
|
subtasks: WorkOrderSubtasks | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def aware_schedule(self):
|
def aware_schedule(self):
|
||||||
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
|
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
|
||||||
raise ValueError("scheduled_at must include timezone")
|
raise ValueError("scheduled_at must include timezone")
|
||||||
|
_validate_unique_subtasks(self.subtasks)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +166,12 @@ class UpdateWorkOrder(Model):
|
|||||||
clear_assignee: StrictBool = False
|
clear_assignee: StrictBool = False
|
||||||
scheduled_at: WireDateTime | None = None
|
scheduled_at: WireDateTime | None = None
|
||||||
clear_scheduled_at: StrictBool = False
|
clear_scheduled_at: StrictBool = False
|
||||||
|
location: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
||||||
|
clear_location: StrictBool = False
|
||||||
|
scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None
|
||||||
|
clear_scenario_code: StrictBool = False
|
||||||
|
subtasks: WorkOrderSubtasks | None = None
|
||||||
|
clear_subtasks: StrictBool = False
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def valid_update(self):
|
def valid_update(self):
|
||||||
@@ -157,8 +182,15 @@ class UpdateWorkOrder(Model):
|
|||||||
raise ValueError("clear_assignee conflicts with assignee")
|
raise ValueError("clear_assignee conflicts with assignee")
|
||||||
if self.clear_scheduled_at and self.scheduled_at is not None:
|
if self.clear_scheduled_at and self.scheduled_at is not None:
|
||||||
raise ValueError("clear_scheduled_at conflicts with scheduled_at")
|
raise ValueError("clear_scheduled_at conflicts with scheduled_at")
|
||||||
|
if self.clear_location and self.location is not None:
|
||||||
|
raise ValueError("clear_location conflicts with location")
|
||||||
|
if self.clear_scenario_code and self.scenario_code is not None:
|
||||||
|
raise ValueError("clear_scenario_code conflicts with scenario_code")
|
||||||
|
if self.clear_subtasks and self.subtasks is not None:
|
||||||
|
raise ValueError("clear_subtasks conflicts with subtasks")
|
||||||
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
|
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
|
||||||
raise ValueError("scheduled_at must include timezone")
|
raise ValueError("scheduled_at must include timezone")
|
||||||
|
_validate_unique_subtasks(self.subtasks)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -182,6 +214,15 @@ class WorkOrder(Model):
|
|||||||
updated_at: WireDateTime
|
updated_at: WireDateTime
|
||||||
created_by: str
|
created_by: str
|
||||||
updated_by: str
|
updated_by: str
|
||||||
|
work_order_type: WorkOrderType = "general"
|
||||||
|
location: str | None = None
|
||||||
|
scenario_code: str | None = None
|
||||||
|
subtasks: WorkOrderSubtasks | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def unique_subtasks(self):
|
||||||
|
_validate_unique_subtasks(self.subtasks)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class WorkOrderPage(Model):
|
class WorkOrderPage(Model):
|
||||||
|
|||||||
@@ -263,7 +263,13 @@ class PostgresStore:
|
|||||||
f"SELECT document FROM peripheral_sample.{table} WHERE project_id=%s AND {id_column}=%s AND deleted_at IS NULL",
|
f"SELECT document FROM peripheral_sample.{table} WHERE project_id=%s AND {id_column}=%s AND deleted_at IS NULL",
|
||||||
(who.project_id, resource_id)).fetchone()
|
(who.project_id, resource_id)).fetchone()
|
||||||
ensure(row is not None, missing_code, missing_message, 404)
|
ensure(row is not None, missing_code, missing_message, 404)
|
||||||
return row["document"]
|
return self._active_managed_document(row["document"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _active_managed_document(document: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Project a current active row without legacy soft-delete metadata."""
|
||||||
|
return {key: value for key, value in document.items()
|
||||||
|
if key not in {"deleted_at", "deleted_by"}}
|
||||||
|
|
||||||
def _list_managed(self, who: Identity, table: str, id_column: str,
|
def _list_managed(self, who: Identity, table: str, id_column: str,
|
||||||
limit: int, cursor: UUID | None) -> dict[str, Any]:
|
limit: int, cursor: UUID | None) -> dict[str, Any]:
|
||||||
@@ -277,7 +283,7 @@ class PostgresStore:
|
|||||||
sql += f" AND {id_column}>%s"
|
sql += f" AND {id_column}>%s"
|
||||||
params.append(cursor)
|
params.append(cursor)
|
||||||
rows = conn.execute(sql + f" ORDER BY {id_column} LIMIT %s", params + [limit + 1]).fetchall()
|
rows = conn.execute(sql + f" ORDER BY {id_column} LIMIT %s", params + [limit + 1]).fetchall()
|
||||||
docs = [row["document"] for row in rows[:limit]]
|
docs = [self._active_managed_document(row["document"]) for row in rows[:limit]]
|
||||||
return {"items": docs, "next_cursor": docs[-1][id_column] if len(rows) > limit else None}
|
return {"items": docs, "next_cursor": docs[-1][id_column] if len(rows) > limit else None}
|
||||||
|
|
||||||
def _save_work_order(self, conn, who: Identity, document: dict[str, Any]) -> dict[str, Any]:
|
def _save_work_order(self, conn, who: Identity, document: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -319,11 +325,18 @@ class PostgresStore:
|
|||||||
"WORK_ORDER_NOT_FOUND", "测试工单不存在")
|
"WORK_ORDER_NOT_FOUND", "测试工单不存在")
|
||||||
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409)
|
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409)
|
||||||
changes = command.model_dump(mode="json", exclude_unset=True,
|
changes = command.model_dump(mode="json", exclude_unset=True,
|
||||||
exclude={"request_id", "expected_revision", "clear_assignee", "clear_scheduled_at"})
|
exclude={"request_id", "expected_revision", "clear_assignee", "clear_scheduled_at",
|
||||||
|
"clear_location", "clear_scenario_code", "clear_subtasks"})
|
||||||
if command.clear_assignee:
|
if command.clear_assignee:
|
||||||
changes["assignee"] = None
|
changes["assignee"] = None
|
||||||
if command.clear_scheduled_at:
|
if command.clear_scheduled_at:
|
||||||
changes["scheduled_at"] = None
|
changes["scheduled_at"] = None
|
||||||
|
if command.clear_location:
|
||||||
|
changes["location"] = None
|
||||||
|
if command.clear_scenario_code:
|
||||||
|
changes["scenario_code"] = None
|
||||||
|
if command.clear_subtasks:
|
||||||
|
changes["subtasks"] = None
|
||||||
document.update(changes)
|
document.update(changes)
|
||||||
document.update(revision=document["revision"] + 1, updated_at=datetime.now(timezone.utc).isoformat(),
|
document.update(revision=document["revision"] + 1, updated_at=datetime.now(timezone.utc).isoformat(),
|
||||||
updated_by=who.user_id)
|
updated_by=who.user_id)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Delete expired project workbench events in bounded batches."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from psycopg.conninfo import conninfo_to_dict
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(REPOSITORY_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||||
|
|
||||||
|
from app.auth.project_dependencies import DB_ROLE_BIZ_DATA
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.infra.db.metadb.database import SessionLocal, close_metadata_engine
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _database_identity(dsn: str) -> tuple[str, str, str]:
|
||||||
|
values = conninfo_to_dict(dsn)
|
||||||
|
return (
|
||||||
|
values.get("host") or values.get("hostaddr") or "localhost",
|
||||||
|
values.get("port") or "5432",
|
||||||
|
values.get("dbname") or values.get("user") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_project(
|
||||||
|
project_id: UUID, dsn: str, retention_days: int, batch_size: int, dry_run: bool
|
||||||
|
) -> int:
|
||||||
|
async with await AsyncConnection.connect(dsn) as conn:
|
||||||
|
if dry_run:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""SELECT count(*) FROM workbench.domain_event_log
|
||||||
|
WHERE project_id=%s
|
||||||
|
AND occurred_at < now() - make_interval(days => %s)""",
|
||||||
|
(project_id, retention_days),
|
||||||
|
)
|
||||||
|
return int((await cursor.fetchone())[0])
|
||||||
|
deleted = 0
|
||||||
|
while True:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""WITH expired AS (
|
||||||
|
SELECT event_seq FROM workbench.domain_event_log
|
||||||
|
WHERE project_id=%s
|
||||||
|
AND occurred_at < now() - make_interval(days => %s)
|
||||||
|
ORDER BY event_seq LIMIT %s
|
||||||
|
)
|
||||||
|
DELETE FROM workbench.domain_event_log e
|
||||||
|
USING expired WHERE e.event_seq=expired.event_seq""",
|
||||||
|
(project_id, retention_days, batch_size),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
count = cursor.rowcount
|
||||||
|
deleted += count
|
||||||
|
if count < batch_size:
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup(
|
||||||
|
retention_days: int, batch_size: int, project_id: UUID | None, dry_run: bool
|
||||||
|
) -> dict[UUID, int]:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
repository = MetadataRepository(session)
|
||||||
|
routings = await repository.list_active_project_db_routings(DB_ROLE_BIZ_DATA)
|
||||||
|
if project_id is not None:
|
||||||
|
routings = [routing for routing in routings if routing.project_id == project_id]
|
||||||
|
if not routings:
|
||||||
|
raise ValueError("active project business database routing was not found")
|
||||||
|
by_database = defaultdict(list)
|
||||||
|
for routing in routings:
|
||||||
|
by_database[_database_identity(routing.dsn)].append(routing)
|
||||||
|
shared = [items for items in by_database.values() if len(items) > 1]
|
||||||
|
if shared:
|
||||||
|
project_ids = ", ".join(str(item.project_id) for items in shared for item in items)
|
||||||
|
raise ValueError(f"shared business database routing is unsupported: {project_ids}")
|
||||||
|
return {
|
||||||
|
routing.project_id: await cleanup_project(
|
||||||
|
routing.project_id, routing.dsn, retention_days, batch_size, dry_run
|
||||||
|
)
|
||||||
|
for routing in routings
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main(args) -> None:
|
||||||
|
try:
|
||||||
|
results = await cleanup(
|
||||||
|
args.retention_days, args.batch_size, args.project_id, args.dry_run
|
||||||
|
)
|
||||||
|
for project_id, count in results.items():
|
||||||
|
action = "would delete" if args.dry_run else "deleted"
|
||||||
|
print(f"{project_id}: {action} {count} events")
|
||||||
|
finally:
|
||||||
|
await close_metadata_engine()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--retention-days", type=int, default=settings.WORKBENCH_EVENT_RETENTION_DAYS
|
||||||
|
)
|
||||||
|
parser.add_argument("--batch-size", type=int, default=5000)
|
||||||
|
parser.add_argument("--project-id", type=UUID)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.retention_days < 1 or args.batch_size < 1:
|
||||||
|
parser.error("retention days and batch size must be positive")
|
||||||
|
asyncio.run(main(args))
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Import one historical synthetic investigation without rewriting Agent history."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from app.domain.schemas.operational_task import AgentArtifactsCommand
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from run_operational_worker import CredentialProvider, WorkerSettings
|
||||||
|
|
||||||
|
|
||||||
|
def convert_plan(snapshot: dict) -> tuple[dict, list[dict], dict]:
|
||||||
|
evidence, nodes = {}, []
|
||||||
|
for node in snapshot["nodes"]:
|
||||||
|
refs = []
|
||||||
|
for text in node.get("evidence", []):
|
||||||
|
evidence_id = "legacy-synthetic:" + hashlib.sha256(text.encode()).hexdigest()[:24]
|
||||||
|
refs.append(evidence_id)
|
||||||
|
evidence[evidence_id] = {
|
||||||
|
"evidence_id": evidence_id, "kind": "synthetic", "summary": text,
|
||||||
|
"source_label": "历史水质投诉合成调查输入", "source_ref": snapshot["session_id"],
|
||||||
|
"observed_at": snapshot["updated_at"],
|
||||||
|
}
|
||||||
|
nodes.append({"id": node["id"], "title": node["title"], "owner": node["owner"],
|
||||||
|
"status": node["status"], "detail": node["detail"], "evidence_refs": refs})
|
||||||
|
plan = {"schema": "execution-plan@1", "title": snapshot["title"],
|
||||||
|
"reason": "从原调查会话导入已保存的计划,未重新执行调查", "nodes": nodes,
|
||||||
|
"edges": [{"from": edge["from"], "to": edge["to"],
|
||||||
|
**({"condition": edge["label"]} if edge.get("label") else {})}
|
||||||
|
for edge in snapshot["edges"]]}
|
||||||
|
judgment = next((node for node in nodes if node["id"] == "STAGE_JUDGMENT"), None)
|
||||||
|
report = {"title": "历史水质投诉调查报告", "current_conclusion": judgment["detail"] if judgment else "历史调查尚无阶段判断",
|
||||||
|
"conclusion_boundary": "合成演练的历史结果,未重新查询当前数据;现场工作以实际反馈为准",
|
||||||
|
"evidence_refs": judgment["evidence_refs"] if judgment else [],
|
||||||
|
"pending_items": [node["detail"] for node in nodes if node["status"] in {"waiting", "pending"}]}
|
||||||
|
return plan, list(evidence.values()), report
|
||||||
|
|
||||||
|
|
||||||
|
async def run(args):
|
||||||
|
if not re.fullmatch(r"ses_[A-Za-z0-9_-]+", args.session_id):
|
||||||
|
raise ValueError("invalid session id")
|
||||||
|
filename = args.session_id.lower() + ".json"
|
||||||
|
metadata = json.loads((args.agent_data / "session-metadata" / filename).read_text())
|
||||||
|
state = json.loads((args.agent_data / "session-ui-states" / filename).read_text())
|
||||||
|
if metadata.get("triggerWorkflow") != "water_quality_complaint" or metadata.get("triggerSource") != "peripheral_sample":
|
||||||
|
raise ValueError("only explicitly synthetic peripheral water investigations can be imported")
|
||||||
|
snapshots = state.get("executionPlans", [])
|
||||||
|
if not snapshots:
|
||||||
|
raise ValueError("session has no saved plan; no progress will be fabricated")
|
||||||
|
snapshot = max(snapshots, key=lambda item: item["revision"])
|
||||||
|
plan, evidence, report = convert_plan(snapshot)
|
||||||
|
# Validate the complete payload before reporting readiness or creating a
|
||||||
|
# backend task. A dry run must catch incompatible historical plan states.
|
||||||
|
artifacts = AgentArtifactsCommand.model_validate({
|
||||||
|
"request_id": str(uuid5(NAMESPACE_URL, f"legacy-artifacts:{metadata.get('projectId')}:{args.session_id}:{snapshot['revision']}")),
|
||||||
|
"source_session_id": args.session_id, "plan": plan, "evidence": evidence, "report": report,
|
||||||
|
}).model_dump(mode="json", by_alias=True)
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"Ready to import {len(plan['nodes'])} nodes and {len(evidence)} synthetic evidence records; no writes")
|
||||||
|
return
|
||||||
|
settings = WorkerSettings.from_environment()
|
||||||
|
if metadata.get("projectId") != settings.project_id:
|
||||||
|
raise ValueError("session does not belong to the worker project")
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
|
||||||
|
headers = await CredentialProvider(settings).headers(client)
|
||||||
|
base = settings.server_url.rstrip("/") + "/api/v1/operational-tasks"
|
||||||
|
response = await client.post(base, headers=headers, json={
|
||||||
|
"request_id": str(uuid5(NAMESPACE_URL, f"legacy-investigation:{settings.project_id}:{args.session_id}")),
|
||||||
|
"task_type": "event_investigation", "workflow": "water_quality_complaint",
|
||||||
|
"title": metadata.get("title") or "水质投诉调查", "summary": "从历史合成调查导入,原会话完整保留",
|
||||||
|
"source": {"kind": "external_system", "external_id": metadata["triggerSubjectId"], "event_kind": "water_quality_complaint"},
|
||||||
|
"trigger": {"kind": "event", "occurred_at": metadata["triggerOccurredAt"], "detail": "历史合成外围投诉事件"},
|
||||||
|
})
|
||||||
|
response.raise_for_status()
|
||||||
|
task = response.json()
|
||||||
|
if task["current_plan_revision"] is not None:
|
||||||
|
print("Task already has a plan; preserved without overwrite")
|
||||||
|
return
|
||||||
|
run = task.get("active_run")
|
||||||
|
if not run:
|
||||||
|
raise RuntimeError("task has no active Agent run")
|
||||||
|
if run.get("agent_session_id") != args.session_id or run["status"] != "running":
|
||||||
|
response = await client.post(
|
||||||
|
f"{base}/{task['task_id']}/runs/{run['run_id']}/claim",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": run["execution_request_id"],
|
||||||
|
"expected_revision": run["revision"],
|
||||||
|
"source_session_id": args.session_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
run = response.json()
|
||||||
|
artifacts["run_id"] = run["run_id"]
|
||||||
|
response = await client.post(f"{base}/{task['task_id']}/agent-artifacts", headers=headers, json=artifacts)
|
||||||
|
response.raise_for_status()
|
||||||
|
run = response.json()["task"]["active_run"]
|
||||||
|
response = await client.post(
|
||||||
|
f"{base}/{task['task_id']}/runs/{run['run_id']}/complete",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": str(uuid5(NAMESPACE_URL, f"legacy-complete:{settings.project_id}:{args.session_id}:{snapshot['revision']}")),
|
||||||
|
"expected_revision": run["revision"],
|
||||||
|
"source_session_id": args.session_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
print(f"Imported task {task['task_id']}; original Agent records unchanged")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("session_id")
|
||||||
|
parser.add_argument("--agent-data", type=Path, default=Path(__file__).resolve().parents[2] / "next-tjwater-agent/data")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
asyncio.run(run(parser.parse_args()))
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Provision the isolated public Keycloak client used by the 15173 UI."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(REPOSITORY_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||||
|
|
||||||
|
from scripts.provision_operational_worker_identity import ( # noqa: E402
|
||||||
|
KeycloakProvisioner,
|
||||||
|
ProvisionError,
|
||||||
|
_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
REDIRECT_URIS = [
|
||||||
|
"http://127.0.0.1:15173/*",
|
||||||
|
"http://localhost:15173/*",
|
||||||
|
]
|
||||||
|
WEB_ORIGINS = [
|
||||||
|
"http://127.0.0.1:15173",
|
||||||
|
"http://localhost:15173",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_public_client(
|
||||||
|
keycloak: KeycloakProvisioner, client_id: str
|
||||||
|
) -> tuple[bool, dict]:
|
||||||
|
matches = _request(
|
||||||
|
keycloak.client,
|
||||||
|
"GET",
|
||||||
|
f"{keycloak.admin_base}/clients",
|
||||||
|
params={"clientId": client_id},
|
||||||
|
).json()
|
||||||
|
exact = [item for item in matches if item.get("clientId") == client_id]
|
||||||
|
if len(exact) > 1:
|
||||||
|
raise ProvisionError("multiple Keycloak clients use the integration client id")
|
||||||
|
created = not exact
|
||||||
|
if created:
|
||||||
|
_request(
|
||||||
|
keycloak.client,
|
||||||
|
"POST",
|
||||||
|
f"{keycloak.admin_base}/clients",
|
||||||
|
json={
|
||||||
|
"clientId": client_id,
|
||||||
|
"name": "TJWater isolated integration UI",
|
||||||
|
"description": "Local 15173 UI; authorization code flow with PKCE only",
|
||||||
|
"enabled": True,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": True,
|
||||||
|
"bearerOnly": False,
|
||||||
|
"serviceAccountsEnabled": False,
|
||||||
|
"standardFlowEnabled": True,
|
||||||
|
"implicitFlowEnabled": False,
|
||||||
|
"directAccessGrantsEnabled": False,
|
||||||
|
"fullScopeAllowed": False,
|
||||||
|
"redirectUris": REDIRECT_URIS,
|
||||||
|
"webOrigins": WEB_ORIGINS,
|
||||||
|
"attributes": {"pkce.code.challenge.method": "S256"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
matches = _request(
|
||||||
|
keycloak.client,
|
||||||
|
"GET",
|
||||||
|
f"{keycloak.admin_base}/clients",
|
||||||
|
params={"clientId": client_id},
|
||||||
|
).json()
|
||||||
|
exact = [item for item in matches if item.get("clientId") == client_id]
|
||||||
|
if len(exact) != 1 or not isinstance(exact[0].get("id"), str):
|
||||||
|
raise ProvisionError("could not resolve the integration UI client")
|
||||||
|
internal_id = exact[0]["id"]
|
||||||
|
representation = _request(
|
||||||
|
keycloak.client,
|
||||||
|
"GET",
|
||||||
|
f"{keycloak.admin_base}/clients/{internal_id}",
|
||||||
|
).json()
|
||||||
|
attributes = dict(representation.get("attributes") or {})
|
||||||
|
attributes["pkce.code.challenge.method"] = "S256"
|
||||||
|
representation.update(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"publicClient": True,
|
||||||
|
"bearerOnly": False,
|
||||||
|
"serviceAccountsEnabled": False,
|
||||||
|
"standardFlowEnabled": True,
|
||||||
|
"implicitFlowEnabled": False,
|
||||||
|
"directAccessGrantsEnabled": False,
|
||||||
|
"fullScopeAllowed": False,
|
||||||
|
"redirectUris": REDIRECT_URIS,
|
||||||
|
"webOrigins": WEB_ORIGINS,
|
||||||
|
"attributes": attributes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_request(
|
||||||
|
keycloak.client,
|
||||||
|
"PUT",
|
||||||
|
f"{keycloak.admin_base}/clients/{internal_id}",
|
||||||
|
json=representation,
|
||||||
|
)
|
||||||
|
verified = _request(
|
||||||
|
keycloak.client,
|
||||||
|
"GET",
|
||||||
|
f"{keycloak.admin_base}/clients/{internal_id}",
|
||||||
|
).json()
|
||||||
|
expected = {
|
||||||
|
"enabled": True,
|
||||||
|
"publicClient": True,
|
||||||
|
"bearerOnly": False,
|
||||||
|
"serviceAccountsEnabled": False,
|
||||||
|
"standardFlowEnabled": True,
|
||||||
|
"implicitFlowEnabled": False,
|
||||||
|
"directAccessGrantsEnabled": False,
|
||||||
|
"fullScopeAllowed": False,
|
||||||
|
}
|
||||||
|
if any(verified.get(key) != value for key, value in expected.items()):
|
||||||
|
raise ProvisionError("integration UI client flow settings did not persist")
|
||||||
|
if set(verified.get("redirectUris") or []) != set(REDIRECT_URIS):
|
||||||
|
raise ProvisionError("integration UI redirect URI allowlist did not persist")
|
||||||
|
if set(verified.get("webOrigins") or []) != set(WEB_ORIGINS):
|
||||||
|
raise ProvisionError("integration UI web origin allowlist did not persist")
|
||||||
|
if (verified.get("attributes") or {}).get("pkce.code.challenge.method") != "S256":
|
||||||
|
raise ProvisionError("integration UI PKCE policy did not persist")
|
||||||
|
return created, {
|
||||||
|
"client_id": client_id,
|
||||||
|
"public_client": True,
|
||||||
|
"standard_flow": True,
|
||||||
|
"pkce": "S256",
|
||||||
|
"implicit_flow": False,
|
||||||
|
"direct_grants": False,
|
||||||
|
"service_accounts": False,
|
||||||
|
"redirect_uris": REDIRECT_URIS,
|
||||||
|
"web_origins": WEB_ORIGINS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--keycloak-url", default="http://192.168.1.114:8088")
|
||||||
|
parser.add_argument("--realm", default="tjwater")
|
||||||
|
parser.add_argument("--admin-username", default="admin")
|
||||||
|
parser.add_argument("--client-id", default="tjwater-integration-ui")
|
||||||
|
args = parser.parse_args()
|
||||||
|
password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or getpass.getpass(
|
||||||
|
"Keycloak admin password: "
|
||||||
|
)
|
||||||
|
if not password:
|
||||||
|
raise SystemExit("administrator password is required")
|
||||||
|
try:
|
||||||
|
keycloak = KeycloakProvisioner(
|
||||||
|
args.keycloak_url, args.realm, args.admin_username, password
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
created, result = ensure_public_client(keycloak, args.client_id)
|
||||||
|
finally:
|
||||||
|
keycloak.close()
|
||||||
|
except ProvisionError as exc:
|
||||||
|
raise SystemExit(f"provisioning failed: {exc}") from None
|
||||||
|
print(json.dumps({"created": created, **result}, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Provision the least-privileged OAuth identity used by operational workers.
|
||||||
|
|
||||||
|
The Keycloak administrator password is read with a hidden prompt or from the
|
||||||
|
process-only KEYCLOAK_ADMIN_PASSWORD environment variable. It is never written
|
||||||
|
to disk. Client secrets are written only to the ignored server .env file.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import dotenv_values, set_key
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(REPOSITORY_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||||
|
|
||||||
|
from app.auth.permissions import permissions_for_context
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.infra.db.metadb import models
|
||||||
|
from app.infra.db.metadb.database import SessionLocal, close_metadata_engine
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _request(client: httpx.Client, method: str, url: str, **kwargs) -> httpx.Response:
|
||||||
|
try:
|
||||||
|
response = client.request(method, url, **kwargs)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProvisionError(f"identity provider request failed: {method} {url}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise ProvisionError(
|
||||||
|
f"identity provider rejected request: {method} {url} status={response.status_code}"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_payload(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
payload = token.split(".")[1]
|
||||||
|
payload += "=" * (-len(payload) % 4)
|
||||||
|
value = json.loads(base64.urlsafe_b64decode(payload))
|
||||||
|
except (IndexError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise ProvisionError("identity provider returned an invalid access token") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ProvisionError("identity provider returned an invalid access token payload")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _audiences(payload: dict) -> set[str]:
|
||||||
|
audience = payload.get("aud", [])
|
||||||
|
if isinstance(audience, str):
|
||||||
|
return {audience}
|
||||||
|
if isinstance(audience, list):
|
||||||
|
return {item for item in audience if isinstance(item, str)}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakProvisioner:
|
||||||
|
def __init__(self, base_url: str, realm: str, admin_username: str, admin_password: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.realm = realm
|
||||||
|
self.client = httpx.Client(timeout=15, follow_redirects=False, trust_env=False)
|
||||||
|
token_response = _request(
|
||||||
|
self.client,
|
||||||
|
"POST",
|
||||||
|
f"{self.base_url}/realms/master/protocol/openid-connect/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "password",
|
||||||
|
"client_id": "admin-cli",
|
||||||
|
"username": admin_username,
|
||||||
|
"password": admin_password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token = token_response.json().get("access_token")
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
raise ProvisionError("administrator authentication returned no access token")
|
||||||
|
self.client.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
self.admin_base = f"{self.base_url}/admin/realms/{realm}"
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def ensure_client(self, client_id: str) -> tuple[str, bool]:
|
||||||
|
matches = _request(
|
||||||
|
self.client,
|
||||||
|
"GET",
|
||||||
|
f"{self.admin_base}/clients",
|
||||||
|
params={"clientId": client_id},
|
||||||
|
).json()
|
||||||
|
exact = [item for item in matches if item.get("clientId") == client_id]
|
||||||
|
if len(exact) > 1:
|
||||||
|
raise ProvisionError("multiple Keycloak clients use the requested client id")
|
||||||
|
created = not exact
|
||||||
|
if created:
|
||||||
|
_request(
|
||||||
|
self.client,
|
||||||
|
"POST",
|
||||||
|
f"{self.admin_base}/clients",
|
||||||
|
json={
|
||||||
|
"clientId": client_id,
|
||||||
|
"name": "TJWater operational worker",
|
||||||
|
"description": "Dedicated backend worker identity; no interactive login",
|
||||||
|
"enabled": True,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": False,
|
||||||
|
"bearerOnly": False,
|
||||||
|
"serviceAccountsEnabled": True,
|
||||||
|
"standardFlowEnabled": False,
|
||||||
|
"implicitFlowEnabled": False,
|
||||||
|
"directAccessGrantsEnabled": False,
|
||||||
|
"fullScopeAllowed": False,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
matches = _request(
|
||||||
|
self.client,
|
||||||
|
"GET",
|
||||||
|
f"{self.admin_base}/clients",
|
||||||
|
params={"clientId": client_id},
|
||||||
|
).json()
|
||||||
|
exact = [item for item in matches if item.get("clientId") == client_id]
|
||||||
|
if len(exact) != 1 or not isinstance(exact[0].get("id"), str):
|
||||||
|
raise ProvisionError("could not resolve the dedicated Keycloak client")
|
||||||
|
internal_id = exact[0]["id"]
|
||||||
|
representation = _request(
|
||||||
|
self.client, "GET", f"{self.admin_base}/clients/{internal_id}"
|
||||||
|
).json()
|
||||||
|
desired = {
|
||||||
|
"enabled": True,
|
||||||
|
"publicClient": False,
|
||||||
|
"bearerOnly": False,
|
||||||
|
"serviceAccountsEnabled": True,
|
||||||
|
"standardFlowEnabled": False,
|
||||||
|
"implicitFlowEnabled": False,
|
||||||
|
"directAccessGrantsEnabled": False,
|
||||||
|
"fullScopeAllowed": False,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
}
|
||||||
|
if any(representation.get(key) != value for key, value in desired.items()):
|
||||||
|
representation.update(desired)
|
||||||
|
_request(
|
||||||
|
self.client,
|
||||||
|
"PUT",
|
||||||
|
f"{self.admin_base}/clients/{internal_id}",
|
||||||
|
json=representation,
|
||||||
|
)
|
||||||
|
return internal_id, created
|
||||||
|
|
||||||
|
def client_secret(self, internal_id: str) -> str:
|
||||||
|
value = _request(
|
||||||
|
self.client,
|
||||||
|
"GET",
|
||||||
|
f"{self.admin_base}/clients/{internal_id}/client-secret",
|
||||||
|
).json().get("value")
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ProvisionError("dedicated client has no usable secret")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def service_account_subject(self, internal_id: str) -> UUID:
|
||||||
|
value = _request(
|
||||||
|
self.client,
|
||||||
|
"GET",
|
||||||
|
f"{self.admin_base}/clients/{internal_id}/service-account-user",
|
||||||
|
).json().get("id")
|
||||||
|
try:
|
||||||
|
return UUID(str(value))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ProvisionError("service account subject is not a UUID") from exc
|
||||||
|
|
||||||
|
def ensure_audience_mapper(self, internal_id: str, audience: str) -> bool:
|
||||||
|
mappers = _request(
|
||||||
|
self.client,
|
||||||
|
"GET",
|
||||||
|
f"{self.admin_base}/clients/{internal_id}/protocol-mappers/models",
|
||||||
|
).json()
|
||||||
|
for mapper in mappers:
|
||||||
|
config = mapper.get("config") or {}
|
||||||
|
if (
|
||||||
|
mapper.get("protocolMapper") == "oidc-audience-mapper"
|
||||||
|
and config.get("included.client.audience") == audience
|
||||||
|
and config.get("access.token.claim") == "true"
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
_request(
|
||||||
|
self.client,
|
||||||
|
"POST",
|
||||||
|
f"{self.admin_base}/clients/{internal_id}/protocol-mappers/models",
|
||||||
|
json={
|
||||||
|
"name": f"audience-{audience}",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": False,
|
||||||
|
"config": {
|
||||||
|
"included.client.audience": audience,
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"lightweight.claim": "false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def service_token(self, client_id: str, client_secret: str) -> tuple[str, dict]:
|
||||||
|
response = _request(
|
||||||
|
self.client,
|
||||||
|
"POST",
|
||||||
|
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token = response.json().get("access_token")
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
raise ProvisionError("service account authentication returned no access token")
|
||||||
|
return token, _jwt_payload(token)
|
||||||
|
|
||||||
|
|
||||||
|
async def _bind_product_identity(
|
||||||
|
subject: UUID,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
email: str,
|
||||||
|
project_id: UUID,
|
||||||
|
project_code: str,
|
||||||
|
) -> tuple[UUID, str, bool, bool]:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
repo = MetadataRepository(session)
|
||||||
|
project = await repo.get_project_by_id(project_id)
|
||||||
|
if project is None or project.code != project_code or project.status != "active":
|
||||||
|
raise ProvisionError("target product project is missing, mismatched, or inactive")
|
||||||
|
user = await repo.get_user_by_keycloak_id(subject)
|
||||||
|
created_user = user is None
|
||||||
|
if user is None:
|
||||||
|
username_conflict = await repo.get_user_by_username(username)
|
||||||
|
email_result = await session.execute(select(models.User).where(models.User.email == email))
|
||||||
|
if username_conflict is not None or email_result.scalar_one_or_none() is not None:
|
||||||
|
raise ProvisionError("product user name or email is already bound to another identity")
|
||||||
|
user = await repo.upsert_user_from_keycloak(
|
||||||
|
keycloak_id=subject,
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
role="service",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
user.username == username
|
||||||
|
and user.role == "user"
|
||||||
|
and user.is_active
|
||||||
|
and not user.is_superuser
|
||||||
|
):
|
||||||
|
# Forward migration for identities provisioned before the dedicated
|
||||||
|
# service role existed.
|
||||||
|
user = await repo.upsert_user_from_keycloak(
|
||||||
|
keycloak_id=subject,
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
role="service",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
user.username != username
|
||||||
|
or user.role != "service"
|
||||||
|
or not user.is_active
|
||||||
|
or user.is_superuser
|
||||||
|
):
|
||||||
|
raise ProvisionError("existing product user has incompatible identity or privileges")
|
||||||
|
membership = await repo.get_project_membership(project_id, user.id)
|
||||||
|
created_membership = membership is None
|
||||||
|
if membership is None:
|
||||||
|
membership = await repo.add_project_member(project_id, user.id, "member")
|
||||||
|
elif membership.project_role != "member":
|
||||||
|
raise ProvisionError("existing project membership has an incompatible role")
|
||||||
|
return user.id, membership.project_role, created_user, created_membership
|
||||||
|
|
||||||
|
|
||||||
|
def _update_worker_environment(
|
||||||
|
env_path: Path,
|
||||||
|
*,
|
||||||
|
token_url: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
project_id: UUID,
|
||||||
|
) -> None:
|
||||||
|
current = dotenv_values(env_path)
|
||||||
|
if current.get("OPERATIONAL_WORKER_BEARER"):
|
||||||
|
raise ProvisionError("a fixed worker bearer already exists; refusing to overwrite it")
|
||||||
|
values = {
|
||||||
|
"OPERATIONAL_WORKER_TOKEN_URL": token_url,
|
||||||
|
"OPERATIONAL_WORKER_CLIENT_ID": client_id,
|
||||||
|
"OPERATIONAL_WORKER_CLIENT_SECRET": client_secret,
|
||||||
|
"OPERATIONAL_WORKER_PROJECT_ID": str(project_id),
|
||||||
|
"OPERATIONAL_WORKER_SERVER_URL": "http://127.0.0.1:8000",
|
||||||
|
"OPERATIONAL_WORKER_AGENT_URL": "http://127.0.0.1:18787",
|
||||||
|
"OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS": "300",
|
||||||
|
"OPERATIONAL_ACTION_PERIPHERAL_URL": "http://127.0.0.1:8011",
|
||||||
|
"OPERATIONAL_DATA_PERIPHERAL_URL": "http://127.0.0.1:8011",
|
||||||
|
"OPERATIONAL_DATA_SERVER_URL": "http://127.0.0.1:8000",
|
||||||
|
}
|
||||||
|
for key, value in values.items():
|
||||||
|
set_key(str(env_path), key, value, quote_mode="always")
|
||||||
|
env_path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_product_context(token: str, project_id: UUID) -> dict:
|
||||||
|
try:
|
||||||
|
response = httpx.get(
|
||||||
|
"http://127.0.0.1:8000/api/v1/agent-auth-context",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"X-Project-Id": str(project_id),
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
follow_redirects=False,
|
||||||
|
trust_env=False,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProvisionError("product identity verification request failed") from exc
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ProvisionError(
|
||||||
|
f"product identity verification was rejected with status={response.status_code}"
|
||||||
|
)
|
||||||
|
value = response.json()
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ProvisionError("product identity verification returned an invalid response")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def provision(args: argparse.Namespace, admin_password: str) -> dict:
|
||||||
|
env_path = Path(args.env_file).resolve()
|
||||||
|
env_values = dotenv_values(env_path)
|
||||||
|
audience = env_values.get("KEYCLOAK_AUDIENCE") or ""
|
||||||
|
if audience != "account":
|
||||||
|
raise ProvisionError("backend audience is not the expected account audience")
|
||||||
|
keycloak = KeycloakProvisioner(
|
||||||
|
args.keycloak_url, args.realm, args.admin_username, admin_password
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
internal_id, created_client = keycloak.ensure_client(args.client_id)
|
||||||
|
secret = keycloak.client_secret(internal_id)
|
||||||
|
subject = keycloak.service_account_subject(internal_id)
|
||||||
|
token, payload = keycloak.service_token(args.client_id, secret)
|
||||||
|
mapper_created = False
|
||||||
|
if audience not in _audiences(payload):
|
||||||
|
mapper_created = keycloak.ensure_audience_mapper(internal_id, audience)
|
||||||
|
token, payload = keycloak.service_token(args.client_id, secret)
|
||||||
|
if audience not in _audiences(payload):
|
||||||
|
raise ProvisionError("service token does not contain the backend audience")
|
||||||
|
if payload.get("sub") != str(subject) or not payload.get("preferred_username"):
|
||||||
|
raise ProvisionError("service token identity claims do not match the service account")
|
||||||
|
realm_management = (payload.get("resource_access") or {}).get("realm-management", {})
|
||||||
|
if realm_management.get("roles"):
|
||||||
|
raise ProvisionError("service account unexpectedly has realm-management roles")
|
||||||
|
product_user_id, project_role, created_user, created_membership = (
|
||||||
|
await _bind_product_identity(
|
||||||
|
subject,
|
||||||
|
username=f"service-account-{args.client_id}",
|
||||||
|
email=f"{args.client_id}@service.local",
|
||||||
|
project_id=UUID(args.project_id),
|
||||||
|
project_code=args.project_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context = ProjectContext(
|
||||||
|
project_id=UUID(args.project_id),
|
||||||
|
project_code=args.project_code,
|
||||||
|
user_id=product_user_id,
|
||||||
|
project_role=project_role,
|
||||||
|
system_role="service",
|
||||||
|
is_superuser=False,
|
||||||
|
)
|
||||||
|
expected_permissions = sorted(permissions_for_context(context))
|
||||||
|
_update_worker_environment(
|
||||||
|
env_path,
|
||||||
|
token_url=(
|
||||||
|
f"{args.keycloak_url.rstrip('/')}/realms/{args.realm}"
|
||||||
|
"/protocol/openid-connect/token"
|
||||||
|
),
|
||||||
|
client_id=args.client_id,
|
||||||
|
client_secret=secret,
|
||||||
|
project_id=UUID(args.project_id),
|
||||||
|
)
|
||||||
|
verified = _verify_product_context(token, UUID(args.project_id))
|
||||||
|
if (
|
||||||
|
verified.get("project_id") != args.project_id
|
||||||
|
or verified.get("network") != args.project_code
|
||||||
|
or verified.get("project_role") != "member"
|
||||||
|
or verified.get("role") != "service"
|
||||||
|
or verified.get("is_superuser") is not False
|
||||||
|
or verified.get("permissions") != expected_permissions
|
||||||
|
):
|
||||||
|
raise ProvisionError("product identity verification returned unexpected authorization")
|
||||||
|
return {
|
||||||
|
"client_created": created_client,
|
||||||
|
"audience_mapper_created": mapper_created,
|
||||||
|
"product_user_created": created_user,
|
||||||
|
"membership_created": created_membership,
|
||||||
|
"project_code": verified["network"],
|
||||||
|
"project_role": verified["project_role"],
|
||||||
|
"system_role": verified["role"],
|
||||||
|
"permissions": verified["permissions"],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
keycloak.close()
|
||||||
|
await close_metadata_engine()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--keycloak-url", default="http://192.168.1.114:8088")
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-insecure-http",
|
||||||
|
action="store_true",
|
||||||
|
help="allow cleartext Keycloak credentials for an explicitly trusted development network",
|
||||||
|
)
|
||||||
|
parser.add_argument("--realm", default="tjwater")
|
||||||
|
parser.add_argument("--admin-username", default="admin")
|
||||||
|
parser.add_argument("--client-id", default="tjwater-operational-worker")
|
||||||
|
parser.add_argument("--project-id", required=True)
|
||||||
|
parser.add_argument("--project-code", default="tjwater_v2")
|
||||||
|
parser.add_argument(
|
||||||
|
"--env-file", default=str(Path(__file__).resolve().parents[1] / ".env")
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
keycloak_url = urlparse(args.keycloak_url)
|
||||||
|
if keycloak_url.scheme != "https" and not args.allow_insecure_http:
|
||||||
|
raise SystemExit(
|
||||||
|
"Refusing to send administrator and client credentials over cleartext HTTP; "
|
||||||
|
"use HTTPS or explicitly pass --allow-insecure-http for development"
|
||||||
|
)
|
||||||
|
password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or getpass.getpass(
|
||||||
|
"Keycloak admin password: "
|
||||||
|
)
|
||||||
|
if not password:
|
||||||
|
raise SystemExit("administrator password is required")
|
||||||
|
try:
|
||||||
|
result = asyncio.run(provision(args, password))
|
||||||
|
except ProvisionError as exc:
|
||||||
|
raise SystemExit(f"provisioning failed: {exc}") from None
|
||||||
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Hand project-scoped queued investigations to the Agent.
|
||||||
|
|
||||||
|
Credentials are deployment environment values, never task/session payloads.
|
||||||
|
Run with --once for an operator-triggered tick or without it for polling.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("operational-worker")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerSettings:
|
||||||
|
server_url: str
|
||||||
|
agent_url: str
|
||||||
|
project_id: str
|
||||||
|
interval_seconds: float = 30
|
||||||
|
max_concurrency: int = 4
|
||||||
|
bearer: str = ""
|
||||||
|
token_url: str = ""
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret: str = ""
|
||||||
|
token_refresh_seconds: float = 300
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environment(cls) -> "WorkerSettings":
|
||||||
|
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)
|
||||||
|
project_id = str(UUID(os.environ["OPERATIONAL_WORKER_PROJECT_ID"]))
|
||||||
|
interval = float(os.environ.get("OPERATIONAL_WORKER_RECONCILE_SECONDS", "30"))
|
||||||
|
if interval < 1:
|
||||||
|
raise ValueError("worker interval must be at least one second")
|
||||||
|
settings = cls(
|
||||||
|
server_url=os.environ.get("OPERATIONAL_WORKER_SERVER_URL", "http://127.0.0.1:8000"),
|
||||||
|
agent_url=os.environ.get("OPERATIONAL_WORKER_AGENT_URL", "http://127.0.0.1:8787"),
|
||||||
|
project_id=project_id,
|
||||||
|
interval_seconds=interval,
|
||||||
|
max_concurrency=int(os.environ.get("OPERATIONAL_WORKER_MAX_CONCURRENCY", "4")),
|
||||||
|
bearer=os.environ.get("OPERATIONAL_WORKER_BEARER", ""),
|
||||||
|
token_url=os.environ.get("OPERATIONAL_WORKER_TOKEN_URL", ""),
|
||||||
|
client_id=os.environ.get("OPERATIONAL_WORKER_CLIENT_ID", ""),
|
||||||
|
client_secret=os.environ.get("OPERATIONAL_WORKER_CLIENT_SECRET", ""),
|
||||||
|
token_refresh_seconds=float(os.environ.get("OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS", "300")),
|
||||||
|
)
|
||||||
|
if not 30 <= settings.token_refresh_seconds <= 600:
|
||||||
|
raise ValueError("service token refresh interval must be between 30 and 600 seconds")
|
||||||
|
if not 1 <= settings.max_concurrency <= 32:
|
||||||
|
raise ValueError("worker concurrency must be between 1 and 32")
|
||||||
|
if not settings.bearer and not all((settings.token_url, settings.client_id, settings.client_secret)):
|
||||||
|
raise ValueError("configure a product service credential for the operational worker")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialProvider:
|
||||||
|
def __init__(self, settings: WorkerSettings):
|
||||||
|
self.settings = settings
|
||||||
|
self._token = ""
|
||||||
|
self._expires = 0.0
|
||||||
|
|
||||||
|
async def headers(self, client: httpx.AsyncClient) -> dict[str, str]:
|
||||||
|
if self.settings.bearer:
|
||||||
|
token = self.settings.bearer.removeprefix("Bearer ")
|
||||||
|
else:
|
||||||
|
if time.monotonic() >= self._expires:
|
||||||
|
response = await client.post(self.settings.token_url, data={
|
||||||
|
"grant_type": "client_credentials", "client_id": self.settings.client_id,
|
||||||
|
"client_secret": self.settings.client_secret,
|
||||||
|
})
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
self._token = payload["access_token"]
|
||||||
|
if not isinstance(self._token, str) or not self._token:
|
||||||
|
raise ValueError("identity provider returned no access token")
|
||||||
|
# Product auth also restricts token age, independently of the
|
||||||
|
# provider's exp. Do not cache a long-lived OAuth token for hours.
|
||||||
|
lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds)
|
||||||
|
self._expires = time.monotonic() + max(1, lifetime - 15)
|
||||||
|
token = self._token
|
||||||
|
return {"Authorization": f"Bearer {token}", "X-Project-Id": self.settings.project_id}
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalWorker:
|
||||||
|
def __init__(self, settings: WorkerSettings, client: httpx.AsyncClient):
|
||||||
|
self.settings = settings
|
||||||
|
self.client = client
|
||||||
|
self.credentials = CredentialProvider(settings)
|
||||||
|
|
||||||
|
async def tick(self) -> int:
|
||||||
|
headers = await self.credentials.headers(self.client)
|
||||||
|
server = self.settings.server_url.rstrip("/")
|
||||||
|
recovery = await self.client.post(
|
||||||
|
f"{server}/api/v1/operational-tasks/leases/recover", headers=headers, json={"limit": 100},
|
||||||
|
)
|
||||||
|
recovery.raise_for_status()
|
||||||
|
active_run_ids: set[str] = set()
|
||||||
|
for status_value in ("claimed", "running"):
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{server}/api/v1/operational-task-runs", headers=headers,
|
||||||
|
params={"status": status_value, "limit": self.settings.max_concurrency},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
active_run_ids.update(str(item["run_id"]) for item in response.json()["items"])
|
||||||
|
available = max(0, self.settings.max_concurrency - len(active_run_ids))
|
||||||
|
if available == 0:
|
||||||
|
return 0
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{server}/api/v1/operational-task-runs", headers=headers,
|
||||||
|
params={"status": "queued", "limit": available},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
queued = response.json()["items"]
|
||||||
|
|
||||||
|
async def dispatch(run: dict) -> bool:
|
||||||
|
if run.get("status") != "queued":
|
||||||
|
return False
|
||||||
|
task_id = str(UUID(run["task_id"]))
|
||||||
|
run_id = str(UUID(run["run_id"]))
|
||||||
|
request_id = str(UUID(run["execution_request_id"]))
|
||||||
|
try:
|
||||||
|
result = await self.client.post(
|
||||||
|
f"{self.settings.agent_url.rstrip('/')}/api/v1/agent/chat/task-executions",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"task_id": task_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
"execution_request_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if result.is_success:
|
||||||
|
payload = result.json()
|
||||||
|
if payload.get("task_id") != task_id or payload.get("run_id") != run_id:
|
||||||
|
raise ValueError("Agent returned a different task run")
|
||||||
|
return True
|
||||||
|
await self._record_dispatch_failure(
|
||||||
|
headers, run, retryable=result.status_code >= 500,
|
||||||
|
code=f"AGENT_HTTP_{result.status_code}",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
await self._record_dispatch_failure(
|
||||||
|
headers, run, retryable=True,
|
||||||
|
code="AGENT_UNAVAILABLE", detail=type(exc).__name__,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
results = await asyncio.gather(*(dispatch(run) for run in queued))
|
||||||
|
return sum(results)
|
||||||
|
|
||||||
|
async def _record_dispatch_failure(
|
||||||
|
self, headers: dict[str, str], run: dict, *, retryable: bool,
|
||||||
|
code: str, detail: str = "Agent rejected task run dispatch",
|
||||||
|
) -> None:
|
||||||
|
response = await self.client.post(
|
||||||
|
f"{self.settings.server_url.rstrip('/')}/api/v1/operational-tasks/"
|
||||||
|
f"{run['task_id']}/runs/{run['run_id']}/dispatch-failure",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
"expected_revision": run["revision"],
|
||||||
|
"failure_code": code,
|
||||||
|
"failure_detail": detail,
|
||||||
|
"retryable": retryable,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async def listen(self, wake: asyncio.Event) -> None:
|
||||||
|
cursor = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
headers = await self.credentials.headers(self.client)
|
||||||
|
async with self.client.stream(
|
||||||
|
"GET",
|
||||||
|
f"{self.settings.server_url.rstrip('/')}/api/v1/workbench/events",
|
||||||
|
headers={**headers, "Accept": "text/event-stream"},
|
||||||
|
params={"after": cursor},
|
||||||
|
timeout=None,
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
event_name = ""
|
||||||
|
reset_required = False
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if line.startswith("id:"):
|
||||||
|
cursor = int(line[3:].strip())
|
||||||
|
elif line.startswith("event:"):
|
||||||
|
event_name = line[6:].strip()
|
||||||
|
elif line.startswith("data:") and event_name == "reset_required":
|
||||||
|
snapshot = await self.client.get(
|
||||||
|
f"{self.settings.server_url.rstrip('/')}/api/v1/workbench/sync-snapshot",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
snapshot.raise_for_status()
|
||||||
|
cursor = int(snapshot.json()["event_cursor"])
|
||||||
|
reset_required = True
|
||||||
|
break
|
||||||
|
elif line.startswith("data:") and event_name == "workbench_event":
|
||||||
|
payload = json.loads(line[5:].strip())
|
||||||
|
if payload.get("topic") == "operational.run.queued":
|
||||||
|
wake.set()
|
||||||
|
elif line == "":
|
||||||
|
event_name = ""
|
||||||
|
if not reset_required:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError):
|
||||||
|
logger.warning("Workbench event stream disconnected; reconciliation remains active")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
async def run(once: bool) -> None:
|
||||||
|
settings = WorkerSettings.from_environment()
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
|
||||||
|
worker = OperationalWorker(settings, client)
|
||||||
|
wake = asyncio.Event()
|
||||||
|
listener = None if once else asyncio.create_task(worker.listen(wake))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
accepted = await worker.tick()
|
||||||
|
logger.info("Task dispatch tick completed: %d accepted", accepted)
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError):
|
||||||
|
# Do not log request headers, response bodies or provider secrets.
|
||||||
|
logger.error("Task dispatch tick failed; persisted tasks remain available for retry")
|
||||||
|
if once:
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
if once:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(wake.wait(), timeout=settings.interval_seconds)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
wake.clear()
|
||||||
|
finally:
|
||||||
|
if listener is not None:
|
||||||
|
listener.cancel()
|
||||||
|
await asyncio.gather(listener, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--once", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
try:
|
||||||
|
asyncio.run(run(args.once))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
@@ -75,6 +75,56 @@ TEST_DATA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def previous_seed_result(
|
||||||
|
store: PostgresStore,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
seed_request_id: UUID,
|
||||||
|
command_key: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Read this script's earlier receipt without weakening API idempotency checks."""
|
||||||
|
with store.connection(readonly=True) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""SELECT command_key,result
|
||||||
|
FROM peripheral_sample.requests
|
||||||
|
WHERE project_id=%s AND actor_id=%s AND request_id=%s""",
|
||||||
|
(project_id, str(ACTOR_ID), seed_request_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if row["command_key"] != command_key:
|
||||||
|
raise RuntimeError("seed request id is already used by another command")
|
||||||
|
return row["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def restore_seeded_resources(
|
||||||
|
store: PostgresStore,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
table: str,
|
||||||
|
id_column: str,
|
||||||
|
resource_ids: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Restore deterministic seed rows that were soft-deleted after an earlier run."""
|
||||||
|
allowed_resources = {
|
||||||
|
("work_orders", "work_order_id"),
|
||||||
|
("test_data_records", "record_id"),
|
||||||
|
}
|
||||||
|
if (table, id_column) not in allowed_resources:
|
||||||
|
raise ValueError("unsupported seeded resource")
|
||||||
|
if not resource_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
with store.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"""UPDATE peripheral_sample.{table}
|
||||||
|
SET document=document - 'deleted_at' - 'deleted_by',
|
||||||
|
deleted_at=NULL, deleted_by=NULL
|
||||||
|
WHERE project_id=%s AND {id_column}=ANY(%s::uuid[])""",
|
||||||
|
(project_id, resource_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_demo_dataset(store: PostgresStore, project_id: str) -> str:
|
def ensure_demo_dataset(store: PostgresStore, project_id: str) -> str:
|
||||||
content = build_demo_content()
|
content = build_demo_content()
|
||||||
content_digest = digest(content)
|
content_digest = digest(content)
|
||||||
@@ -112,8 +162,9 @@ def main() -> int:
|
|||||||
dataset_digest = ensure_demo_dataset(store, project_id)
|
dataset_digest = ensure_demo_dataset(store, project_id)
|
||||||
order_ids = []
|
order_ids = []
|
||||||
for item in WORK_ORDERS:
|
for item in WORK_ORDERS:
|
||||||
|
seed_request_id = request_id(item["key"])
|
||||||
command = CreateWorkOrder(
|
command = CreateWorkOrder(
|
||||||
request_id=request_id(item["key"]),
|
request_id=seed_request_id,
|
||||||
title=item["title"],
|
title=item["title"],
|
||||||
summary=item["summary"],
|
summary=item["summary"],
|
||||||
status=item["status"],
|
status=item["status"],
|
||||||
@@ -121,12 +172,27 @@ def main() -> int:
|
|||||||
assignee=item["assignee"],
|
assignee=item["assignee"],
|
||||||
scheduled_at=item["scheduled_at"],
|
scheduled_at=item["scheduled_at"],
|
||||||
)
|
)
|
||||||
order_ids.append(store.create_work_order(who, command)["work_order_id"])
|
previous = previous_seed_result(
|
||||||
|
store,
|
||||||
|
project_id,
|
||||||
|
seed_request_id=seed_request_id,
|
||||||
|
command_key="create_work_order",
|
||||||
|
)
|
||||||
|
result = previous or store.create_work_order(who, command)
|
||||||
|
order_ids.append(result["work_order_id"])
|
||||||
|
restore_seeded_resources(
|
||||||
|
store,
|
||||||
|
project_id,
|
||||||
|
table="work_orders",
|
||||||
|
id_column="work_order_id",
|
||||||
|
resource_ids=order_ids,
|
||||||
|
)
|
||||||
|
|
||||||
record_ids = []
|
record_ids = []
|
||||||
for point_id, label, observed_at, value, unit, quality in TEST_DATA:
|
for point_id, label, observed_at, value, unit, quality in TEST_DATA:
|
||||||
|
seed_request_id = request_id(f"test-data/{point_id}/{observed_at}")
|
||||||
command = CreateTestDataRecord(
|
command = CreateTestDataRecord(
|
||||||
request_id=request_id(f"test-data/{point_id}/{observed_at}"),
|
request_id=seed_request_id,
|
||||||
label=label,
|
label=label,
|
||||||
point_id=point_id,
|
point_id=point_id,
|
||||||
observed_at=observed_at,
|
observed_at=observed_at,
|
||||||
@@ -135,7 +201,21 @@ def main() -> int:
|
|||||||
quality=quality,
|
quality=quality,
|
||||||
notes="外围样板固定合成数据,不是现场监测记录。",
|
notes="外围样板固定合成数据,不是现场监测记录。",
|
||||||
)
|
)
|
||||||
record_ids.append(store.create_test_data(who, command)["record_id"])
|
previous = previous_seed_result(
|
||||||
|
store,
|
||||||
|
project_id,
|
||||||
|
seed_request_id=seed_request_id,
|
||||||
|
command_key="create_test_data",
|
||||||
|
)
|
||||||
|
result = previous or store.create_test_data(who, command)
|
||||||
|
record_ids.append(result["record_id"])
|
||||||
|
restore_seeded_resources(
|
||||||
|
store,
|
||||||
|
project_id,
|
||||||
|
table="test_data_records",
|
||||||
|
id_column="record_id",
|
||||||
|
resource_ids=record_ids,
|
||||||
|
)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Project peripheral demo orders into the backend workbench order store."""
|
"""Idempotently seed the workbench's original demo reservation tasks."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID, uuid4
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
@@ -21,158 +21,311 @@ from app.native.wndb.core.connection import project_connection
|
|||||||
|
|
||||||
SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql"
|
SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql"
|
||||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||||
PROJECTION_SOURCE = "合成数据预约工单"
|
DATASET_ID = "workbench-demo-orders@1"
|
||||||
DEMO_WORK_ORDER_ID = "demo-work-order-peripheral-inspection"
|
DEMO_DATE = datetime(2026, 9, 17, tzinfo=SHANGHAI)
|
||||||
|
SEEDED_WORK_ORDER_PREFIX = "mock-work-order-"
|
||||||
|
LEGACY_WORK_ORDER_SOURCES = (
|
||||||
|
"合成数据预约工单",
|
||||||
|
"外围样板预约工单",
|
||||||
|
)
|
||||||
|
|
||||||
ORDER_DETAILS = {
|
WORK_ORDER_TEMPLATES = (
|
||||||
"合成通信核查": {
|
{
|
||||||
"location": "DEMO-P001",
|
"id": "incident-valve-dispatch",
|
||||||
"duration_minutes": 30,
|
"code": "DEMO-WO-20260917-001",
|
||||||
"reply_requirements": ["记录通信恢复时间", "确认缺口数据是否补传"],
|
"title": "演示工单:华山路阀门边界确认",
|
||||||
|
"summary": "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
|
||||||
|
"detail": "该条目为工作台演示数据,用于展示人工确认界面,不代表真实提交或岗位通知。",
|
||||||
|
"recommendation": "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
|
||||||
|
"source": "异常事件分析",
|
||||||
|
"location": "华山路沿线阀门组 VG-11",
|
||||||
|
"dispatcher": "调度中心值班长",
|
||||||
|
"assignee": "调度执行岗",
|
||||||
|
"priority": "urgent",
|
||||||
|
"risk_level": "attention",
|
||||||
|
"phase": "dispatch",
|
||||||
|
"start_hour": 10,
|
||||||
|
"start_minute": 30,
|
||||||
|
"duration_minutes": 40,
|
||||||
|
"dispatch_lead_minutes": 30,
|
||||||
|
"reply_window_minutes": 20,
|
||||||
|
"reply_requirements": [
|
||||||
|
"确认阀门当前开度",
|
||||||
|
"记录影响分区和用户范围",
|
||||||
|
"回传派发确认截图",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"合成压力偏差复核": {
|
{
|
||||||
"location": "DEMO-P001 / DEMO-P002",
|
"id": "valve-operation-execution",
|
||||||
|
"code": "DEMO-WO-20260917-002",
|
||||||
|
"title": "演示工单:北辰分区阀门开度调整",
|
||||||
|
"summary": "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
|
||||||
|
"detail": "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
|
||||||
|
"recommendation": "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
|
||||||
|
"source": "调度方案确认",
|
||||||
|
"location": "北辰分区边界阀门 BV-07",
|
||||||
|
"dispatcher": "管网调度岗",
|
||||||
|
"assignee": "北辰抢修二组",
|
||||||
|
"priority": "urgent",
|
||||||
|
"risk_level": "attention",
|
||||||
|
"phase": "execution",
|
||||||
|
"start_hour": 11,
|
||||||
|
"start_minute": 0,
|
||||||
|
"duration_minutes": 55,
|
||||||
|
"dispatch_lead_minutes": 25,
|
||||||
|
"reply_window_minutes": 20,
|
||||||
|
"reply_requirements": [
|
||||||
|
"上传阀门操作前后照片",
|
||||||
|
"记录最终开度",
|
||||||
|
"复核北辰末端压力不低于 0.22MPa",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pump-boundary-reply",
|
||||||
|
"code": "DEMO-WO-20260917-003",
|
||||||
|
"title": "演示工单:泵站边界复核复令",
|
||||||
|
"summary": "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
|
||||||
|
"detail": "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
|
||||||
|
"recommendation": "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
|
||||||
|
"source": "模型复核建议",
|
||||||
|
"location": "北辰二级泵站 P-02",
|
||||||
|
"dispatcher": "模型复核岗",
|
||||||
|
"assignee": "泵站运行班组",
|
||||||
|
"priority": "normal",
|
||||||
|
"risk_level": "normal",
|
||||||
|
"phase": "reply",
|
||||||
|
"start_hour": 13,
|
||||||
|
"start_minute": 0,
|
||||||
"duration_minutes": 45,
|
"duration_minutes": 45,
|
||||||
"reply_requirements": ["回填相邻测点对照结果", "确认合成样本复核结论"],
|
"dispatch_lead_minutes": 20,
|
||||||
|
"reply_window_minutes": 35,
|
||||||
|
"reply_requirements": [
|
||||||
|
"上传泵站运行截图",
|
||||||
|
"填写出水压力复核结果",
|
||||||
|
"确认模型边界已同步更新",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"合成零流量样本确认": {
|
)
|
||||||
"location": "DEMO-F001",
|
|
||||||
"duration_minutes": 20,
|
|
||||||
"reply_requirements": ["确认零值显示", "确认缺测值显示"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value: str) -> datetime:
|
def _clock(value: datetime) -> str:
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
|
|
||||||
|
|
||||||
def _time_label(value: datetime) -> str:
|
|
||||||
return value.astimezone(SHANGHAI).strftime("%H:%M")
|
return value.astimezone(SHANGHAI).strftime("%H:%M")
|
||||||
|
|
||||||
|
|
||||||
def _time_range(start: datetime, end: datetime) -> str:
|
def _clock_range(start: datetime, duration_minutes: int) -> str:
|
||||||
return f"{_time_label(start)}-{_time_label(end)}"
|
return f"{_clock(start)}-{_clock(start + timedelta(minutes=duration_minutes))}"
|
||||||
|
|
||||||
|
|
||||||
def _subtask_status(status: str) -> str:
|
def _stage_statuses(phase: str, position: str) -> tuple[str, str, str]:
|
||||||
mapped = {
|
if position == "before":
|
||||||
"open": "pending",
|
|
||||||
"in_progress": "running",
|
|
||||||
"completed": "completed",
|
|
||||||
}.get(status)
|
|
||||||
if mapped is None:
|
|
||||||
raise ValueError(f"unsupported peripheral work-order status: {status}")
|
|
||||||
return mapped
|
|
||||||
|
|
||||||
|
|
||||||
def _stage_statuses(status: str) -> tuple[str, str, str]:
|
|
||||||
if status == "completed":
|
|
||||||
return "done", "done", "done"
|
return "done", "done", "done"
|
||||||
if status == "running":
|
if position == "after":
|
||||||
|
return "pending", "pending", "pending"
|
||||||
|
if phase == "dispatch":
|
||||||
|
return "current", "pending", "pending"
|
||||||
|
if phase == "execution":
|
||||||
return "done", "current", "pending"
|
return "done", "current", "pending"
|
||||||
return "current", "pending", "pending"
|
return "done", "done", "current"
|
||||||
|
|
||||||
|
|
||||||
def project_peripheral_subtask(document: dict) -> dict:
|
def _subtask_status(phase: str, position: str) -> str:
|
||||||
status = _subtask_status(document.get("status"))
|
if position == "before":
|
||||||
scheduled_at = _parse_timestamp(document["scheduled_at"])
|
return "completed"
|
||||||
details = ORDER_DETAILS.get(document["title"], {})
|
if position == "after" or phase == "dispatch":
|
||||||
duration_minutes = int(details.get("duration_minutes", 30))
|
return "pending"
|
||||||
execution_end = scheduled_at + timedelta(minutes=duration_minutes)
|
return "running"
|
||||||
reply_end = execution_end + timedelta(minutes=15)
|
|
||||||
dispatch_status, execution_status, reply_status = _stage_statuses(status)
|
|
||||||
assignee = document.get("assignee") or "待分派班组"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": document["work_order_id"],
|
def _build_subtasks(template: dict, scheduled_at: datetime) -> list[dict]:
|
||||||
"title": document["title"],
|
requirements = template["reply_requirements"]
|
||||||
"summary": document["summary"],
|
phase = template["phase"]
|
||||||
"status": status,
|
active_index = {
|
||||||
"scheduled_at": document["scheduled_at"],
|
"dispatch": 0,
|
||||||
"location": details.get("location", "演示点位"),
|
"execution": min(1, len(requirements) - 1),
|
||||||
"assignee": assignee,
|
"reply": len(requirements) - 1,
|
||||||
"duration_minutes": duration_minutes,
|
}[phase]
|
||||||
"reply_window_minutes": 15,
|
subtask_duration = max(
|
||||||
"stages": [
|
5,
|
||||||
|
(template["duration_minutes"] + len(requirements) - 1) // len(requirements),
|
||||||
|
)
|
||||||
|
subtasks = []
|
||||||
|
|
||||||
|
for index, requirement in enumerate(requirements):
|
||||||
|
position = (
|
||||||
|
"before"
|
||||||
|
if index < active_index
|
||||||
|
else "after"
|
||||||
|
if index > active_index
|
||||||
|
else "active"
|
||||||
|
)
|
||||||
|
subtask_start = scheduled_at + timedelta(minutes=index * subtask_duration)
|
||||||
|
execution_end = subtask_start + timedelta(minutes=subtask_duration)
|
||||||
|
reply_end = execution_end + timedelta(minutes=template["reply_window_minutes"])
|
||||||
|
dispatch_status, execution_status, reply_status = _stage_statuses(
|
||||||
|
phase, position
|
||||||
|
)
|
||||||
|
subtasks.append(
|
||||||
{
|
{
|
||||||
"id": "dispatch",
|
"id": f"{template['id']}-subtask-{index + 1}",
|
||||||
"sequence": 1,
|
"title": requirement,
|
||||||
"title": "派发",
|
"summary": f"完成“{requirement}”并保留可复核的阶段记录。",
|
||||||
"status": dispatch_status,
|
"status": _subtask_status(phase, position),
|
||||||
"owner": "演示调度",
|
"scheduled_at": subtask_start,
|
||||||
"time_label": _time_label(scheduled_at - timedelta(minutes=15)),
|
"location": template["location"],
|
||||||
"description": "预约工单子任务已进入演示调度流程。",
|
"assignee": template["assignee"],
|
||||||
},
|
"duration_minutes": subtask_duration,
|
||||||
{
|
"reply_window_minutes": template["reply_window_minutes"],
|
||||||
"id": "execution",
|
"stages": [
|
||||||
"sequence": 2,
|
{
|
||||||
"title": "执行",
|
"id": "dispatch",
|
||||||
"status": execution_status,
|
"sequence": 1,
|
||||||
"owner": assignee,
|
"title": "派发",
|
||||||
"time_label": _time_range(scheduled_at, execution_end),
|
"status": dispatch_status,
|
||||||
"description": "按外围合成场景执行本项核查。",
|
"owner": template["dispatcher"],
|
||||||
},
|
"time_label": _clock(
|
||||||
{
|
subtask_start
|
||||||
"id": "reply",
|
- timedelta(minutes=template["dispatch_lead_minutes"])
|
||||||
"sequence": 3,
|
),
|
||||||
"title": "复令",
|
"description": (
|
||||||
"status": reply_status,
|
f"确认{template['location']}任务范围、优先级和到场窗口,"
|
||||||
"owner": assignee,
|
f"派发给{template['assignee']}。"
|
||||||
"time_label": _time_range(execution_end, reply_end),
|
),
|
||||||
"description": "回传本项核查结果并完成子任务闭环。",
|
},
|
||||||
},
|
{
|
||||||
],
|
"id": "execution",
|
||||||
"reply_requirements": details.get(
|
"sequence": 2,
|
||||||
"reply_requirements", ["回传演示核查结果"]
|
"title": "执行中",
|
||||||
|
"status": execution_status,
|
||||||
|
"owner": template["assignee"],
|
||||||
|
"time_label": _clock_range(subtask_start, subtask_duration),
|
||||||
|
"description": template["summary"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reply",
|
||||||
|
"sequence": 3,
|
||||||
|
"title": "待复令",
|
||||||
|
"status": reply_status,
|
||||||
|
"owner": f"{template['assignee']} → 调度中心",
|
||||||
|
"time_label": _clock_range(
|
||||||
|
execution_end,
|
||||||
|
template["reply_window_minutes"],
|
||||||
|
),
|
||||||
|
"description": (
|
||||||
|
"提交处置结果、现场照片和影响复核,"
|
||||||
|
f"最迟 {_clock(reply_end)} 前完成复令。"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"reply_requirements": [f"回传“{requirement}”的执行结果"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return subtasks
|
||||||
|
|
||||||
|
|
||||||
|
def build_demo_work_orders() -> list[dict]:
|
||||||
|
"""Build the three reservation tasks previously provided by the frontend mock."""
|
||||||
|
documents = []
|
||||||
|
for template in WORK_ORDER_TEMPLATES:
|
||||||
|
scheduled_at = DEMO_DATE.replace(
|
||||||
|
hour=template["start_hour"],
|
||||||
|
minute=template["start_minute"],
|
||||||
|
)
|
||||||
|
status = {
|
||||||
|
"dispatch": "pending",
|
||||||
|
"execution": "running",
|
||||||
|
"reply": "awaiting_reply",
|
||||||
|
}[template["phase"]]
|
||||||
|
updated_at = {
|
||||||
|
"dispatch": scheduled_at - timedelta(minutes=5),
|
||||||
|
"execution": scheduled_at + timedelta(minutes=20),
|
||||||
|
"reply": scheduled_at + timedelta(minutes=template["duration_minutes"]),
|
||||||
|
}[template["phase"]]
|
||||||
|
projected = {
|
||||||
|
"id": f"{SEEDED_WORK_ORDER_PREFIX}{template['id']}",
|
||||||
|
"code": template["code"],
|
||||||
|
"scheduled_at": scheduled_at,
|
||||||
|
"title": template["title"],
|
||||||
|
"summary": template["summary"],
|
||||||
|
"status": status,
|
||||||
|
"risk_level": template["risk_level"],
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"detail": template["detail"],
|
||||||
|
"recommendation": template["recommendation"],
|
||||||
|
"duration_minutes": template["duration_minutes"],
|
||||||
|
"source": template["source"],
|
||||||
|
"dispatcher": template["dispatcher"],
|
||||||
|
"priority": template["priority"],
|
||||||
|
"subtasks": _build_subtasks(template, scheduled_at),
|
||||||
|
}
|
||||||
|
documents.append(
|
||||||
|
WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
def replace_demo_work_orders(conn, project_id: UUID, documents: list[dict]) -> None:
|
||||||
|
"""Remove the obsolete synthetic projection and replace this seed dataset."""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM workbench.work_orders
|
||||||
|
WHERE project_id = %s
|
||||||
|
AND (
|
||||||
|
work_order_id LIKE 'demo-work-order-%%'
|
||||||
|
OR work_order_id LIKE %s
|
||||||
|
OR document->>'source' = ANY(%s)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
project_id,
|
||||||
|
f"{SEEDED_WORK_ORDER_PREFIX}%",
|
||||||
|
list(LEGACY_WORK_ORDER_SOURCES),
|
||||||
),
|
),
|
||||||
}
|
)
|
||||||
|
for document in documents:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO workbench.work_orders
|
||||||
|
(project_id, work_order_id, scheduled_at, updated_at, document)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (project_id, work_order_id) DO UPDATE
|
||||||
|
SET scheduled_at = EXCLUDED.scheduled_at,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
document = EXCLUDED.document
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
project_id,
|
||||||
|
document["id"],
|
||||||
|
document["scheduled_at"],
|
||||||
|
document["updated_at"],
|
||||||
|
Jsonb(document),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def project_peripheral_work_order(source_documents: list[dict]) -> dict:
|
def publish_demo_work_order_batch_event(conn, project_id: UUID, count: int) -> None:
|
||||||
if not source_documents:
|
"""Invalidate the demo projection once, never once per seeded row."""
|
||||||
raise ValueError("source documents must not be empty")
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO workbench.domain_event_log
|
||||||
subtasks = [project_peripheral_subtask(document) for document in source_documents]
|
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||||
scheduled_values = [_parse_timestamp(item["scheduled_at"]) for item in source_documents]
|
aggregate_revision,occurred_at,metadata)
|
||||||
updated_values = [_parse_timestamp(item["updated_at"]) for item in source_documents]
|
SELECT %s,%s,'workbench.work_order.changed','demo_work_order_dataset',%s,
|
||||||
end_values = [
|
COALESCE(max(aggregate_revision),0)+1,now(),%s
|
||||||
scheduled + timedelta(minutes=subtask["duration_minutes"])
|
FROM workbench.domain_event_log
|
||||||
for scheduled, subtask in zip(scheduled_values, subtasks, strict=True)
|
WHERE project_id=%s AND aggregate_type='demo_work_order_dataset'
|
||||||
]
|
AND aggregate_id=%s
|
||||||
statuses = {subtask["status"] for subtask in subtasks}
|
RETURNING event_seq""",
|
||||||
if statuses == {"completed"}:
|
(
|
||||||
status = "completed"
|
project_id, uuid4(), DATASET_ID,
|
||||||
elif statuses == {"pending"}:
|
Jsonb({"namespace": "demo-work-order", "count": count}),
|
||||||
status = "pending"
|
project_id, DATASET_ID,
|
||||||
else:
|
|
||||||
status = "running"
|
|
||||||
priority = "urgent" if any(
|
|
||||||
document.get("priority") in {"high", "urgent"}
|
|
||||||
for document in source_documents
|
|
||||||
) else "normal"
|
|
||||||
|
|
||||||
projected = {
|
|
||||||
"id": DEMO_WORK_ORDER_ID,
|
|
||||||
"code": "DEMO-WO-001",
|
|
||||||
"scheduled_at": min(scheduled_values),
|
|
||||||
"title": "外围合成数据核查",
|
|
||||||
"summary": "依次核查合成通信、压力偏差和零流量样本。",
|
|
||||||
"status": status,
|
|
||||||
"risk_level": "attention" if priority == "urgent" else "normal",
|
|
||||||
"updated_at": max(updated_values),
|
|
||||||
"detail": "外围样板中的三项 synthetic 核查已组合为一张预约工单,仅用于演示。",
|
|
||||||
"recommendation": "按子任务逐项核查并分别完成阶段回传。",
|
|
||||||
"duration_minutes": max(
|
|
||||||
1,
|
|
||||||
int((max(end_values) - min(scheduled_values)).total_seconds() // 60),
|
|
||||||
),
|
),
|
||||||
"source": PROJECTION_SOURCE,
|
)
|
||||||
"dispatcher": "演示调度",
|
row = cursor.fetchone()
|
||||||
"priority": priority,
|
conn.execute(
|
||||||
"subtasks": subtasks,
|
"SELECT pg_notify('workbench_events', %s)",
|
||||||
}
|
(f'{project_id}:{row["event_seq"]}',),
|
||||||
return WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -180,73 +333,34 @@ def main() -> int:
|
|||||||
parser.add_argument("--database-name", default="tjwater_v2")
|
parser.add_argument("--database-name", default="tjwater_v2")
|
||||||
parser.add_argument("--project-id", required=True, type=UUID)
|
parser.add_argument("--project-id", required=True, type=UUID)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
documents = build_demo_work_orders()
|
||||||
|
|
||||||
with project_connection(args.database_name) as conn, conn.transaction():
|
with project_connection(args.database_name) as conn, conn.transaction():
|
||||||
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
|
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
|
||||||
source_rows = conn.execute(
|
replace_demo_work_orders(conn, args.project_id, documents)
|
||||||
"""
|
publish_demo_work_order_batch_event(conn, args.project_id, len(documents))
|
||||||
SELECT document
|
|
||||||
FROM peripheral_sample.work_orders
|
|
||||||
WHERE project_id = %s
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND document->>'origin' = 'synthetic'
|
|
||||||
AND document->>'scheduled_at' IS NOT NULL
|
|
||||||
AND document->>'status' IN ('open', 'in_progress', 'completed')
|
|
||||||
ORDER BY (document->>'scheduled_at')::timestamptz, work_order_id
|
|
||||||
""",
|
|
||||||
(args.project_id,),
|
|
||||||
).fetchall()
|
|
||||||
source_documents = [row["document"] for row in source_rows]
|
|
||||||
if not source_documents:
|
|
||||||
raise RuntimeError("no eligible peripheral demo work orders found")
|
|
||||||
documents = [project_peripheral_work_order(source_documents)]
|
|
||||||
|
|
||||||
conn.execute(
|
print(
|
||||||
"""
|
json.dumps(
|
||||||
DELETE FROM workbench.work_orders
|
|
||||||
WHERE project_id = %s
|
|
||||||
AND (
|
|
||||||
work_order_id LIKE 'demo-work-order-%%'
|
|
||||||
OR document->>'source' = %s
|
|
||||||
OR document->>'source' = '外围样板预约工单'
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(args.project_id, PROJECTION_SOURCE),
|
|
||||||
)
|
|
||||||
for document in documents:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO workbench.work_orders
|
|
||||||
(project_id, work_order_id, scheduled_at, updated_at, document)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (project_id, work_order_id) DO UPDATE
|
|
||||||
SET scheduled_at = EXCLUDED.scheduled_at,
|
|
||||||
updated_at = EXCLUDED.updated_at,
|
|
||||||
document = EXCLUDED.document
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
args.project_id,
|
|
||||||
document["id"],
|
|
||||||
document["scheduled_at"],
|
|
||||||
document["updated_at"],
|
|
||||||
Jsonb(document),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
print(json.dumps({
|
|
||||||
"database": args.database_name,
|
|
||||||
"project_id": str(args.project_id),
|
|
||||||
"source": PROJECTION_SOURCE,
|
|
||||||
"work_orders": [
|
|
||||||
{
|
{
|
||||||
"id": document["id"],
|
"database": args.database_name,
|
||||||
"code": document["code"],
|
"project_id": str(args.project_id),
|
||||||
"title": document["title"],
|
"dataset_id": DATASET_ID,
|
||||||
"subtask_count": len(document["subtasks"]),
|
"work_orders": [
|
||||||
}
|
{
|
||||||
for document in documents
|
"id": document["id"],
|
||||||
],
|
"code": document["code"],
|
||||||
}, ensure_ascii=False, indent=2))
|
"title": document["title"],
|
||||||
|
"status": document["status"],
|
||||||
|
"subtask_count": len(document["subtasks"]),
|
||||||
|
}
|
||||||
|
for document in documents
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Synchronize the explicitly synthetic peripheral work orders into task projections."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from run_operational_worker import CredentialProvider, WorkerSettings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("peripheral-task-sync")
|
||||||
|
STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress",
|
||||||
|
"awaiting_reply": "awaiting_feedback", "completed": "completed", "cancelled": "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
def work_order_snapshot(order: dict) -> dict:
|
||||||
|
"""Preserve external process facts without deriving stages from total status."""
|
||||||
|
required = (
|
||||||
|
"work_order_id", "revision", "title", "summary", "status", "priority",
|
||||||
|
"origin", "updated_at",
|
||||||
|
)
|
||||||
|
snapshot = {key: order[key] for key in required}
|
||||||
|
snapshot.update({key: order.get(key) for key in (
|
||||||
|
"assignee", "scheduled_at", "location", "scenario_code", "subtasks",
|
||||||
|
)})
|
||||||
|
snapshot["work_order_type"] = order.get("work_order_type", "general")
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, peripheral_url: str,
|
||||||
|
credentials: CredentialProvider) -> int:
|
||||||
|
headers = await credentials.headers(client)
|
||||||
|
tasks_url = f"{settings.server_url.rstrip('/')}/api/v1/operational-tasks"
|
||||||
|
existing, event_tasks, investigations, offset = {}, {}, [], 0
|
||||||
|
while True:
|
||||||
|
response = await client.get(tasks_url, headers=headers, params={"limit": 100, "offset": offset})
|
||||||
|
response.raise_for_status()
|
||||||
|
page = response.json()
|
||||||
|
for task in page["items"]:
|
||||||
|
if task["task_type"] == "external_work_order":
|
||||||
|
existing[task["source"].get("external_id")] = task
|
||||||
|
elif task["task_type"] == "event_investigation":
|
||||||
|
event_tasks[task.get("source", {}).get("external_id")] = task
|
||||||
|
if task["status"] in {"queued", "waiting_human", "running"}:
|
||||||
|
investigations.append(task)
|
||||||
|
if page.get("next_offset") is None:
|
||||||
|
break
|
||||||
|
offset = page["next_offset"]
|
||||||
|
|
||||||
|
linked: dict[str, list[str]] = {}
|
||||||
|
for task in investigations:
|
||||||
|
response = await client.get(f"{tasks_url}/{task['task_id']}", headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
for action in response.json().get("actions", []):
|
||||||
|
receipt = action.get("receipt")
|
||||||
|
if action["kind"] == "create_work_order" and receipt and receipt["adapter"] == "peripheral_sample":
|
||||||
|
linked.setdefault(receipt["receipt_id"], []).append(task["task_id"])
|
||||||
|
|
||||||
|
cursor, synchronized = None, 0
|
||||||
|
while True:
|
||||||
|
params = {"limit": 100}
|
||||||
|
if cursor:
|
||||||
|
params["cursor"] = cursor
|
||||||
|
response = await client.get(f"{peripheral_url.rstrip('/')}/api/sample/v1/work-orders",
|
||||||
|
headers=headers, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
page = response.json()
|
||||||
|
for order in page["items"]:
|
||||||
|
if order["project_id"] != settings.project_id or order["origin"] != "synthetic":
|
||||||
|
raise ValueError("peripheral work order scope or origin mismatch")
|
||||||
|
external_id = f"peripheral_sample:{order['work_order_id']}"
|
||||||
|
# Complaint intake is an event even if no browser was open when the
|
||||||
|
# external system created it. Use the same source key as the UI.
|
||||||
|
event_kind = order.get("work_order_type")
|
||||||
|
if (
|
||||||
|
event_kind in {"water_quality_complaint", "burst_report"}
|
||||||
|
and order["work_order_id"] not in event_tasks
|
||||||
|
and order["work_order_id"] not in linked
|
||||||
|
):
|
||||||
|
event_request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:investigation"))
|
||||||
|
event = await client.post(tasks_url, headers=headers, json={
|
||||||
|
"request_id": event_request_id, "task_type": "event_investigation", "workflow": event_kind,
|
||||||
|
"title": "水质投诉调查" if event_kind == "water_quality_complaint" else "爆管事件调查", "summary": f"合成外围事件:{order['summary']}",
|
||||||
|
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
|
||||||
|
"source": {"kind": "external_system", "external_id": order["work_order_id"],
|
||||||
|
"external_revision": order["revision"], "event_kind": event_kind},
|
||||||
|
"trigger": {"kind": "event", "occurred_at": order.get("scheduled_at") or order["created_at"],
|
||||||
|
"detail": f"外围样板事件上报 · {order.get('location') or '位置待确认'}"},
|
||||||
|
})
|
||||||
|
event.raise_for_status()
|
||||||
|
event_tasks[order["work_order_id"]] = event.json()
|
||||||
|
task = existing.get(external_id)
|
||||||
|
if task is None:
|
||||||
|
request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:create"))
|
||||||
|
result = await client.post(tasks_url, headers=headers, json={
|
||||||
|
"request_id": request_id, "task_type": "external_work_order", "workflow": "external-work-order",
|
||||||
|
"title": order["title"], "summary": f"合成外围工单:{order['summary']}",
|
||||||
|
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
|
||||||
|
"source": {"kind": "external_system", "external_id": external_id,
|
||||||
|
"event_kind": order.get("work_order_type", "general")},
|
||||||
|
"trigger": {"kind": "external_sync", "occurred_at": order["created_at"],
|
||||||
|
"detail": "外围样板工单同步,进度以外部系统反馈为准"},
|
||||||
|
"scheduled_for": order["scheduled_at"],
|
||||||
|
})
|
||||||
|
result.raise_for_status()
|
||||||
|
task = result.json()
|
||||||
|
existing[external_id] = task
|
||||||
|
event_id = f"{external_id}:{order['revision']}"
|
||||||
|
feedback = {
|
||||||
|
"request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id,
|
||||||
|
"source_revision": order["revision"], "status": STATUS[order["status"]],
|
||||||
|
"occurred_at": order["updated_at"],
|
||||||
|
"detail": f"合成外围工单 {order['work_order_id']}:{order['status']}",
|
||||||
|
"evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": order["summary"],
|
||||||
|
"source_label": "外围样板合成工单", "source_ref": external_id,
|
||||||
|
"observed_at": order["updated_at"]},
|
||||||
|
}
|
||||||
|
targets = set(linked.get(order["work_order_id"], []))
|
||||||
|
event_task = event_tasks.get(order["work_order_id"])
|
||||||
|
if event_task and order["revision"] > (event_task["source"].get("external_revision") or 0):
|
||||||
|
targets.add(event_task["task_id"])
|
||||||
|
if (task["source"].get("external_revision") or 0) < order["revision"]:
|
||||||
|
targets.add(task["task_id"])
|
||||||
|
for target in targets:
|
||||||
|
payload = dict(feedback)
|
||||||
|
if target == task["task_id"]:
|
||||||
|
payload["work_order_snapshot"] = work_order_snapshot(order)
|
||||||
|
result = await client.post(f"{tasks_url}/{target}/external-feedback", headers=headers, json=payload)
|
||||||
|
result.raise_for_status()
|
||||||
|
synchronized += 1
|
||||||
|
cursor = page.get("next_cursor")
|
||||||
|
if not cursor:
|
||||||
|
return synchronized
|
||||||
|
|
||||||
|
|
||||||
|
async def run(once: bool):
|
||||||
|
settings = WorkerSettings.from_environment()
|
||||||
|
peripheral_url = os.environ["OPERATIONAL_ACTION_PERIPHERAL_URL"]
|
||||||
|
credentials = CredentialProvider(settings)
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
count = await sync_once(client, settings, peripheral_url, credentials)
|
||||||
|
logger.info("Peripheral progress synchronization completed: %d revisions", count)
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError):
|
||||||
|
logger.error("Peripheral synchronization failed; next tick will reconcile persisted revisions")
|
||||||
|
if once:
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
if once:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(settings.interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--once", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
try:
|
||||||
|
asyncio.run(run(args.once))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
@@ -56,6 +56,11 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"burst.run",
|
"burst.run",
|
||||||
"burst.view",
|
"burst.view",
|
||||||
|
"operational_action.work_order.approve",
|
||||||
|
"operational_action.work_order.execute",
|
||||||
|
"operational_task.external_sync",
|
||||||
|
"operational_task.view",
|
||||||
|
"operational_task.write",
|
||||||
"optimization.run",
|
"optimization.run",
|
||||||
"optimization.view",
|
"optimization.view",
|
||||||
"risk.run",
|
"risk.run",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.problem_details import install_problem_details_handlers
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_http_error_preserves_domain_code_and_recovery_fields():
|
||||||
|
app = FastAPI()
|
||||||
|
install_problem_details_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/lease")
|
||||||
|
async def expired_lease():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"code": "LEASE_EXPIRED",
|
||||||
|
"message": "Agent lease has expired",
|
||||||
|
"retryable": False,
|
||||||
|
"related_refs": ["task-1"],
|
||||||
|
"recovery_action": "claim_new_generation",
|
||||||
|
"retry_after_seconds": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = TestClient(app).get("/lease")
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.headers["content-type"].startswith("application/problem+json")
|
||||||
|
assert response.json() == {
|
||||||
|
"type": "https://tjwater.example/problems/LEASE-EXPIRED",
|
||||||
|
"title": "Conflict",
|
||||||
|
"status": 409,
|
||||||
|
"detail": "Agent lease has expired",
|
||||||
|
"instance": "/lease",
|
||||||
|
"code": "LEASE_EXPIRED",
|
||||||
|
"trace_id": response.json()["trace_id"],
|
||||||
|
"errors": [],
|
||||||
|
"retryable": False,
|
||||||
|
"related_refs": ["task-1"],
|
||||||
|
"recovery_action": "claim_new_generation",
|
||||||
|
"retry_after_seconds": None,
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ from app.auth.permissions import (
|
|||||||
ENVIRONMENT_MANAGE,
|
ENVIRONMENT_MANAGE,
|
||||||
MODEL_IMPORT,
|
MODEL_IMPORT,
|
||||||
OPTIMIZATION_RUN,
|
OPTIMIZATION_RUN,
|
||||||
|
OPERATIONAL_ACTION_COMMAND_APPROVE,
|
||||||
|
OPERATIONAL_ACTION_COMMAND_EXECUTE,
|
||||||
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
||||||
|
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||||
|
OPERATIONAL_TASK_EXECUTE,
|
||||||
|
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||||
|
OPERATIONAL_TASK_VIEW,
|
||||||
|
OPERATIONAL_TASK_WRITE,
|
||||||
SCADA_CLEAN,
|
SCADA_CLEAN,
|
||||||
SIMULATION_RUN,
|
SIMULATION_RUN,
|
||||||
SIMULATION_VIEW,
|
SIMULATION_VIEW,
|
||||||
@@ -52,11 +60,36 @@ def test_project_role_permission_matrix():
|
|||||||
assert SIMULATION_RUN in member
|
assert SIMULATION_RUN in member
|
||||||
assert OPTIMIZATION_RUN in member
|
assert OPTIMIZATION_RUN in member
|
||||||
assert MODEL_IMPORT not in member
|
assert MODEL_IMPORT not in member
|
||||||
|
assert OPERATIONAL_TASK_VIEW in member
|
||||||
|
assert OPERATIONAL_TASK_WRITE in member
|
||||||
|
assert OPERATIONAL_TASK_EXECUTE not in member
|
||||||
|
assert OPERATIONAL_TASK_EXTERNAL_SYNC not in member
|
||||||
|
assert OPERATIONAL_ACTION_WORK_ORDER_APPROVE in member
|
||||||
|
assert OPERATIONAL_ACTION_WORK_ORDER_EXECUTE in member
|
||||||
|
assert OPERATIONAL_ACTION_COMMAND_APPROVE not in member
|
||||||
|
assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in member
|
||||||
assert WEBGIS_VIEW in viewer
|
assert WEBGIS_VIEW in viewer
|
||||||
assert SIMULATION_VIEW in viewer
|
assert SIMULATION_VIEW in viewer
|
||||||
assert WEBGIS_EDIT not in viewer
|
assert WEBGIS_EDIT not in viewer
|
||||||
assert SCADA_CLEAN not in viewer
|
assert SCADA_CLEAN not in viewer
|
||||||
assert SIMULATION_RUN not in viewer
|
assert SIMULATION_RUN not in viewer
|
||||||
|
assert OPERATIONAL_TASK_VIEW in viewer
|
||||||
|
assert OPERATIONAL_TASK_WRITE not in viewer
|
||||||
|
assert OPERATIONAL_ACTION_WORK_ORDER_APPROVE not in viewer
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_identity_gets_only_project_scoped_control_permissions():
|
||||||
|
service = resolve_permissions(
|
||||||
|
project_role="member",
|
||||||
|
system_role="service",
|
||||||
|
is_superuser=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert OPERATIONAL_TASK_VIEW in service
|
||||||
|
assert OPERATIONAL_TASK_EXECUTE in service
|
||||||
|
assert OPERATIONAL_TASK_EXTERNAL_SYNC in service
|
||||||
|
assert ENVIRONMENT_MANAGE not in service
|
||||||
|
assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in service
|
||||||
|
|
||||||
|
|
||||||
def test_system_admin_permissions_do_not_grant_project_business_access():
|
def test_system_admin_permissions_do_not_grant_project_business_access():
|
||||||
@@ -70,6 +103,16 @@ def test_system_admin_permissions_do_not_grant_project_business_access():
|
|||||||
assert AUDIT_VIEW in permissions
|
assert AUDIT_VIEW in permissions
|
||||||
assert MODEL_IMPORT in permissions
|
assert MODEL_IMPORT in permissions
|
||||||
assert WEBGIS_VIEW not in permissions
|
assert WEBGIS_VIEW not in permissions
|
||||||
|
assert OPERATIONAL_ACTION_COMMAND_APPROVE not in permissions
|
||||||
|
assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in permissions
|
||||||
|
|
||||||
|
project_operator = resolve_permissions(
|
||||||
|
project_role="member",
|
||||||
|
system_role="admin",
|
||||||
|
is_superuser=False,
|
||||||
|
)
|
||||||
|
assert OPERATIONAL_ACTION_COMMAND_APPROVE in project_operator
|
||||||
|
assert OPERATIONAL_ACTION_COMMAND_EXECUTE in project_operator
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|||||||
@@ -41,3 +41,11 @@ def test_rbac_migration_normalizes_legacy_roles():
|
|||||||
assert "'dispatcher'" in sql
|
assert "'dispatcher'" in sql
|
||||||
assert "THEN 'member'" in sql
|
assert "THEN 'member'" in sql
|
||||||
assert "ELSE 'viewer'" in sql
|
assert "ELSE 'viewer'" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_identity_migration_extends_business_roles():
|
||||||
|
sql = Path("resources/sql/016_metadata_service_identities.sql").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "role IN ('admin', 'service', 'user')" in sql
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""Opt-in PostgreSQL lifecycle check; all schema/data changes roll back."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.auth.permissions import permissions_for_context
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.core.config import get_pgconn_string
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReconcileCommand,
|
||||||
|
AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand,
|
||||||
|
NotificationAcknowledgeCommand, OperationalTaskCreate,
|
||||||
|
)
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
from app.infra.integrations.operational_actions import (
|
||||||
|
ActionDispatchOutcome, HttpOperationalActionDispatcher, OperationalActionSettings,
|
||||||
|
)
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
os.environ.get("OPERATIONAL_TASKS_STORAGE_TEST") != "1", reason="explicit PostgreSQL test opt-in required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back():
|
||||||
|
migration = "\n".join(
|
||||||
|
(Path(__file__).parents[2] / f"resources/sql/{name}").read_text()
|
||||||
|
for name in ("014_operational_tasks.sql", "015_workbench_events_and_task_runs.sql")
|
||||||
|
)
|
||||||
|
# The migration is normally atomic itself. This test owns an outer rollback
|
||||||
|
# transaction, so it executes its statements without transaction delimiters.
|
||||||
|
migration = migration.replace("\nBEGIN;", "").replace("\nCOMMIT;", "")
|
||||||
|
context = ProjectContext(project_id=uuid4(), project_code="isolated-storage-test",
|
||||||
|
user_id=uuid4(), project_role="member")
|
||||||
|
conn = await psycopg.AsyncConnection.connect(get_pgconn_string(), row_factory=dict_row, connect_timeout=3)
|
||||||
|
try:
|
||||||
|
async with conn.transaction(force_rollback=True):
|
||||||
|
await conn.execute(migration)
|
||||||
|
repository = OperationalTaskRepository(conn, context.project_id)
|
||||||
|
service = OperationalTaskService(repository, context, permissions_for_context(context))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
command = OperationalTaskCreate(
|
||||||
|
request_id=uuid4(), task_type="event_investigation", workflow="water_quality_complaint",
|
||||||
|
title="AUTOTEST 合成调查", summary="隔离事务验证,不是生产投诉",
|
||||||
|
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
||||||
|
trigger={"kind": "manual", "occurred_at": now, "detail": "transactional integration test"},
|
||||||
|
)
|
||||||
|
task = await service.create(command)
|
||||||
|
assert (await service.create(command)).task_id == task.task_id
|
||||||
|
assert task.status == "queued"
|
||||||
|
queued_rows = await repository.list_tasks(
|
||||||
|
task_type=None, status="queued", limit=100, offset=0
|
||||||
|
)
|
||||||
|
assert [row["task_id"] for row in queued_rows] == [str(task.task_id)]
|
||||||
|
all_rows = await repository.list_tasks(
|
||||||
|
task_type=None, status=None, limit=100, offset=0
|
||||||
|
)
|
||||||
|
assert [row["task_id"] for row in all_rows] == [str(task.task_id)]
|
||||||
|
claimed_run = await service.claim_run(task.task_id, task.active_run.run_id, AgentRunCommand(
|
||||||
|
request_id=task.active_run.execution_request_id,
|
||||||
|
expected_revision=task.active_run.revision,
|
||||||
|
source_session_id="test-investigation-session",
|
||||||
|
))
|
||||||
|
task = await service.detail(task.task_id)
|
||||||
|
assert task.active_run == claimed_run
|
||||||
|
artifact = AgentArtifactsCommand.model_validate({
|
||||||
|
"request_id": str(uuid4()), "source_session_id": "test-investigation-session",
|
||||||
|
"run_id": str(task.active_run.run_id),
|
||||||
|
"evidence": [{"evidence_id": "synthetic-1", "kind": "synthetic", "summary": "合成采样输入",
|
||||||
|
"source_label": "事务集成测试", "observed_at": now.isoformat()}],
|
||||||
|
"plan": {"schema": "execution-plan@1", "title": "调查", "reason": "等待采样",
|
||||||
|
"nodes": [{"id": "review", "title": "核对输入", "owner": "agent", "status": "completed",
|
||||||
|
"detail": "已读取合成输入", "evidence_refs": ["synthetic-1"]},
|
||||||
|
{"id": "sample", "title": "现场采样", "owner": "human", "status": "waiting",
|
||||||
|
"detail": "等待外部反馈", "evidence_refs": []}],
|
||||||
|
"edges": [{"from": "review", "to": "sample"}]},
|
||||||
|
"report": {"title": "调查阶段结论", "current_conclusion": "待现场核验", "conclusion_boundary": "合成输入",
|
||||||
|
"evidence_refs": ["synthetic-1"], "pending_items": ["现场采样"]},
|
||||||
|
})
|
||||||
|
published = await service.publish_artifacts(task.task_id, artifact)
|
||||||
|
assert published.task.status == "running"
|
||||||
|
assert published.task.latest_report.report.evidence_refs == ["synthetic-1"]
|
||||||
|
assert (await service.publish_artifacts(task.task_id, artifact)).report_revision == 1
|
||||||
|
task = await service.complete_run(
|
||||||
|
task.task_id,
|
||||||
|
published.task.active_run.run_id,
|
||||||
|
AgentRunCommand(
|
||||||
|
request_id=uuid4(),
|
||||||
|
expected_revision=published.task.active_run.revision,
|
||||||
|
source_session_id="test-investigation-session",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert task.status == "waiting_human"
|
||||||
|
action = await service.propose_action(task.task_id, ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="需要现场采样", evidence_refs=["synthetic-1"],
|
||||||
|
payload={"title": "采样", "description": "合成核验", "work_order_type": "sampling"},
|
||||||
|
))
|
||||||
|
member_notifications, member_next = await service.list_notifications(
|
||||||
|
unread_only=False, limit=100, offset=0
|
||||||
|
)
|
||||||
|
assert member_next is None
|
||||||
|
assert member_notifications
|
||||||
|
assert all(item.task_id == task.task_id for item in member_notifications)
|
||||||
|
action_pending = next(
|
||||||
|
item for item in member_notifications if item.kind == "action_pending"
|
||||||
|
)
|
||||||
|
task_created = next(
|
||||||
|
item for item in member_notifications if item.kind == "task_created"
|
||||||
|
)
|
||||||
|
assert action_pending.required_permission == action.required_approval_permission
|
||||||
|
|
||||||
|
viewer_context = ProjectContext(
|
||||||
|
project_id=context.project_id,
|
||||||
|
project_code=context.project_code,
|
||||||
|
user_id=uuid4(),
|
||||||
|
project_role="viewer",
|
||||||
|
)
|
||||||
|
viewer_service = OperationalTaskService(
|
||||||
|
OperationalTaskRepository(conn, viewer_context.project_id),
|
||||||
|
viewer_context,
|
||||||
|
permissions_for_context(viewer_context),
|
||||||
|
)
|
||||||
|
viewer_notifications, _ = await viewer_service.list_notifications(
|
||||||
|
unread_only=True, limit=100, offset=0
|
||||||
|
)
|
||||||
|
assert task_created.notification_id in {
|
||||||
|
item.notification_id for item in viewer_notifications
|
||||||
|
}
|
||||||
|
assert action_pending.notification_id not in {
|
||||||
|
item.notification_id for item in viewer_notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledged = await service.acknowledge_notification(
|
||||||
|
task_created.notification_id,
|
||||||
|
NotificationAcknowledgeCommand(
|
||||||
|
request_id=uuid4(), expected_version=task_created.version
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert acknowledged.status == "read"
|
||||||
|
assert acknowledged.task_id == task.task_id
|
||||||
|
|
||||||
|
persisted_service = OperationalTaskService(
|
||||||
|
OperationalTaskRepository(conn, context.project_id),
|
||||||
|
context,
|
||||||
|
permissions_for_context(context),
|
||||||
|
)
|
||||||
|
persisted_notifications, _ = await persisted_service.list_notifications(
|
||||||
|
unread_only=False, limit=100, offset=0
|
||||||
|
)
|
||||||
|
persisted_created = next(
|
||||||
|
item for item in persisted_notifications
|
||||||
|
if item.notification_id == task_created.notification_id
|
||||||
|
)
|
||||||
|
assert persisted_created.status == "read"
|
||||||
|
assert persisted_created.version == task_created.version + 1
|
||||||
|
assert persisted_created.read_by == f"principal:{context.user_id}"
|
||||||
|
unread_notifications, _ = await persisted_service.list_notifications(
|
||||||
|
unread_only=True, limit=100, offset=0
|
||||||
|
)
|
||||||
|
assert task_created.notification_id not in {
|
||||||
|
item.notification_id for item in unread_notifications
|
||||||
|
}
|
||||||
|
viewer_after_member_ack, _ = await viewer_service.list_notifications(
|
||||||
|
unread_only=True, limit=100, offset=0
|
||||||
|
)
|
||||||
|
assert task_created.notification_id in {
|
||||||
|
item.notification_id for item in viewer_after_member_ack
|
||||||
|
}
|
||||||
|
action = await service.decide_action(task.task_id, action.action_id, ActionDecisionCommand(
|
||||||
|
request_id=uuid4(), expected_version=action.version, decision="approved",
|
||||||
|
))
|
||||||
|
assert action.execution_status == "not_submitted"
|
||||||
|
class NoAdapter:
|
||||||
|
async def dispatch(self, **kwargs):
|
||||||
|
return ActionDispatchOutcome("unsupported", "测试不执行外部动作")
|
||||||
|
action = await service.submit_action(task.task_id, action.action_id, ActionExecuteCommand(
|
||||||
|
request_id=uuid4(), expected_version=action.version,
|
||||||
|
), NoAdapter(), None)
|
||||||
|
assert action.execution_status == "unsupported"
|
||||||
|
fresh_service = OperationalTaskService(OperationalTaskRepository(conn, context.project_id),
|
||||||
|
context, permissions_for_context(context))
|
||||||
|
assert (await fresh_service.detail(task.task_id)).actions[0].action_id == action.action_id
|
||||||
|
before_feedback = await fresh_service.detail(task.task_id)
|
||||||
|
feedback = ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id="synthetic-sampling-order", source_revision=2,
|
||||||
|
status="completed", occurred_at=now, detail="合成采样工单完成,调查仍需分析",
|
||||||
|
evidence={"evidence_id": "synthetic-sampling-order:2", "kind": "synthetic",
|
||||||
|
"summary": "合成采样结果已回传", "source_label": "事务集成外围工单",
|
||||||
|
"observed_at": now},
|
||||||
|
)
|
||||||
|
resumed = (await fresh_service.external_feedback(task.task_id, feedback)).task
|
||||||
|
assert resumed.status == "queued"
|
||||||
|
assert resumed.execution_generation == before_feedback.execution_generation
|
||||||
|
assert resumed.execution_request_id == before_feedback.execution_request_id
|
||||||
|
assert resumed.agent_session_id is None
|
||||||
|
assert len(resumed.evidence) == 2
|
||||||
|
replayed = (await fresh_service.external_feedback(task.task_id, feedback)).task
|
||||||
|
assert replayed.execution_generation == resumed.execution_generation
|
||||||
|
assert replayed.revision == resumed.revision
|
||||||
|
claimed_run = await fresh_service.claim_run(
|
||||||
|
task.task_id,
|
||||||
|
resumed.active_run.run_id,
|
||||||
|
AgentRunCommand(
|
||||||
|
request_id=resumed.active_run.execution_request_id,
|
||||||
|
expected_revision=resumed.active_run.revision,
|
||||||
|
source_session_id="test-investigation-followup-session",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
resumed = await fresh_service.detail(task.task_id)
|
||||||
|
assert resumed.active_run == claimed_run
|
||||||
|
with pytest.raises(DomainError) as stale_session:
|
||||||
|
await fresh_service.publish_artifacts(task.task_id, artifact.model_copy(update={
|
||||||
|
"request_id": uuid4(), "run_id": resumed.active_run.run_id,
|
||||||
|
}))
|
||||||
|
assert stale_session.value.code == "SESSION_MISMATCH"
|
||||||
|
assert (await fresh_service.detail(task.task_id)).agent_session_id == resumed.agent_session_id
|
||||||
|
contract_output = os.environ.get("OPERATIONAL_TASK_CONTRACT_OUTPUT")
|
||||||
|
if contract_output:
|
||||||
|
# Optional generated test artifact for cross-repository consumers.
|
||||||
|
Path(contract_output).write_text(
|
||||||
|
(await fresh_service.detail(task.task_id)).model_dump_json(indent=2, by_alias=True), encoding="utf-8"
|
||||||
|
)
|
||||||
|
outsider = OperationalTaskService(OperationalTaskRepository(conn, uuid4()),
|
||||||
|
context, permissions_for_context(context))
|
||||||
|
with pytest.raises(DomainError):
|
||||||
|
await outsider.detail(task.task_id)
|
||||||
|
sampling = await fresh_service.propose_action(task.task_id, ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="需要补充采样",
|
||||||
|
evidence_refs=["synthetic-1"],
|
||||||
|
payload={"title": "补充采样", "description": "隔离事务测试", "work_order_type": "sampling"},
|
||||||
|
))
|
||||||
|
sampling = await fresh_service.decide_action(task.task_id, sampling.action_id, ActionDecisionCommand(
|
||||||
|
request_id=uuid4(), expected_version=sampling.version, decision="approved",
|
||||||
|
))
|
||||||
|
calls, execution_ids = [], []
|
||||||
|
receipt_ready = False
|
||||||
|
order_id = str(uuid4())
|
||||||
|
|
||||||
|
def sample_transport(request):
|
||||||
|
calls.append(request.method)
|
||||||
|
assert request.headers["X-Project-Id"] == str(context.project_id)
|
||||||
|
if request.method == "POST":
|
||||||
|
execution_ids.append(json.loads(request.content)["request_id"])
|
||||||
|
raise httpx.ReadTimeout("simulated lost response", request=request)
|
||||||
|
if not receipt_ready:
|
||||||
|
return httpx.Response(404, json={"effect": "none"})
|
||||||
|
assert request.url.path.endswith(execution_ids[0])
|
||||||
|
return httpx.Response(200, json={"request_id": execution_ids[0], "result": {
|
||||||
|
"work_order_id": order_id, "project_id": str(context.project_id),
|
||||||
|
"created_at": now.isoformat(), "origin": "synthetic", "status": "open",
|
||||||
|
}})
|
||||||
|
|
||||||
|
dispatcher = HttpOperationalActionDispatcher(
|
||||||
|
OperationalActionSettings(_env_file=None, peripheral_url="http://sample.test"),
|
||||||
|
transport=httpx.MockTransport(sample_transport),
|
||||||
|
)
|
||||||
|
submission = ActionExecuteCommand(request_id=uuid4(), expected_version=sampling.version)
|
||||||
|
unknown = await fresh_service.submit_action(task.task_id, sampling.action_id, submission,
|
||||||
|
dispatcher, "Bearer TEST_ONLY")
|
||||||
|
assert unknown.execution_status == "unknown"
|
||||||
|
assert calls == ["POST", "GET"]
|
||||||
|
receipt_ready = True
|
||||||
|
confirmed = await fresh_service.reconcile_action(task.task_id, sampling.action_id,
|
||||||
|
ActionReconcileCommand(request_id=uuid4(), expected_version=unknown.version),
|
||||||
|
dispatcher, "Bearer TEST_ONLY")
|
||||||
|
assert confirmed.execution_status == "succeeded"
|
||||||
|
assert confirmed.receipt.receipt_id == order_id
|
||||||
|
assert confirmed.receipt.external_status == "open"
|
||||||
|
assert confirmed.execution_request_id == unknown.execution_request_id
|
||||||
|
assert calls == ["POST", "GET", "GET"]
|
||||||
|
await fresh_service.submit_action(task.task_id, sampling.action_id, submission,
|
||||||
|
dispatcher, "Bearer TEST_ONLY")
|
||||||
|
assert calls == ["POST", "GET", "GET"]
|
||||||
|
assert (await fresh_service.detail(task.task_id)).status == "queued"
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
@@ -208,9 +208,13 @@ def test_managed_work_orders_and_test_data_persist_revision_and_isolation(sample
|
|||||||
priority="high",
|
priority="high",
|
||||||
assignee="AUTOTEST",
|
assignee="AUTOTEST",
|
||||||
scheduled_at="2026-09-18T01:00:00Z",
|
scheduled_at="2026-09-18T01:00:00Z",
|
||||||
|
work_order_type="water_quality_complaint",
|
||||||
|
location="AUTOTEST 演示小区",
|
||||||
|
scenario_code="WQ-SECONDARY-SUPPLY-001",
|
||||||
)
|
)
|
||||||
order = store.create_work_order(who, order_command)
|
order = store.create_work_order(who, order_command)
|
||||||
WorkOrder.model_validate(order)
|
WorkOrder.model_validate(order)
|
||||||
|
assert order["work_order_type"] == "water_quality_complaint"
|
||||||
assert PostgresStore(store.settings).get_work_order(who, order["work_order_id"]) == order
|
assert PostgresStore(store.settings).get_work_order(who, order["work_order_id"]) == order
|
||||||
assert store.create_work_order(who, order_command) == order
|
assert store.create_work_order(who, order_command) == order
|
||||||
order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
|
order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from scripts.import_agent_investigation import convert_plan, run
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot():
|
||||||
|
return {"session_id": "ses_test", "revision": 1, "updated_at": "2026-09-23T00:00:00Z",
|
||||||
|
"title": "合成调查", "nodes": [
|
||||||
|
{"id": "STAGE_JUDGMENT", "title": "阶段判断", "owner": "agent",
|
||||||
|
"status": "completed", "detail": "合成证据不足以确定原因", "evidence": ["合成输入"]},
|
||||||
|
{"id": "SAMPLE", "title": "采样", "owner": "human", "status": "waiting",
|
||||||
|
"detail": "等待现场采样反馈", "evidence": []},
|
||||||
|
], "edges": [{"from": "STAGE_JUDGMENT", "to": "SAMPLE"}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_conversion_preserves_waiting_and_explicit_synthetic_boundary():
|
||||||
|
plan, evidence, report = convert_plan(snapshot())
|
||||||
|
assert plan["nodes"][1]["status"] == "waiting"
|
||||||
|
assert all(item["kind"] == "synthetic" for item in evidence)
|
||||||
|
assert report["pending_items"] == ["等待现场采样反馈"]
|
||||||
|
assert report["current_conclusion"] == "合成证据不足以确定原因"
|
||||||
|
assert "未重新查询当前数据" in report["conclusion_boundary"]
|
||||||
|
assert report["evidence_refs"] == [evidence[0]["evidence_id"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dry_run_validates_historical_schema_before_any_network_access(tmp_path, monkeypatch, capsys):
|
||||||
|
metadata_dir = tmp_path / "session-metadata"
|
||||||
|
states_dir = tmp_path / "session-ui-states"
|
||||||
|
metadata_dir.mkdir()
|
||||||
|
states_dir.mkdir()
|
||||||
|
(metadata_dir / "ses_test.json").write_text(json.dumps({
|
||||||
|
"triggerWorkflow": "water_quality_complaint", "triggerSource": "peripheral_sample",
|
||||||
|
"projectId": "10000000-0000-4000-8000-000000000001",
|
||||||
|
}))
|
||||||
|
state = snapshot()
|
||||||
|
state_path = states_dir / "ses_test.json"
|
||||||
|
state_path.write_text(json.dumps({"executionPlans": [state]}))
|
||||||
|
|
||||||
|
def no_network(*args, **kwargs):
|
||||||
|
raise AssertionError("dry run must not open a network client")
|
||||||
|
|
||||||
|
monkeypatch.setattr("scripts.import_agent_investigation.httpx.AsyncClient", no_network)
|
||||||
|
args = argparse.Namespace(session_id="ses_test", agent_data=tmp_path, dry_run=True)
|
||||||
|
await run(args)
|
||||||
|
assert "2 nodes and 1 synthetic evidence records; no writes" in capsys.readouterr().out
|
||||||
|
state["nodes"][1]["status"] = "invented_status"
|
||||||
|
state_path.write_text(json.dumps({"executionPlans": [state]}))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
await run(args)
|
||||||
|
assert "Ready" not in capsys.readouterr().out
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.infra.integrations.operational_actions import (
|
||||||
|
HttpOperationalActionDispatcher, OperationalActionSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
def inputs(kind="create_work_order"):
|
||||||
|
project_id, task_id = uuid4(), uuid4()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {"title": "现场采样", "description": "核验投诉水质", "work_order_type": "sampling"}
|
||||||
|
if kind == "issue_command":
|
||||||
|
payload = {"command_type": "set_valve", "target": {"kind": "valve", "id": "V-01"},
|
||||||
|
"parameters": {"opening": 20}, "impact_summary": "影响V-01阀门控制范围",
|
||||||
|
"preconditions": ["现场人员确认"],
|
||||||
|
"expires_at": (now + timedelta(minutes=10)).isoformat()}
|
||||||
|
return {
|
||||||
|
"context": SimpleNamespace(project_id=project_id),
|
||||||
|
"task": SimpleNamespace(task_id=task_id, project_id=project_id),
|
||||||
|
"action": SimpleNamespace(task_id=task_id, action_id=uuid4(), version=1, kind=kind,
|
||||||
|
execution_request_id=uuid4(),
|
||||||
|
approval_status="approved", payload=payload,
|
||||||
|
decided_by="operator", decided_at=now),
|
||||||
|
"credential": "Bearer TEST_ONLY",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def settings(**kwargs):
|
||||||
|
return OperationalActionSettings(_env_file=None, peripheral_url="http://sample.test",
|
||||||
|
command_gateway_url="http://commands.test", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_work_order_confirms_creation_not_field_completion():
|
||||||
|
data = inputs()
|
||||||
|
calls = []
|
||||||
|
def handler(request):
|
||||||
|
calls.append(request)
|
||||||
|
return httpx.Response(201, json={"work_order_id": str(uuid4()),
|
||||||
|
"project_id": str(data["context"].project_id), "created_at": "2026-09-23T08:00:00Z",
|
||||||
|
"origin": "synthetic", "status": "open"})
|
||||||
|
adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler))
|
||||||
|
result = await adapter.dispatch(**data)
|
||||||
|
assert result.status == "succeeded"
|
||||||
|
assert result.receipt.external_status == "open"
|
||||||
|
assert "现场工作进度" in result.detail
|
||||||
|
assert calls[0].url.path == "/api/sample/v1/work-orders"
|
||||||
|
assert calls[0].headers["X-Project-Id"] == str(data["context"].project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_work_order_receipt_accepts_awaiting_reply_status():
|
||||||
|
data = inputs()
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(201, json={
|
||||||
|
"work_order_id": str(uuid4()),
|
||||||
|
"project_id": str(data["context"].project_id),
|
||||||
|
"created_at": "2026-09-23T08:00:00Z",
|
||||||
|
"origin": "synthetic",
|
||||||
|
"status": "awaiting_reply",
|
||||||
|
})
|
||||||
|
adapter = HttpOperationalActionDispatcher(
|
||||||
|
settings(), transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await adapter.dispatch(**data)
|
||||||
|
|
||||||
|
assert result.status == "succeeded"
|
||||||
|
assert result.receipt.external_status == "awaiting_reply"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_action_work_order_is_not_reclassified_as_a_new_inbound_event():
|
||||||
|
data = inputs()
|
||||||
|
data["action"].payload["work_order_type"] = "water_quality_complaint"
|
||||||
|
submitted = {}
|
||||||
|
def handler(request):
|
||||||
|
submitted.update(__import__("json").loads(request.content))
|
||||||
|
return httpx.Response(201, json={
|
||||||
|
"work_order_id": str(uuid4()),
|
||||||
|
"project_id": str(data["context"].project_id),
|
||||||
|
"created_at": "2026-09-23T08:00:00Z",
|
||||||
|
"origin": "synthetic",
|
||||||
|
"status": "open",
|
||||||
|
})
|
||||||
|
|
||||||
|
result = await HttpOperationalActionDispatcher(
|
||||||
|
settings(), transport=httpx.MockTransport(handler)
|
||||||
|
).dispatch(**data)
|
||||||
|
|
||||||
|
assert result.status == "succeeded"
|
||||||
|
assert submitted["work_order_type"] == "general"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_lost_create_response_recovers_same_request_without_second_write():
|
||||||
|
data, methods = inputs(), []
|
||||||
|
def handler(request):
|
||||||
|
methods.append(request.method)
|
||||||
|
if request.method == "POST":
|
||||||
|
raise httpx.ReadTimeout("lost", request=request)
|
||||||
|
return httpx.Response(200, json={"request_id": request.url.path.split("/")[-1],
|
||||||
|
"result": {"work_order_id": str(uuid4()), "project_id": str(data["context"].project_id),
|
||||||
|
"created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"}})
|
||||||
|
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
|
||||||
|
assert result.status == "succeeded"
|
||||||
|
assert methods == ["POST", "GET"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unconfirmed_receipt_remains_unknown_not_failed_or_succeeded():
|
||||||
|
data = inputs()
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json={"work_order_id": str(uuid4()), "project_id": str(uuid4()),
|
||||||
|
"created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"})
|
||||||
|
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
|
||||||
|
assert result.status == "unknown"
|
||||||
|
assert result.receipt is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unapproved_and_expired_actions_never_reach_external_system():
|
||||||
|
def handler(request):
|
||||||
|
raise AssertionError("no external call expected")
|
||||||
|
adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler))
|
||||||
|
data = inputs()
|
||||||
|
data["action"].approval_status = "pending"
|
||||||
|
assert (await adapter.dispatch(**data)).status == "failed"
|
||||||
|
data = inputs("issue_command")
|
||||||
|
data["action"].payload["expires_at"] = "2020-01-01T00:00:00Z"
|
||||||
|
assert (await adapter.dispatch(**data)).status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_command_acceptance_is_not_execution_completion():
|
||||||
|
data = inputs("issue_command")
|
||||||
|
def handler(request):
|
||||||
|
import json
|
||||||
|
body = json.loads(request.content)
|
||||||
|
assert body["command"]["target"]["id"] == "V-01"
|
||||||
|
return httpx.Response(202, json={"request_id": body["request_id"],
|
||||||
|
"project_id": body["project_id"], "receipt_id": "gateway-1", "status": "accepted",
|
||||||
|
"submitted_at": "2026-09-23T08:00:00Z", "completed_at": None, "detail": "已接收"})
|
||||||
|
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
|
||||||
|
assert result.status == "submitted"
|
||||||
|
assert result.receipt.completed_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_missing_configuration_does_not_invent_receipt():
|
||||||
|
adapter = HttpOperationalActionDispatcher(OperationalActionSettings(
|
||||||
|
_env_file=None, peripheral_url="", command_gateway_url=""))
|
||||||
|
result = await adapter.dispatch(**inputs())
|
||||||
|
assert result.status == "unsupported"
|
||||||
|
assert result.receipt is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_reconciliation_is_read_only_and_keeps_execution_identity_across_versions():
|
||||||
|
data, paths = inputs("issue_command"), []
|
||||||
|
def handler(request):
|
||||||
|
assert request.method == "GET"
|
||||||
|
paths.append(request.url.path)
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"request_id": str(data["action"].execution_request_id),
|
||||||
|
"project_id": str(data["context"].project_id), "receipt_id": "confirmed-command",
|
||||||
|
"status": "completed", "submitted_at": "2026-09-23T08:00:00Z",
|
||||||
|
"completed_at": "2026-09-23T08:01:00Z", "detail": "执行回执已确认",
|
||||||
|
})
|
||||||
|
adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler))
|
||||||
|
assert (await adapter.reconcile(**data)).status == "succeeded"
|
||||||
|
data["action"].version += 1
|
||||||
|
assert (await adapter.reconcile(**data)).status == "succeeded"
|
||||||
|
assert paths[0] == paths[1]
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.domain.schemas.operational_data import TaskDataQuery
|
||||||
|
from app.infra.integrations.operational_data import TaskDataError, TaskDataSettings, read_task_data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
def context():
|
||||||
|
return ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", project_role="member")
|
||||||
|
|
||||||
|
|
||||||
|
def settings():
|
||||||
|
return TaskDataSettings(_env_file=None, server_url="http://server.test", peripheral_url="http://sample.test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_actual_read_has_scoped_headers_and_synthetic_provenance():
|
||||||
|
ctx = context()
|
||||||
|
def handler(request):
|
||||||
|
assert request.method == "GET"
|
||||||
|
assert request.url.path == "/api/sample/v1/demo-data"
|
||||||
|
assert not request.url.params
|
||||||
|
assert request.headers["X-Project-Id"] == str(ctx.project_id)
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"project_id": str(ctx.project_id),
|
||||||
|
"source_id": "tjwater_v2:peripheral_sample.demo_datasets:water-demo-v1",
|
||||||
|
"source_revision": "sha256:test",
|
||||||
|
"points": [{"point_id": "DEMO-P001"}],
|
||||||
|
"observations": [
|
||||||
|
{"point_id": "DEMO-P001", "observed_at": "2026-09-23T00:30:00Z", "value": 0.22},
|
||||||
|
{"point_id": "DEMO-P002", "observed_at": "2026-09-23T00:30:00Z", "value": 1.0},
|
||||||
|
{"point_id": "DEMO-P001", "observed_at": "2026-09-24T00:00:00Z", "value": 2.0},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
result = await read_task_data(context=ctx, credential="Bearer TEST_ONLY", settings=settings(),
|
||||||
|
query=TaskDataQuery(source="peripheral_observations", point_id="DEMO-P001",
|
||||||
|
start="2026-09-23T00:00:00Z", end="2026-09-23T01:00:00Z"),
|
||||||
|
transport=httpx.MockTransport(handler))
|
||||||
|
assert result.origin == "synthetic"
|
||||||
|
assert result.data["items"][0]["value"] == 0.22
|
||||||
|
assert len(result.data["items"]) == 1
|
||||||
|
assert result.data["point_id"] == "DEMO-P001"
|
||||||
|
assert result.data["window"] == {
|
||||||
|
"start": "2026-09-23T00:00:00+00:00", "end": "2026-09-23T01:00:00+00:00",
|
||||||
|
}
|
||||||
|
assert result.source_ref.startswith(
|
||||||
|
"peripheral_demo_data:DEMO-P001:2026-09-23T00:00:00+00:00:"
|
||||||
|
"2026-09-23T01:00:00+00:00:sha256:"
|
||||||
|
)
|
||||||
|
assert not result.truncated
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_historical_observations_keep_monitoring_endpoint():
|
||||||
|
def handler(request):
|
||||||
|
assert request.url.path == "/api/sample/v1/monitoring/observations"
|
||||||
|
assert request.url.params["point_id"] == "100026"
|
||||||
|
return httpx.Response(200, json={"items": [], "truncated": False, "origin": "historical_replay"})
|
||||||
|
result = await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
||||||
|
query=TaskDataQuery(source="peripheral_observations", point_id="100026",
|
||||||
|
start="2026-09-23T00:00:00Z", end="2026-09-23T01:00:00Z"),
|
||||||
|
transport=httpx.MockTransport(handler))
|
||||||
|
assert result.origin == "system_data"
|
||||||
|
assert result.data["origin"] == "historical_replay"
|
||||||
|
assert result.source_ref.startswith("peripheral_historical_replay:peripheral_observations:sha256:")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("origin,expected", [("historical_replay", "system_data"), ("synthetic", "synthetic")])
|
||||||
|
async def test_monitoring_point_provenance_follows_source_not_transport(origin, expected):
|
||||||
|
result = await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
||||||
|
query=TaskDataQuery(source="peripheral_points"),
|
||||||
|
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"origin": origin, "items": []})))
|
||||||
|
assert result.origin == expected
|
||||||
|
assert result.data["origin"] == origin
|
||||||
|
assert result.source_ref.startswith(
|
||||||
|
"peripheral_historical_replay:" if origin == "historical_replay" else "peripheral_points:"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_monitoring_data_without_known_provenance_is_not_saved_as_fact():
|
||||||
|
with pytest.raises(TaskDataError, match="来源"):
|
||||||
|
await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
||||||
|
query=TaskDataQuery(source="peripheral_points"),
|
||||||
|
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"items": []})))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_paged_data_is_marked_incomplete_and_receipt_is_content_bound():
|
||||||
|
values = [{"items": [1], "total": 2}, {"items": [2], "total": 2}]
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json=values.pop(0))
|
||||||
|
kwargs = dict(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
||||||
|
query=TaskDataQuery(source="scada_devices"), transport=httpx.MockTransport(handler))
|
||||||
|
first, second = await read_task_data(**kwargs), await read_task_data(**kwargs)
|
||||||
|
assert first.truncated and second.truncated
|
||||||
|
assert first.source_ref != second.source_ref
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("shape", ["root", "items", "list"])
|
||||||
|
async def test_cross_project_or_failed_query_cannot_become_evidence(shape):
|
||||||
|
ctx = context()
|
||||||
|
foreign = {"project_id": str(uuid4()), "value": 0.22}
|
||||||
|
payload = {"root": foreign, "items": {"project_id": str(ctx.project_id), "items": [foreign]},
|
||||||
|
"list": [foreign]}[shape]
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
with pytest.raises(TaskDataError, match="其他项目"):
|
||||||
|
await read_task_data(context=ctx, credential="Bearer TEST_ONLY", settings=settings(),
|
||||||
|
query=TaskDataQuery(source="workbench_runtime"), transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_is_a_finite_read_scope_with_bounded_time_and_device_count():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TaskDataQuery(source="http://arbitrary.test/delete")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TaskDataQuery(source="scada_readings", device_ids=["p1"],
|
||||||
|
start="2026-09-01T00:00:00Z", end="2026-09-23T00:00:00Z")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TaskDataQuery(source="peripheral_observations")
|
||||||
@@ -0,0 +1,852 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth.permissions import (
|
||||||
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
||||||
|
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||||
|
OPERATIONAL_TASK_VIEW,
|
||||||
|
)
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
ActionDecisionCommand,
|
||||||
|
ActionExecuteCommand,
|
||||||
|
ActionProposalCommand,
|
||||||
|
AgentArtifactsCommand,
|
||||||
|
AgentRunCommand,
|
||||||
|
ExternalFeedbackCommand,
|
||||||
|
HumanApprovalAction,
|
||||||
|
NotificationAcknowledgeCommand,
|
||||||
|
OperationalTaskNotification,
|
||||||
|
OperationalTaskRun,
|
||||||
|
OperationalTaskSummary,
|
||||||
|
)
|
||||||
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationRepository:
|
||||||
|
def __init__(self, notification):
|
||||||
|
self.notification = notification
|
||||||
|
self.reads = {}
|
||||||
|
self.requests = {}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self):
|
||||||
|
yield self
|
||||||
|
|
||||||
|
async def lock(self, _key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_request(self, scope, request_id):
|
||||||
|
return self.requests.get((scope, request_id))
|
||||||
|
|
||||||
|
async def save_request(self, scope, request_id, request_digest, response):
|
||||||
|
self.requests[(scope, request_id)] = {
|
||||||
|
"request_digest": request_digest,
|
||||||
|
"response": response,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_notification(self, notification_id, user_id):
|
||||||
|
if notification_id != str(self.notification.notification_id):
|
||||||
|
return None
|
||||||
|
read = self.reads.get(user_id)
|
||||||
|
return self.notification.model_copy(update={
|
||||||
|
"status": "read" if read else "unread",
|
||||||
|
"version": 2 if read else 1,
|
||||||
|
"read_at": read,
|
||||||
|
"read_by": f"principal:{user_id}" if read else None,
|
||||||
|
}).model_dump(mode="json")
|
||||||
|
|
||||||
|
async def mark_notification_read(self, notification_id, user_id, version, read_at):
|
||||||
|
assert version == 2
|
||||||
|
self.reads[user_id] = read_at
|
||||||
|
|
||||||
|
|
||||||
|
def _context(user_id=None, *, system_role="user"):
|
||||||
|
return ProjectContext(
|
||||||
|
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
|
||||||
|
project_role="member", system_role=system_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_notification_read_state_is_isolated_per_user():
|
||||||
|
now = datetime(2026, 9, 23, tzinfo=timezone.utc)
|
||||||
|
notification = OperationalTaskNotification(
|
||||||
|
notification_id=uuid4(), task_id=uuid4(), kind="task_created",
|
||||||
|
title="任务", description="已创建", status="unread", version=1,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
repository = NotificationRepository(notification)
|
||||||
|
first = _context()
|
||||||
|
second = _context()
|
||||||
|
first_service = OperationalTaskService(
|
||||||
|
repository, first, {OPERATIONAL_TASK_VIEW}, clock=lambda: now
|
||||||
|
)
|
||||||
|
second_service = OperationalTaskService(
|
||||||
|
repository, second, {OPERATIONAL_TASK_VIEW}, clock=lambda: now
|
||||||
|
)
|
||||||
|
|
||||||
|
request_id = uuid4()
|
||||||
|
read = await first_service.acknowledge_notification(
|
||||||
|
notification.notification_id,
|
||||||
|
NotificationAcknowledgeCommand(request_id=request_id, expected_version=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert read.status == "read"
|
||||||
|
assert (await repository.get_notification(
|
||||||
|
str(notification.notification_id), str(second.user_id)
|
||||||
|
))["status"] == "unread"
|
||||||
|
|
||||||
|
second_read = await second_service.acknowledge_notification(
|
||||||
|
notification.notification_id,
|
||||||
|
NotificationAcknowledgeCommand(request_id=request_id, expected_version=1),
|
||||||
|
)
|
||||||
|
assert second_read.status == "read"
|
||||||
|
assert second_read.read_by == f"principal:{second.user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_plan_state_wins_over_future_human_waiting_node():
|
||||||
|
nodes = [
|
||||||
|
type("Node", (), {"status": "running", "owner": "agent"})(),
|
||||||
|
type("Node", (), {"status": "waiting", "owner": "human"})(),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert OperationalTaskService._status_from_plan(nodes, []) == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_approval_prevents_plan_from_marking_task_completed():
|
||||||
|
action = HumanApprovalAction(
|
||||||
|
action_id=uuid4(), task_id=uuid4(), version=1,
|
||||||
|
kind="create_work_order", reason="现场采样",
|
||||||
|
payload={"title": "采样", "description": "核验", "work_order_type": "sampling"},
|
||||||
|
evidence_refs=[], required_approval_permission="work-order.approve",
|
||||||
|
required_execution_permission="work-order.execute",
|
||||||
|
approval_status="pending", execution_status="not_submitted",
|
||||||
|
proposed_by="principal:test", proposed_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()]
|
||||||
|
|
||||||
|
assert OperationalTaskService._status_from_plan(nodes, [action]) == "waiting_human"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_agent_run_failure_marks_task_failed_even_with_waiting_work():
|
||||||
|
nodes = [
|
||||||
|
type("Node", (), {"id": "FIELD_SAMPLE", "status": "waiting", "owner": "human"})(),
|
||||||
|
type("Node", (), {"id": "AGENT_RUN_FAILURE", "status": "failed", "owner": "agent"})(),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert OperationalTaskService._status_from_plan(nodes, []) == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_targeted_notification_cannot_be_acknowledged_without_permission():
|
||||||
|
now = datetime(2026, 9, 23, tzinfo=timezone.utc)
|
||||||
|
notification = OperationalTaskNotification(
|
||||||
|
notification_id=uuid4(), task_id=uuid4(), kind="action_pending",
|
||||||
|
title="待确认", description="命令", required_permission="command.approve",
|
||||||
|
status="unread", version=1, created_at=now,
|
||||||
|
)
|
||||||
|
service = OperationalTaskService(
|
||||||
|
NotificationRepository(notification), _context(), {OPERATIONAL_TASK_VIEW},
|
||||||
|
clock=lambda: now,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(DomainError) as exc:
|
||||||
|
await service.acknowledge_notification(
|
||||||
|
notification.notification_id,
|
||||||
|
NotificationAcknowledgeCommand(request_id=uuid4(), expected_version=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.code == "PERMISSION_DENIED"
|
||||||
|
|
||||||
|
|
||||||
|
def _task_document(context, now, *, lease=None):
|
||||||
|
task_id = uuid4()
|
||||||
|
return OperationalTaskSummary(
|
||||||
|
project_id=context.project_id,
|
||||||
|
task_id=task_id,
|
||||||
|
task_type="event_investigation",
|
||||||
|
workflow="water_quality_complaint",
|
||||||
|
status="running",
|
||||||
|
priority="normal",
|
||||||
|
revision=2,
|
||||||
|
execution_generation=1,
|
||||||
|
execution_request_id=uuid4(),
|
||||||
|
title="调查",
|
||||||
|
summary="验证状态机",
|
||||||
|
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
||||||
|
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
||||||
|
scheduled_for=None,
|
||||||
|
agent_session_id="session-1",
|
||||||
|
agent_lease_expires_at=lease,
|
||||||
|
current_plan_revision=None,
|
||||||
|
current_report_revision=None,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRepository:
|
||||||
|
def __init__(self, task, *, action=None):
|
||||||
|
self.task = task
|
||||||
|
self.action = action
|
||||||
|
self.requests = {}
|
||||||
|
self.evidence = {}
|
||||||
|
self.feedback = {}
|
||||||
|
self.plans = []
|
||||||
|
self.reports = []
|
||||||
|
self.notifications = []
|
||||||
|
self.locks = []
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self):
|
||||||
|
yield self
|
||||||
|
|
||||||
|
async def lock(self, key):
|
||||||
|
self.locks.append(key)
|
||||||
|
|
||||||
|
async def get_request(self, scope, request_id):
|
||||||
|
return self.requests.get((scope, request_id))
|
||||||
|
|
||||||
|
async def save_request(self, scope, request_id, request_digest, response):
|
||||||
|
self.requests[(scope, request_id)] = {
|
||||||
|
"request_digest": request_digest,
|
||||||
|
"response": response,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_task(self, task_id):
|
||||||
|
return self.task if task_id == self.task["task_id"] else None
|
||||||
|
|
||||||
|
async def update_task(self, task):
|
||||||
|
self.task = task
|
||||||
|
|
||||||
|
async def get_latest_plan(self, _task_id):
|
||||||
|
return self.plans[-1] if self.plans else None
|
||||||
|
|
||||||
|
async def get_latest_report(self, _task_id):
|
||||||
|
return self.reports[-1] if self.reports else None
|
||||||
|
|
||||||
|
async def insert_plan(self, _task_id, snapshot):
|
||||||
|
self.plans.append(snapshot)
|
||||||
|
|
||||||
|
async def insert_report(self, _task_id, snapshot):
|
||||||
|
self.reports.append(snapshot)
|
||||||
|
|
||||||
|
async def list_evidence(self, _task_id):
|
||||||
|
return list(self.evidence.values())
|
||||||
|
|
||||||
|
async def get_evidence(self, _task_id, evidence_id):
|
||||||
|
return self.evidence.get(evidence_id)
|
||||||
|
|
||||||
|
async def insert_evidence(self, _task_id, evidence):
|
||||||
|
self.evidence[evidence["evidence_id"]] = evidence
|
||||||
|
|
||||||
|
async def list_actions(self, _task_id):
|
||||||
|
return [self.action] if self.action else []
|
||||||
|
|
||||||
|
async def get_action(self, _task_id, action_id):
|
||||||
|
if self.action and self.action["action_id"] == action_id:
|
||||||
|
return self.action
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_action(self, _task_id, action):
|
||||||
|
self.action = action
|
||||||
|
|
||||||
|
async def insert_action(self, _task_id, action):
|
||||||
|
self.action = action
|
||||||
|
|
||||||
|
async def insert_notification(self, notification):
|
||||||
|
self.notifications.append(notification)
|
||||||
|
|
||||||
|
async def get_feedback(self, task_id, source_event_id):
|
||||||
|
return self.feedback.get((task_id, source_event_id))
|
||||||
|
|
||||||
|
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
||||||
|
self.feedback[(task_id, feedback["source_event_id"])] = {
|
||||||
|
"source_revision": feedback["source_revision"],
|
||||||
|
"document": feedback,
|
||||||
|
"accepted_at": accepted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RunTaskRepository(TaskRepository):
|
||||||
|
def __init__(self, task, run):
|
||||||
|
super().__init__(task)
|
||||||
|
self.run = run
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
async def get_active_run(self, task_id):
|
||||||
|
if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}:
|
||||||
|
return self.run
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_run(self, run_id):
|
||||||
|
return self.run if run_id == self.run["run_id"] else None
|
||||||
|
|
||||||
|
async def update_run(self, run, expected_revision):
|
||||||
|
assert run["revision"] == expected_revision + 1
|
||||||
|
self.run = run
|
||||||
|
|
||||||
|
async def insert_run(self, run):
|
||||||
|
self.run = run
|
||||||
|
|
||||||
|
async def append_event(self, **event):
|
||||||
|
self.events.append(event)
|
||||||
|
return len(self.events)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_run(task, now):
|
||||||
|
return OperationalTaskRun(
|
||||||
|
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
||||||
|
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
||||||
|
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
||||||
|
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
||||||
|
next_dispatch_at=now, created_at=now, updated_at=now,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_heartbeat_does_not_change_task_business_revision():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = RunTaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
|
updated = await service.heartbeat_run(
|
||||||
|
task["task_id"], repository.run["run_id"],
|
||||||
|
AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.revision == 3
|
||||||
|
assert repository.task["revision"] == 2
|
||||||
|
assert repository.events == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_complete_without_same_generation_artifacts_fails_run_explicitly():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = RunTaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
|
completed = await service.complete_run(
|
||||||
|
task["task_id"], repository.run["run_id"],
|
||||||
|
AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repository.run["status"] == "failed"
|
||||||
|
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
|
||||||
|
assert completed.lifecycle_status == "failed"
|
||||||
|
assert completed.active_run is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_external_feedback_coalesces_into_an_already_queued_run():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=None)
|
||||||
|
task.update(
|
||||||
|
status="queued", lifecycle_status="open", execution_generation=2,
|
||||||
|
execution_request_id=str(uuid4()), agent_session_id=None,
|
||||||
|
)
|
||||||
|
run = _active_run(task, now)
|
||||||
|
run.update(
|
||||||
|
generation=2, execution_request_id=task["execution_request_id"],
|
||||||
|
status="queued", revision=1, agent_session_id=None,
|
||||||
|
lease_expires_at=None, heartbeat_at=None, started_at=None,
|
||||||
|
)
|
||||||
|
repository = RunTaskRepository(task, run)
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
|
result = await service.external_feedback(
|
||||||
|
task["task_id"],
|
||||||
|
ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id="sample-result", source_revision=1,
|
||||||
|
status="completed", occurred_at=now, detail="新的现场结果",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.task.execution_generation == 2
|
||||||
|
assert str(result.task.execution_request_id) == task["execution_request_id"]
|
||||||
|
assert result.task.active_run is not None
|
||||||
|
assert str(result.task.active_run.run_id) == run["run_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _waiting_plan(action_id=None):
|
||||||
|
node = {
|
||||||
|
"id": "field-work",
|
||||||
|
"title": "现场处置",
|
||||||
|
"owner": "human",
|
||||||
|
"status": "waiting",
|
||||||
|
"detail": "等待审批与执行",
|
||||||
|
"evidence_refs": [],
|
||||||
|
"action_refs": [str(action_id)] if action_id else [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"schema": "execution-plan@1",
|
||||||
|
"title": "调查计划",
|
||||||
|
"reason": "等待现场处置",
|
||||||
|
"nodes": [node],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_report():
|
||||||
|
return {
|
||||||
|
"title": "本代调查报告",
|
||||||
|
"current_conclusion": "等待现场处置",
|
||||||
|
"conclusion_boundary": "尚无现场回执",
|
||||||
|
"evidence_refs": [],
|
||||||
|
"recommendations": ["完成现场处置"],
|
||||||
|
"pending_items": ["现场处置"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = RunTaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
|
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
||||||
|
))
|
||||||
|
assert planned.task.status == "running"
|
||||||
|
assert planned.task.agent_lease_expires_at == now + timedelta(minutes=1)
|
||||||
|
assert planned.task.latest_plan.execution_generation == 1
|
||||||
|
|
||||||
|
reported = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||||
|
))
|
||||||
|
assert reported.task.status == "running"
|
||||||
|
assert reported.task.latest_report.execution_generation == 1
|
||||||
|
|
||||||
|
completed = await service.complete_run(
|
||||||
|
task["task_id"], repository.run["run_id"],
|
||||||
|
AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=repository.run["revision"],
|
||||||
|
source_session_id="session-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert completed.status == "waiting_human"
|
||||||
|
assert completed.agent_lease_expires_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_report_does_not_close_from_legacy_or_prior_generation_plan():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
task["execution_generation"] = 2
|
||||||
|
repository = TaskRepository(task)
|
||||||
|
repository.plans.append({
|
||||||
|
"revision": 1,
|
||||||
|
"execution_generation": None,
|
||||||
|
"source_session_id": "session-1",
|
||||||
|
"published_at": now.isoformat(),
|
||||||
|
"published_by": "principal:test",
|
||||||
|
"plan": _waiting_plan(),
|
||||||
|
})
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
|
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.task.status == "running"
|
||||||
|
assert result.task.latest_report.execution_generation == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_run():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = RunTaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
||||||
|
))
|
||||||
|
revision_before_proposal = repository.run["revision"]
|
||||||
|
lease_before_proposal = repository.run["lease_expires_at"]
|
||||||
|
|
||||||
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||||
|
node_id="field-work", evidence_refs=[],
|
||||||
|
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
||||||
|
))
|
||||||
|
assert repository.task["status"] == "running"
|
||||||
|
assert repository.run["revision"] == revision_before_proposal
|
||||||
|
assert repository.run["lease_expires_at"] == lease_before_proposal
|
||||||
|
heartbeat = await service.heartbeat_run(
|
||||||
|
task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=revision_before_proposal,
|
||||||
|
source_session_id="session-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert heartbeat.status == "running"
|
||||||
|
|
||||||
|
published = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1",
|
||||||
|
run_id=repository.run["run_id"],
|
||||||
|
plan=_waiting_plan(action.action_id), report=_current_report(),
|
||||||
|
))
|
||||||
|
closed = await service.complete_run(
|
||||||
|
task["task_id"], repository.run["run_id"],
|
||||||
|
AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=published.task.active_run.revision,
|
||||||
|
source_session_id="session-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert closed.status == "waiting_human"
|
||||||
|
assert closed.actions[0].action_id == action.action_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_rejected_action_requeues_task_in_a_new_execution_generation():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = RunTaskRepository(task, _active_run(task, now).copy())
|
||||||
|
service = OperationalTaskService(
|
||||||
|
repository,
|
||||||
|
context,
|
||||||
|
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||||
|
clock=lambda: now,
|
||||||
|
)
|
||||||
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||||
|
evidence_refs=[],
|
||||||
|
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
||||||
|
))
|
||||||
|
original_request_id = repository.task["execution_request_id"]
|
||||||
|
|
||||||
|
rejected = await service.decide_action(
|
||||||
|
task["task_id"],
|
||||||
|
action.action_id,
|
||||||
|
ActionDecisionCommand(
|
||||||
|
request_id=uuid4(), expected_version=action.version,
|
||||||
|
decision="rejected", comment="当前方案不执行",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rejected.approval_status == "rejected"
|
||||||
|
assert rejected.version == action.version + 1
|
||||||
|
assert repository.task["status"] == "queued"
|
||||||
|
assert repository.task["execution_generation"] == 2
|
||||||
|
assert repository.task["execution_request_id"] != original_request_id
|
||||||
|
assert repository.task["agent_session_id"] is None
|
||||||
|
assert repository.task["agent_lease_expires_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_action_decision_rejects_a_stale_expected_version():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = RunTaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(
|
||||||
|
repository,
|
||||||
|
context,
|
||||||
|
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||||
|
clock=lambda: now,
|
||||||
|
)
|
||||||
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||||
|
evidence_refs=[],
|
||||||
|
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
||||||
|
))
|
||||||
|
|
||||||
|
with pytest.raises(DomainError) as exc:
|
||||||
|
await service.decide_action(
|
||||||
|
task["task_id"],
|
||||||
|
action.action_id,
|
||||||
|
ActionDecisionCommand(
|
||||||
|
request_id=uuid4(), expected_version=action.version + 1,
|
||||||
|
decision="approved",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.code == "VERSION_CONFLICT"
|
||||||
|
assert repository.action["approval_status"] == "pending"
|
||||||
|
assert repository.action["version"] == action.version
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_new_payload_creates_a_new_pending_action_without_inheriting_approval():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = TaskRepository(task)
|
||||||
|
service = OperationalTaskService(
|
||||||
|
repository,
|
||||||
|
context,
|
||||||
|
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||||
|
clock=lambda: now,
|
||||||
|
)
|
||||||
|
original = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="需要首次核验",
|
||||||
|
evidence_refs=[],
|
||||||
|
payload={"title": "首次核验", "description": "原始内容", "work_order_type": "sampling"},
|
||||||
|
))
|
||||||
|
approved = await service.decide_action(
|
||||||
|
task["task_id"],
|
||||||
|
original.action_id,
|
||||||
|
ActionDecisionCommand(
|
||||||
|
request_id=uuid4(), expected_version=original.version, decision="approved",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
revised = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="内容已修改",
|
||||||
|
evidence_refs=[],
|
||||||
|
payload={"title": "修改后核验", "description": "新的内容", "work_order_type": "sampling"},
|
||||||
|
))
|
||||||
|
|
||||||
|
assert approved.approval_status == "approved"
|
||||||
|
assert revised.action_id != approved.action_id
|
||||||
|
assert revised.payload != approved.payload
|
||||||
|
assert revised.approval_status == "pending"
|
||||||
|
assert revised.execution_status == "not_submitted"
|
||||||
|
assert revised.version == 1
|
||||||
|
assert revised.decided_by is None
|
||||||
|
assert revised.decided_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def _work_order_snapshot(work_order_id, now):
|
||||||
|
return {
|
||||||
|
"work_order_id": str(work_order_id),
|
||||||
|
"revision": 2,
|
||||||
|
"title": "复测预约",
|
||||||
|
"summary": "等待现场复测回令",
|
||||||
|
"status": "awaiting_reply",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "水质班组",
|
||||||
|
"scheduled_at": (now + timedelta(hours=1)).isoformat(),
|
||||||
|
"work_order_type": "water_quality_complaint",
|
||||||
|
"location": "联调小区",
|
||||||
|
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
|
||||||
|
"subtasks": [{
|
||||||
|
"id": "sampling",
|
||||||
|
"title": "现场采样",
|
||||||
|
"summary": "采集并复测",
|
||||||
|
"status": "running",
|
||||||
|
"scheduled_at": (now + timedelta(hours=1)).isoformat(),
|
||||||
|
"location": "联调小区",
|
||||||
|
"assignee": "水质班组",
|
||||||
|
"duration_minutes": 60,
|
||||||
|
"reply_window_minutes": 120,
|
||||||
|
"stages": [{
|
||||||
|
"id": "reply",
|
||||||
|
"sequence": 1,
|
||||||
|
"title": "等待回令",
|
||||||
|
"status": "current",
|
||||||
|
"owner": "水质班组",
|
||||||
|
"time_label": "待反馈",
|
||||||
|
"description": "上传复测结果",
|
||||||
|
}],
|
||||||
|
"reply_requirements": ["上传检测结果"],
|
||||||
|
}],
|
||||||
|
"origin": "synthetic",
|
||||||
|
"updated_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_external_feedback_exposes_validated_work_order_snapshot_and_refreshes_summary():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
work_order_id = uuid4()
|
||||||
|
task = _task_document(context, now, lease=None)
|
||||||
|
task.update(
|
||||||
|
task_type="external_work_order",
|
||||||
|
workflow="water_quality_complaint",
|
||||||
|
source={
|
||||||
|
"kind": "external_system",
|
||||||
|
"external_id": f"peripheral_sample:{work_order_id}",
|
||||||
|
"external_revision": 1,
|
||||||
|
"event_kind": "water_quality_complaint",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
repository = TaskRepository(task)
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
result = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(),
|
||||||
|
source_event_id=f"peripheral_sample:{work_order_id}",
|
||||||
|
source_revision=2,
|
||||||
|
status="awaiting_feedback",
|
||||||
|
occurred_at=now,
|
||||||
|
detail="等待现场复测回令",
|
||||||
|
work_order_snapshot=_work_order_snapshot(work_order_id, now),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.task.status == "waiting_human"
|
||||||
|
assert result.task.title == "复测预约"
|
||||||
|
assert result.task.summary == "等待现场复测回令"
|
||||||
|
assert result.task.scheduled_for == now + timedelta(hours=1)
|
||||||
|
assert result.task.priority == "urgent"
|
||||||
|
assert result.task.work_order_snapshot is not None
|
||||||
|
assert result.task.work_order_snapshot.subtasks[0].stages[0].status == "current"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_event_investigation_rejects_work_order_snapshot():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = TaskRepository(task)
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
work_order_id = uuid4()
|
||||||
|
|
||||||
|
with pytest.raises(DomainError) as exc:
|
||||||
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=str(work_order_id), source_revision=2,
|
||||||
|
status="awaiting_feedback", occurred_at=now, detail="不应接受",
|
||||||
|
work_order_snapshot=_work_order_snapshot(work_order_id, now),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert exc.value.code == "INVALID_FEEDBACK_SNAPSHOT"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_expired_owned_lease_is_rejected_with_stable_code():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now - timedelta(seconds=1))
|
||||||
|
repository = RunTaskRepository(task, _active_run(task, now))
|
||||||
|
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
|
with pytest.raises(DomainError) as exc:
|
||||||
|
await service.heartbeat_run(
|
||||||
|
task["task_id"], repository.run["run_id"],
|
||||||
|
AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.code == "LEASE_EXPIRED"
|
||||||
|
assert repository.run["revision"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_interrupted_submit_replays_unknown_without_dispatching_twice():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
task.update(status="waiting_human", agent_lease_expires_at=None)
|
||||||
|
action = HumanApprovalAction(
|
||||||
|
action_id=uuid4(),
|
||||||
|
task_id=task["task_id"],
|
||||||
|
version=2,
|
||||||
|
kind="create_work_order",
|
||||||
|
reason="现场核验",
|
||||||
|
payload={"title": "采样", "description": "核验", "work_order_type": "sampling"},
|
||||||
|
evidence_refs=[],
|
||||||
|
required_approval_permission="work-order.approve",
|
||||||
|
required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||||
|
approval_status="approved",
|
||||||
|
execution_status="not_submitted",
|
||||||
|
proposed_by="principal:agent",
|
||||||
|
proposed_at=now,
|
||||||
|
decided_by="principal:approver",
|
||||||
|
decided_at=now,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
repository = TaskRepository(task, action=action)
|
||||||
|
service = OperationalTaskService(
|
||||||
|
repository,
|
||||||
|
context,
|
||||||
|
{OPERATIONAL_ACTION_WORK_ORDER_EXECUTE},
|
||||||
|
clock=lambda: now,
|
||||||
|
)
|
||||||
|
command = ActionExecuteCommand(request_id=uuid4(), expected_version=2)
|
||||||
|
|
||||||
|
class LostResponseDispatcher:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def dispatch(self, **_kwargs):
|
||||||
|
self.calls += 1
|
||||||
|
raise RuntimeError("connection lost after submission")
|
||||||
|
|
||||||
|
dispatcher = LostResponseDispatcher()
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await service.submit_action(
|
||||||
|
task["task_id"], action["action_id"], command, dispatcher, None
|
||||||
|
)
|
||||||
|
|
||||||
|
replay = await service.submit_action(
|
||||||
|
task["task_id"], action["action_id"], command, dispatcher, None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert dispatcher.calls == 1
|
||||||
|
assert replay.execution_status == "unknown"
|
||||||
|
assert replay.execution_request_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_snapshot_reuse_returns_the_original_provenance_and_rejects_collision():
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = TaskRepository(task)
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
original = TaskDataSnapshot(
|
||||||
|
source="peripheral_points",
|
||||||
|
source_ref="peripheral_points:sha256:same",
|
||||||
|
queried_at=now,
|
||||||
|
origin="synthetic",
|
||||||
|
data={"items": [{"id": "p-1"}]},
|
||||||
|
truncated=False,
|
||||||
|
)
|
||||||
|
evidence, stored = await service.record_data_snapshot(task["task_id"], original)
|
||||||
|
|
||||||
|
replayed = original.model_copy(update={"queried_at": now + timedelta(minutes=1)})
|
||||||
|
replay_evidence, replay_snapshot = await service.record_data_snapshot(
|
||||||
|
task["task_id"], replayed
|
||||||
|
)
|
||||||
|
|
||||||
|
assert replay_evidence == evidence
|
||||||
|
assert replay_snapshot == stored == original
|
||||||
|
|
||||||
|
collision = original.model_copy(update={"data": {"items": [{"id": "p-2"}]}})
|
||||||
|
with pytest.raises(DomainError) as exc:
|
||||||
|
await service.record_data_snapshot(task["task_id"], collision)
|
||||||
|
assert exc.value.code == "EVIDENCE_CONFLICT"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_repository_task_update_rejects_a_stale_revision():
|
||||||
|
cursor = SimpleNamespace(rowcount=0)
|
||||||
|
connection = SimpleNamespace(execute=AsyncMock(return_value=cursor))
|
||||||
|
repository = OperationalTaskRepository(connection, uuid4())
|
||||||
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
task["revision"] = 3
|
||||||
|
|
||||||
|
with pytest.raises(DomainError) as exc:
|
||||||
|
await repository.update_task(task)
|
||||||
|
|
||||||
|
assert exc.value.code == "VERSION_CONFLICT"
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import json
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_persisted_queue_retries_with_backend_execution_identity():
|
||||||
|
task_id, run_id, execution_id = str(uuid4()), str(uuid4()), str(uuid4())
|
||||||
|
submitted = []
|
||||||
|
def handler(request):
|
||||||
|
assert request.headers["Authorization"] == "Bearer TEST_ONLY"
|
||||||
|
assert not request.url.path.endswith("schedules/dispatch-due")
|
||||||
|
if request.url.path.endswith("leases/recover"):
|
||||||
|
return httpx.Response(200, json={"items": []})
|
||||||
|
if request.method == "GET":
|
||||||
|
if request.url.params.get("status") != "queued":
|
||||||
|
return httpx.Response(200, json={"items": []})
|
||||||
|
return httpx.Response(200, json={"items": [{
|
||||||
|
"task_id": task_id, "run_id": run_id, "execution_request_id": execution_id,
|
||||||
|
"status": "queued", "revision": 1,
|
||||||
|
}]})
|
||||||
|
body = json.loads(request.content)
|
||||||
|
submitted.append(body)
|
||||||
|
return httpx.Response(202, json={"task_id": task_id, "run_id": run_id, "session_id": "execution-session"})
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
worker = OperationalWorker(settings, client)
|
||||||
|
assert await worker.tick() == 1
|
||||||
|
assert await worker.tick() == 1
|
||||||
|
assert submitted[0] == submitted[1]
|
||||||
|
assert submitted[0] == {
|
||||||
|
"task_id": task_id, "run_id": run_id, "execution_request_id": execution_id,
|
||||||
|
}
|
||||||
|
assert "credential" not in submitted[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("run_status", ["claimed", "running", "failed"])
|
||||||
|
async def test_only_queued_runs_are_submitted(run_status):
|
||||||
|
def handler(request):
|
||||||
|
assert not request.url.path.endswith("schedules/dispatch-due")
|
||||||
|
if request.method == "GET":
|
||||||
|
requested = request.url.params.get("status")
|
||||||
|
if requested == run_status and run_status in {"claimed", "running"}:
|
||||||
|
return httpx.Response(200, json={"items": [{"run_id": str(uuid4()), "status": run_status}]})
|
||||||
|
return httpx.Response(200, json={"items": []})
|
||||||
|
assert request.url.host == "server.test"
|
||||||
|
return httpx.Response(200, json={"items": []})
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
assert await OperationalWorker(settings, client).tick() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_bounded_queue_continues_after_one_agent_handoff_fails():
|
||||||
|
runs = [{"task_id": str(uuid4()), "run_id": str(uuid4()),
|
||||||
|
"execution_request_id": str(uuid4()), "status": "queued",
|
||||||
|
"revision": 1} for _ in range(2)]
|
||||||
|
limits, submitted, failures = [], [], []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
if request.method == "GET":
|
||||||
|
if request.url.params.get("status") != "queued":
|
||||||
|
return httpx.Response(200, json={"items": []})
|
||||||
|
limits.append(int(request.url.params["limit"]))
|
||||||
|
return httpx.Response(200, json={"items": runs})
|
||||||
|
if request.url.host == "server.test":
|
||||||
|
if request.url.path.endswith("leases/recover"):
|
||||||
|
return httpx.Response(200, json={"items": []})
|
||||||
|
failures.append(json.loads(request.content))
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
body = json.loads(request.content)
|
||||||
|
submitted.append(body)
|
||||||
|
if body["task_id"] == runs[0]["task_id"]:
|
||||||
|
return httpx.Response(503, json={"detail": "temporary failure"})
|
||||||
|
return httpx.Response(202, json={"task_id": body["task_id"], "run_id": body["run_id"]})
|
||||||
|
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
assert await OperationalWorker(settings, client).tick() == 1
|
||||||
|
assert limits == [settings.max_concurrency]
|
||||||
|
assert [body["task_id"] for body in submitted] == [run["task_id"] for run in runs]
|
||||||
|
assert [body["execution_request_id"] for body in submitted] == [run["execution_request_id"] for run in runs]
|
||||||
|
assert failures[0]["retryable"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_service_credentials_are_cached_and_refreshed_without_entering_task_payload(monkeypatch):
|
||||||
|
clock = [100.0]
|
||||||
|
monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0])
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
assert request.url == "https://identity.test/token"
|
||||||
|
assert "authorization" not in request.headers
|
||||||
|
return httpx.Response(200, json={"access_token": f"TEST_ONLY_{len(requests)}", "expires_in": 60})
|
||||||
|
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()),
|
||||||
|
token_url="https://identity.test/token", client_id="test-service",
|
||||||
|
client_secret="TEST_ONLY_SECRET")
|
||||||
|
provider = CredentialProvider(settings)
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
first = await provider.headers(client)
|
||||||
|
clock[0] = 140.0
|
||||||
|
assert await provider.headers(client) == first
|
||||||
|
assert len(requests) == 1
|
||||||
|
clock[0] = 146.0
|
||||||
|
refreshed = await provider.headers(client)
|
||||||
|
assert len(requests) == 2
|
||||||
|
assert first["Authorization"] == "Bearer TEST_ONLY_1"
|
||||||
|
assert refreshed == {"Authorization": "Bearer TEST_ONLY_2", "X-Project-Id": settings.project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_long_lived_oauth_token_is_refreshed_before_product_token_age_limit(monkeypatch):
|
||||||
|
clock, requests = [0.0], []
|
||||||
|
monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0])
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
return httpx.Response(200, json={"access_token": "TEST_ONLY", "expires_in": 3600})
|
||||||
|
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()),
|
||||||
|
token_url="https://identity.test/token", client_id="test-service",
|
||||||
|
client_secret="TEST_ONLY_SECRET")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
provider = CredentialProvider(settings)
|
||||||
|
await provider.headers(client)
|
||||||
|
clock[0] = 301.0
|
||||||
|
await provider.headers(client)
|
||||||
|
assert len(requests) == 2
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from samples.peripheral.auth import Identity
|
||||||
|
from samples.peripheral.config import SampleSettings
|
||||||
|
from samples.peripheral.schemas import TestDataRecord as DataRecordSchema
|
||||||
|
from samples.peripheral.schemas import WorkOrder
|
||||||
|
from samples.peripheral.store import PostgresStore
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def __init__(self, *, row=None, rows=None):
|
||||||
|
self.row = row
|
||||||
|
self.rows = rows or []
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self.row
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return self.rows
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedProjectionConnection:
|
||||||
|
def __init__(self, table: str, document: dict):
|
||||||
|
self.table = table
|
||||||
|
self.document = document
|
||||||
|
|
||||||
|
def execute(self, statement, parameters):
|
||||||
|
normalized = " ".join(statement.split())
|
||||||
|
if "FROM peripheral_sample.scenarios" in normalized:
|
||||||
|
return FakeResult(row=None)
|
||||||
|
if f"FROM peripheral_sample.{self.table}" in normalized:
|
||||||
|
row = {"document": self.document}
|
||||||
|
return FakeResult(row=row, rows=[row])
|
||||||
|
raise AssertionError(f"unexpected query: {normalized}")
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedProjectionStore(PostgresStore):
|
||||||
|
def __init__(self, table: str, document: dict):
|
||||||
|
super().__init__(SampleSettings())
|
||||||
|
self.fake_connection = ManagedProjectionConnection(table, document)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connection(self, *, readonly=False, timeseries=False):
|
||||||
|
assert readonly is True
|
||||||
|
assert timeseries is False
|
||||||
|
yield self.fake_connection
|
||||||
|
|
||||||
|
|
||||||
|
def work_order_document() -> dict:
|
||||||
|
now = "2026-09-18T00:00:00Z"
|
||||||
|
return {
|
||||||
|
"work_order_id": str(uuid4()),
|
||||||
|
"project_id": str(uuid4()),
|
||||||
|
"revision": 2,
|
||||||
|
"title": "恢复后的演示工单",
|
||||||
|
"summary": "当前行已恢复,但旧实现留下 tombstone 字段。",
|
||||||
|
"status": "open",
|
||||||
|
"priority": "normal",
|
||||||
|
"assignee": None,
|
||||||
|
"scheduled_at": None,
|
||||||
|
"origin": "synthetic",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"created_by": str(uuid4()),
|
||||||
|
"updated_by": str(uuid4()),
|
||||||
|
"work_order_type": "general",
|
||||||
|
"location": None,
|
||||||
|
"scenario_code": None,
|
||||||
|
"deleted_at": now,
|
||||||
|
"deleted_by": str(uuid4()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_work_order_projection_removes_only_tombstone_fields():
|
||||||
|
document = work_order_document()
|
||||||
|
stored_document = deepcopy(document)
|
||||||
|
store = ManagedProjectionStore("work_orders", stored_document)
|
||||||
|
who = Identity(document["project_id"], str(uuid4()), False)
|
||||||
|
|
||||||
|
result = store.get_work_order(who, document["work_order_id"])
|
||||||
|
|
||||||
|
assert "deleted_at" not in result
|
||||||
|
assert "deleted_by" not in result
|
||||||
|
assert stored_document == document
|
||||||
|
WorkOrder.model_validate(result)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("table", "id_column", "document", "model"),
|
||||||
|
[
|
||||||
|
("work_orders", "work_order_id", work_order_document(), WorkOrder),
|
||||||
|
(
|
||||||
|
"test_data_records",
|
||||||
|
"record_id",
|
||||||
|
{
|
||||||
|
"record_id": str(uuid4()),
|
||||||
|
"project_id": str(uuid4()),
|
||||||
|
"revision": 2,
|
||||||
|
"label": "恢复后的演示数据",
|
||||||
|
"point_id": "DEMO-P001",
|
||||||
|
"observed_at": "2026-09-18T00:00:00Z",
|
||||||
|
"value": 1.0,
|
||||||
|
"unit": "m",
|
||||||
|
"quality": "valid",
|
||||||
|
"notes": "",
|
||||||
|
"origin": "synthetic",
|
||||||
|
"created_at": "2026-09-18T00:00:00Z",
|
||||||
|
"updated_at": "2026-09-18T00:00:00Z",
|
||||||
|
"created_by": str(uuid4()),
|
||||||
|
"updated_by": str(uuid4()),
|
||||||
|
"deleted_at": "2026-09-18T00:00:00Z",
|
||||||
|
"deleted_by": str(uuid4()),
|
||||||
|
},
|
||||||
|
DataRecordSchema,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_active_managed_list_projection_removes_tombstone_fields(
|
||||||
|
table, id_column, document, model
|
||||||
|
):
|
||||||
|
store = ManagedProjectionStore(table, document)
|
||||||
|
who = Identity(document["project_id"], str(uuid4()), False)
|
||||||
|
|
||||||
|
page = store._list_managed(who, table, id_column, limit=10, cursor=None)
|
||||||
|
|
||||||
|
assert len(page["items"]) == 1
|
||||||
|
assert "deleted_at" not in page["items"][0]
|
||||||
|
assert "deleted_by" not in page["items"][0]
|
||||||
|
model.model_validate(page["items"][0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_projection_does_not_hide_unrelated_fields():
|
||||||
|
document = {
|
||||||
|
"deleted_at": "2026-09-18T00:00:00Z",
|
||||||
|
"deleted_by": str(uuid4()),
|
||||||
|
"unexpected_field": "must remain visible to schema validation",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = PostgresStore._active_managed_document(document)
|
||||||
|
|
||||||
|
assert result == {"unexpected_field": "must remain visible to schema validation"}
|
||||||
|
assert "deleted_at" in document
|
||||||
|
assert "deleted_by" in document
|
||||||
@@ -33,7 +33,9 @@ class ManagedStore:
|
|||||||
return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: {
|
return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: {
|
||||||
"title": command.title or "updated", "summary": command.summary or "summary",
|
"title": command.title or "updated", "summary": command.summary or "summary",
|
||||||
"status": command.status or "open", "priority": command.priority or "normal",
|
"status": command.status or "open", "priority": command.priority or "normal",
|
||||||
"assignee": None, "scheduled_at": None}})()) | {"revision": command.expected_revision + 1,
|
"assignee": None, "scheduled_at": None, "work_order_type": "general",
|
||||||
|
"location": command.location, "scenario_code": command.scenario_code,
|
||||||
|
"subtasks": command.subtasks}})()) | {"revision": command.expected_revision + 1,
|
||||||
"work_order_id": str(work_order_id)}
|
"work_order_id": str(work_order_id)}
|
||||||
|
|
||||||
def delete_work_order(self, who, work_order_id, command):
|
def delete_work_order(self, who, work_order_id, command):
|
||||||
@@ -82,14 +84,34 @@ def test_managed_work_order_and_data_routes_are_typed_crud():
|
|||||||
created = client.post("/api/sample/v1/work-orders", json={
|
created = client.post("/api/sample/v1/work-orders", json={
|
||||||
"request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调",
|
"request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调",
|
||||||
"status": "open", "priority": "high", "assignee": None, "scheduled_at": None,
|
"status": "open", "priority": "high", "assignee": None, "scheduled_at": None,
|
||||||
|
"work_order_type": "water_quality_complaint", "location": "演示小区",
|
||||||
|
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
|
||||||
|
"subtasks": [{
|
||||||
|
"id": "sampling", "title": "现场采样", "summary": "采集二次供水样本",
|
||||||
|
"status": "running", "scheduled_at": "2026-09-18T01:00:00Z",
|
||||||
|
"location": "演示小区", "assignee": "水质班组", "duration_minutes": 60,
|
||||||
|
"reply_window_minutes": 120, "reply_requirements": ["上传检测结果"],
|
||||||
|
"stages": [{"id": "dispatch", "sequence": 1, "title": "派发",
|
||||||
|
"status": "current", "owner": "调度员", "time_label": "待执行",
|
||||||
|
"description": "确认采样人员"}],
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
|
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
|
||||||
|
assert created.json()["work_order_type"] == "water_quality_complaint"
|
||||||
|
assert created.json()["location"] == "演示小区"
|
||||||
|
assert created.json()["scenario_code"] == "WQ-SECONDARY-SUPPLY-001"
|
||||||
|
assert created.json()["subtasks"][0]["stages"][0]["status"] == "current"
|
||||||
work_order_id = created.json()["work_order_id"]
|
work_order_id = created.json()["work_order_id"]
|
||||||
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
|
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||||
"request_id": str(uuid4()), "expected_revision": 1, "status": "in_progress",
|
"request_id": str(uuid4()), "expected_revision": 1, "status": "awaiting_reply",
|
||||||
|
"location": "复测点", "clear_scenario_code": True, "clear_subtasks": True,
|
||||||
"clear_assignee": False, "clear_scheduled_at": False,
|
"clear_assignee": False, "clear_scheduled_at": False,
|
||||||
})
|
})
|
||||||
assert changed.status_code == 200 and changed.json()["revision"] == 2
|
assert changed.status_code == 200 and changed.json()["revision"] == 2
|
||||||
|
assert changed.json()["status"] == "awaiting_reply"
|
||||||
|
update_command = next(call[3] for call in fake.calls if call[0] == "update_work_order")
|
||||||
|
assert update_command.location == "复测点"
|
||||||
|
assert update_command.clear_scenario_code and update_command.clear_subtasks
|
||||||
deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={
|
deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||||
"request_id": str(uuid4()), "expected_revision": 2,
|
"request_id": str(uuid4()), "expected_revision": 2,
|
||||||
})
|
})
|
||||||
@@ -120,6 +142,8 @@ def test_managed_update_models_reject_empty_or_conflicting_changes():
|
|||||||
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}),
|
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}),
|
||||||
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1,
|
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1,
|
||||||
"value": 1.0, "clear_value": True}),
|
"value": 1.0, "clear_value": True}),
|
||||||
|
(UpdateWorkOrder, {"request_id": request_id, "expected_revision": 1,
|
||||||
|
"location": "新位置", "clear_location": True}),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
model.model_validate(payload)
|
model.model_validate(payload)
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import json
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
|
||||||
|
from scripts.sync_peripheral_work_orders import sync_once, work_order_snapshot
|
||||||
|
from app.domain.schemas.operational_task import ExternalFeedbackCommand
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
|
||||||
|
project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4()))
|
||||||
|
tasks, writes = [], []
|
||||||
|
order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||||
|
"revision": 1, "title": "水表维修", "summary": "等待现场班组处理", "priority": "normal",
|
||||||
|
"status": "open", "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z",
|
||||||
|
"scheduled_at": None, "work_order_type": "general"}
|
||||||
|
def handler(request):
|
||||||
|
if request.url.host == "sample.test":
|
||||||
|
return httpx.Response(200, json={"items": [order], "next_cursor": None})
|
||||||
|
if request.method == "GET":
|
||||||
|
return httpx.Response(200, json={"items": tasks, "next_offset": None})
|
||||||
|
body = json.loads(request.content)
|
||||||
|
writes.append((request.url.path, body))
|
||||||
|
if request.url.path.endswith("external-feedback"):
|
||||||
|
tasks[0]["source"]["external_revision"] = body["source_revision"]
|
||||||
|
tasks[0]["status"] = body["status"]
|
||||||
|
return httpx.Response(200, json={"task": tasks[0]})
|
||||||
|
task = {**body, "task_id": task_id}
|
||||||
|
tasks.append(task)
|
||||||
|
return httpx.Response(201, json=task)
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
credentials = CredentialProvider(settings)
|
||||||
|
assert await sync_once(client, settings, "http://sample.test", credentials) == 1
|
||||||
|
assert await sync_once(client, settings, "http://sample.test", credentials) == 0
|
||||||
|
order.update(revision=2, status="awaiting_reply", updated_at="2026-09-23T09:00:00Z",
|
||||||
|
title="水表维修复核", assignee="测试班组", location="测试位置",
|
||||||
|
scheduled_at="2026-09-24T08:00:00Z")
|
||||||
|
assert await sync_once(client, settings, "http://sample.test", credentials) == 1
|
||||||
|
assert len(tasks) == 1
|
||||||
|
assert len(writes) == 3
|
||||||
|
assert writes[-1][1]["status"] == "awaiting_feedback"
|
||||||
|
assert writes[-1][1]["source_revision"] == 2
|
||||||
|
snapshot = writes[-1][1]["work_order_snapshot"]
|
||||||
|
assert snapshot["status"] == "awaiting_reply"
|
||||||
|
assert snapshot["title"] == "水表维修复核"
|
||||||
|
assert snapshot["assignee"] == "测试班组"
|
||||||
|
assert snapshot["location"] == "测试位置"
|
||||||
|
assert snapshot["scheduled_at"] == "2026-09-24T08:00:00Z"
|
||||||
|
assert snapshot["subtasks"] is None
|
||||||
|
# Exercise the real API schema, including the prefixed external identity.
|
||||||
|
accepted = ExternalFeedbackCommand.model_validate(writes[-1][1])
|
||||||
|
assert str(accepted.work_order_snapshot.work_order_id) == order_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sample_sync_rejects_cross_project_data_before_writing():
|
||||||
|
def handler(request):
|
||||||
|
assert request.method == "GET"
|
||||||
|
return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "synthetic"}]
|
||||||
|
if request.url.host == "sample.test" else []), "next_offset": None})
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
with pytest.raises(ValueError, match="scope"):
|
||||||
|
await sync_once(client, settings, "http://sample.test", CredentialProvider(settings))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("investigation_status", ["waiting_human", "queued"])
|
||||||
|
async def test_created_work_order_receipt_routes_feedback_back_to_investigation(investigation_status):
|
||||||
|
project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4()))
|
||||||
|
received = []
|
||||||
|
def handler(request):
|
||||||
|
if request.url.host == "sample.test":
|
||||||
|
return httpx.Response(200, json={"items": [{
|
||||||
|
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||||
|
"revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z",
|
||||||
|
"title": "合成采样工单", "priority": "normal",
|
||||||
|
"work_order_type": "water_quality_complaint", "created_at": "2026-09-23T08:00:00Z",
|
||||||
|
"scheduled_at": None,
|
||||||
|
}], "next_cursor": None})
|
||||||
|
if request.method == "POST":
|
||||||
|
assert request.url.path.endswith("external-feedback"), "Linked child orders must not start another investigation"
|
||||||
|
received.append(request.url.path)
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["source_event_id"] == f"peripheral_sample:{order_id}"
|
||||||
|
assert ("work_order_snapshot" in payload) == request.url.path.endswith(f"{projection_id}/external-feedback")
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
if request.url.path.endswith(investigation_id):
|
||||||
|
return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": {
|
||||||
|
"adapter": "peripheral_sample", "receipt_id": order_id,
|
||||||
|
}}]})
|
||||||
|
return httpx.Response(200, json={"items": [
|
||||||
|
{"task_id": investigation_id, "task_type": "event_investigation", "status": investigation_status},
|
||||||
|
{"task_id": projection_id, "task_type": "external_work_order", "source": {
|
||||||
|
"external_id": f"peripheral_sample:{order_id}", "external_revision": 1,
|
||||||
|
}},
|
||||||
|
], "next_offset": None})
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
assert await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) == 2
|
||||||
|
assert any(investigation_id in path for path in received)
|
||||||
|
assert any(projection_id in path for path in received)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("event_kind", ["water_quality_complaint", "burst_report"])
|
||||||
|
async def test_external_events_start_investigations_without_browser(event_kind):
|
||||||
|
project_id, order_id = str(uuid4()), str(uuid4())
|
||||||
|
created = []
|
||||||
|
def handler(request):
|
||||||
|
if request.method == "GET" and request.url.host == "server.test":
|
||||||
|
return httpx.Response(200, json={"items": [], "next_offset": None})
|
||||||
|
if request.url.host == "sample.test":
|
||||||
|
return httpx.Response(200, json={"items": [{
|
||||||
|
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "revision": 1,
|
||||||
|
"title": "外围测试事件", "summary": "模拟外部系统上报", "priority": "urgent", "status": "open",
|
||||||
|
"created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z",
|
||||||
|
"scheduled_at": "2026-09-23T07:30:00Z", "work_order_type": event_kind, "location": "测试位置",
|
||||||
|
}], "next_cursor": None})
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
if request.url.path.endswith("external-feedback"):
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
task = {**payload, "task_id": str(uuid4())}
|
||||||
|
created.append(task)
|
||||||
|
return httpx.Response(201, json=task)
|
||||||
|
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
await sync_once(client, settings, "http://sample.test", CredentialProvider(settings))
|
||||||
|
assert {task["task_type"] for task in created} == {"event_investigation", "external_work_order"}
|
||||||
|
investigation = next(task for task in created if task["task_type"] == "event_investigation")
|
||||||
|
assert investigation["workflow"] == event_kind
|
||||||
|
assert investigation["source"]["external_id"] == order_id
|
||||||
|
assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_preserves_nested_process_without_inventing_completed_stages():
|
||||||
|
order = {
|
||||||
|
"work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步",
|
||||||
|
"status": "in_progress", "priority": "normal", "origin": "synthetic",
|
||||||
|
"updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general",
|
||||||
|
"subtasks": [{
|
||||||
|
"id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行",
|
||||||
|
"status": "pending", "scheduled_at": "2026-09-24T08:00:00Z",
|
||||||
|
"location": "测试位置", "assignee": "测试班组", "duration_minutes": 30,
|
||||||
|
"reply_window_minutes": 15, "reply_requirements": ["检查记录"],
|
||||||
|
"stages": [{"id": "dispatch", "sequence": 1, "title": "派发", "status": "pending",
|
||||||
|
"owner": "测试调度", "time_label": "待安排", "description": "尚未派发"}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
snapshot = work_order_snapshot(order)
|
||||||
|
assert snapshot["subtasks"] == order["subtasks"]
|
||||||
|
assert snapshot["subtasks"][0]["stages"][0]["status"] == "pending"
|
||||||
|
assert work_order_snapshot({key: value for key, value in order.items() if key != "subtasks"})["subtasks"] is None
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.services import scheme_management
|
from app.services import scheme_management
|
||||||
|
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
|
||||||
|
|
||||||
|
|
||||||
def _mock_connection(monkeypatch):
|
def _mock_connection(monkeypatch):
|
||||||
@@ -97,3 +99,48 @@ def test_run_and_business_result_share_one_transaction(monkeypatch) -> None:
|
|||||||
|
|
||||||
assert created_ids == [(connection, run_id)]
|
assert created_ids == [(connection, run_id)]
|
||||||
assert result_ids == [(connection, run_id)]
|
assert result_ids == [(connection, run_id)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_business_result_emits_one_event_after_result(monkeypatch) -> None:
|
||||||
|
connection = object()
|
||||||
|
context = MagicMock()
|
||||||
|
context.__enter__.return_value = connection
|
||||||
|
monkeypatch.setattr(scheme_management, "project_transaction", lambda _name: context)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
scheme_management.AnalysisRepository,
|
||||||
|
"create_run_sync",
|
||||||
|
lambda _conn, **_kwargs: calls.append("run"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
scheme_management.AnalysisRepository,
|
||||||
|
"insert_result_sync",
|
||||||
|
lambda _conn, _run_id, **_kwargs: calls.append("result"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
scheme_management.AnalysisRepository,
|
||||||
|
"append_run_changed_event_sync",
|
||||||
|
lambda _conn, **event: calls.append(("event", event)),
|
||||||
|
)
|
||||||
|
project_id = uuid4()
|
||||||
|
routing = ActiveProjectRouting(
|
||||||
|
project_code="test", business_dsn="postgresql://test/db", project_id=project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
with activate_project_routing(routing):
|
||||||
|
run_id = scheme_management.store_analysis_run_with_result(
|
||||||
|
name="test", scheme_name="analysis", scheme_type="dma",
|
||||||
|
username="alice", scheme_start_time="2026-09-23T00:00:00Z",
|
||||||
|
scheme_detail={}, result_type="summary", result_payload={"rows": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls[:2] == ["run", "result"]
|
||||||
|
assert calls[2][0] == "event"
|
||||||
|
assert calls[2][1] == {
|
||||||
|
"project_id": project_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
"revision": 1,
|
||||||
|
"status": "completed",
|
||||||
|
"occurred_at": calls[2][1]["occurred_at"],
|
||||||
|
"results_committed": True,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts.seed_peripheral_sample import (
|
||||||
|
ACTOR_ID,
|
||||||
|
previous_seed_result,
|
||||||
|
restore_seeded_resources,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def __init__(self, row=None):
|
||||||
|
self.row = row
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self.row
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def __init__(self, row=None):
|
||||||
|
self.row = row
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def execute(self, statement, parameters):
|
||||||
|
self.calls.append((" ".join(statement.split()), parameters))
|
||||||
|
return FakeResult(self.row)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self, row=None):
|
||||||
|
self.connection_value = FakeConnection(row)
|
||||||
|
self.readonly = None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connection(self, readonly=False):
|
||||||
|
self.readonly = readonly
|
||||||
|
yield self.connection_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_previous_seed_result_reuses_legacy_receipt():
|
||||||
|
resource_id = str(uuid4())
|
||||||
|
store = FakeStore({
|
||||||
|
"command_key": "create_work_order",
|
||||||
|
"result": {"work_order_id": resource_id},
|
||||||
|
})
|
||||||
|
seed_request_id = uuid4()
|
||||||
|
|
||||||
|
result = previous_seed_result(
|
||||||
|
store,
|
||||||
|
"project-1",
|
||||||
|
seed_request_id=seed_request_id,
|
||||||
|
command_key="create_work_order",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"work_order_id": resource_id}
|
||||||
|
assert store.readonly is True
|
||||||
|
assert store.connection_value.calls[0][1] == (
|
||||||
|
"project-1",
|
||||||
|
str(ACTOR_ID),
|
||||||
|
seed_request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_seeded_resources_only_restores_allowed_tables():
|
||||||
|
store = FakeStore()
|
||||||
|
resource_ids = [str(uuid4()), str(uuid4())]
|
||||||
|
|
||||||
|
restore_seeded_resources(
|
||||||
|
store,
|
||||||
|
"project-1",
|
||||||
|
table="work_orders",
|
||||||
|
id_column="work_order_id",
|
||||||
|
resource_ids=resource_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
statement, parameters = store.connection_value.calls[0]
|
||||||
|
assert "UPDATE peripheral_sample.work_orders" in statement
|
||||||
|
assert "document=document - 'deleted_at' - 'deleted_by'" in statement
|
||||||
|
assert "deleted_at=NULL, deleted_by=NULL" in statement
|
||||||
|
assert parameters == ("project-1", resource_ids)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="unsupported seeded resource"):
|
||||||
|
restore_seeded_resources(
|
||||||
|
store,
|
||||||
|
"project-1",
|
||||||
|
table="requests",
|
||||||
|
id_column="request_id",
|
||||||
|
resource_ids=resource_ids,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from scripts.seed_workbench_work_orders import (
|
||||||
|
DATASET_ID,
|
||||||
|
LEGACY_WORK_ORDER_SOURCES,
|
||||||
|
build_demo_work_orders,
|
||||||
|
replace_demo_work_orders,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def execute(self, statement, parameters=None):
|
||||||
|
self.calls.append((" ".join(statement.split()), parameters))
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_demo_work_orders_restores_original_mock_tasks():
|
||||||
|
documents = build_demo_work_orders()
|
||||||
|
|
||||||
|
assert DATASET_ID == "workbench-demo-orders@1"
|
||||||
|
assert [document["title"] for document in documents] == [
|
||||||
|
"演示工单:华山路阀门边界确认",
|
||||||
|
"演示工单:北辰分区阀门开度调整",
|
||||||
|
"演示工单:泵站边界复核复令",
|
||||||
|
]
|
||||||
|
assert [document["status"] for document in documents] == [
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"awaiting_reply",
|
||||||
|
]
|
||||||
|
assert all(len(document["subtasks"]) == 3 for document in documents)
|
||||||
|
assert all("合成" not in str(document) for document in documents)
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_work_orders_keep_one_current_stage_per_active_task():
|
||||||
|
documents = build_demo_work_orders()
|
||||||
|
|
||||||
|
for document in documents:
|
||||||
|
current_stages = [
|
||||||
|
stage
|
||||||
|
for subtask in document["subtasks"]
|
||||||
|
for stage in subtask["stages"]
|
||||||
|
if stage["status"] == "current"
|
||||||
|
]
|
||||||
|
assert len(current_stages) == 1
|
||||||
|
|
||||||
|
assert documents[0]["subtasks"][0]["stages"][0]["status"] == "current"
|
||||||
|
assert documents[1]["subtasks"][1]["stages"][1]["status"] == "current"
|
||||||
|
assert documents[2]["subtasks"][2]["stages"][2]["status"] == "current"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_removes_legacy_projection_before_inserting_demo_tasks():
|
||||||
|
connection = FakeConnection()
|
||||||
|
documents = build_demo_work_orders()
|
||||||
|
|
||||||
|
replace_demo_work_orders(connection, "project-1", documents)
|
||||||
|
|
||||||
|
delete_statement, delete_parameters = connection.calls[0]
|
||||||
|
assert delete_statement.startswith("DELETE FROM workbench.work_orders")
|
||||||
|
assert "demo-work-order-%" in delete_statement
|
||||||
|
assert delete_parameters == (
|
||||||
|
"project-1",
|
||||||
|
"mock-work-order-%",
|
||||||
|
list(LEGACY_WORK_ORDER_SOURCES),
|
||||||
|
)
|
||||||
|
assert len(connection.calls) == 4
|
||||||
|
assert all(
|
||||||
|
call[0].startswith("INSERT INTO workbench.work_orders")
|
||||||
|
for call in connection.calls[1:]
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.infra.db.postgresql.workbench_event_hub import WorkbenchEventHub, WorkbenchEventHubManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_project_subscribers_share_one_hub_and_idle_close(monkeypatch):
|
||||||
|
start = AsyncMock()
|
||||||
|
close = AsyncMock()
|
||||||
|
monkeypatch.setattr(WorkbenchEventHub, "start", start)
|
||||||
|
monkeypatch.setattr(WorkbenchEventHub, "close", close)
|
||||||
|
manager = WorkbenchEventHubManager(idle_seconds=0.01)
|
||||||
|
project_id = uuid4()
|
||||||
|
|
||||||
|
async with manager.subscribe(project_id, "postgresql://test/db") as first:
|
||||||
|
async with manager.subscribe(project_id, "postgresql://test/db") as second:
|
||||||
|
assert first is second
|
||||||
|
assert first.subscribers == 2
|
||||||
|
assert first.subscribers == 1
|
||||||
|
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
assert len(manager._hubs) == 0
|
||||||
|
close.assert_awaited_once()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api.v1.endpoints import workbench_events as endpoint
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.infra.db.project_routing import ActiveProjectRouting
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHub:
|
||||||
|
generation = 0
|
||||||
|
|
||||||
|
async def oldest_cursor(self):
|
||||||
|
return 5
|
||||||
|
|
||||||
|
async def current_cursor(self):
|
||||||
|
return 8
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHubManager:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def subscribe(self, _project_id, _dsn):
|
||||||
|
yield FakeHub()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMetadataRepository:
|
||||||
|
def __init__(self, routings):
|
||||||
|
self.routings = routings
|
||||||
|
self.session = SimpleNamespace(closed=False, close=self.close)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.session.closed = True
|
||||||
|
|
||||||
|
async def list_active_project_db_routings(self, _role):
|
||||||
|
return self.routings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_expired_event_cursor_requires_snapshot_reset(monkeypatch):
|
||||||
|
project_id = uuid4()
|
||||||
|
dsn = "postgresql://test@db.example:5432/project_a"
|
||||||
|
routing = ActiveProjectRouting("project-a", dsn, project_id=project_id)
|
||||||
|
metadata = FakeMetadataRepository([
|
||||||
|
SimpleNamespace(project_id=project_id, dsn=dsn)
|
||||||
|
])
|
||||||
|
monkeypatch.setattr(endpoint, "workbench_event_hubs", FakeHubManager())
|
||||||
|
|
||||||
|
response = await endpoint.workbench_events(
|
||||||
|
after=1,
|
||||||
|
last_event_id=None,
|
||||||
|
ctx=ProjectContext(
|
||||||
|
project_id=project_id, project_code="project-a", user_id=uuid4(),
|
||||||
|
project_role="member",
|
||||||
|
),
|
||||||
|
routing=routing,
|
||||||
|
metadata_repo=metadata,
|
||||||
|
)
|
||||||
|
first = await anext(response.body_iterator)
|
||||||
|
|
||||||
|
assert metadata.session.closed is True
|
||||||
|
assert "event: reset_required" in first
|
||||||
|
assert '"cursor":1' in first
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_shared_business_database_disables_event_stream():
|
||||||
|
project_id = uuid4()
|
||||||
|
dsn = "postgresql://user:secret@db.example:5432/shared"
|
||||||
|
metadata = FakeMetadataRepository([
|
||||||
|
SimpleNamespace(project_id=project_id, dsn=dsn),
|
||||||
|
SimpleNamespace(project_id=uuid4(), dsn="postgresql://other:other@db.example/shared"),
|
||||||
|
])
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await endpoint.workbench_events(
|
||||||
|
after=0,
|
||||||
|
last_event_id=None,
|
||||||
|
ctx=ProjectContext(
|
||||||
|
project_id=project_id, project_code="project-a", user_id=uuid4(),
|
||||||
|
project_role="member",
|
||||||
|
),
|
||||||
|
routing=ActiveProjectRouting("project-a", dsn, project_id=project_id),
|
||||||
|
metadata_repo=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 503
|
||||||
Reference in New Issue
Block a user