feat(workbench): persist operational workflows and events

This commit is contained in:
2026-09-24 04:19:02 +08:00
parent baff7dfe9e
commit 9335bdc1ea
66 changed files with 16580 additions and 238 deletions
+19
View File
@@ -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
+47 -5
View File
@@ -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,
) )
+570
View File
@@ -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)
+153
View File
@@ -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 "",
)
+10
View File
@@ -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(
+31
View File
@@ -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)
+2
View File
@@ -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,
) )
+15
View File
@@ -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"
+1 -1
View File
@@ -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"]
+44
View File
@@ -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
+567
View File
@@ -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
+49
View File
@@ -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)
+37 -3
View File
@@ -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
+19
View File
@@ -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()]
+2
View File
@@ -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()
+177
View File
@@ -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
View File
@@ -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
+52 -18
View File
@@ -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]:
+3
View File
@@ -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(
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "de342f10ac2949eb337513b1d1cb6b595083be2fad2860c9ccd92eebe2758e6b" "sha256": "3669e1b9ba31dad83f9c53028fd974b8bacac0f9d3069f644e49254baf6bb82f"
} }
} }
} }
+303
View File
@@ -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
+22
View File
@@ -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
```
+108
View File
@@ -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,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。
+54
View File
@@ -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` 权限。生产运行前需按部署身份显式授予新表权限。
+6
View File
@@ -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,因此浏览器关闭后流程仍可继续。
+1 -1
View File
@@ -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`。
+1 -1
View File
@@ -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。外围种子脚本仍可重复执行,并会恢复曾被软删除的固定演示工单和测试数据。
+202
View File
@@ -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'));
+42 -1
View File
@@ -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):
+16 -3
View File
@@ -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)
+114
View File
@@ -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))
+130
View File
@@ -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()))
+169
View File
@@ -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()
+259
View File
@@ -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
+84 -4
View File
@@ -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,
+309 -195
View File
@@ -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
+170
View File
@@ -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
+5
View File
@@ -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",
+42
View File
@@ -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,
}
+43
View File
@@ -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
+8
View File
@@ -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]
+131
View File
@@ -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")
+852
View File
@@ -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"
+141
View File
@@ -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)
+162
View File
@@ -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,
}
+91
View File
@@ -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:]
)
+32
View File
@@ -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()
+94
View File
@@ -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