247 lines
11 KiB
Python
247 lines
11 KiB
Python
"""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)
|