diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3157fc4ab..61db26edb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ All notable changes to Bambuddy will be documented in this file.
- **HMS Action buttons now reach the printer (#1830, H2D/H2C wrong-plate verification)** — The HMS Actions feature shipped in #1743 looked correct at the publish layer but the firmware silently dropped the commands at the printer, so clicking "Stop printing", "Problem solved and resume", or "Ignore and resume" did nothing visible on the live H2D — the modal kept reappearing, the print stayed paused, and the route still returned `200 OK`. Three independent bugs combined into one user-facing failure. **(1) Wrong command shape for resume / stop.** `hms_resume()` and `hms_stop()` sent the documented-but-not-actually-used `{"err": , "param": "reserve", "job_id": , ...}` shape that BambuStudio never produces. Bambu firmware rejects this silently — verified by injecting candidate shapes on `device//request` against a live H2D paused on a wrong-plate HMS: the `err`-bearing shape held PAUSE → PAUSE for the full window, the plain `{"print":{"command":"stop","param":"","sequence_id":"0"}}` transitioned PAUSE → FAILED in 1.7s, the same plain `resume` transitioned PAUSE → RUNNING in <2s. Fix: both helpers send the plain shape now, no `err`, no `job_id`, no `param:"reserve"`. **(2) `IGNORE_RESUME` mapped to the wrong command for paused prints.** The original mapping dispatched `idle_ignore` for both `IGNORE_RESUME` and `NO_REMINDER_NEXT_TIME`. `idle_ignore` is BambuStudio's "dismiss this warning" command and only works for non-pause warnings — verified against the H2D, idle_ignore on a paused print is silently rejected regardless of `err`. `hms_ignore()` now branches on `self.state.gcode_state == "PAUSE"`: paused → dispatch plain `resume` (which is what the button actually means on a paused print), running/idle → keep `idle_ignore` with the `type=0/1` persistence flag. `DONT_REMIND_NEXT_TIME` on PAUSE degrades to resume too — the "don't remind" flag can't ride along on a resume but the user's clicked-action intent (continue printing) is honoured. **(3) 64-bit `hms[]`-array faults truncated to a non-matching `err` (#1830 §(1)).** The hms[] parser at line 2740 built the short code as `f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}"`, discarding 32 of the 64 bits of the fault identifier. For codes whose full form is e.g. `0C00_0300_0002_000C`, the truncated `0C00000C` doesn't match what the firmware compares against in `idle_ignore`. New `HMSError.full_code` field carries the canonical hex identifier — 16 chars `f"{attr:08X}{code:08X}"` for hms[]-sourced faults, 8 chars `f"{print_error:08X}"` for print_error-sourced faults (which are already 32-bit). Catalog lookup tries the 16-char form first and falls back to the 8-char short code so existing entries keep matching. Frontend echoes `error.full_code` back as `HmsActionBody.print_error` instead of recomputing the short code; the schema's pattern relaxes to `^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$` to accept both lengths. **(4) Masking failure — publish-success returned as printer-ack (#1830 §(3)).** `execute_hms_action` returned True the moment the publish succeeded, so any of the three bugs above produced `200 OK` while the printer ignored the command and the modal kept popping. The `/hms/execute-action` route now snapshots `(gcode_state, print_error, hms_errors count)` before dispatch, awaits `HMS_ACTION_ACK_WAIT_SECONDS` (default 2.5s, module-level so tests override), and returns `502 "Printer did not acknowledge HMS action within 2.5s"` if none of those moved. Every accepted HMS action mutates at least one of the three, so this is a clean signal. **Empirical verification.** A test harness on `device/0948BB540200427/request` confirmed each shape against the live H2D: a print sent with deliberately-wrong build plate raises `print_error=0x05008051` ("Detected build plate is not the same as the Gcode file"), the printer enters `gcode_state=PAUSE`, and the new command shapes transition out correctly. The current Bambuddy code (before this fix) failed to act on every button. **Tests.** `test_hms_actions.py` shape assertions rewritten — `test_resume_is_plain_no_err_no_job_id`, `test_stop_is_plain_no_err_no_job_id`, `test_ignore_resume_dispatches_resume_when_print_paused`, `test_ignore_resume_uses_idle_ignore_when_not_paused`, `test_dont_remind_dispatches_resume_when_paused`, `test_dont_remind_uses_idle_ignore_type_one_when_not_paused`, `test_idle_ignore_accepts_16_char_full_code`. New `TestHMSFullCode` class in `test_bambu_mqtt.py` pins the parser contract — `test_hms_array_path_populates_16_char_full_code`, `test_print_error_path_populates_8_char_full_code`, `test_hms_array_catalog_lookup_tries_16_char_first`, `test_hms_array_catalog_falls_back_to_8_char`. New integration cases in `test_printers_api.py` — `test_execute_hms_action_no_printer_ack_returns_502`, `test_execute_hms_action_accepts_16_char_full_code`. The malformed-input test now covers 9- and 15-char rejections (the relaxed pattern accepts 8 OR 16, nothing in between). `pytest -n 30 backend/tests/unit/services/test_hms_actions.py backend/tests/unit/services/test_bambu_mqtt.py backend/tests/unit/services/test_printer_manager.py backend/tests/integration/test_printers_api.py` green (509 + 181). `ruff check` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. No new i18n key — the frontend toast on action failure already uses the existing `hmsErrors.actionFailed` string, which now gets the more accurate "Printer did not acknowledge" message instead of "Failed to send action". The `HMSError.full_code` field defaults to `""` so old in-memory state surviving a backend upgrade (without an MQTT reconnect) degrades to the existing 8-char short code via the frontend's `||` fallback.
### Added
+- **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-`