pipeline_runs.py 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188
  1. """API routes for Slicer Pipeline runs (#1425 PR B + PR C).
  2. PR B implemented single-target dispatch: one Run-pipeline click =
  3. slice the source once → enqueue ONE print on ``target_printer_id``.
  4. PR C extends this with:
  5. * ``copies > 1`` — slice once, enqueue N copies.
  6. * ``target_kind='printer_class'`` — pipeline targets a Bambu model code
  7. (X1C / P1S / H2D / …); orchestrator distributes copies across matching
  8. printers using the pipeline's ``fanout_strategy``.
  9. * Retry-failed runs that re-attempt only the failed/cancelled copies of
  10. a partial-failure run.
  11. * Dashboard list endpoint (``GET /pipeline-runs``) with status + pipeline
  12. filters and pagination.
  13. * WebSocket ``pipeline_run_updated`` events on state transitions so the
  14. dashboard refreshes live without polling.
  15. The slice itself runs through ``slice_dispatch`` (same path as the manual
  16. SliceModal), so the ``Slicing X — Generating G-code 75%`` toast renders
  17. end-to-end. The slice job's id rides on the run response so the frontend
  18. can call ``trackJob`` directly.
  19. """
  20. from __future__ import annotations
  21. import json
  22. import logging
  23. from datetime import datetime, timezone
  24. from pathlib import Path
  25. from typing import Literal
  26. from fastapi import APIRouter, Depends, HTTPException
  27. from sqlalchemy import delete, desc, func, select
  28. from sqlalchemy.ext.asyncio import AsyncSession
  29. from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
  30. from backend.app.api.routes.slicer_presets import listed_ref, load_stock_aliases
  31. from backend.app.core.auth import (
  32. ApiKeyActor,
  33. QueueReviewRequired,
  34. RequestActor,
  35. RequestPrinterScope,
  36. RequirePermissionIfAuthEnabled,
  37. is_auth_enabled,
  38. )
  39. from backend.app.core.config import settings as app_settings
  40. from backend.app.core.database import async_session, get_db
  41. from backend.app.core.permissions import Permission
  42. from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope, ensure_model_target_allowed
  43. from backend.app.core.row_visibility import may_reach_row
  44. from backend.app.core.websocket import ws_manager
  45. from backend.app.models.archive import PrintArchive
  46. from backend.app.models.library import LibraryFile
  47. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  48. from backend.app.models.print_queue import PrintQueueItem
  49. from backend.app.models.printer import Printer
  50. from backend.app.models.slicer_pipeline import SlicerPipeline
  51. from backend.app.models.user import User
  52. from backend.app.schemas.pipeline_run import (
  53. CheckEligibilityRequest,
  54. EligibilityIssueResponse,
  55. EligibilityReportResponse,
  56. PerPrinterReport as PerPrinterReportResponse,
  57. PipelineJobResponse,
  58. PipelineRunCreateRequest,
  59. PipelineRunListResponse,
  60. PipelineRunResponse,
  61. )
  62. from backend.app.schemas.slicer import PresetRef, SliceRequest, checked_bed_type
  63. from backend.app.services.pipeline_eligibility import (
  64. EligibilityReport,
  65. check_pipeline_eligibility,
  66. )
  67. from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
  68. from backend.app.services.print_defaults import default_print_options
  69. from backend.app.services.queue_announcements import announce_queue_jobs_added
  70. logger = logging.getLogger(__name__)
  71. pipeline_run_create_router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
  72. pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
  73. # ---------------------------------------------------------------------------
  74. # Helpers
  75. # ---------------------------------------------------------------------------
  76. def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
  77. return EligibilityReportResponse(
  78. ok=report.ok,
  79. target_kind=report.target_kind,
  80. target_printer_id=report.target_printer_id,
  81. target_printer_name=report.target_printer_name,
  82. target_model_class=report.target_model_class,
  83. issues=[
  84. EligibilityIssueResponse(
  85. kind=issue.kind,
  86. slot_index=issue.slot_index,
  87. expected=issue.expected,
  88. actual=issue.actual,
  89. )
  90. for issue in report.issues
  91. ],
  92. printer_reports=[
  93. PerPrinterReportResponse(
  94. printer_id=r.printer_id,
  95. printer_name=r.printer_name,
  96. ok=r.ok,
  97. issues=[
  98. EligibilityIssueResponse(
  99. kind=i.kind,
  100. slot_index=i.slot_index,
  101. expected=i.expected,
  102. actual=i.actual,
  103. )
  104. for i in r.issues
  105. ],
  106. )
  107. for r in report.printer_reports
  108. ],
  109. )
  110. async def _load_pipeline(db: AsyncSession, pipeline_id: int) -> SlicerPipeline:
  111. pipeline = (
  112. await db.execute(
  113. select(SlicerPipeline).where(
  114. SlicerPipeline.id == pipeline_id,
  115. SlicerPipeline.is_deleted.is_(False),
  116. )
  117. )
  118. ).scalar_one_or_none()
  119. if pipeline is None:
  120. raise HTTPException(404, "Pipeline not found")
  121. return pipeline
  122. async def _load_printer_status(printer_id: int | None) -> dict | None:
  123. """Snapshot the printer_manager's live PrinterState for the eligibility
  124. matcher. Returns ``None`` when the printer has no MQTT client."""
  125. if printer_id is None:
  126. return None
  127. from backend.app.services.printer_manager import printer_manager
  128. state = printer_manager.get_status(printer_id)
  129. if state is None:
  130. return None
  131. return {"connected": state.connected, "raw_data": state.raw_data}
  132. def _make_status_lookup():
  133. """Closure that snapshots the printer_manager once per printer_id call.
  134. Passed to the matcher's class-targeting branch so it can read live state
  135. for every candidate printer."""
  136. def _lookup(printer_id: int) -> dict | None:
  137. from backend.app.services.printer_manager import printer_manager
  138. state = printer_manager.get_status(printer_id)
  139. if state is None:
  140. return None
  141. return {"connected": state.connected, "raw_data": state.raw_data}
  142. return _lookup
  143. def _slice_request_from_pipeline(
  144. pipeline: SlicerPipeline, stock_aliases: dict[str, PresetRef] | None = None
  145. ) -> SliceRequest:
  146. """``stock_aliases`` is the preset listing's map (``load_stock_aliases``):
  147. a ref the listing hides behind its Bambu Cloud twin slices with that twin,
  148. as it does from the Slice dialog (#3386)."""
  149. aliases = stock_aliases or {}
  150. try:
  151. raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
  152. except (json.JSONDecodeError, TypeError):
  153. raw_filaments = []
  154. filament_presets = [
  155. _stored_ref(aliases, r["source"], r["id"])
  156. for r in raw_filaments
  157. if isinstance(r, dict) and "source" in r and "id" in r
  158. ]
  159. # Rows saved before #3359 can hold a name no slicer knows; say so instead
  160. # of failing the run with a validation dump.
  161. try:
  162. bed_type = checked_bed_type(pipeline.bed_type)
  163. except ValueError:
  164. raise HTTPException(
  165. status_code=400,
  166. detail=(
  167. f"The pipeline's build plate {pipeline.bed_type!r} is not one the slicers know. "
  168. "Save the pipeline again from the Slice dialog with another plate."
  169. ),
  170. ) from None
  171. return SliceRequest(
  172. printer_preset=_stored_ref(aliases, pipeline.printer_preset_source, pipeline.printer_preset_id),
  173. process_preset=_stored_ref(aliases, pipeline.process_preset_source, pipeline.process_preset_id),
  174. filament_presets=filament_presets,
  175. bed_type=bed_type,
  176. export_3mf=True,
  177. )
  178. def _stored_ref(aliases: dict[str, PresetRef], source: str, preset_id: str) -> PresetRef:
  179. """A ref as the pipeline row stores it, pointed at the twin the listing shows."""
  180. return listed_ref(aliases, PresetRef.model_validate({"source": source, "id": preset_id}))
  181. async def _pipeline_slice_request(
  182. db: AsyncSession, pipeline: SlicerPipeline, cloud_user_id: int | None
  183. ) -> SliceRequest:
  184. """The run's slice request, with the aliases of the user who resolves its
  185. cloud presets (``current_user_id`` on ``slice_and_persist``), not the
  186. run's owner: a key without the cloud scope resolves none, so a hidden
  187. Standard ref mapped to its cloud twin would be refused (#3386)."""
  188. cloud_user = await db.get(User, cloud_user_id) if cloud_user_id is not None else None
  189. return _slice_request_from_pipeline(pipeline, await load_stock_aliases(db, cloud_user))
  190. def _compute_job_status(
  191. persisted: str,
  192. queue_entry: PrintQueueItem | None,
  193. ) -> str:
  194. if persisted in ("failed", "cancelled", "completed"):
  195. return persisted
  196. if queue_entry is None:
  197. # Dispatched, but its queue entry is gone without a final status stored
  198. # (deleted before jobs stored it, or removed by the database itself):
  199. # printed or not cannot be told, so neither failed nor cancelled.
  200. return "unknown" if persisted in ("queued", "printing") else persisted
  201. qs = queue_entry.status
  202. if qs == "completed":
  203. return "completed"
  204. if qs in ("failed", "aborted"):
  205. return "failed"
  206. if qs == "cancelled":
  207. return "cancelled"
  208. if qs == "printing":
  209. return "printing"
  210. return "queued"
  211. def _roll_up_run_status(
  212. persisted: str,
  213. job_statuses: list[str],
  214. ) -> str:
  215. """Compute the run-level status from the per-job statuses.
  216. Terminal-persisted always wins for explicit cancels / hard failures so
  217. the dashboard doesn't flicker when one job's queue entry hasn't caught
  218. up. Otherwise:
  219. - all completed → completed
  220. - any in_progress / printing / queued / dispatching → in_progress
  221. - any failed alongside any completed → partial_failure
  222. - all failed/cancelled → failed
  223. """
  224. if persisted in ("cancelled",):
  225. return persisted
  226. if not job_statuses:
  227. return persisted
  228. completed = sum(1 for s in job_statuses if s == "completed")
  229. # A copy whose outcome is unknown ended without failing anything to retry.
  230. unknown = sum(1 for s in job_statuses if s == "unknown")
  231. failed = sum(1 for s in job_statuses if s == "failed")
  232. cancelled = sum(1 for s in job_statuses if s == "cancelled")
  233. in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
  234. total = len(job_statuses)
  235. if completed + unknown == total:
  236. return "completed"
  237. if in_flight > 0:
  238. return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
  239. # All copies are in terminal states.
  240. if failed == 0 and cancelled == total:
  241. return "cancelled"
  242. if completed + unknown > 0 and (failed > 0 or cancelled > 0):
  243. return "partial_failure"
  244. if failed > 0:
  245. return "failed"
  246. return persisted
  247. async def _materialise_run(
  248. db: AsyncSession, run: PipelineRun, actor: User | ApiKeyActor | None = None
  249. ) -> PipelineRunResponse:
  250. """The run as ``actor`` sees it: its source and queue jobs are named only
  251. when ``actor`` may see them where they live. No actor names everything."""
  252. pipeline_name: str | None = None
  253. target_kind = None
  254. target_printer_id = None
  255. target_model_class = None
  256. fanout_strategy = None
  257. if run.pipeline_id:
  258. pipeline = (
  259. await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
  260. ).scalar_one_or_none()
  261. if pipeline:
  262. pipeline_name = pipeline.name
  263. target_kind = pipeline.target_kind # type: ignore[assignment]
  264. target_printer_id = pipeline.target_printer_id
  265. target_model_class = pipeline.target_model_class
  266. fanout_strategy = pipeline.fanout_strategy # type: ignore[assignment]
  267. source_filename: str | None = None
  268. source_library_file_id = run.source_library_file_id
  269. source_archive_id = run.source_archive_id
  270. if run.source_library_file_id:
  271. src = (
  272. await db.execute(select(LibraryFile).where(LibraryFile.id == run.source_library_file_id))
  273. ).scalar_one_or_none()
  274. if src is not None and may_reach_row(
  275. src.created_by_id, actor, Permission.LIBRARY_READ_ALL, Permission.LIBRARY_READ_OWN
  276. ):
  277. source_filename = src.filename
  278. elif src is not None:
  279. source_library_file_id = None
  280. elif run.source_archive_id:
  281. arc = (
  282. await db.execute(select(PrintArchive).where(PrintArchive.id == run.source_archive_id))
  283. ).scalar_one_or_none()
  284. if arc is not None and may_reach_row(
  285. arc.created_by_id, actor, Permission.ARCHIVES_READ_ALL, Permission.ARCHIVES_READ_OWN
  286. ):
  287. source_filename = arc.print_name or arc.filename
  288. elif arc is not None:
  289. source_archive_id = None
  290. job_rows = (
  291. (
  292. await db.execute(
  293. select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id).order_by(PipelineJob.copy_index)
  294. )
  295. )
  296. .scalars()
  297. .all()
  298. )
  299. job_responses: list[PipelineJobResponse] = []
  300. job_live_statuses: list[str] = []
  301. for job in job_rows:
  302. queue_entry = None
  303. if job.queue_entry_id:
  304. queue_entry = (
  305. await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
  306. ).scalar_one_or_none()
  307. printer_name: str | None = None
  308. if job.assigned_printer_id:
  309. p = (await db.execute(select(Printer).where(Printer.id == job.assigned_printer_id))).scalar_one_or_none()
  310. printer_name = p.name if p else None
  311. live_job_status = _compute_job_status(job.status, queue_entry)
  312. job_live_statuses.append(live_job_status)
  313. job_responses.append(
  314. PipelineJobResponse(
  315. id=job.id,
  316. pipeline_run_id=job.pipeline_run_id,
  317. copy_index=job.copy_index,
  318. assigned_printer_id=job.assigned_printer_id,
  319. assigned_printer_name=printer_name,
  320. queue_entry_id=job.queue_entry_id
  321. if queue_entry is None
  322. or may_reach_row(queue_entry.created_by_id, actor, Permission.QUEUE_READ_ALL, Permission.QUEUE_READ_OWN)
  323. else None,
  324. status=live_job_status, # type: ignore[arg-type]
  325. error_message=job.error_message,
  326. dispatched_at=job.dispatched_at,
  327. completed_at=job.completed_at,
  328. )
  329. )
  330. rolled_up = _roll_up_run_status(run.status, job_live_statuses)
  331. return PipelineRunResponse(
  332. id=run.id,
  333. pipeline_id=run.pipeline_id,
  334. pipeline_name=pipeline_name,
  335. source_library_file_id=source_library_file_id,
  336. source_archive_id=source_archive_id,
  337. source_filename=source_filename,
  338. parent_run_id=run.parent_run_id,
  339. copies=run.copies,
  340. copies_completed=sum(1 for s in job_live_statuses if s == "completed"),
  341. copies_failed=sum(1 for s in job_live_statuses if s == "failed"),
  342. copies_cancelled=sum(1 for s in job_live_statuses if s == "cancelled"),
  343. copies_in_progress=sum(
  344. 1 for s in job_live_statuses if s in ("printing", "queued", "awaiting_printer", "pending")
  345. ),
  346. status=rolled_up, # type: ignore[arg-type]
  347. slice_job_id=run.slice_job_id,
  348. sliced_library_file_id=run.sliced_library_file_id,
  349. eligibility_overridden=run.eligibility_overridden,
  350. error_message=run.error_message,
  351. created_by=run.created_by,
  352. created_at=run.created_at,
  353. started_at=run.started_at,
  354. completed_at=run.completed_at,
  355. jobs=job_responses,
  356. target_kind=target_kind,
  357. target_printer_id=target_printer_id,
  358. target_model_class=target_model_class,
  359. fanout_strategy=fanout_strategy,
  360. )
  361. async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
  362. """Broadcast a ``pipeline_run_updated`` event with the full materialised
  363. run. Per-user routing via ``broadcast_to_user`` falls back to a global
  364. broadcast when ``created_by`` is None on auth-disabled installs."""
  365. try:
  366. # Without a user, broadcast_to_user would send it to everyone. With auth
  367. # on, a run whose user was deleted is nobody's to watch (the runs page polls).
  368. if run.created_by is None and await is_auth_enabled(db):
  369. return
  370. payload = await _materialise_run(db, run)
  371. await ws_manager.broadcast_to_user(
  372. run.created_by,
  373. {
  374. "type": "pipeline_run_updated",
  375. "run": payload.model_dump(mode="json"),
  376. },
  377. )
  378. except Exception:
  379. logger.exception("Failed to broadcast pipeline_run_updated for run %d", run.id)
  380. # ---------------------------------------------------------------------------
  381. # Source resolution + orchestration
  382. # ---------------------------------------------------------------------------
  383. SourceKind = Literal["library_file", "archive"]
  384. async def _resolve_source(
  385. db: AsyncSession,
  386. *,
  387. library_file_id: int | None,
  388. archive_id: int | None,
  389. user: User | ApiKeyActor | None,
  390. printer_scope: PrinterScope,
  391. ) -> tuple[SourceKind, int, str, Path]:
  392. # Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
  393. # source they can see. Without this a READ_OWN caller could reference
  394. # another user's library file / archive by raw id and have it sliced (and,
  395. # via /run, printed) even though a direct GET on that id returned 404.
  396. # Only auth off (user is None) keeps can_read_all=True. An API key arrives
  397. # as its owner's RequestActor, matching the library/archive read helpers.
  398. from backend.app.api.routes.archives import _ensure_archive_visible
  399. from backend.app.api.routes.library import _ensure_library_file_visible
  400. if library_file_id is not None:
  401. lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
  402. can_read_all = user is None or user.has_permission(Permission.LIBRARY_READ_ALL.value)
  403. lib = _ensure_library_file_visible(lib, user, can_read_all)
  404. src_path = (
  405. Path(app_settings.base_dir) / lib.file_path
  406. ) # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route, which writes a UUID-named file under base_dir/library_files/.
  407. if not src_path.exists():
  408. raise HTTPException(404, "Source library file missing on disk")
  409. return ("library_file", lib.id, lib.filename, src_path)
  410. assert archive_id is not None
  411. arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
  412. can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
  413. arc = await _ensure_archive_visible(arc, user, can_read_all, printer_scope)
  414. # Running a pipeline on an archive prints it again: the same rules as
  415. # queueing it, including the right to reprint it.
  416. from backend.app.api.routes.print_queue import _assert_can_queue_archive
  417. await _assert_can_queue_archive(arc, user, printer_scope)
  418. rel = arc.source_3mf_path or arc.file_path
  419. if not rel:
  420. raise HTTPException(400, "Archive has no source file to slice")
  421. src_path = (
  422. Path(app_settings.base_dir) / rel
  423. ) # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators that already do resolve+relative_to containment.
  424. if not src_path.exists():
  425. raise HTTPException(404, "Archive source file missing on disk")
  426. name = arc.filename or arc.print_name or src_path.name
  427. return ("archive", arc.id, name, src_path)
  428. async def _pick_assignments(
  429. db: AsyncSession,
  430. pipeline: SlicerPipeline,
  431. copies: int,
  432. printer_scope: PrinterScope = ALL_PRINTERS,
  433. ) -> list[tuple[int | None, str | None]]:
  434. """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
  435. ``copies`` per the pipeline's fanout strategy. ``target_model_class``
  436. items leave ``printer_id`` None so the scheduler picks any free matching
  437. printer; specific assignments fill ``printer_id``. Class targeting only
  438. pins copies to printers in the runner's ``printer_scope`` (#1727)."""
  439. target_kind = pipeline.target_kind or "specific_printer"
  440. if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
  441. assert pipeline.target_printer_id is not None
  442. return [(pipeline.target_printer_id, None)] * copies
  443. # Class-targeting. Enumerate matching printers + apply the strategy.
  444. matching = (
  445. (
  446. await db.execute(
  447. select(Printer)
  448. .where(func.lower(Printer.model) == (pipeline.target_model_class or "").lower())
  449. .where(Printer.is_active.is_(True))
  450. .order_by(Printer.id)
  451. )
  452. )
  453. .scalars()
  454. .all()
  455. )
  456. matching = [p for p in matching if printer_scope.allows(p.id)]
  457. if not matching:
  458. # Shouldn't reach here when eligibility passes, but failing gracefully
  459. # is better than a TypeError on next-slot pick.
  460. return [(None, pipeline.target_model_class)] * copies
  461. strategy = pipeline.fanout_strategy or "max_parallel"
  462. if strategy == "fill_one_first":
  463. # Pin every copy to the first match. Scheduler dispatches them serially
  464. # to that printer. If the printer breaks, copies wait; that's the
  465. # documented trade-off.
  466. return [(matching[0].id, None)] * copies
  467. if strategy == "round_robin":
  468. # Cycle through eligible printers — copy ``i`` lands on
  469. # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
  470. return [(matching[i % len(matching)].id, None) for i in range(copies)]
  471. # max_parallel — leave printer_id=None, set target_model so the scheduler
  472. # picks any free X1C / P1S / … for each item independently. In the
  473. # printers' own spelling: the class is matched without regard to case.
  474. return [(None, matching[0].model)] * copies
  475. def _make_orchestration_callable(
  476. *,
  477. run_id: int,
  478. pipeline_id: int,
  479. src_kind: SourceKind,
  480. src_id: int,
  481. src_filename: str,
  482. src_path: Path,
  483. creator_user_id: int | None,
  484. copies: int,
  485. printer_scope: PrinterScope = ALL_PRINTERS,
  486. review_required: bool = False,
  487. ):
  488. """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
  489. background slice job. Wraps slice + multi-copy enqueue + state update."""
  490. async def _orchestrate(slice_job_id: int) -> dict:
  491. from backend.app.api.routes.library import slice_and_persist
  492. async with async_session() as session:
  493. run = (await session.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
  494. pipeline = (
  495. await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
  496. ).scalar_one_or_none()
  497. if run is None or pipeline is None:
  498. logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
  499. return {}
  500. # Honour a cancel that landed between ``POST /run`` returning and
  501. # this background task starting. If the run was cancelled while
  502. # still in ``queued`` we must NOT flip it back to ``slicing`` —
  503. # the operator's intent was to stop, and overwriting status here
  504. # was the bug that left runs stuck at ``dispatching`` after a
  505. # user-side cancel (#1425 PR C bug report).
  506. if run.status == "cancelled":
  507. logger.info("pipeline_run %d was cancelled before slicing started", run_id)
  508. return {}
  509. run.status = "slicing"
  510. run.started_at = datetime.now(timezone.utc)
  511. await session.commit()
  512. await _publish_run_event(session, run)
  513. model_bytes = src_path.read_bytes()
  514. folder_id: int | None = None
  515. if src_kind == "library_file":
  516. lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
  517. if lib is not None:
  518. folder_id = lib.folder_id
  519. try:
  520. # Inside the try: a row with a bed type no slicer knows fails
  521. # the run instead of leaving it in "slicing" (#3359).
  522. slice_request = await _pipeline_slice_request(session, pipeline, creator_user_id)
  523. slice_response = await slice_and_persist(
  524. session,
  525. model_bytes=model_bytes,
  526. model_filename=src_filename,
  527. folder_id=folder_id,
  528. extra_metadata={
  529. f"sliced_from_{src_kind}_id": src_id,
  530. "sliced_via_pipeline_id": pipeline.id,
  531. "sliced_via_pipeline_run_id": run.id,
  532. },
  533. request=slice_request,
  534. current_user_id=creator_user_id,
  535. job_id=slice_job_id,
  536. # The run's owner, also when a key without the cloud scope
  537. # started it and no one resolves cloud presets.
  538. owner_id=run.created_by,
  539. )
  540. except HTTPException as exc:
  541. run.status = "failed"
  542. run.error_message = f"Slice failed: {exc.detail}"
  543. run.completed_at = datetime.now(timezone.utc)
  544. await session.commit()
  545. await _publish_run_event(session, run)
  546. raise
  547. except Exception as exc:
  548. logger.exception("Pipeline run %d slice raised unexpectedly", run_id)
  549. run.status = "failed"
  550. run.error_message = f"Slice failed: {exc}"
  551. run.completed_at = datetime.now(timezone.utc)
  552. await session.commit()
  553. await _publish_run_event(session, run)
  554. raise
  555. run.sliced_library_file_id = slice_response.library_file_id
  556. # Re-check cancellation: the slice can take minutes, and the
  557. # operator may have hit Cancel during that window. Refresh from
  558. # the DB rather than trusting our in-memory `run` (the cancel
  559. # route writes via a separate session). When cancelled, don't
  560. # enqueue print queue items — that's the whole point of cancel.
  561. await session.refresh(run)
  562. if run.status == "cancelled":
  563. logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
  564. await session.commit()
  565. return slice_response.model_dump()
  566. # PR C: enqueue N copies per the picked assignment strategy.
  567. assignments = await _pick_assignments(session, pipeline, copies, printer_scope)
  568. jobs = (
  569. (
  570. await session.execute(
  571. select(PipelineJob)
  572. .where(PipelineJob.pipeline_run_id == run_id)
  573. .order_by(PipelineJob.copy_index)
  574. )
  575. )
  576. .scalars()
  577. .all()
  578. )
  579. if len(jobs) != copies:
  580. logger.warning("pipeline_run %d expected %d jobs, found %d", run_id, copies, len(jobs))
  581. # No per-job ask-for-outcome toggle on a pipeline run either (#1898).
  582. confirm_outcome = await confirm_outcome_for_new_queue_item(session)
  583. # Nor print options: the install's defaults.
  584. print_options = await default_print_options(session)
  585. for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
  586. queue_item = PrintQueueItem(
  587. printer_id=printer_id,
  588. target_model=target_model,
  589. library_file_id=slice_response.library_file_id,
  590. # From the run as refreshed after the slice, not the id the
  591. # request started with: a user deleted meanwhile is gone
  592. # from it, and SQLite may have given the id to someone else.
  593. created_by_id=run.created_by,
  594. status="pending",
  595. confirm_outcome=confirm_outcome,
  596. # The copies wait for review like any other job of theirs (#1620)
  597. # A restricted user deleted while it sliced: as delete_user
  598. # stages their ownerless "any model" jobs, or these would run
  599. # on every printer of the model, not only on theirs (#1727).
  600. manual_start=review_required
  601. or (
  602. run.created_by is None
  603. and creator_user_id is not None
  604. and printer_id is None
  605. and not printer_scope.is_unrestricted
  606. ),
  607. **print_options,
  608. )
  609. session.add(queue_item)
  610. await session.flush()
  611. job.queue_entry_id = queue_item.id
  612. job.assigned_printer_id = printer_id # may be None for max_parallel
  613. # Don't write job.status yet — final cancellation check below
  614. # may flip it to 'cancelled' instead. dispatched_at is fine to
  615. # set unconditionally since the orchestration actually got here.
  616. job.dispatched_at = datetime.now(timezone.utc)
  617. # Final cancellation check before committing 'dispatching'. The
  618. # cancel route writes via a separate session so we have to refresh
  619. # to see the latest. If the cancel landed in this narrow window —
  620. # AFTER the post-slice refresh but BEFORE this commit — the queue
  621. # entries we just created would otherwise pick up and print. Mark
  622. # them + the per-copy jobs cancelled so the user's intent sticks.
  623. await session.refresh(run)
  624. if run.status == "cancelled":
  625. logger.info(
  626. "pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
  627. run_id,
  628. len(jobs),
  629. )
  630. for job in jobs:
  631. if job.queue_entry_id:
  632. qe = (
  633. await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
  634. ).scalar_one_or_none()
  635. if qe is not None and qe.status in ("pending", "queued"):
  636. qe.status = "cancelled"
  637. qe.completed_at = datetime.now(timezone.utc)
  638. if job.status not in ("completed", "failed", "cancelled"):
  639. job.status = "cancelled"
  640. job.completed_at = datetime.now(timezone.utc)
  641. await session.commit()
  642. await _publish_run_event(session, run)
  643. return slice_response.model_dump()
  644. for job in jobs:
  645. job.status = "queued"
  646. run.status = "dispatching"
  647. await session.commit()
  648. await announce_queue_jobs_added(session, [job.queue_entry_id for job in jobs if job.queue_entry_id])
  649. await _publish_run_event(session, run)
  650. return slice_response.model_dump()
  651. return _orchestrate
  652. # ---------------------------------------------------------------------------
  653. # /slicer-pipelines/{id}/check-eligibility
  654. # ---------------------------------------------------------------------------
  655. @pipeline_run_create_router.post("/{pipeline_id}/check-eligibility", response_model=EligibilityReportResponse)
  656. async def check_eligibility(
  657. pipeline_id: int,
  658. body: CheckEligibilityRequest,
  659. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
  660. printer_scope: PrinterScope = RequestPrinterScope,
  661. db: AsyncSession = Depends(get_db, scope="function"),
  662. actor: User | ApiKeyActor | None = RequestActor,
  663. ):
  664. pipeline = await _load_pipeline(db, pipeline_id)
  665. printer_scope.ensure(pipeline.target_printer_id)
  666. await _resolve_source(
  667. db,
  668. library_file_id=body.source_library_file_id,
  669. archive_id=body.source_archive_id,
  670. user=actor,
  671. printer_scope=printer_scope,
  672. )
  673. if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
  674. report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
  675. else:
  676. status = await _load_printer_status(pipeline.target_printer_id)
  677. report = await check_pipeline_eligibility(db, pipeline, status)
  678. return _serialise_status(report)
  679. # ---------------------------------------------------------------------------
  680. # /slicer-pipelines/{id}/run
  681. # ---------------------------------------------------------------------------
  682. @pipeline_run_create_router.post("/{pipeline_id}/run", response_model=PipelineRunResponse, status_code=202)
  683. async def run_pipeline(
  684. pipeline_id: int,
  685. body: PipelineRunCreateRequest,
  686. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
  687. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  688. printer_scope: PrinterScope = RequestPrinterScope,
  689. review_required: bool = QueueReviewRequired,
  690. db: AsyncSession = Depends(get_db, scope="function"),
  691. actor: User | ApiKeyActor | None = RequestActor,
  692. ):
  693. from backend.app.api.routes.settings import get_setting
  694. from backend.app.services.slice_dispatch import slice_dispatch
  695. pipeline = await _load_pipeline(db, pipeline_id)
  696. # The pipeline is shared config; running it is limited to what the caller
  697. # may print on (#1727)
  698. printer_scope.ensure(pipeline.target_printer_id)
  699. # An API key is checked as its owner (RequestActor), like its owner's own
  700. # session; ``creator`` below is a separate question.
  701. src_kind, src_id, src_filename, src_path = await _resolve_source(
  702. db,
  703. library_file_id=body.source_library_file_id,
  704. archive_id=body.source_archive_id,
  705. user=actor,
  706. printer_scope=printer_scope,
  707. )
  708. # The permission gate answers an API-keyed request with current_user=None,
  709. # so a pipeline built on Bambu/Orca Cloud presets would have nobody whose
  710. # stored cloud token could resolve them. Fall back to the key's owner, the
  711. # same fallback POST /library/files/{id}/slice makes (#1182 follow-up).
  712. # Only keys with the cloud scope resolve to an owner here; everything else
  713. # stays None and slices against local presets exactly as before.
  714. creator = current_user or api_key_cloud_owner
  715. # Copies left to the scheduler run within their creator's printers. A
  716. # limited API key can't be held to its own printers that way -- its cloud
  717. # owner may see more -- and even a pinning strategy falls back to "any
  718. # printer of the class" when none of the class is in scope, so a limited
  719. # key may only run pipelines aimed at one printer (#1727).
  720. if pipeline.target_printer_id is None:
  721. ensure_model_target_allowed(current_user, printer_scope)
  722. # Cap copies against the configured ceiling.
  723. raw_cap = await get_setting(db, "pipeline_max_copies")
  724. try:
  725. cap = int(raw_cap) if raw_cap else 50
  726. except (TypeError, ValueError):
  727. cap = 50
  728. if body.copies > cap:
  729. raise HTTPException(
  730. 422,
  731. f"copies={body.copies} exceeds pipeline_max_copies setting ({cap})",
  732. )
  733. # Eligibility pre-flight.
  734. if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
  735. report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
  736. else:
  737. status = await _load_printer_status(pipeline.target_printer_id)
  738. report = await check_pipeline_eligibility(db, pipeline, status)
  739. if not report.ok and not body.force:
  740. raise HTTPException(status_code=409, detail=_serialise_status(report).model_dump())
  741. # Need a target — specific or class — to dispatch.
  742. if pipeline.target_printer_id is None and not pipeline.target_model_class:
  743. raise HTTPException(
  744. 400,
  745. "Pipeline has no target. Open the pipeline in Settings → Workflow → Pipelines and choose a target printer or printer class.",
  746. )
  747. run = PipelineRun(
  748. pipeline_id=pipeline.id,
  749. source_library_file_id=src_id if src_kind == "library_file" else None,
  750. source_archive_id=src_id if src_kind == "archive" else None,
  751. copies=body.copies,
  752. status="queued",
  753. eligibility_overridden=(not report.ok and body.force),
  754. # The run, and the queue jobs it adds, are the key's owner's when a key
  755. # starts it, as a job the webhook queues is (``creator`` is only who
  756. # resolves cloud presets).
  757. created_by=creator.id if creator else (actor.id if isinstance(actor, ApiKeyActor) else None),
  758. )
  759. db.add(run)
  760. await db.flush()
  761. # One PipelineJob per copy. PR B was copies=1, PR C generalises.
  762. for i in range(body.copies):
  763. db.add(
  764. PipelineJob(
  765. pipeline_run_id=run.id,
  766. copy_index=i,
  767. status="pending",
  768. )
  769. )
  770. await db.commit()
  771. await db.refresh(run)
  772. await _publish_run_event(db, run)
  773. orchestrate = _make_orchestration_callable(
  774. run_id=run.id,
  775. pipeline_id=pipeline.id,
  776. src_kind=src_kind,
  777. src_id=src_id,
  778. src_filename=src_filename,
  779. src_path=src_path,
  780. creator_user_id=creator.id if creator else None,
  781. copies=body.copies,
  782. printer_scope=printer_scope,
  783. review_required=review_required,
  784. )
  785. slice_job = await slice_dispatch.enqueue(
  786. kind="library_file" if src_kind == "library_file" else "archive",
  787. source_id=src_id,
  788. source_name=src_filename,
  789. owner_id=run.created_by,
  790. run=orchestrate,
  791. )
  792. run.slice_job_id = slice_job.id
  793. await db.commit()
  794. await db.refresh(run)
  795. return await _materialise_run(db, run, actor)
  796. # ---------------------------------------------------------------------------
  797. # Lists, reads, cancel, retry-failed
  798. # ---------------------------------------------------------------------------
  799. def _run_scope_clause(printer_scope: PrinterScope):
  800. """Runs the caller may see (#1727): their pipeline targets no printer out of
  801. scope, and no copy was assigned to one. None when unrestricted."""
  802. if printer_scope.is_unrestricted:
  803. return None
  804. allowed = printer_scope.printer_ids
  805. hidden_by_job = select(PipelineJob.pipeline_run_id).where(
  806. PipelineJob.assigned_printer_id.is_not(None), PipelineJob.assigned_printer_id.not_in(allowed)
  807. )
  808. hidden_pipelines = select(SlicerPipeline.id).where(
  809. SlicerPipeline.target_printer_id.is_not(None), SlicerPipeline.target_printer_id.not_in(allowed)
  810. )
  811. return PipelineRun.id.not_in(hidden_by_job) & (
  812. PipelineRun.pipeline_id.is_(None) | PipelineRun.pipeline_id.not_in(hidden_pipelines)
  813. )
  814. async def _load_run_in_scope(db: AsyncSession, run_id: int, printer_scope: PrinterScope) -> PipelineRun:
  815. query = select(PipelineRun).where(PipelineRun.id == run_id)
  816. if (clause := _run_scope_clause(printer_scope)) is not None:
  817. query = query.where(clause)
  818. run = (await db.execute(query)).scalar_one_or_none()
  819. if run is None:
  820. raise HTTPException(404, "Pipeline run not found")
  821. return run
  822. @pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
  823. async def list_runs_for_pipeline(
  824. pipeline_id: int,
  825. limit: int = 10,
  826. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
  827. db: AsyncSession = Depends(get_db, scope="function"),
  828. printer_scope: PrinterScope = RequestPrinterScope,
  829. actor: User | ApiKeyActor | None = RequestActor,
  830. ):
  831. limit = max(1, min(limit, 100))
  832. conditions = [PipelineRun.pipeline_id == pipeline_id]
  833. if (clause := _run_scope_clause(printer_scope)) is not None:
  834. conditions.append(clause)
  835. rows = (
  836. (await db.execute(select(PipelineRun).where(*conditions).order_by(PipelineRun.id.desc()).limit(limit)))
  837. .scalars()
  838. .all()
  839. )
  840. total = (await db.execute(select(func.count()).select_from(PipelineRun).where(*conditions))).scalar() or 0
  841. return PipelineRunListResponse(
  842. runs=[await _materialise_run(db, r, actor) for r in rows],
  843. total=total,
  844. )
  845. @pipeline_run_router.get("", response_model=PipelineRunListResponse)
  846. async def list_all_runs(
  847. limit: int = 25,
  848. offset: int = 0,
  849. pipeline_id: int | None = None,
  850. status: str | None = None,
  851. target_printer_id: int | None = None,
  852. target_model_class: str | None = None,
  853. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
  854. db: AsyncSession = Depends(get_db, scope="function"),
  855. printer_scope: PrinterScope = RequestPrinterScope,
  856. actor: User | ApiKeyActor | None = RequestActor,
  857. ):
  858. """Dashboard list. Newest first; filters on pipeline_id + status +
  859. target_printer_id + target_model_class. The ``status`` filter matches
  860. the persisted snapshot, not the live roll-up — in-progress runs may
  861. appear under ``dispatching`` until the next state transition writes
  862. through. ``target_*`` filters JOIN to the pipeline so runs whose
  863. pipeline currently points at the printer / class are returned."""
  864. limit = max(1, min(limit, 100))
  865. offset = max(0, offset)
  866. stmt = select(PipelineRun)
  867. count_stmt = select(func.count()).select_from(PipelineRun)
  868. if (clause := _run_scope_clause(printer_scope)) is not None:
  869. stmt = stmt.where(clause)
  870. count_stmt = count_stmt.where(clause)
  871. if pipeline_id is not None:
  872. stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
  873. count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
  874. if status:
  875. stmt = stmt.where(PipelineRun.status == status)
  876. count_stmt = count_stmt.where(PipelineRun.status == status)
  877. if target_printer_id is not None or target_model_class is not None:
  878. stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
  879. count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
  880. if target_printer_id is not None:
  881. stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
  882. count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
  883. if target_model_class is not None:
  884. stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
  885. count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
  886. rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
  887. total = (await db.execute(count_stmt)).scalar() or 0
  888. return PipelineRunListResponse(
  889. runs=[await _materialise_run(db, r, actor) for r in rows],
  890. total=total,
  891. )
  892. _TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
  893. @pipeline_run_router.post("/clear")
  894. async def clear_terminal_runs(
  895. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
  896. db: AsyncSession = Depends(get_db, scope="function"),
  897. printer_scope: PrinterScope = RequestPrinterScope,
  898. actor: User | ApiKeyActor | None = RequestActor,
  899. ):
  900. """Delete every terminal pipeline run the caller could cancel (completed / failed / cancelled /
  901. partial_failure). In-flight runs (queued / slicing / dispatching /
  902. in_progress) are preserved — clearing those mid-flight would lose the
  903. operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
  904. relationship; the linked PrintQueueItem rows stay (they have their own
  905. lifecycle on the queue page)."""
  906. # Count first so the response can report how many got cleared. Done
  907. # under the same session/transaction as the delete so the numbers can't
  908. # drift if another caller races in.
  909. # Runs on printers the caller can't see are left alone (#1727)
  910. conditions: list = [PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)]
  911. if (clause := _run_scope_clause(printer_scope)) is not None:
  912. conditions.append(clause)
  913. # Other users' runs only for someone who may change everyone's queue jobs
  914. user = actor.owner if isinstance(actor, ApiKeyActor) else actor
  915. if actor is not None and user is not None and not user.has_permission(Permission.QUEUE_UPDATE_ALL.value):
  916. conditions.append(PipelineRun.created_by == user.id)
  917. count_stmt = select(func.count()).select_from(PipelineRun).where(*conditions)
  918. n = (await db.execute(count_stmt)).scalar() or 0
  919. if n > 0:
  920. await db.execute(delete(PipelineRun).where(*conditions))
  921. await db.commit()
  922. return {"deleted": n}
  923. @pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
  924. async def get_run(
  925. run_id: int,
  926. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
  927. db: AsyncSession = Depends(get_db, scope="function"),
  928. printer_scope: PrinterScope = RequestPrinterScope,
  929. actor: User | ApiKeyActor | None = RequestActor,
  930. ):
  931. run = await _load_run_in_scope(db, run_id, printer_scope)
  932. return await _materialise_run(db, run, actor)
  933. @pipeline_run_router.post("/{run_id}/cancel", response_model=PipelineRunResponse)
  934. async def cancel_run(
  935. run_id: int,
  936. _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
  937. db: AsyncSession = Depends(get_db, scope="function"),
  938. printer_scope: PrinterScope = RequestPrinterScope,
  939. actor: User | ApiKeyActor | None = RequestActor,
  940. ):
  941. """Cancel a queued / in-flight run. Cascades to all non-terminal queue
  942. entries; in-flight prints continue on the printer (operator must Stop).
  943. The run's queue entries are its starter's: cancelling it takes the right to
  944. change their queue jobs, as cancelling one of them on the queue does."""
  945. run = await _load_run_in_scope(db, run_id, printer_scope)
  946. if not may_reach_row(run.created_by, actor, Permission.QUEUE_UPDATE_ALL, Permission.QUEUE_UPDATE_OWN):
  947. raise HTTPException(403, "Only the user who started this run can cancel it")
  948. if run.status in ("completed", "failed", "cancelled", "partial_failure"):
  949. return await _materialise_run(db, run, actor)
  950. run.status = "cancelled"
  951. run.completed_at = datetime.now(timezone.utc)
  952. if not run.error_message:
  953. run.error_message = "Cancelled by user"
  954. from backend.app.services.finance_budget import release_budget_reservation
  955. cancelled_entries: list[int] = []
  956. job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
  957. for job in job_rows:
  958. queue_entry = None
  959. if job.queue_entry_id:
  960. queue_entry = (
  961. await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
  962. ).scalar_one_or_none()
  963. if queue_entry is not None and queue_entry.status in ("pending", "queued"):
  964. queue_entry.status = "cancelled"
  965. queue_entry.completed_at = datetime.now(timezone.utc)
  966. # As a cancel from the queue does: its budget hold is freed
  967. await release_budget_reservation(
  968. db, source_type="print_queue", source_id=queue_entry.id, status="released"
  969. )
  970. cancelled_entries.append(queue_entry.id)
  971. if job.status in ("queued", "printing") and queue_entry is None:
  972. # Dispatched, its entry gone: whether it printed is unknown, and
  973. # calling it cancelled would make retry print it again.
  974. continue
  975. if job.status not in ("completed", "failed", "cancelled"):
  976. job.status = "cancelled"
  977. job.completed_at = datetime.now(timezone.utc)
  978. await db.commit()
  979. if cancelled_entries:
  980. from backend.app.services.print_batch import refresh_batches_after
  981. from backend.app.services.print_scheduler import scheduler
  982. await refresh_batches_after(cancelled_entries)
  983. # A preheating entry's dispatch is parked in a sleep and cannot see the
  984. # status written above (#2727): told, it stops heating the printer.
  985. for entry_id in cancelled_entries:
  986. scheduler.notify_dispatch_cancelled(entry_id)
  987. await db.refresh(run)
  988. await _publish_run_event(db, run)
  989. return await _materialise_run(db, run, actor)
  990. @pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
  991. async def retry_failed(
  992. run_id: int,
  993. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
  994. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  995. printer_scope: PrinterScope = RequestPrinterScope,
  996. review_required: bool = QueueReviewRequired,
  997. db: AsyncSession = Depends(get_db, scope="function"),
  998. actor: User | ApiKeyActor | None = RequestActor,
  999. ):
  1000. """Create a new run with copies = (failed + cancelled count) from the
  1001. parent. Same pipeline, same source. Eligibility re-checked at run time
  1002. (it might pass this time — operator may have fixed the issue)."""
  1003. parent = await _load_run_in_scope(db, run_id, printer_scope)
  1004. if parent.pipeline_id is None:
  1005. raise HTTPException(400, "Original pipeline was deleted; cannot retry")
  1006. if parent.source_library_file_id is None and parent.source_archive_id is None:
  1007. raise HTTPException(400, "Original source was deleted; cannot retry")
  1008. # Count the parent's failed + cancelled jobs.
  1009. parent_jobs = (
  1010. (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == parent.id))).scalars().all()
  1011. )
  1012. fail_count = 0
  1013. for j in parent_jobs:
  1014. queue_entry = None
  1015. if j.queue_entry_id:
  1016. queue_entry = (
  1017. await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == j.queue_entry_id))
  1018. ).scalar_one_or_none()
  1019. live = _compute_job_status(j.status, queue_entry)
  1020. if live in ("failed", "cancelled"):
  1021. fail_count += 1
  1022. if fail_count == 0:
  1023. raise HTTPException(400, "No failed copies to retry")
  1024. # Build the request payload the same way the user would have via /run.
  1025. body = PipelineRunCreateRequest(
  1026. source_library_file_id=parent.source_library_file_id,
  1027. source_archive_id=parent.source_archive_id,
  1028. copies=fail_count,
  1029. force=True, # operator already accepted eligibility on the parent
  1030. )
  1031. # Reuse the run_pipeline route logic via a direct call — keeps the
  1032. # orchestration single-sourced. The result inherits parent_run_id. Every
  1033. # dependency it declares has to be forwarded explicitly: FastAPI resolves
  1034. # those only for a routed request, so an omitted one would arrive as the
  1035. # Depends() marker object itself rather than as None.
  1036. new_run_response = await run_pipeline(
  1037. parent.pipeline_id,
  1038. body,
  1039. current_user=current_user,
  1040. api_key_cloud_owner=api_key_cloud_owner,
  1041. actor=actor,
  1042. printer_scope=printer_scope,
  1043. review_required=review_required,
  1044. db=db,
  1045. )
  1046. # Stamp parent_run_id on the freshly-created run.
  1047. new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()
  1048. if new_row is not None:
  1049. new_row.parent_run_id = parent.id
  1050. await db.commit()
  1051. await db.refresh(new_row)
  1052. return await _materialise_run(db, new_row, actor)
  1053. return new_run_response