feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -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",
|
||||
assignee="AUTOTEST",
|
||||
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)
|
||||
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 store.create_work_order(who, order_command) == order
|
||||
order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
|
||||
|
||||
Reference in New Issue
Block a user