From a1a0cbdf39cd8c2e8495d2deac3272115a340de8 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 7 Jul 2026 14:26:40 +0200 Subject: [PATCH] Updated CHANGELOG --- CHANGELOG.md | 84 +++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5457411..4770d39fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,6 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Added -- **Spool labels: low-resolution / black-and-white thermal-printer support (#1870, reporter @fireboyff, monochrome requested by @Geoff-S)** — The QR code on the small 40 × 30 mm box label rendered too densely to print reliably on 203 dpi thermal printers — its lines bled together and it wouldn't scan. Two causes: the QR was sized at 20% of the label's inner width, which on the narrowest template came out at just ~7.5 mm (half of every other template), and it used `ERROR_CORRECT_M`. Fixes, applied adaptively so every template benefits with no configuration: the roomy layout now gives the QR a 12 mm minimum size (box_40x30 goes 7.5 → 12 mm, ~1.8 → ~3.5 dots per module at 203 dpi) while still capping it by height/width, and label QRs now use `ERROR_CORRECT_L` (same payload, fewer/chunkier modules — a label doesn't need M-level damage recovery). Also added a **Monochrome (black & white printer)** option in the label print dialog: it drops the colour swatch (a useless grey block on a B&W printer) and widens the text column, with the colour still conveyed by the hex-code line. -- **Indonesian Rupiah (IDR) currency support (#1869, reporter @qoatzelcoat)** — Added `IDR` with the `Rp` symbol to `frontend/src/utils/currency.ts`; appears in Settings → Cost Tracking and formats spool/print costs as `Rp `. Backend already accepts any 3-letter ISO code (`filament.currency` / `settings.currency` are freeform `String(3)`), so no schema change or migration was needed. -- **Preheat & Heat Soak before queued prints — per-item override + per-filament chamber targets (#1468, reporter @embed-3d)** — New scheduler stage that heats the bed (and the chamber, on printers that support it) and holds at temperature before each queued print starts, intended for engineering filaments (PA, ABS) where adhesion and warp depend on a warm chamber. **Why it doesn't already work in the slicer.** BambuStudio / OrcaSlicer can emit `M191` (wait-for-chamber-temp) in start-G-code, but Bambu firmware silently ignores `M191`, so any "wait for chamber" line in the slicer's start sequence is a no-op. The reporter confirmed this by trying the [MakerWorld chamber-heating G-code](https://makerworld.com/de/models/1200262-g-code-for-x1c-chamber-heating-and-heat-soak) in OrcaSlicer and finding the chamber-heating step wouldn't fire. Implementing this at the orchestration layer — Bambuddy waiting on `state.temperatures` between FTP upload and `start_print` — is the right architectural place; the slicer side is a dead end. **Where it fires.** `print_scheduler._preheat_and_soak()`, called from `_start_print()` immediately before the FTP upload section (`print_scheduler.py:2306`-ish). Best-effort: any failure (printer drops, gcode refused, no bed temp in metadata) logs and returns rather than failing the queue item — the normal upload + start path runs straight after. **Hardware-tier behaviour (three branches; the OP collapsed two of them and we kept them distinct):** (1) Active chamber heater — `H2C / H2D / H2D Pro / H2S / X2D / X1E` (`supports_chamber_heater()` true) — dispatches `M141 Sx` for the configured target then polls `state.temperatures["chamber"]` against it. (2) Chamber sensor only — `X1C / P2S` (`supports_chamber_temp()` true but `supports_chamber_heater()` false) — no `M141`, polls the chamber sensor and considers the chamber phase satisfied when bed radiation has driven the sensor to target. Radiant warm-up to ABS-friendly temps on a cold X1C is 20-30 min — the `max_wait_seconds` cap (default 900 s, range 60-3600) is a hard ceiling so a cold room can't stall the queue indefinitely; falls through to the soak phase if the chamber never converges. (3) No chamber sensor — `P1S / P1P / A1 / A1 Mini` — no chamber wait possible (the `chamber_temper` value these models report is meaningless per `printer_manager.supports_chamber_temp`), so only the bed phase + soak timer apply. **Bed target** is read from the archive's parsed `bed_temperature` metadata (the same `bed_temperature_initial_layer` / `bed_temperature` field `archive.py:438` already extracts from the 3MF); if missing the preheat stage skips and logs rather than guessing a default that might wreck a non-PLA print. **Settings — Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card.** Master toggle (`preheat_enabled`, default off — disabled installs see no behavioural change), `preheat_chamber_target` (°C, 0-60, default 0 = chamber phase disabled; PA: 50, ABS: 45, PETG-CF: 40), `preheat_max_wait_seconds` (60-3600, default 900), `preheat_soak_seconds` (0-1800, default 300). The numeric fields auto-disable in the UI when the master toggle is off so they read as "config that's not currently doing anything." A static helper line under the inputs spells out the three hardware tiers so users don't have to consult the wiki to know what their printer will do. **Tests.** Eight new cases in `backend/tests/unit/test_scheduler_preheat.py`: disabled-setting skip (no M140 / M141 dispatched), no-bed-temp-in-archive skip, `H2D` dispatches both `M140` and `M141`, `X1C` dispatches only `M140` (the explicit chamber-sensor-but-no-heater regression guard — wiring this to `supports_chamber_temp()` alone would have falsely fired `M141` on the entire X1 family), `P1S` ignores its meaningless `chamber` reading and lets only the soak timer run, `preheat_chamber_target=0` keeps the bed phase but skips chamber even on a heater-capable printer, lost-client mid-flow returns silently, lost-state mid-wait exits the poll loop gracefully and still soaks. `asyncio.sleep` patched to `AsyncMock` so the soak phase doesn't actually wait — assertions are on what was scheduled, not wall-clock. 8/8 green. **Wiki.** `bambuddy-wiki/docs/features/monitoring.md` gains a new "Preheat & Heat Soak" subsection under the queue/scheduling area documenting the three hardware tiers and the four-setting interface, so users with X1C or P1S know upfront what the feature can and cannot do for their printer. **i18n.** 13 new keys × 11 locales (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere — no English fallback. **Scope.** Backend (scheduler + schema + settings route) + frontend (settings card + AppSettings type) + tests + wiki. No DB migration (uses the existing key/value `Settings` table). No new permission (Settings → Workflow already gates on the same admin scope). The OP's "select option per print" UX is not part of this drop — preheat is a global default applied to every queued item that has a parseable bed temperature; per-queue-item override would need a `PrintQueueItem` schema migration plus print-modal and queue-modify UI work that would have widened the change beyond the scope agreed with the user. **Rework on user review.** First cut shipped a global-only design: one single `preheat_chamber_target` int in Settings → Workflow, no per-print override. User flagged two gaps on review: (1) you can't enable preheat for a single queue item, (2) different filaments need different chamber temps but the setting was a single value. Both are fair: PA needs 50, PETG-CF wants 40, PLA wants 0, but the global single-int forced one number across all of them. Reworked the data shape: replaced the single `preheat_chamber_target` int with `preheat_filament_targets` (JSON map of normalised filament type → °C, user-editable in the same card via the new `PreheatFilamentTargetsEditor` component) and added two columns to `PrintQueueItem` — `preheat_override` (`inherit` / `on` / `off`, default `inherit`) and `preheat_chamber_target_override` (nullable int, beats the filament-map derivation). The scheduler's resolution order: `item.preheat_override == 'off'` skips entirely; `'inherit'` falls back to the global `preheat_enabled` master toggle; `'on'` forces the stage even when the global is off. Chamber target: `item.preheat_chamber_target_override` (explicit °C) > max of `preheat_filament_targets[normalize(t.tray_type)]` across loaded AMS slots > 0. Mixed PA+PLA load picks PA's 50 (max-across-slots, NOT lowest-common-denominator — PA's chamber requirement is the binding constraint, PLA doesn't suffer from being warm). PLA-only prints derive 0 and skip the chamber phase automatically without the user touching anything. The per-print UI lives in `PrintModal`'s "Print Options" panel — tri-state segmented control (Inherit / On / Off) plus an optional chamber-target override input (shown only when override ≠ Off, blank = use filament map). Same control in edit-queue-item mode so you can flip preheat on an already-queued print. Tests grew from 8 cases to 15 across three categories — override resolution (3), chamber-target derivation (5), hardware-tier branching (5), plus 2 helper-fn tests — all green. DB migration added: `preheat_override VARCHAR(10) DEFAULT 'inherit'` and `preheat_chamber_target_override INTEGER NULL` on `print_queue`, idempotent via `_safe_execute`. Existing rows behave exactly as before (inherit + null = use global). i18n grew from 13 keys to 19 × 11 locales — real translations for the new override radio + per-filament editor strings, no English fallback. Frontend `PrintQueueItem` TS interface and `PrintQueueItemCreate` / `PrintQueueItemUpdate` shapes updated to carry the new fields end-to-end. **PrintOptions.tsx now uses `options[key as 'bed_levelling']` for the boolean rows since `options[key]` would type-error against the new non-boolean preheat keys** — minor TS-only adjustment, no behavioural change. **Airduct flap follows the resolved chamber target — bidirectional, idempotent.** Reported by user mid-test on H2D: preheat ran M141 for ABS but the cooling/heating airduct flap stayed in cooling, the open exhaust vent actively fought the heater and the chamber crawled toward target. The H-series (H2C/H2D/H2D Pro/H2S), X2D, and P2S all have a motorised flap with two modes: cooling (modeId=0, open exhaust, vents heat — right for PLA/PETG/TPU) and heating (modeId=1, closed exhaust, recirculates warm air — right for ABS/ASA/PC/PA). Bambu's firmware does **not** auto-switch the flap based on M141; whatever mode the user last left it in persists. So a PLA→ABS workflow inherits PLA's cooling-mode flap and the heater never wins; conversely an ABS→PLA workflow inherits ABS's heating-mode recirculation and runs PLA's chamber hot. **Fix.** New `supports_airduct(model)` helper in `printer_manager.py` mirrors the frontend whitelist (`P2S`, `X2D`, `H2C`, `H2D`, `H2D Pro`, `H2S` plus their internal codes). In `_preheat_and_soak`, after the bed dispatch and BEFORE the chamber M141: read the printer's current `state.airduct_mode`, derive the desired mode from the resolved chamber target (`chamber_target > 0` → heating, `chamber_target == 0` → cooling), and only fire `set_airduct_mode` when current ≠ desired. **The bidirectional switch is the load-bearing part** per Martin: when the resolved chamber target is 0 (PLA-only print, or per-item override disables chamber), the flap MUST switch to cooling even on a heater-capable printer that was previously running ABS, otherwise the closed-flap recirculation cooks PLA. The idempotency check (read `state.airduct_mode`, compare against desired before sending) keeps the flap motor from cycling needlessly when it's already where we want it. **Gating** is on `supports_airduct(model)` only — distinct from `supports_chamber_heater(model)`: X1E has a chamber heater but no flap (skipped), P2S has a flap but no active heater (still flipped, because even a passive-sensor printer benefits from the right airflow for its filament); the intersection that needs both is H2C/H2D/H2D Pro/H2S/X2D and they all work. **No post-print restoration** per Martin's preference — once a preheat sets the flap, it stays there for the print's duration (the print itself wants the same mode the preheat picked) and for any subsequent prints until the next preheat decision flips it. Best-effort: any `set_airduct_mode` failure logs and continues; M141 still fires regardless so a stuck flap doesn't kill the print. **Tests.** Four new cases in `test_scheduler_preheat.py`: `test_h2d_chamber_heat_switches_airduct_to_heating` (the OP scenario — cooling→heating before M141 for ABS), `test_h2d_chamber_zero_switches_airduct_to_cooling` (heating→cooling for a PLA print on a previously-warm flap), `test_h2d_airduct_already_correct_idempotent` (no command sent when current mode matches desired), `test_x1c_no_airduct_flap_never_fires_set_airduct` (gate regression guard — X1C has chamber sensor + chamber heater logic adjacent but no flap, must not leak the command). 21/21 in the file now. -- **API keys can log maintenance — new "Manage Maintenance" scope for HA-style automations (#1832 follow-up, reporter @MorganMLGman)** — Follow-up on the closed #1832 thread: reporter noted that maintenance had no checkbox in the API-key permissions UI and wasn't clearly denied in the wiki, unlike the other admin-only surfaces. Root cause was that `MAINTENANCE_CREATE / _UPDATE / _DELETE` sat on `_APIKEY_DENIED_PERMISSIONS` in `backend/app/core/auth.py`, so every API-key call to `POST /maintenance/items/{id}/perform` (mark maintenance as done — the useful endpoint for a "cleaned nozzle every N hours" Home Assistant automation) returned `403 "API keys cannot be used for administrative operations."` **Fix.** New `can_manage_maintenance` scope flag on `api_keys`, following the same shape as `can_manage_library` and `can_manage_inventory`. Wires the three maintenance write permissions into `_APIKEY_SCOPE_BY_PERMISSION` under the new flag; drops them from the denylist. Covers the per-printer maintenance CRUD (assign/remove items, edit intervals, log completion), the type-catalog CRUD (system types + custom types), and — via `MAINTENANCE_UPDATE` on the perform endpoint — the load-bearing "reset counter" call. `MAINTENANCE_READ` stays under `can_read_status`. **Backfill choice.** Distinct from the `can_manage_library` / `can_manage_inventory` migrations, which mirrored `can_queue` to preserve existing capability from the prior denylist-model. Maintenance writes were EXPLICITLY denied for every API key pre-migration, so no existing integration relies on them — backfilling to `can_queue` would silently widen scope on upgrade for every queue-capable key without unlocking any real use case. Existing rows backfill to **FALSE**; new keys created via the Settings UI default to **TRUE** (matches the safe-on-by-default pattern for the two prior scopes). Users opt in per key from Settings → API Keys → Edit. **CLI.** Bundled SpoolBuddy kiosk key gets `can_manage_maintenance=False` — kiosks don't need it, keep them minimally scoped. **Tests.** RBAC matrix in `backend/tests/integration/test_auth_apikey_rbac.py` extended: `valid_flags` set + `_each_scope_flag_has_at_least_one_permission` parametrize decorator + `_FakeApiKey.__init__` all pick up the new flag; three new `_SCOPE_CASES` for `MAINTENANCE_CREATE / _UPDATE / _DELETE`; the cross-scope leakage guard (`other_flags` set at line 355) widened from four flags to six so a `can_manage_library` toggle can't accidentally allow a `MAINTENANCE_UPDATE` call (and vice versa). 78/78 in `test_auth_apikey_rbac.py` + adjacent auth suites green. **Frontend.** New checkbox in the Settings → API Keys create form (between Manage Inventory and Allow Cloud Access), new teal badge in the key list, TS types extended (`APIKey`, `APIKeyCreate`, `APIKeyUpdate`). **i18n.** Three new keys (`settings.manageMaintenance`, `settings.manageMaintenanceDescription`, `settings.maintenanceBadge`) × 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere per convention. **Wiki.** `bambuddy-wiki/docs/features/api-keys.md` — new row in the permissions table, new bullet in the Principle-of-Least-Privilege list ("Home Assistant maintenance-log automation: Read Status + Manage Maintenance"), and a paragraph in Upgrade Notes explaining the FALSE backfill so existing users don't see a silent scope widening. **Migration.** Idempotent `_safe_execute` `ALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE` + one-shot `UPDATE api_keys SET can_manage_maintenance = FALSE` guarded by column-existed-before check, mirroring the two prior scope migrations. Works on both SQLite and Postgres — no dialect-specific column type needed since it's just a BOOLEAN. - **Slicer Pipelines — multi-copy batches, class targeting, fanout strategies, runs dashboard, retry-failed, live WS updates (#1425 PR C — completes the v3 design)** — The PR A/B drop turned slice-modal preset bundles into one-click dispatches with a pinned target printer. PR C closes the original issue with full production-batch semantics: an operator picks a saved pipeline, types in a number of copies, and Bambuddy slices once and distributes the prints across a fleet according to the pipeline's chosen fanout strategy. The runs dashboard surfaces every active and historical run with filters, per-row expandable per-copy status, cancel-in-flight, and retry-failed-copies. WebSocket pushes keep the dashboard and the in-Settings "Last run" chip live without polling. **Backend.** `PipelineRunCreateRequest.copies` (Pydantic `ge=1, le=1000`) replaces the implicit 1 from PR B; the orchestration loop creates one `PipelineJob` row per copy. `SlicerPipelineUpdate` accepts `target_kind` (`specific_printer` / `printer_class`), `target_model_class` (Bambu model code: A1 / A1 Mini / P1P / P1S / P2S / X1 / X1C / X1E / H2D / H2D Pro / H2C / X2D), and `fanout_strategy` (`max_parallel` / `round_robin` / `fill_one_first`). A new `pipeline_max_copies` setting (default 50, Pydantic `ge=1, le=1000`) gates the copies input in the Run-with-pipeline modal and is enforced again at `POST /run` time so an API caller can't bypass the cap. PR C also adds `PipelineRun.parent_run_id` (nullable FK to itself, ON DELETE SET NULL) so retry runs link back to the run whose failed copies they re-attempt. **Eligibility for class targeting.** The matcher in `services/pipeline_eligibility.py` now branches on `pipeline.target_kind`: the specific-printer path is unchanged (PR B parity), the new class-targeting path enumerates every `Printer` whose `model` matches `pipeline.target_model_class`, runs the per-printer slot-by-slot check for each via a `status_lookup` closure that the route handler hands in (so the matcher stays pure-ish for unit tests), and returns a top-level `printer_reports: list[PerPrinterReport]` with `ok` derived as `any` across the candidates. New issue kinds: `no_class_matches` (the install has zero printers in the chosen model class) and `class_not_set` (target_kind is `printer_class` but no model was picked). The lenient-policy story is the same — operators can `Run anyway` past blocking issues, and `PipelineRun.eligibility_overridden` is set so the audit trail shows it. **Orchestration + fanout.** A new `_pick_assignments(pipeline, copies)` helper returns `[(printer_id_or_None, target_model_or_None), …]` of length copies per the picked strategy. `max_parallel` sets `target_model=pipeline.target_model_class` on every queue item and leaves `printer_id=None` — the existing print scheduler's model-based dispatch picks any idle matching printer per item; the result is that multiple printers grab work in parallel without any new scheduler code. `round_robin` enumerates eligible printers (`is_active=True`, model matches) ordered by id and assigns copy `i` to `eligible[i % len(eligible)]` — each item gets a fixed `printer_id`, the wear distributes evenly. `fill_one_first` pins every copy to `eligible[0]` so a one-printer fleet stays one-printer even when others come online mid-run; the documented trade-off is that a printer failure freezes the queue at that printer until the operator intervenes. All three flows reuse the same slice-once path; the slice runs through `slice_dispatch.enqueue` exactly as PR B did so the persistent progress toast renders end-to-end for batches just like single-copy runs. **Routes.** `GET /pipeline-runs?limit&offset&pipeline_id&status` is the dashboard endpoint — newest-first, paginated, filterable by pipeline and persisted snapshot status. `POST /pipeline-runs/{id}/retry-failed` counts the parent's failed-or-cancelled jobs at the live (queue-entry-aware) status level, builds a fresh `PipelineRunCreateRequest` with `copies=that count` and `force=True` (operator already accepted eligibility on the parent), routes it through the existing `run_pipeline` handler, and stamps `parent_run_id` on the result. Returns 400 when the parent's source or pipeline was deleted, or when there are no failed copies to retry. `POST /pipeline-runs/{id}/cancel` extends PR B's cancel to cascade across N queue entries — only the ones still in `pending` / `queued` are touched so in-flight prints continue on the printer (operator must Stop on the machine). **WebSocket.** New `pipeline_run_updated` event type carries the full materialised `PipelineRunResponse` and fires on every state transition (`queued → slicing → dispatching → in_progress → completed | failed | partial_failure | cancelled`). Per-user routing via `ws_manager.broadcast_to_user(run.created_by, …)` so each operator sees their own runs without cross-user noise; auth-disabled installs broadcast to all connections (PR B's pattern). The frontend's `useWebSocket` switch handles it by invalidating both `['pipeline-runs-all']` (the dashboard) and `['pipeline-runs', pipeline_id]` (the per-pipeline "Last run" chip in Settings). The dashboard still polls every 15 s as a belt-and-suspenders for missed messages. **Run status roll-up.** A new `_roll_up_run_status` function computes the run-level status from the per-job statuses at read time: all-completed → `completed`, any in-flight → `in_progress`, some completed + some failed → the new `partial_failure` status (this is what gets the Retry-failed button), all failed → `failed`. The persisted snapshot is still written on terminal transitions for the dashboard's status filter to remain useful. `copies_completed` / `_failed` / `_cancelled` / `_in_progress` counts ride on the response so per-row "1/3 · 2 failed" summaries don't need a second query. **Frontend.** The Settings → Workflow → Pipelines pipeline editor grows three new controls in the edit form: a radio for `target_kind` (Specific printer / Printer class), a model-class picker filtered to the models present on at least one installed `Printer` row (so users can't pick "H2C" if they only have X1Cs), and a fanout-strategy radio with the three options labelled with their use cases. The read-only row reflects class targeting with a "X1C · Round robin" line in place of the printer name. `RunWithPipelineModal` grows a number input for copies bounded by `settings.pipeline_max_copies`, accepts class-targeted pipelines (the "Apply pipeline" button is enabled when the pipeline has either a pinned printer OR a class target), and the pipeline-list row shows "Any X1C" instead of a printer name for class pipelines. The "Run pipeline" Setting → Workflow → Queue & Dispatch sub-tab gets a new "Slicer Pipeline limits" card with the max-copies input (bounded 1–1000 client-side, server enforces the same). **New dashboard page at `/pipelines/runs`** (sidebar entry under Print Queue, gated on `pipelines:read`). Lists every run across every pipeline with two dropdown filters (pipeline + persisted snapshot status) and pagination at 25 per page. Each row shows pipeline name, status chip (`partial_failure` is amber), source file, created-at timestamp, and "{completed}/{copies}" + "{failed} failed" rollup. Click the chevron to expand a per-copy panel listing each `PipelineJob`'s assigned printer + status + error message. In-flight runs get a Cancel button; partial-failure / failed runs get a Retry-failed button. **i18n.** ~43 new keys across `nav.pipelineRuns`, `pipelineRuns.*` (title / filters / pagination / job-status chips / toasts), `settings.pipelines.field.*` (targetKind / fanout / class), `settings.pipelines.runs.status.partial_failure`, `settings.pipelineLimits.*`, `library.runWithPipeline.*` (copies / copiesHint / classTarget / issue.noClassMatches / issue.classNotSet), and `common.previous` / `common.next` — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5516 leaves per locale, no English fallback. `Copies` / `{{n}} copies` / `max {{n}}` added to the French + Italian cognate allowlists where they're genuine. **Tests.** Six new backend cases in `test_pipeline_runs_api.py` covering copies-cap rejection (schema gate at 1000), 3-copy run creates 3 jobs with sequential `copy_index`, class eligibility with two X1C candidates returns a 2-entry `printer_reports` array, class eligibility with no matching printers in install returns `no_class_matches`, dashboard list endpoint with pagination + status filter, retry-failed correctly counts failed jobs from a partial-failure parent and stamps `parent_run_id`. Plus the existing 16 PR A/B cases were lightly updated where `class_not_set` is now a valid no-target signal alongside `printer_not_set`. Five new frontend cases in `PipelineRunsPage.test.tsx` pin the dashboard's empty state, list rendering, Cancel button on in-flight runs, Retry-failed button on partial-failure runs, and per-row expand to show jobs. Three updated frontend cases (`RunWithPipelineModal.test.tsx`) assert the new four-arg signature on `runPipeline` (`pipelineId, source, force, copies`). One updated `SettingsPage.test.tsx` sidebar-order test reflects the new `pipelineRuns` nav entry between `queue` and `projects`. **Suites.** `pytest -n 30 backend/tests/` 6539/6539 green; `npx vitest run` 2284/2284 green (173 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** PR C closes the v3 design — no further pipeline PRs are queued. The existing print scheduler's model-based dispatch (`PrintQueueItem.target_model` + `target_location` + `required_filament_types`) is the only thing that makes class targeting actually distribute work; PR C just plugs into it. The `fill_one_first` strategy's "one printer fails, queue stalls" trade-off is documented in the editor's option-row hover-hint and in the orchestrator code comment — it's the correct behaviour for "I want one printer to finish a batch end-to-end" and the wrong behaviour for "I want resilience"; the right strategy for resilience is `max_parallel`. **Cross-printer-class pipelines** (e.g. one pipeline targeting "any X1C OR P1S") remain out of scope — make two pipelines, one per class. - **Slicer Pipelines — Archive entry point + progress toast for pipeline-driven slicing (#1425 PR B follow-up)** — Two real gaps from the PR B drop. (1) The Run-with-pipeline button only existed in the file manager — operators who keep their working files in archives had to copy them out to the library to use a pipeline. (2) Triggering a slice via a pipeline produced a silent multi-second-to-minute wait — the manual SliceModal flow has the sticky `Slicing X — Generating G-code 75%` persistent toast, the pipeline path went through `asyncio.create_task` directly and never registered with `SliceJobTracker`. **Fix.** (1) `POST /slicer-pipelines/{id}/check-eligibility` and `POST /slicer-pipelines/{id}/run` now accept `source_archive_id` as an alternative to `source_library_file_id` (XOR — Pydantic validator rejects both-set and neither-set), and the eligibility-check and orchestration paths branch via `_resolve_source` which reads `archive.source_3mf_path` with a fallback to `archive.file_path`. `PipelineRun.source_archive_id` is a new nullable FK column (Postgres + SQLite `ALTER TABLE` in `run_migrations` — idempotent via `_safe_execute`). `PipelineRunResponse` echoes the field. ArchiveCard's context menu picks up a `Run with pipeline` item alongside the existing Slice action (only on source archives — gcode archives already have Print + Open in BambuStudio), gated on `useSlicerApi` + `pipelines:run`. Path-safety: `Path(base_dir) / archive.source_3mf_path` carries a `SEC-PATH-OK` marker citing the upload-time validator at `_resolve_source_3mf_path` (same comment style as `routes/archives.py:3955`); the `LibraryFile.file_path` site gets the same treatment. (2) The pipeline orchestrator is now the `run` callable of a `slice_dispatch.enqueue` call — the same dispatcher the manual `SliceModal` flow uses — instead of a bare `asyncio.create_task`. The SliceJob's lifecycle (`pending → running → completed/failed`) drives the existing progress toast end to end: same persistent toast, same `Generating G-code 75%` weave from the sidecar's `--pipe` channel, same auto-replace with a transient success/error toast on terminal. `PipelineRun.slice_job_id` is set on the run row before the route returns 202, so the frontend can call `useSliceJobTracker().trackJob(slice_job_id, source.kind, source.filename)` from `RunWithPipelineModal`'s `runMutation.onSuccess` — same one-call surface that `SliceModal`'s slice mutation already uses. (3) `RunWithPipelineModal`'s `source` prop is now `{kind: 'libraryFile' | 'archive', id, filename}` (mirrors `SliceModal.SliceSource`); `api.checkPipelineEligibility` + `api.runPipeline` take a discriminated-union source argument and route to the right backend field. `PipelineRun` TS type grows `source_archive_id`. **Tests.** Three new backend cases in `test_pipeline_runs_api.py` — archive-source happy path (creates a PrintArchive row + on-disk file, posts with `source_archive_id`, verifies the response carries `source_archive_id` + `slice_job_id` from a stubbed `slice_dispatch.enqueue`), XOR rejection both-set, XOR rejection neither-set. The existing three run/cancel cases were updated to patch `backend.app.services.slice_dispatch.slice_dispatch.enqueue` (the new mock target) instead of the removed `_run_pipeline_orchestration` helper, and the run-happy-path now asserts `slice_job_id == 9001` arrives on the response. One new frontend case in `RunWithPipelineModal.test.tsx` pins the archive flow end to end (`checkPipelineEligibility` called with `{kind: 'archive', id: 7}`, then `runPipeline` with the same). The existing fast/slow path tests were updated to wrap in `SliceJobTrackerProvider` (the new `useSliceJobTracker` hook requires it) and to assert the new discriminated-union source argument. **Suites.** `pytest -n 30 backend/tests/` 6533/6533 green; `npx vitest run` 2279/2279 green (172 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** No new i18n keys — both fixes reuse the existing PR B keys. No new permission. The archive flow only branches at the source-resolution layer; everything downstream (eligibility, slice, queue dispatch) is the same code path the library flow uses. PR C scope (multi-copy + class targeting + fanout) is unchanged. - **Slicer Pipelines — Run a pipeline on a file with one click (#1425 PR B)** — PR A landed the bundle (save & apply preset slots in the SliceModal). PR B turns that bundle into an actual one-click dispatcher: file-manager rows now carry a `Run with pipeline ▾` button that slices the source through the pipeline's pinned printer/process/filament/bed-type combo and enqueues the print on the pipeline's pinned target printer. **Scope.** Single-target dispatch — `target_kind='specific_printer'` only. Multi-copy batch + class targeting + fanout strategies are PR C; the schema columns are already in place from PR A so PR C is code-only. **Backend.** Two new SQLAlchemy models — `PipelineRun` (one row per Run-pipeline click, carries the slice_job + sliced_library_file ids + snapshot status) and `PipelineJob` (one row per copy; PR B always 1, PR C variable). Soft-link to slicer_pipelines via `ondelete='SET NULL'` so run history survives a pipeline delete; same for source_library_file. **`status`** on the run is a *persisted snapshot* that gets terminal transitions written (slice failure, cancel, completion); in-flight reads roll up the live state of the linked queue entry via `_compute_run_status` — that keeps the status accurate (`pending → printing → completed`) without a background watcher writing on every queue tick. **Eligibility matcher** at `services/pipeline_eligibility.py` — given a pipeline + the live `PrinterState` from `printer_manager.get_status`, returns a structured report with typed issues: `printer_not_set`, `printer_not_found`, `printer_disabled` (from `Printer.is_active` shipped with #1476), `printer_offline`, `filament_type_mismatch`, `filament_color_mismatch`, `ams_slot_missing`, `filament_unverified` (cloud/standard tier presets can't be statically read here; surface as info, not a block). Canonical filament-type map mirrors `print_scheduler._canonical_filament_type` so `PLA Basic` / `PLA Matte` / etc. all collapse to `PLA` for the type comparison; colour normalises to six-hex-digit lowercase. **Eligibility is lenient with confirmation** — the report drives the frontend confirmation modal, but the user can `Run anyway` (sets `eligibility_overridden=True` on the run row so the audit trail shows which runs bypassed pre-flight). **Routes.** Two new routers — `pipeline_run_create_router` mounted under `/slicer-pipelines` (POST `/{id}/check-eligibility`, POST `/{id}/run`, GET `/{id}/runs?limit=N`) and `pipeline_run_router` at `/pipeline-runs` (GET `/{id}`, POST `/{id}/cancel`). `POST /run` returns 202 with the run shape; orchestration happens in a fire-and-forget `asyncio.create_task` that opens its own DB session (the request's session is closed by the time it runs) and walks: status='slicing' → `slice_and_persist` with the pipeline's `SliceRequest` → on success `status='dispatching'` + insert `PrintQueueItem` with `printer_id=target_printer_id, library_file_id=sliced_library_file_id`. The existing scheduler picks the queue entry up on its next tick. `POST /run` with eligibility issues and no `force` returns 409 with the report inside `detail` so the frontend can render the same confirmation modal it would for an explicit pre-flight; `force=true` bypasses the 409 but a missing `target_printer_id` still 400s (defence in depth — the UI can't enqueue the print without a target). `POST /cancel` is idempotent on terminal states and cascades to the linked queue entry when its status is still `pending` / `queued` (in-flight prints continue — operator must Stop on the printer itself). **SlicerPipeline.target_kind / target_printer_id** become writable via `PUT /slicer-pipelines/{id}` — the schema accepts both fields, the route treats `target_printer_id=0` as "clear" (the empty-`