Files
maziggy 3ef197e4e0 feat(slicer): Pipelines — multi-copy + class targeting + fanout + runs dashboard + retry-failed + WS updates (#1425 PR C — completes the v3 design)
PR A/B turned the slice modal's preset bundle into a one-click dispatch
with a pinned target printer. PR C closes the original issue: operators
type in a number of copies, Bambuddy slices once and distributes prints
across a fleet per the pipeline's chosen fanout strategy. A new dashboard
surfaces every run with filters, expandable per-copy status, cancel,
and retry-failed-copies. WS pushes keep everything live.

Backend
- copies field on POST /run, capped by new pipeline_max_copies setting
  (default 50, hard cap 1000). PipelineRun.parent_run_id chains retries.
- SlicerPipelineUpdate accepts target_kind (specific_printer /
  printer_class), target_model_class, fanout_strategy.
- Eligibility matcher branches: class-targeting enumerates matching
  Printer rows, runs per-printer checks via a status_lookup closure,
  returns printer_reports[]. New issue kinds: no_class_matches,
  class_not_set.
- _pick_assignments distributes copies per strategy:
  - max_parallel: target_model set, printer_id None — scheduler picks
  - round_robin: copy i → eligible[i % N], fixed printer_id
  - fill_one_first: all copies pinned to eligible[0]
  All three reuse the slice-once path through slice_dispatch.enqueue.
- New routes:
  - GET /pipeline-runs (paginated, filterable by pipeline + status)
  - POST /pipeline-runs/{id}/retry-failed (creates child run with
    copies = failed+cancelled count, parent_run_id set)
  - Cancel cascades to all N queue entries (only pending/queued)
- _roll_up_run_status computes run-level status from per-job statuses;
  introduces partial_failure for "some completed, some failed".
- ws_manager.broadcast_to_user emits pipeline_run_updated on every
  state transition with the full materialised response.

Frontend
- Pipeline editor: target_kind radio + class picker (filtered to
  installed models) + fanout-strategy radio. Read-only row shows
  "X1C · Round robin" for class pipelines.
- RunWithPipelineModal: copies number input bounded by
  settings.pipeline_max_copies. Accepts class-targeted pipelines.
- Settings → Workflow → Queue & Dispatch: new "Slicer Pipeline limits"
  card with the max-copies input.
- New /pipelines/runs dashboard page (sidebar entry, gated on
  pipelines:read). Two-filter dropdown, 25-per-page pagination, per-row
  expandable to job list, Cancel + Retry-failed buttons.
- useWebSocket case for pipeline_run_updated invalidates both
  pipeline-runs-all and pipeline-runs/{id} query keys.
2026-06-27 16:52:05 +02:00

174 lines
6.1 KiB
Python

"""Pydantic schemas for PipelineRun + eligibility (#1425 PR B + PR C)."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class EligibilityIssueResponse(BaseModel):
"""Single eligibility issue — see ``services/pipeline_eligibility.py`` for
the full list of ``kind`` values and what each means."""
kind: Literal[
"printer_not_set",
"printer_not_found",
"printer_disabled",
"printer_offline",
"filament_type_mismatch",
"filament_color_mismatch",
"ams_slot_missing",
"filament_unverified",
"no_class_matches", # PR C: target_kind='printer_class' and zero printers in the install match the model
"class_not_set", # PR C: target_kind='printer_class' with no target_model_class
]
slot_index: int | None = None
expected: str | None = None
actual: str | None = None
class PerPrinterReport(BaseModel):
"""One row of class-targeting eligibility — per matching printer.
PR C extends the top-level report with this list so the confirmation modal
can show ``3 of 5 X1Cs eligible`` plus a per-printer breakdown of why each
candidate is or isn't usable.
"""
printer_id: int
printer_name: str
ok: bool
issues: list[EligibilityIssueResponse] = []
class EligibilityReportResponse(BaseModel):
"""Returned by both ``POST /check-eligibility`` and (on 409) ``POST /run``
so the frontend can render the same modal in either flow.
``ok`` semantics:
- ``target_kind='specific_printer'``: ``ok`` mirrors that single
printer's eligibility (no blocking issues).
- ``target_kind='printer_class'``: ``ok`` is True iff **at least one**
matching printer passes — the run can dispatch even if some
candidates in the class are offline / filament-mismatched, because
the scheduler will pick any eligible one. The per-printer list lives
on ``printer_reports`` so the operator sees the full picture.
``issues`` carries class-level issues only (``no_class_matches``,
``class_not_set``) — per-printer detail moves to ``printer_reports``.
"""
ok: bool
target_kind: Literal["specific_printer", "printer_class"] = "specific_printer"
target_printer_id: int | None = None
target_printer_name: str | None = None
target_model_class: str | None = None
issues: list[EligibilityIssueResponse] = []
printer_reports: list[PerPrinterReport] = []
class CheckEligibilityRequest(BaseModel):
"""Exactly one of ``source_library_file_id`` / ``source_archive_id`` must
be set."""
source_library_file_id: int | None = None
source_archive_id: int | None = None
force: bool = Field(default=False)
@model_validator(mode="after")
def exactly_one_source(self) -> "CheckEligibilityRequest":
if (self.source_library_file_id is None) == (self.source_archive_id is None):
raise ValueError("exactly one of source_library_file_id or source_archive_id must be set")
return self
class PipelineRunCreateRequest(BaseModel):
"""``copies`` defaults to 1 (PR B parity). The route handler enforces the
``pipeline_max_copies`` setting on top of the schema's lower bound."""
source_library_file_id: int | None = None
source_archive_id: int | None = None
copies: int = Field(default=1, ge=1, le=1000)
force: bool = Field(
default=False,
description=(
"When False (default), the route returns 409 with the eligibility "
"report if any blocking issue exists. When True, the run starts "
"even when issues exist — recorded on PipelineRun.eligibility_overridden."
),
)
@model_validator(mode="after")
def exactly_one_source(self) -> "PipelineRunCreateRequest":
if (self.source_library_file_id is None) == (self.source_archive_id is None):
raise ValueError("exactly one of source_library_file_id or source_archive_id must be set")
return self
class PipelineJobResponse(BaseModel):
id: int
pipeline_run_id: int
copy_index: int
assigned_printer_id: int | None
assigned_printer_name: str | None = None
queue_entry_id: int | None
status: Literal[
"pending",
"awaiting_printer",
"queued",
"printing",
"completed",
"failed",
"cancelled",
]
error_message: str | None = None
dispatched_at: datetime | None = None
completed_at: datetime | None = None
class PipelineRunResponse(BaseModel):
id: int
pipeline_id: int | None
pipeline_name: str | None = None
source_library_file_id: int | None
source_archive_id: int | None = None
source_filename: str | None = None
parent_run_id: int | None = None
copies: int
# Roll-up counts used by the dashboard's per-row summary. Computed at read
# time from the per-job statuses so they always match the live state.
copies_completed: int = 0
copies_failed: int = 0
copies_cancelled: int = 0
copies_in_progress: int = 0
status: Literal[
"queued",
"slicing",
"dispatching",
"in_progress",
"completed",
"failed",
"partial_failure", # PR C: some copies succeeded, some failed/cancelled
"cancelled",
]
slice_job_id: int | None
sliced_library_file_id: int | None
eligibility_overridden: bool
error_message: str | None = None
created_by: int | None
created_at: datetime
started_at: datetime | None
completed_at: datetime | None
jobs: list[PipelineJobResponse] = []
# Pipeline target snapshot — copied onto the response so the dashboard
# doesn't need a second query to display "Run on X1C class" per row.
target_kind: Literal["specific_printer", "printer_class"] | None = None
target_printer_id: int | None = None
target_model_class: str | None = None
fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
class PipelineRunListResponse(BaseModel):
runs: list[PipelineRunResponse] = []
total: int = 0 # PR C: for the dashboard's paginator