191 lines
6.7 KiB
Python
191 lines
6.7 KiB
Python
"""Task-detail API for externally initiated single-agent deep links.
|
|
|
|
The detail snapshot is deliberately owned by DeerFlow. The embedding system
|
|
writes it once, then the browser only reads it after login; no frontend call is
|
|
made back to the embedding system for task content.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import APIRouter, HTTPException, Path, Query, Request
|
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
|
|
|
|
from app.gateway.deps import get_task_deeplink_detail_store
|
|
|
|
router = APIRouter(prefix="/api/task-deeplink-details", tags=["task-deeplink-details"])
|
|
|
|
_B5_DETAIL_TYPE = "b5-1"
|
|
_MAX_EXTRA_BYTES = 64 * 1024
|
|
_MAX_SUB_TYPE = 64
|
|
|
|
|
|
class TaskDeeplinkDetailUpsertRequest(BaseModel):
|
|
"""Complete task snapshot supplied by the embedding system.
|
|
|
|
``extra`` is intentionally an object, not a bag of top-level fields, so
|
|
the stable contract stays small while integrators can attach their own
|
|
structured fields. ``type`` is the business type (the deep-link
|
|
``?type=``): the same task id may hold one snapshot per type; omitting it
|
|
addresses the legacy no-type partition.
|
|
"""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
task_name: str = Field(
|
|
min_length=1,
|
|
max_length=512,
|
|
validation_alias=AliasChoices("taskName", "task_name"),
|
|
)
|
|
task_content: str = Field(
|
|
min_length=1,
|
|
max_length=20_000,
|
|
validation_alias=AliasChoices("taskContent", "task_content"),
|
|
)
|
|
overview: str = Field(default="", max_length=4_000)
|
|
detail_sub_type: str = Field(
|
|
default="",
|
|
max_length=_MAX_SUB_TYPE,
|
|
validation_alias=AliasChoices("type", "taskType", "sub_type"),
|
|
)
|
|
extra: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
@field_validator("task_name", "task_content", "overview", "detail_sub_type")
|
|
@classmethod
|
|
def strip_text(cls, value: str) -> str:
|
|
return value.strip()
|
|
|
|
@field_validator("task_name", "task_content")
|
|
@classmethod
|
|
def require_nonblank_text(cls, value: str) -> str:
|
|
if not value:
|
|
raise ValueError("text must not be blank")
|
|
return value
|
|
|
|
@field_validator("extra")
|
|
@classmethod
|
|
def require_bounded_json_object(cls, value: dict[str, Any]) -> dict[str, Any]:
|
|
try:
|
|
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("extra must be JSON serializable") from exc
|
|
if len(encoded.encode("utf-8")) > _MAX_EXTRA_BYTES:
|
|
raise ValueError(f"extra must not exceed {_MAX_EXTRA_BYTES} bytes")
|
|
return value
|
|
|
|
|
|
class TaskDeeplinkDetailResponse(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
detail_type: Literal["b5-1"] = Field(serialization_alias="type")
|
|
detail_id: str = Field(serialization_alias="id")
|
|
detail_sub_type: str = Field(default="", serialization_alias="taskType")
|
|
task_name: str = Field(serialization_alias="taskName")
|
|
task_content: str = Field(serialization_alias="taskContent")
|
|
overview: str = ""
|
|
extra: dict[str, Any] = Field(default_factory=dict)
|
|
version: int
|
|
updated_at: str | None = Field(default=None, serialization_alias="updatedAt")
|
|
|
|
|
|
class TaskDeeplinkDetailEnvelope(BaseModel):
|
|
data: TaskDeeplinkDetailResponse
|
|
|
|
|
|
class TaskDeeplinkDetailUpsertEnvelope(TaskDeeplinkDetailEnvelope):
|
|
created: bool
|
|
changed: bool
|
|
|
|
|
|
def _detail_response(row: dict[str, Any]) -> TaskDeeplinkDetailResponse:
|
|
return TaskDeeplinkDetailResponse(
|
|
detail_type=row["detail_type"],
|
|
detail_id=row["detail_id"],
|
|
detail_sub_type=str(row.get("sub_type") or ""),
|
|
task_name=row["task_name"],
|
|
task_content=row["task_content"],
|
|
overview=row.get("overview") or "",
|
|
extra=row.get("extra") or {},
|
|
version=int(row.get("version") or 1),
|
|
updated_at=str(row["updated_at"]) if row.get("updated_at") else None,
|
|
)
|
|
|
|
|
|
def _normalized_detail_id(detail_id: str) -> str:
|
|
normalized = detail_id.strip()
|
|
if not normalized:
|
|
raise HTTPException(status_code=422, detail="detail id must not be blank")
|
|
return normalized
|
|
|
|
|
|
def _writer_id(request: Request) -> str:
|
|
"""Audit identity for a snapshot write.
|
|
|
|
The PUT endpoint is credential-free (integration systems write before the
|
|
user's browser is involved), so an unauthenticated caller is recorded as
|
|
the external integrator; a logged-in browser write keeps the user id.
|
|
"""
|
|
|
|
user = getattr(request.state, "user", None)
|
|
if user is not None:
|
|
return str(getattr(user, "id", "system"))
|
|
return "external:task-deeplink"
|
|
|
|
|
|
@router.get("/{detail_type}/{detail_id}", response_model=TaskDeeplinkDetailEnvelope)
|
|
async def get_task_deeplink_detail(
|
|
request: Request,
|
|
detail_type: Literal["b5-1"],
|
|
detail_id: str = Path(min_length=1, max_length=128),
|
|
type: str = Query(default="", max_length=_MAX_SUB_TYPE),
|
|
) -> TaskDeeplinkDetailEnvelope:
|
|
"""Read one persisted snapshot for a post-login deep-link workspace.
|
|
|
|
``?type=`` selects the business-type partition written via the PUT body's
|
|
``type`` field; omitted means the legacy no-type partition.
|
|
"""
|
|
|
|
store = get_task_deeplink_detail_store(request)
|
|
row = await store.get_detail(
|
|
detail_type,
|
|
_normalized_detail_id(detail_id),
|
|
sub_type=type.strip(),
|
|
)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Task deep-link detail not found")
|
|
return TaskDeeplinkDetailEnvelope(data=_detail_response(row))
|
|
|
|
|
|
@router.put("/{detail_type}/{detail_id}", response_model=TaskDeeplinkDetailUpsertEnvelope)
|
|
async def upsert_task_deeplink_detail(
|
|
request: Request,
|
|
body: TaskDeeplinkDetailUpsertRequest,
|
|
detail_type: Literal["b5-1"],
|
|
detail_id: str = Path(min_length=1, max_length=128),
|
|
) -> TaskDeeplinkDetailUpsertEnvelope:
|
|
"""Idempotently create or update a snapshot before ``goPath=b5-1``.
|
|
|
|
Open write endpoint: no login session or integration key is required
|
|
(AuthMiddleware/CSRF let the PUT through); the writer is only recorded
|
|
for audit. Reads still require a normal authenticated session.
|
|
"""
|
|
|
|
store = get_task_deeplink_detail_store(request)
|
|
row, created, changed = await store.upsert_detail(
|
|
detail_type,
|
|
_normalized_detail_id(detail_id),
|
|
sub_type=body.detail_sub_type,
|
|
task_name=body.task_name,
|
|
task_content=body.task_content,
|
|
overview=body.overview,
|
|
extra=body.extra,
|
|
updated_by=_writer_id(request),
|
|
)
|
|
return TaskDeeplinkDetailUpsertEnvelope(
|
|
data=_detail_response(row),
|
|
created=created,
|
|
changed=changed,
|
|
)
|