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
+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(