Remove retired query paths and repository compatibility branches, and share task test fixtures. Cancel pending questions and resolve their notifications when recovering expired runs.
119 lines
7.3 KiB
Python
119 lines
7.3 KiB
Python
"""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]:
|
||
return await service.repo.list_questions(str(task_id))
|
||
|
||
|
||
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)
|