mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
112 lines
5.2 KiB
Python
112 lines
5.2 KiB
Python
"""Models for a Slicer Pipeline run (#1425 PR B).
|
|
|
|
A PipelineRun is one "Run pipeline" click: slice the source file once with the
|
|
pipeline's four preset slots, then enqueue a single print on the pipeline's
|
|
pinned target printer (PR B = single-target dispatch). PR C extends this with
|
|
copies > 1 and class targeting + fanout strategies.
|
|
|
|
Status on a PipelineRun is mostly COMPUTED from the underlying slice_job
|
|
(in-memory) + the linked queue_entry's state at read time — see
|
|
``api/routes/pipeline_runs.py`` ``_compute_run_status`` for the rules. The
|
|
``status`` column is the persisted snapshot used as a fallback / for filtering
|
|
in list queries; it's updated on terminal transitions (slice failure, cancel,
|
|
or queue-entry completion).
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from backend.app.core.database import Base
|
|
|
|
|
|
class PipelineRun(Base):
|
|
"""One run-pipeline invocation. PR B always carries exactly one
|
|
PipelineJob (copies=1); PR C will allow N."""
|
|
|
|
__tablename__ = "pipeline_runs"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
# Pipeline + source. ``ondelete='SET NULL'`` on both so run history survives
|
|
# the user soft-deleting a pipeline or removing the source library file.
|
|
pipeline_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("slicer_pipelines.id", ondelete="SET NULL"))
|
|
source_library_file_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("library_files.id", ondelete="SET NULL")
|
|
)
|
|
# Mutually exclusive with source_library_file_id. When set, the orchestrator
|
|
# reads ``archive.source_3mf_path`` (falling back to ``file_path``) for the
|
|
# slice input. Lets ArchiveCard's "Run with pipeline" reuse the same /run
|
|
# endpoint instead of growing a second route.
|
|
source_archive_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_archives.id", ondelete="SET NULL"))
|
|
|
|
# Set when this run was created by ``POST /pipeline-runs/{parent}/retry-failed``.
|
|
# Chains the new run back to the run whose failed copies it re-attempts so
|
|
# the dashboard can show "Retry of run #N" inline. ``SET NULL`` so cleaning
|
|
# up old runs doesn't dangle retries.
|
|
parent_run_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="SET NULL"))
|
|
|
|
copies: Mapped[int] = mapped_column(Integer, default=1)
|
|
|
|
# Snapshot status — terminal transitions are persisted here, in-flight
|
|
# reads compute from slice_job + queue_entry. Values:
|
|
# 'queued', 'slicing', 'dispatching', 'in_progress',
|
|
# 'completed', 'failed', 'cancelled'
|
|
status: Mapped[str] = mapped_column(String(20), default="queued")
|
|
|
|
# Slice integration. slice_job_id is the in-memory slice_dispatch id (so
|
|
# it's a plain int, not an FK). sliced_library_file_id is the produced
|
|
# gcode.3mf row.
|
|
slice_job_id: Mapped[int | None] = mapped_column(Integer)
|
|
sliced_library_file_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("library_files.id", ondelete="SET NULL")
|
|
)
|
|
|
|
# True when the operator chose to "Run anyway" past eligibility issues
|
|
# (filament mismatch, etc.). Surfaced in run history so the audit log
|
|
# shows which runs bypassed the pre-flight.
|
|
eligibility_overridden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
|
|
|
error_message: Mapped[str | None] = mapped_column(Text)
|
|
|
|
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
started_at: Mapped[datetime | None] = mapped_column(DateTime)
|
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
|
|
|
jobs: Mapped[list["PipelineJob"]] = relationship(
|
|
back_populates="run",
|
|
cascade="all, delete-orphan",
|
|
order_by="PipelineJob.copy_index",
|
|
)
|
|
|
|
|
|
class PipelineJob(Base):
|
|
"""One copy within a PipelineRun. PR B: always exactly one per run.
|
|
|
|
Each job binds the run to one queue entry (``queue_entry_id``). The
|
|
queue entry's status drives this job's status; this row mostly carries
|
|
the run-side narrative (dispatch timestamps, error message) so deleting
|
|
the queue entry later doesn't lose the audit trail.
|
|
"""
|
|
|
|
__tablename__ = "pipeline_jobs"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
pipeline_run_id: Mapped[int] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="CASCADE"))
|
|
copy_index: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
assigned_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
|
|
queue_entry_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_queue.id", ondelete="SET NULL"))
|
|
|
|
# Values: 'pending', 'awaiting_printer', 'queued', 'printing',
|
|
# 'completed', 'failed', 'cancelled'
|
|
status: Mapped[str] = mapped_column(String(20), default="pending")
|
|
error_message: Mapped[str | None] = mapped_column(Text)
|
|
|
|
dispatched_at: Mapped[datetime | None] = mapped_column(DateTime)
|
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
|
|
|
run: Mapped["PipelineRun"] = relationship(back_populates="jobs")
|