"""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)