feat(tasks): define investigation report contract and history
This commit is contained in:
@@ -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