slice_jobs.py 2.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
  1. """Polling endpoint for the in-memory slice-job dispatcher.
  2. POST /library/files/{id}/slice and POST /archives/{id}/slice return a
  3. job_id and a status_url pointing here. The frontend polls this until
  4. status flips to `completed` or `failed`.
  5. """
  6. from fastapi import APIRouter, Depends, HTTPException
  7. from backend.app.core.auth import require_ownership_permission
  8. from backend.app.core.permissions import Permission
  9. from backend.app.models.user import User
  10. from backend.app.services.slice_dispatch import slice_dispatch
  11. router = APIRouter(prefix="/slice-jobs", tags=["slice-jobs"])
  12. @router.get("/{job_id}")
  13. async def get_slice_job(
  14. job_id: int,
  15. # Job IDs are sequential integers and the body leaks source filenames plus
  16. # the resulting library_file_id / archive_id. Gate on the library read
  17. # permission family (own/all) and then scope per-row: a READ_OWN caller may
  18. # only poll jobs they started (SliceJob.owner_id).
  19. auth: tuple[User | None, bool] = Depends(
  20. require_ownership_permission(
  21. Permission.LIBRARY_READ_ALL,
  22. Permission.LIBRARY_READ_OWN,
  23. )
  24. ),
  25. ):
  26. user, can_read_all = auth
  27. job = slice_dispatch.get(job_id)
  28. if job is None:
  29. raise HTTPException(status_code=404, detail="Slice job not found or expired")
  30. # Per-row scoping. Jobs started by API-key / auth-disabled callers have
  31. # owner_id=None and are visible only to READ_ALL pollers (fail-closed,
  32. # mirrors the library ownerless-row rule). 404 not 403 to avoid job-id
  33. # enumeration.
  34. if not can_read_all and (user is None or job.owner_id != user.id):
  35. raise HTTPException(status_code=404, detail="Slice job not found or expired")
  36. body: dict = {
  37. "job_id": job.id,
  38. "status": job.status,
  39. "kind": job.kind,
  40. "source_id": job.source_id,
  41. "source_name": job.source_name,
  42. "created_at": job.created_at.isoformat(),
  43. "started_at": job.started_at.isoformat() if job.started_at else None,
  44. "completed_at": job.completed_at.isoformat() if job.completed_at else None,
  45. # Live progress fed by the sidecar's --pipe channel. Null when
  46. # the slicer hasn't emitted yet (early "Initializing" phase) or
  47. # the sidecar doesn't support progress (older versions).
  48. "progress": job.progress,
  49. }
  50. if job.status == "completed":
  51. body["result"] = job.result
  52. elif job.status == "failed":
  53. body["error_status"] = job.error_status
  54. body["error_detail"] = job.error_detail
  55. return body