feat(tasks): define investigation report contract and history
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user