351 lines
20 KiB
Python
351 lines
20 KiB
Python
"""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 (
|
|
"008_workbench_alert_events.sql",
|
|
"011_workbench_work_orders.sql",
|
|
"014_operational_tasks.sql",
|
|
"015_workbench_events_and_task_runs.sql",
|
|
"017_workbench_schema_comments.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)
|
|
comment_rows = await (
|
|
await conn.execute(
|
|
"""SELECT c.relname, obj_description(c.oid, 'pg_class') AS description
|
|
FROM pg_class c
|
|
JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
WHERE n.nspname='workbench' AND c.relkind='r'""",
|
|
)
|
|
).fetchall()
|
|
assert len(comment_rows) >= 17
|
|
assert all(row["description"] for row in comment_rows)
|
|
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": {"stage": "final", "outcome": "concluded", "stop_reason": None, "data_gaps": [], "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 == "completed"
|
|
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_approval_pending"
|
|
)
|
|
report_notice = next(
|
|
item for item in member_notifications
|
|
if item.kind == "report_updated"
|
|
)
|
|
assert not any(item.kind in {
|
|
"task_created", "human_input_required", "task_completed"
|
|
} for item in member_notifications)
|
|
assert report_notice.report_reference.revision == 1
|
|
assert report_notice.report_reference.execution_generation == 1
|
|
history, next_offset = await service.list_reports(task.task_id, 20, 0)
|
|
assert next_offset is None and history[0].revision == 1
|
|
assert (await service.report(task.task_id, 1)).report.model_dump() == artifact.report.model_dump()
|
|
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 report_notice.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(
|
|
report_notice.notification_id,
|
|
NotificationAcknowledgeCommand(
|
|
request_id=uuid4(), expected_version=report_notice.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 == report_notice.notification_id
|
|
)
|
|
assert persisted_created.status == "read"
|
|
assert persisted_created.version == report_notice.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 report_notice.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 report_notice.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)
|
|
await fresh_service.publish_artifacts(task.task_id, artifact.model_copy(update={
|
|
"request_id": uuid4(), "run_id": resumed.active_run.run_id,
|
|
"source_session_id": "test-investigation-followup-session",
|
|
}))
|
|
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"
|
|
# A separate investigation ends normally on an evidenced empty query.
|
|
missing = await service.create(command.model_copy(update={
|
|
"request_id": uuid4(), "title": "AUTOTEST 缺数退出",
|
|
}))
|
|
missing_run = await service.claim_run(missing.task_id, missing.active_run.run_id, AgentRunCommand(
|
|
request_id=missing.active_run.execution_request_id,
|
|
expected_revision=missing.active_run.revision, source_session_id="missing-data-session"))
|
|
exit_artifact = AgentArtifactsCommand(
|
|
request_id=uuid4(), source_session_id="missing-data-session", run_id=missing_run.run_id,
|
|
evidence=[{"evidence_id": "empty-sample", "kind": "synthetic", "summary": "查询成功,无采样记录",
|
|
"source_label": "合成空查询", "observed_at": now.isoformat()}],
|
|
plan={"schema": "execution-plan@1", "title": "调查", "reason": "关键数据不足",
|
|
"nodes": [{"id": "query", "title": "核对采样", "owner": "agent", "status": "completed",
|
|
"detail": "查询范围内无记录", "evidence_refs": ["empty-sample"]}]},
|
|
report={"title": "无法形成结论", "current_conclusion": "无法确定投诉原因",
|
|
"conclusion_boundary": "缺少末梢采样", "evidence_refs": ["empty-sample"],
|
|
"stage": "final", "outcome": "insufficient_data", "stop_reason": "关键采样数据为空",
|
|
"data_gaps": [{"gap_id": "sample", "node_id": "query", "missing_data": "末梢采样",
|
|
"scope": "投诉时段", "impact": "无法判断异常来源", "required_data": "末梢采样结果",
|
|
"evidence_refs": ["empty-sample"], "blocking": True}]})
|
|
exited = await service.publish_artifacts(missing.task_id, exit_artifact)
|
|
await service.complete_run(missing.task_id, missing_run.run_id, AgentRunCommand(
|
|
request_id=uuid4(), expected_revision=exited.task.active_run.revision,
|
|
source_session_id="missing-data-session"))
|
|
restored = await fresh_service.detail(missing.task_id)
|
|
assert restored.lifecycle_status == "completed"
|
|
assert restored.active_run is None
|
|
assert restored.latest_report.report.outcome == "insufficient_data"
|
|
assert restored.latest_report.report.data_gaps[0].blocking is True
|
|
assert not restored.actions
|
|
finally:
|
|
await conn.close()
|