"""Task-scoped submission status for roundtable artifacts. This API records only an artifact reference (thread + sandbox path) and its submission state. It intentionally does not fetch task detail, copy Markdown, or deliver a file to an external task system. Routes (authenticated by the Gateway middleware): GET /api/roundtable/tasks/{task_id}/artifacts?submitted=true PUT /api/roundtable/tasks/{task_id}/artifacts/submission """ from __future__ import annotations from pathlib import PurePosixPath from typing import Any from fastapi import APIRouter, HTTPException, Query, Request from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator from app.gateway.deps import get_current_user from deerflow.persistence.roundtable_artifact_submissions import RoundtableArtifactSubmissionStore from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict router = APIRouter(prefix="/api/roundtable/tasks", tags=["roundtable-artifact-submissions"]) class ArtifactReferenceInput(BaseModel): """Reference to a generated file; the file content remains in its thread sandbox.""" model_config = ConfigDict(populate_by_name=True) session_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("sessionId", "session_id")) thread_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("threadId", "thread_id")) node_key: StrictStr | None = Field(default=None, max_length=256, validation_alias=AliasChoices("nodeKey", "node_key")) source_key: StrictStr = Field(..., min_length=1, max_length=512, validation_alias=AliasChoices("sourceKey", "source_key")) path: StrictStr = Field(..., min_length=1, max_length=2048) name: StrictStr | None = Field(default=None, max_length=512) mime_type: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("mimeType", "mime_type")) revision: StrictInt = Field(..., ge=0) @field_validator("session_id", "thread_id", "node_key", "source_key", "path", "name", "mime_type", mode="before") @classmethod def _trim_text(cls, value: Any) -> Any: if isinstance(value, str): value = value.strip() return value or None return value class ArtifactSubmissionRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) task_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("taskId", "task_id")) submitted: bool register_only: bool = Field(default=False, validation_alias=AliasChoices("registerOnly", "register_only")) submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id")) task_delivery_id: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("taskDeliveryId", "task_delivery_id")) expected_version: int | None = Field(default=None, ge=0, validation_alias=AliasChoices("expectedVersion", "expected_version")) artifact: ArtifactReferenceInput @field_validator("task_id", "submission_id", "task_delivery_id", mode="before") @classmethod def _trim_optional_text(cls, value: Any) -> Any: if isinstance(value, str): value = value.strip() return value or None return value class ArtifactReferenceResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) session_id: str | None = Field(default=None, serialization_alias="sessionId") thread_id: str | None = Field(default=None, serialization_alias="threadId") node_key: str | None = Field(default=None, serialization_alias="nodeKey") source_key: str = Field(serialization_alias="sourceKey") path: str name: str | None = None mime_type: str | None = Field(default=None, serialization_alias="mimeType") revision: int class ArtifactSubmissionResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str task_id: str = Field(serialization_alias="taskId") version: int = Field(default=0) submitted: bool submitted_at: str | None = Field(default=None, serialization_alias="submittedAt") cancelled_at: str | None = Field(default=None, serialization_alias="cancelledAt") task_delivery_id: str | None = Field(default=None, serialization_alias="taskDeliveryId") artifact: ArtifactReferenceResponse class ArtifactSubmissionListResponse(BaseModel): items: list[ArtifactSubmissionResponse] def _get_store(request: Request) -> RoundtableArtifactSubmissionStore: store = getattr(request.app.state, "roundtable_artifact_submission_store", None) if store is None: raise HTTPException(status_code=503, detail="Roundtable artifact submission store not available") return store def _task_id_or_422(value: str) -> str: task_id = value.strip() if not task_id: raise HTTPException(status_code=422, detail="task id is required") if len(task_id) > 128: raise HTTPException(status_code=422, detail="task id must be at most 128 characters") return task_id def _artifact_payload(artifact: ArtifactReferenceInput) -> dict[str, Any]: source_key = artifact.source_key path = artifact.path if not source_key or not path: raise HTTPException(status_code=422, detail="artifact.sourceKey and artifact.path are required") name = artifact.name or PurePosixPath(path).name or path return { "session_id": artifact.session_id, "thread_id": artifact.thread_id, "node_key": artifact.node_key, "source_key": source_key, "path": path, "name": name, "mime_type": artifact.mime_type, "revision": artifact.revision, } def _response(row: dict[str, Any]) -> ArtifactSubmissionResponse: return ArtifactSubmissionResponse( id=str(row["id"]), task_id=str(row["task_id"]), version=int(row.get("version", 0)), submitted=bool(row["submitted"]), submitted_at=row.get("submitted_at"), cancelled_at=row.get("cancelled_at"), task_delivery_id=row.get("task_delivery_id"), artifact=ArtifactReferenceResponse( session_id=row.get("session_id"), thread_id=row.get("thread_id"), node_key=row.get("node_key"), source_key=str(row["source_key"]), path=str(row["path"]), name=row.get("name"), mime_type=row.get("mime_type"), revision=int(row["revision"]), ), ) @router.get("/{task_id}/artifacts", response_model=ArtifactSubmissionListResponse) async def list_artifact_submissions( request: Request, task_id: str, submitted: bool | None = Query(default=True), ) -> ArtifactSubmissionListResponse: """List task artifacts by submission state (defaults to submitted only).""" rows = await _get_store(request).list_by_task(_task_id_or_422(task_id), submitted=submitted) return ArtifactSubmissionListResponse(items=[_response(row) for row in rows]) @router.put("/{task_id}/artifacts/submission", response_model=ArtifactSubmissionResponse) async def set_artifact_submission( request: Request, task_id: str, body: ArtifactSubmissionRequest, ) -> ArtifactSubmissionResponse: """Create/update a submission or retain an audit row while cancelling it.""" path_task_id = _task_id_or_422(task_id) if body.task_id is not None and body.task_id != path_task_id: raise HTTPException(status_code=422, detail="taskId must match the path task id") artifact = _artifact_payload(body.artifact) actor_id = await get_current_user(request) store = _get_store(request) if body.submitted: # The frontend previews submitted artifacts by threadId + path. Without # a thread reference a saved state could never be previewed correctly. if not artifact["thread_id"]: raise HTTPException(status_code=422, detail="artifact.threadId is required when submitted is true") try: row = await store.submit( path_task_id, artifact, task_delivery_id=body.task_delivery_id, actor_id=actor_id, expected_version=body.expected_version, ) except SubmissionVersionConflict as conflict: raise HTTPException( status_code=409, detail={ "code": "version_conflict", "message": "提交产物版本冲突:另一写入已先提交,请刷新后重试。", "current_version": conflict.row.get("version", 0), "submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False), }, ) from conflict elif body.register_only: if not artifact["thread_id"]: raise HTTPException(status_code=422, detail="artifact.threadId is required when registering an artifact") try: row = await store.register( path_task_id, artifact, task_delivery_id=body.task_delivery_id, actor_id=actor_id, expected_version=body.expected_version, ) except SubmissionVersionConflict as conflict: raise HTTPException( status_code=409, detail={ "code": "version_conflict", "message": "登记产物版本冲突:另一写入已更新,请刷新后重试。", "current_version": conflict.row.get("version", 0), "submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False), }, ) from conflict else: try: row = await store.cancel( path_task_id, artifact, submission_id=body.submission_id, actor_id=actor_id, expected_version=body.expected_version, ) except SubmissionVersionConflict as conflict: raise HTTPException( status_code=409, detail={ "code": "version_conflict", "message": "取消产物版本冲突:另一写入已先提交,旧取消不能撤销新提交。", "current_version": conflict.row.get("version", 0), "submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False), }, ) from conflict if row is None: raise HTTPException(status_code=404, detail="Artifact submission not found") return _response(row)