feat: extend operational investigation workflows
Generic Container CI/CD / test-build-publish (push) Successful in 3m53s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 63af998045
commit 05183aff93
82 changed files with 4644 additions and 330 deletions
+120
View File
@@ -0,0 +1,120 @@
"""Question lifecycle sharing the operational task transaction and notification bus."""
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
from app.domain.domain_errors import require
from app.domain.schemas.operational_task import OperationalTaskDetail, OperationalTaskSummary
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
if TYPE_CHECKING:
from app.services.operational_tasks import OperationalTaskService
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
# Legacy test repositories do not implement question persistence.
reader = getattr(service.repo, "list_questions", None)
return await reader(str(task_id)) if reader else []
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
for raw in await list_questions(service, task_id):
question = TaskQuestionRequest.model_validate(raw)
if question.status != "pending":
continue
question = question.model_copy(update={"status": "cancelled", "version": question.version + 1})
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
await service._resolve_notifications(task_id, kinds=["human_input_required"],
subject_type="question", subject_id=question.question_request_id,
now=now, reason="question_superseded")
async def create_question(service: "OperationalTaskService", task_id: UUID,
command: TaskQuestionCreate) -> TaskQuestionRequest:
require(OPERATIONAL_TASK_EXECUTE in service.permissions, "PERMISSION_DENIED",
"Current user cannot publish task questions", status=403)
body = command.model_dump(mode="json")
scope = f"task:{task_id}:question:create"
async with service.repo.transaction():
await service.repo.lock(f"task:{task_id}")
replay = await service._request_replay(scope, command.request_id, body)
if replay:
return TaskQuestionRequest.model_validate(await service.repo.get_question(
str(task_id), replay["question_request_id"]))
task = await service._task(task_id)
service._require_actionable_task(task)
require(task["task_type"] == "event_investigation", "INVALID_TASK_TYPE",
"Questions require an investigation task", status=409)
await service.repo.lock(f"run:{command.run_id}")
run = await service._run(task_id, command.run_id)
require(run["status"] in {"claimed", "running"}
and run["generation"] == task["execution_generation"]
and run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Question must belong to the active investigation run", status=409)
now = service._now()
service._require_active_run_lease(run, now)
await service._require_no_pending_calculations(task_id, run)
require(not any(q["status"] == "pending" for q in await list_questions(service, task_id)),
"QUESTION_PENDING", "Answer the existing questions first", status=409)
question = TaskQuestionRequest(question_request_id=uuid4(), task_id=task_id,
run_id=command.run_id, execution_generation=run["generation"],
source_session_id=command.source_session_id, questions=command.questions,
status="pending", version=1, created_at=now)
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
task.update(revision=task["revision"] + 1, updated_at=now)
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
await service._task_event(task, now)
await service._notify(task_id, "human_input_required", task["title"],
";".join(q.title for q in question.questions), now,
required_permission=OPERATIONAL_TASK_WRITE, subject_type="question",
subject_id=question.question_request_id)
await service._save_request(scope, command.request_id, body,
{"question_request_id": str(question.question_request_id)})
return question
async def answer_question(service: "OperationalTaskService", task_id: UUID, question_id: UUID,
command: TaskQuestionReply) -> OperationalTaskDetail:
require(OPERATIONAL_TASK_WRITE in service.permissions, "PERMISSION_DENIED",
"Current user cannot answer task questions", status=403)
body = command.model_dump(mode="json")
scope = f"task:{task_id}:question:{question_id}:answer"
async with service.repo.transaction():
await service.repo.lock(f"task:{task_id}")
if await service._request_replay(scope, command.request_id, body):
return await service.detail(task_id)
task = await service._task(task_id)
service._require_actionable_task(task)
raw = await service.repo.get_question(str(task_id), str(question_id))
require(raw is not None, "QUESTION_NOT_FOUND", "Question not found", status=404)
question = TaskQuestionRequest.model_validate(raw)
require(question.status == "pending" and question.version == command.expected_version
and question.execution_generation == task["execution_generation"],
"VERSION_CONFLICT", "Question is no longer pending in the current generation", status=409)
require(task["lifecycle_status"] == "waiting_human"
and await service.repo.get_active_run(str(task_id)) is None,
"RUN_NOT_SETTLED", "Wait until the investigation has entered waiting_human", status=409)
answers = {a.question_id: a for a in command.answers}
known = {q.question_id for q in question.questions}
require(len(answers) == len(command.answers) and set(answers) <= known,
"ANSWER_INVALID", "Unknown or duplicate questions", status=422)
for q in question.questions:
answer = answers.get(q.question_id)
selected = answer.selected_option_ids if answer else []
text = answer.custom_text.strip() if answer else ""
require(len(set(selected)) == len(selected) and set(selected) <= {o.id for o in q.options}
and (q.mode == "multiple" or len(selected) <= 1)
and (q.allow_custom or not text) and (not q.required or bool(selected or text)),
"ANSWER_INVALID", "Answer does not satisfy the question", status=422)
now = service._now()
question = question.model_copy(update={"status": "answered", "version": question.version + 1,
"answers": command.answers, "answered_at": now, "answered_by": service._actor()})
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
await service._resolve_notifications(task_id, kinds=["human_input_required"],
subject_type="question", subject_id=question_id, now=now, reason="question_answered")
await service._requeue_task(task, now)
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
await service._task_event(task, now)
await service._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
return await service.detail(task_id)