feat(tasks): define investigation report contract and history

This commit is contained in:
2026-10-10 16:02:46 +08:00
parent 4a63fcd7ff
commit 2b6fc5aa52
12 changed files with 1380 additions and 52 deletions
+32 -1
View File
@@ -50,6 +50,8 @@ from app.domain.schemas.operational_task import (
ExternalSourceWithdrawalCommand,
HumanApprovalAction,
HumanEvidenceCommand,
InvestigationReportPage,
InvestigationReportRevision,
NotificationAcknowledgeCommand,
OperationalTaskCreate,
OperationalTaskDetail,
@@ -100,6 +102,34 @@ def _raise_domain(exc: DomainError):
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
@router.get("/operational-tasks/{task_id}/reports", response_model=InvestigationReportPage)
async def list_investigation_reports(
task_id: UUID,
limit: int = Query(default=20, 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_reports(task_id, limit, offset)
return InvestigationReportPage(items=items, next_offset=next_offset)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/reports/{revision}", response_model=InvestigationReportRevision)
async def get_investigation_report(
task_id: UUID,
revision: int,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).report(task_id, revision)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks",
response_model=OperationalTaskDetail,
@@ -417,11 +447,12 @@ async def record_operational_task_run_dispatch_failure(
)
async def get_operational_task_agent_context(
task_id: UUID,
report_revision: int | None = Query(default=None, ge=1),
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)
return await _service(ctx, conn).agent_context(task_id, report_revision)
except DomainError as exc:
_raise_domain(exc)
+87 -3
View File
@@ -1,12 +1,13 @@
"""Contracts for project-scoped operational tasks and human-approved actions."""
from __future__ import annotations
import re
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.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.domain.schemas.workbench_runtime import (
WorkbenchDateTime,
@@ -190,13 +191,84 @@ class StoredEvidenceRecord(EvidenceRecord):
data_snapshot: TaskDataSnapshot | None = None
class InvestigationReportInput(StrictModel):
class InvestigationDataGap(StrictModel):
gap_id: NonEmptyStr
node_id: NonEmptyStr | None = None
missing_data: NonEmptyStr
scope: NonEmptyStr
impact: NonEmptyStr
required_data: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(min_length=1)
blocking: StrictBool
class InvestigationDiagramNodeRef(StrictModel):
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
label: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
class InvestigationDiagram(StrictModel):
title: NonEmptyStr
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
node_refs: list[InvestigationDiagramNodeRef] = Field(default_factory=list, max_length=100)
@model_validator(mode="after")
def validate_description(self):
if not re.match(r"^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b", self.mermaid):
raise ValueError("investigation diagrams must be Mermaid flowcharts")
if "```" in self.mermaid or "%%{" in self.mermaid or re.search(r"(?:^|[;\n])\s*click\b", self.mermaid):
raise ValueError("diagram descriptions cannot contain fences, directives or click commands")
ids = [node.node_id for node in self.node_refs]
if len(ids) != len(set(ids)):
raise ValueError("diagram node references must be unique")
return self
class InvestigationReport(StrictModel):
"""Read model: absent historical facts remain unknown, never inferred."""
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)
stage: Literal["interim", "final"] | None = None
outcome: Literal["concluded", "insufficient_data"] | None = None
stop_reason: NonEmptyStr | None = None
data_gaps: list[InvestigationDataGap] | None = None
diagram: InvestigationDiagram | None = None
@model_validator(mode="after")
def validate_result(self):
if self.outcome is None:
if self.stage is not None or self.stop_reason is not None or self.data_gaps is not None:
raise ValueError("structured report fields must be supplied together")
return self
if self.stage is None or self.data_gaps is None:
raise ValueError("structured reports require stage and data_gaps")
ids = [gap.gap_id for gap in self.data_gaps]
if len(ids) != len(set(ids)):
raise ValueError("data gap ids must be unique")
blocking = any(gap.blocking for gap in self.data_gaps)
if self.outcome == "insufficient_data":
if self.stage != "final" or not self.stop_reason or not blocking:
raise ValueError("insufficient data requires a final report, stop reason and blocking gap")
if self.recommendations or self.pending_items:
raise ValueError("insufficient data ends the investigation without downstream recommendations or pending work")
elif self.stop_reason is not None or blocking:
raise ValueError("concluded report cannot contain a stop reason or blocking gap")
return self
class InvestigationReportInput(InvestigationReport):
"""Write model: every new report explicitly declares its business result."""
stage: Literal["interim", "final"]
outcome: Literal["concluded", "insufficient_data"]
data_gaps: list[InvestigationDataGap]
class ExecutionPlanSnapshot(StrictModel):
@@ -214,7 +286,17 @@ class InvestigationReportRevision(StrictModel):
source_session_id: NonEmptyStr
published_at: WorkbenchDateTime
published_by: NonEmptyStr
report: InvestigationReportInput
report: InvestigationReport
class InvestigationReportPage(StrictModel):
items: list[InvestigationReportRevision]
next_offset: int | None = Field(default=None, ge=0)
class InvestigationReportReference(StrictModel):
execution_generation: PositiveInt
revision: PositiveInt
class AgentArtifactsCommand(StrictModel):
@@ -481,6 +563,7 @@ class OperationalTaskRunPage(StrictModel):
class AgentTaskContext(StrictModel):
task: OperationalTaskDetail
approved_data_scopes: list[NonEmptyStr]
selected_report: InvestigationReportRevision | None = None
class AgentRunCommand(StrictModel):
@@ -610,6 +693,7 @@ class OperationalTaskNotification(StrictModel):
lifecycle_status: Literal["active", "resolved"] = "active"
subject_type: Literal["task", "run", "access_request", "action"] = "task"
subject_id: UUID | None = None
report_reference: InvestigationReportReference | None = None
resolved_at: WorkbenchDateTime | None = None
resolution_reason: NonEmptyStr | None = None
status: Literal["unread", "read"]
+19 -1
View File
@@ -409,6 +409,24 @@ class OperationalTaskRepository:
async def get_latest_report(self, task_id: str) -> dict | None:
return await self._latest_artifact("operational_task_reports", task_id)
async def get_report(self, task_id: str, revision: int) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_reports
WHERE project_id=%s AND task_id=%s AND revision=%s""",
(self.project_id, task_id, revision),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def list_reports(self, task_id: str, limit: int, offset: int) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_reports
WHERE project_id=%s AND task_id=%s ORDER BY revision DESC
LIMIT %s OFFSET %s""",
(self.project_id, task_id, limit, offset),
)
return [row["document"] for row in await cursor.fetchall()]
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")
@@ -535,7 +553,7 @@ class OperationalTaskRepository:
SET document=document || jsonb_build_object(
'lifecycle_status','resolved',
'resolved_at',to_jsonb(%s::timestamptz),
'resolution_reason',%s
'resolution_reason',%s::text
)
WHERE project_id=%s AND task_id=%s
AND COALESCE(document->>'lifecycle_status','active')='active'
+107 -23
View File
@@ -45,6 +45,7 @@ from app.domain.schemas.operational_task import (
HumanApprovalAction,
HumanEvidenceCommand,
InvestigationReportRevision,
InvestigationReportReference,
IssueCommandPayload,
NotificationAcknowledgeCommand,
OperationalTaskCreate,
@@ -248,7 +249,22 @@ class OperationalTaskService:
work_order_snapshot=work_order_snapshot,
)
async def agent_context(self, task_id: UUID) -> AgentTaskContext:
async def report(self, task_id: UUID, revision: int) -> InvestigationReportRevision:
await self._task(task_id)
value = await self.repo.get_report(str(task_id), revision)
require(value is not None, "REPORT_NOT_FOUND", "Report revision not found", status=404)
return InvestigationReportRevision.model_validate(value)
async def list_reports(
self, task_id: UUID, limit: int, offset: int
) -> tuple[list[InvestigationReportRevision], int | None]:
await self._task(task_id)
rows = await self.repo.list_reports(str(task_id), limit + 1, offset)
return [InvestigationReportRevision.model_validate(row) for row in rows[:limit]], (
offset + limit if len(rows) > limit else None
)
async def agent_context(self, task_id: UUID, report_revision: int | None = None) -> AgentTaskContext:
scopes: list[str] = []
if SIMULATION_VIEW in self.permissions:
scopes.extend([
@@ -263,6 +279,7 @@ class OperationalTaskService:
return AgentTaskContext(
task=await self.detail(task_id),
approved_data_scopes=list(dict.fromkeys(scopes)),
selected_report=await self.report(task_id, report_revision) if report_revision is not None else None,
)
async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool:
@@ -693,6 +710,10 @@ class OperationalTaskService:
"Only a running run accepts Agent artifacts", status=409)
now = self._now()
self._require_active_run_lease(active_run, now)
previous_report = await self.repo.get_latest_report(str(task_id))
require(not previous_report or previous_report.get("execution_generation") != task["execution_generation"]
or previous_report["report"].get("outcome") != "insufficient_data",
"INVESTIGATION_ENDED", "Insufficient-data investigation requires a new run", status=409)
known_evidence = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
for item in command.evidence:
document = _json(EvidenceRecord(
@@ -763,6 +784,20 @@ class OperationalTaskService:
current_plan = snapshot
if command.report:
self._require_evidence_refs(command.report.evidence_refs, known_evidence)
for gap in command.report.data_gaps:
require(set(gap.evidence_refs) <= set(command.report.evidence_refs),
"REPORT_GAP_EVIDENCE_INVALID",
"Gap evidence must be included in report evidence", status=422)
if command.report.diagram:
for node in command.report.diagram.node_refs:
self._require_evidence_refs(node.evidence_refs, set(command.report.evidence_refs))
require(all(str(ref) in actions for ref in node.action_refs),
"ACTION_NOT_FOUND", "Diagram references an unknown action", status=422)
if command.report.outcome == "insufficient_data":
require(not any(action.approval_status == "pending" or (
action.approval_status == "approved" and action.execution_status in {"not_submitted", "submitted", "unknown"}
) for action in actions.values()), "REPORT_ACTION_PENDING",
"Resolve existing actions before ending for insufficient data", status=409)
report_revision = (report_revision or 0) + 1
report = InvestigationReportRevision(
revision=report_revision,
@@ -779,6 +814,25 @@ class OperationalTaskService:
occurred_at=now,
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"])},
)
previous_result = previous_report["report"] if previous_report else None
if command.report.stage == "final" and (
previous_report is None
or previous_report.get("execution_generation") != task["execution_generation"]
or previous_result.get("stage") != "final"
or any(previous_result.get(key) != _json(command.report).get(key)
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
"evidence_refs", "recommendations", "pending_items", "data_gaps"))
):
await self._resolve_notifications(
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
now=now, reason="report_superseded",
)
await self._notify(
task_id, "report_updated", task["title"], command.report.current_conclusion, now,
report_reference=InvestigationReportReference(
execution_generation=task["execution_generation"], revision=report_revision,
),
)
if current_plan is None:
stored_plan = await self.repo.get_latest_plan(str(task_id))
if stored_plan is not None:
@@ -804,14 +858,11 @@ class OperationalTaskService:
)
else:
# Compatibility for pre-015 repository adapters only.
if (
command.report and current_plan is not None
and current_plan.execution_generation == task["execution_generation"]
and current_plan.source_session_id == command.source_session_id
):
task["status"] = self._status_from_plan(
current_plan.plan.nodes, actions.values()
)
if command.report:
if task["task_type"] == "event_investigation":
task["status"] = self._investigation_status(actions.values()) if command.report.stage == "final" else "running"
else:
task["status"] = self._status_from_plan(current_plan.plan.nodes, actions.values()) if current_plan else "running"
if task["status"] in {"waiting_human", "completed", "failed", "cancelled"}:
task["lifecycle_status"] = task["status"]
task["agent_lease_expires_at"] = (
@@ -839,13 +890,21 @@ class OperationalTaskService:
task = await self._task(task_id)
self._require_actionable_task(task)
self._validate_action_payload(command)
if task["task_type"] == "event_investigation":
report = await self.repo.get_latest_report(str(task_id))
require(report is not None
and report.get("execution_generation") == task["execution_generation"]
and report["report"].get("outcome") == "concluded",
"ACTION_CONCLUSION_REQUIRED",
"Publish a current concluded report before proposing an action", status=409)
if command.node_id:
latest_plan = await self.repo.get_latest_plan(str(task_id))
require(latest_plan is not None, "PLAN_NODE_NOT_FOUND",
"Action cannot be attached before a plan is published", status=409)
node_ids = {node["id"] for node in latest_plan["plan"]["nodes"]}
require(command.node_id in node_ids, "PLAN_NODE_NOT_FOUND",
"Action references an unknown plan node", refs=[command.node_id], status=409)
if task["task_type"] != "event_investigation":
latest_plan = await self.repo.get_latest_plan(str(task_id))
require(latest_plan is not None, "PLAN_NODE_NOT_FOUND",
"Action cannot be attached before a plan is published", status=409)
node_ids = {node["id"] for node in latest_plan["plan"]["nodes"]}
require(command.node_id in node_ids, "PLAN_NODE_NOT_FOUND",
"Action references an unknown plan node", refs=[command.node_id], status=409)
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.evidence_refs, known)
now = self._now()
@@ -1658,18 +1717,21 @@ class OperationalTaskService:
if report_document is not None else None
)
same_generation = (
plan is not None and report is not None
and plan.execution_generation == run["generation"]
report is not None
and report.execution_generation == run["generation"]
and plan.source_session_id == command.source_session_id
and report.source_session_id == command.source_session_id
and (task["task_type"] == "event_investigation" or (
plan is not None
and plan.execution_generation == run["generation"]
and plan.source_session_id == command.source_session_id
))
)
if not same_generation:
if not same_generation or report.report.stage != "final":
final_outcome = "failed"
lifecycle = "failed"
failure_code = "ARTIFACT_INCOMPLETE"
failure_detail = (
"Run requires a final plan and report from the same generation"
"Run requires a final report from the same generation"
)
else:
actions = [
@@ -1680,9 +1742,19 @@ class OperationalTaskService:
TaskAccessRequest.model_validate(item)
for item in await self._list_access_requests(task_id)
]
lifecycle = self._status_from_plan(
plan.plan.nodes, actions, access_requests
)
if task["task_type"] == "event_investigation":
lifecycle = self._investigation_status(actions)
elif report.report.outcome == "insufficient_data":
terminal = all(node.status in {"completed", "skipped", "cancelled"}
for node in plan.plan.nodes)
pending_action = any(action.approval_status == "pending" or (
action.approval_status == "approved" and action.execution_status in {"not_submitted", "submitted", "unknown"}
) for action in actions)
lifecycle = "completed" if terminal and not pending_action else "running"
else:
lifecycle = self._status_from_plan(
plan.plan.nodes, actions, () if task["task_type"] == "event_investigation" else access_requests
)
if lifecycle == "running":
final_outcome = "failed"
lifecycle = "failed"
@@ -1810,6 +1882,16 @@ class OperationalTaskService:
return "waiting_human"
return "running"
@staticmethod
def _investigation_status(actions: Iterable[HumanApprovalAction]) -> str:
"""Only recorded business actions can keep an investigation waiting."""
return "waiting_human" if any(
action.approval_status == "pending" or (
action.approval_status == "approved"
and action.execution_status in {"not_submitted", "submitted", "unknown"}
) for action in actions
) else "completed"
@staticmethod
def _validate_plan_state(plan, known_evidence: set[str], actions: dict[str, HumanApprovalAction]) -> None:
nodes = {node.id: node for node in plan.nodes}
@@ -1963,6 +2045,7 @@ class OperationalTaskService:
self, task_id: UUID, kind: str, title: str, description: str, now: datetime,
*, required_permission: str | None = None,
subject_type: str = "task", subject_id: UUID | None = None,
report_reference: InvestigationReportReference | None = None,
) -> None:
notification = OperationalTaskNotification(
notification_id=uuid4(), task_id=task_id, kind=kind,
@@ -1970,6 +2053,7 @@ class OperationalTaskService:
created_at=now, required_permission=required_permission,
lifecycle_status="active", subject_type=subject_type,
subject_id=subject_id or task_id,
report_reference=report_reference,
)
await self.repo.insert_notification(_json(notification))
await self._append_event(