소스 검색

feat(slicer): Pipelines — archive entry point + slicer progress toast (#1425 PR B follow-up)

Two real gaps from the PR B drop:

1. Run-with-pipeline only existed in the file manager. Operators who keep
   working files in archives had to copy them 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 shows the sticky
   "Slicing X - Generating G-code 75%" persistent toast; the pipeline
   path went through asyncio.create_task directly and never registered
   with SliceJobTracker.

Archive entry point
- POST /slicer-pipelines/{id}/check-eligibility and /run accept
  source_archive_id as an alternative to source_library_file_id (XOR,
  enforced by Pydantic validator).
- PipelineRun.source_archive_id is a new nullable FK column with the
  ALTER TABLE migration in run_migrations (idempotent via _safe_execute,
  works on SQLite + Postgres).
- _resolve_source branches: archive path reads source_3mf_path with
  fallback to file_path, mirroring routes/archives.py.
- ArchiveCard's context menu picks up a "Run with pipeline" item next to
  Slice (only on source archives), gated on useSlicerApi + pipelines:run.
  Slice (only on source archives), gated on useSlicerApi + pipelines:run.
- Path-safety: SEC-PATH-OK markers added at both LibraryFile.file_path
  and archive.source_3mf_path join sites, citing the upload-time
  validators.

Progress toast
- Pipeline orchestration is now the `run` callable of a
  slice_dispatch.enqueue call — the same dispatcher SliceModal uses —
  instead of a bare asyncio.create_task. The SliceJob lifecycle drives
  the existing progress toast end to end with no separate notification
  surface for pipeline runs.
- PipelineRun.slice_job_id is set before the 202 returns.
- RunWithPipelineModal calls useSliceJobTracker().trackJob() from
  runMutation.onSuccess.
- RunWithPipelineModal source prop is now {kind, id, filename}
  mirroring SliceModal.SliceSource; api.checkPipelineEligibility +
  api.runPipeline take a discriminated-union source argument.
maziggy 2 달 전
부모
커밋
4bbf0f031e
34개의 변경된 파일3098개의 추가작업 그리고 13개의 파일을 삭제
  1. 0 0
      CHANGELOG.md
  2. 618 0
      backend/app/api/routes/pipeline_runs.py
  3. 16 0
      backend/app/api/routes/slicer_pipelines.py
  4. 10 0
      backend/app/core/database.py
  5. 3 0
      backend/app/main.py
  6. 3 0
      backend/app/models/__init__.py
  7. 105 0
      backend/app/models/pipeline_run.py
  8. 124 0
      backend/app/schemas/pipeline_run.py
  9. 7 0
      backend/app/schemas/slicer_pipeline.py
  10. 263 0
      backend/app/services/pipeline_eligibility.py
  11. 529 0
      backend/tests/integration/test_pipeline_runs_api.py
  12. 1 1
      frontend/scripts/check-i18n-parity.mjs
  13. 208 0
      frontend/src/__tests__/components/RunWithPipelineModal.test.tsx
  14. 112 1
      frontend/src/api/client.ts
  15. 362 0
      frontend/src/components/RunWithPipelineModal.tsx
  16. 136 7
      frontend/src/components/SlicerPipelinesPanel.tsx
  17. 46 0
      frontend/src/i18n/locales/de.ts
  18. 50 0
      frontend/src/i18n/locales/en.ts
  19. 46 0
      frontend/src/i18n/locales/es.ts
  20. 46 0
      frontend/src/i18n/locales/fr.ts
  21. 46 0
      frontend/src/i18n/locales/it.ts
  22. 46 0
      frontend/src/i18n/locales/ja.ts
  23. 47 1
      frontend/src/i18n/locales/ko.ts
  24. 46 0
      frontend/src/i18n/locales/pt-BR.ts
  25. 46 0
      frontend/src/i18n/locales/tr.ts
  26. 46 0
      frontend/src/i18n/locales/zh-CN.ts
  27. 46 0
      frontend/src/i18n/locales/zh-TW.ts
  28. 47 0
      frontend/src/pages/ArchivesPage.tsx
  29. 41 1
      frontend/src/pages/FileManagerPage.tsx
  30. 0 0
      static/assets/index-Bydn78aP.css
  31. 0 0
      static/assets/index-CEw4Iy3m.js
  32. 2 2
      static/index.html
  33. 0 0
      test_pipeline_archive_source.3mf
  34. 0 0
      test_pipeline_run_1.3mf

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
CHANGELOG.md


+ 618 - 0
backend/app/api/routes/pipeline_runs.py

@@ -0,0 +1,618 @@
+"""API routes for Slicer Pipeline runs (#1425 PR B).
+
+PR B implements single-target dispatch: one Run-pipeline click =
+  slice the source file once with the pipeline's four preset slots →
+  enqueue one print on the pipeline's pinned target_printer_id.
+
+PR C extends this with copies > 1 + class targeting + fanout strategies;
+the data model already carries those columns so this file is the only
+place that changes shape.
+
+The slice is enqueued via ``slice_dispatch`` (the same path the manual
+SliceModal uses), so the in-process progress toast renders for pipeline
+runs the same way it does for manual slicing. The slice job's id lives
+on PipelineRun.slice_job_id and is returned from ``POST /run`` so the
+frontend can call ``trackJob`` directly.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Literal
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import async_session, get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.pipeline_run import (
+    CheckEligibilityRequest,
+    EligibilityIssueResponse,
+    EligibilityReportResponse,
+    PipelineJobResponse,
+    PipelineRunCreateRequest,
+    PipelineRunListResponse,
+    PipelineRunResponse,
+)
+from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.services.pipeline_eligibility import (
+    EligibilityReport,
+    check_pipeline_eligibility,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# Two routers — one for the per-pipeline endpoints (mounted under the
+# existing ``/slicer-pipelines`` prefix) and one for the per-run endpoints
+# at ``/pipeline-runs``. Keeps the URL shape natural.
+pipeline_run_create_router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
+pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
+    return EligibilityReportResponse(
+        ok=report.ok,
+        target_printer_id=report.target_printer_id,
+        target_printer_name=report.target_printer_name,
+        issues=[
+            EligibilityIssueResponse(
+                kind=issue.kind,
+                slot_index=issue.slot_index,
+                expected=issue.expected,
+                actual=issue.actual,
+            )
+            for issue in report.issues
+        ],
+    )
+
+
+async def _load_pipeline(db: AsyncSession, pipeline_id: int) -> SlicerPipeline:
+    pipeline = (
+        await db.execute(
+            select(SlicerPipeline).where(
+                SlicerPipeline.id == pipeline_id,
+                SlicerPipeline.is_deleted.is_(False),
+            )
+        )
+    ).scalar_one_or_none()
+    if pipeline is None:
+        raise HTTPException(404, "Pipeline not found")
+    return pipeline
+
+
+async def _load_printer_status(printer_id: int | None) -> dict | None:
+    """Snapshot the printer_manager's live PrinterState for the eligibility
+    matcher. Returns ``None`` when the printer has no MQTT client (offline at
+    the manager level)."""
+    if printer_id is None:
+        return None
+    from backend.app.services.printer_manager import printer_manager
+
+    state = printer_manager.get_status(printer_id)
+    if state is None:
+        return None
+    return {"connected": state.connected, "raw_data": state.raw_data}
+
+
+def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
+    """Materialise a SliceRequest from a pipeline so we can hand it to
+    ``slice_and_persist`` exactly the way the existing SliceModal flow does."""
+    try:
+        raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
+    except (json.JSONDecodeError, TypeError):
+        raw_filaments = []
+    filament_presets = [
+        PresetRef(source=r["source"], id=r["id"])
+        for r in raw_filaments
+        if isinstance(r, dict) and "source" in r and "id" in r
+    ]
+    return SliceRequest(
+        printer_preset=PresetRef(source=pipeline.printer_preset_source, id=pipeline.printer_preset_id),
+        process_preset=PresetRef(source=pipeline.process_preset_source, id=pipeline.process_preset_id),
+        filament_presets=filament_presets,
+        bed_type=pipeline.bed_type,
+        export_3mf=True,
+    )
+
+
+def _compute_run_status(
+    persisted: str,
+    job: PipelineJob | None,
+    queue_entry: PrintQueueItem | None,
+) -> str:
+    if persisted in ("failed", "cancelled", "completed"):
+        return persisted
+    if queue_entry is None:
+        return persisted
+    queue_status = queue_entry.status
+    if queue_status == "completed":
+        return "completed"
+    if queue_status in ("failed", "cancelled", "aborted"):
+        return "failed" if queue_status != "cancelled" else "cancelled"
+    if queue_status == "printing":
+        return "in_progress"
+    return "dispatching" if persisted == "dispatching" else "queued"
+
+
+def _compute_job_status(
+    persisted: str,
+    queue_entry: PrintQueueItem | None,
+) -> str:
+    if persisted in ("failed", "cancelled", "completed"):
+        return persisted
+    if queue_entry is None:
+        return persisted
+    qs = queue_entry.status
+    if qs == "completed":
+        return "completed"
+    if qs in ("failed", "aborted"):
+        return "failed"
+    if qs == "cancelled":
+        return "cancelled"
+    if qs == "printing":
+        return "printing"
+    return "queued"
+
+
+async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
+    pipeline_name: str | None = None
+    if run.pipeline_id:
+        pipeline = (
+            await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
+        ).scalar_one_or_none()
+        pipeline_name = pipeline.name if pipeline else None
+
+    source_filename: str | None = None
+    if run.source_library_file_id:
+        src = (
+            await db.execute(select(LibraryFile).where(LibraryFile.id == run.source_library_file_id))
+        ).scalar_one_or_none()
+        source_filename = src.filename if src else None
+    elif run.source_archive_id:
+        arc = (
+            await db.execute(select(PrintArchive).where(PrintArchive.id == run.source_archive_id))
+        ).scalar_one_or_none()
+        source_filename = (arc.print_name or arc.filename) if arc else None
+
+    job_rows = (
+        (
+            await db.execute(
+                select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id).order_by(PipelineJob.copy_index)
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    job_responses: list[PipelineJobResponse] = []
+    rolled_up_status = run.status
+    for job in job_rows:
+        queue_entry = None
+        if job.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+            ).scalar_one_or_none()
+
+        printer_name: str | None = None
+        if job.assigned_printer_id:
+            p = (await db.execute(select(Printer).where(Printer.id == job.assigned_printer_id))).scalar_one_or_none()
+            printer_name = p.name if p else None
+
+        live_job_status = _compute_job_status(job.status, queue_entry)
+        job_responses.append(
+            PipelineJobResponse(
+                id=job.id,
+                pipeline_run_id=job.pipeline_run_id,
+                copy_index=job.copy_index,
+                assigned_printer_id=job.assigned_printer_id,
+                assigned_printer_name=printer_name,
+                queue_entry_id=job.queue_entry_id,
+                status=live_job_status,  # type: ignore[arg-type]
+                error_message=job.error_message,
+                dispatched_at=job.dispatched_at,
+                completed_at=job.completed_at,
+            )
+        )
+        rolled_up_status = _compute_run_status(run.status, job, queue_entry)
+
+    return PipelineRunResponse(
+        id=run.id,
+        pipeline_id=run.pipeline_id,
+        pipeline_name=pipeline_name,
+        source_library_file_id=run.source_library_file_id,
+        source_archive_id=run.source_archive_id,
+        source_filename=source_filename,
+        copies=run.copies,
+        status=rolled_up_status,  # type: ignore[arg-type]
+        slice_job_id=run.slice_job_id,
+        sliced_library_file_id=run.sliced_library_file_id,
+        eligibility_overridden=run.eligibility_overridden,
+        error_message=run.error_message,
+        created_by=run.created_by,
+        created_at=run.created_at,
+        started_at=run.started_at,
+        completed_at=run.completed_at,
+        jobs=job_responses,
+    )
+
+
+# ---------------------------------------------------------------------------
+# Source resolution + orchestration
+# ---------------------------------------------------------------------------
+
+
+SourceKind = Literal["library_file", "archive"]
+
+
+async def _resolve_source(
+    db: AsyncSession,
+    *,
+    library_file_id: int | None,
+    archive_id: int | None,
+) -> tuple[SourceKind, int, str, Path]:
+    """Look up the source row + on-disk path for either input kind. Raises 404
+    when the row or its file is missing — same shape as the SliceModal's flow
+    for both libraryFile and archive."""
+    if library_file_id is not None:
+        lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
+        if lib is None:
+            raise HTTPException(404, "Source library file not found")
+        src_path = (
+            Path(app_settings.base_dir) / lib.file_path
+        )  # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route (routes/library.py POST /files), which writes a UUID-named file under base_dir/library_files/ — never user-controlled at this site.
+        if not src_path.exists():
+            raise HTTPException(404, "Source library file missing on disk")
+        return ("library_file", lib.id, lib.filename, src_path)
+
+    assert archive_id is not None
+    arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if arc is None:
+        raise HTTPException(404, "Source archive not found")
+    rel = arc.source_3mf_path or arc.file_path
+    if not rel:
+        raise HTTPException(400, "Archive has no source file to slice")
+    src_path = (
+        Path(app_settings.base_dir) / rel
+    )  # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators (_resolve_source_3mf_path + archive ingestion) that already do resolve+relative_to containment. Mirrors routes/archives.py:3955.
+    if not src_path.exists():
+        raise HTTPException(404, "Archive source file missing on disk")
+    name = arc.filename or arc.print_name or src_path.name
+    return ("archive", arc.id, name, src_path)
+
+
+def _make_orchestration_callable(
+    *,
+    run_id: int,
+    pipeline_id: int,
+    src_kind: SourceKind,
+    src_id: int,
+    src_filename: str,
+    src_path: Path,
+    target_printer_id: int,
+    creator_user_id: int | None,
+):
+    """Return the async callable that ``slice_dispatch.enqueue`` will run as
+    the background slice job. Wrapping it as the slice job's ``run`` means the
+    SliceJob's lifecycle (pending → running → completed/failed) drives the
+    progress toast on the frontend exactly the same as a manual SliceModal
+    slice — no separate notification surface for pipeline runs."""
+
+    async def _orchestrate(slice_job_id: int) -> dict:
+        # Local import — slice_and_persist lives in routes/library which
+        # imports back into this module transitively via slicer_pipelines.
+        from backend.app.api.routes.library import slice_and_persist
+
+        async with async_session() as session:
+            run = (await session.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+            pipeline = (
+                await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
+            ).scalar_one_or_none()
+            if run is None or pipeline is None:
+                logger.warning(
+                    "pipeline_run %d or pipeline %d disappeared mid-orchestration",
+                    run_id,
+                    pipeline_id,
+                )
+                return {}
+
+            # Refresh the snapshot status now that slicing has actually
+            # started — the route handler already wrote slice_job_id but
+            # left status='queued' until this point.
+            run.status = "slicing"
+            run.started_at = datetime.now(timezone.utc)
+            await session.commit()
+
+            slice_request = _slice_request_from_pipeline(pipeline)
+            model_bytes = src_path.read_bytes()
+
+            # Resolve the folder for the sliced output. Library sources
+            # keep their folder; archive sources fall through to root.
+            folder_id: int | None = None
+            if src_kind == "library_file":
+                lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
+                if lib is not None:
+                    folder_id = lib.folder_id
+
+            try:
+                slice_response = await slice_and_persist(
+                    session,
+                    model_bytes=model_bytes,
+                    model_filename=src_filename,
+                    folder_id=folder_id,
+                    extra_metadata={
+                        f"sliced_from_{src_kind}_id": src_id,
+                        "sliced_via_pipeline_id": pipeline.id,
+                        "sliced_via_pipeline_run_id": run.id,
+                    },
+                    request=slice_request,
+                    current_user_id=creator_user_id,
+                    job_id=slice_job_id,  # threads --pipe progress
+                )
+            except HTTPException as exc:
+                # _SliceJobError isn't directly importable here without a
+                # heavier dep; surface the slice failure cleanly and let
+                # the dispatcher's generic Exception path mark the
+                # SliceJob failed. Persist the pipeline-side state here.
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc.detail}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                raise
+            except Exception as exc:
+                logger.exception("Pipeline run %d slice raised unexpectedly", run_id)
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                raise
+
+            run.sliced_library_file_id = slice_response.library_file_id
+
+            # PR B copies=1 — exactly one PipelineJob to dispatch.
+            job = (
+                (await session.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run_id)))
+                .scalars()
+                .first()
+            )
+            if job is None:
+                logger.warning("pipeline_run %d has no PipelineJob row", run_id)
+                run.status = "failed"
+                run.error_message = "Internal: missing pipeline_job row"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                return slice_response.model_dump()
+
+            queue_item = PrintQueueItem(
+                printer_id=target_printer_id,
+                library_file_id=slice_response.library_file_id,
+                created_by_id=creator_user_id,
+                status="pending",
+            )
+            session.add(queue_item)
+            await session.flush()
+
+            job.queue_entry_id = queue_item.id
+            job.assigned_printer_id = target_printer_id
+            job.status = "queued"
+            job.dispatched_at = datetime.now(timezone.utc)
+
+            run.status = "dispatching"
+            await session.commit()
+
+            return slice_response.model_dump()
+
+    return _orchestrate
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/check-eligibility
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.post("/{pipeline_id}/check-eligibility", response_model=EligibilityReportResponse)
+async def check_eligibility(
+    pipeline_id: int,
+    body: CheckEligibilityRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Pre-flight check before a Run-pipeline click. Returns an eligibility
+    report; the frontend uses it to show a confirmation modal if any blocking
+    issue exists. Accepts either a library file id or an archive id (XOR)."""
+    pipeline = await _load_pipeline(db, pipeline_id)
+    # Source resolution — same shape as /run uses, so a 404 here means /run
+    # would also 404 and the user can't proceed.
+    await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+    )
+    status = await _load_printer_status(pipeline.target_printer_id)
+    report = await check_pipeline_eligibility(db, pipeline, status)
+    return _serialise_status(report)
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/run
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.post("/{pipeline_id}/run", response_model=PipelineRunResponse, status_code=202)
+async def run_pipeline(
+    pipeline_id: int,
+    body: PipelineRunCreateRequest,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Kick off a pipeline run. PR B: copies=1, target = pipeline's pinned
+    printer. Returns 202 immediately; the slice runs in the background via
+    ``slice_dispatch`` and the slice job id rides on the response so the
+    frontend can attach the progress toast."""
+    from backend.app.services.slice_dispatch import slice_dispatch
+
+    pipeline = await _load_pipeline(db, pipeline_id)
+    src_kind, src_id, src_filename, src_path = await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+    )
+
+    # Eligibility pre-flight. ``force=True`` from the confirmation modal
+    # bypasses the 409.
+    status = await _load_printer_status(pipeline.target_printer_id)
+    report = await check_pipeline_eligibility(db, pipeline, status)
+    if not report.ok and not body.force:
+        raise HTTPException(status_code=409, detail=_serialise_status(report).model_dump())
+
+    if pipeline.target_printer_id is None:
+        raise HTTPException(
+            400,
+            "Pipeline has no target printer set. Open the pipeline in Settings → Workflow → Pipelines and choose a target.",
+        )
+
+    run = PipelineRun(
+        pipeline_id=pipeline.id,
+        source_library_file_id=src_id if src_kind == "library_file" else None,
+        source_archive_id=src_id if src_kind == "archive" else None,
+        copies=1,
+        status="queued",
+        eligibility_overridden=(not report.ok and body.force),
+        created_by=current_user.id if current_user else None,
+    )
+    db.add(run)
+    await db.flush()
+
+    job = PipelineJob(
+        pipeline_run_id=run.id,
+        copy_index=0,
+        assigned_printer_id=pipeline.target_printer_id,
+        status="pending",
+    )
+    db.add(job)
+    await db.commit()
+    await db.refresh(run)
+
+    # Enqueue the slice job. The callable inside slice_dispatch.enqueue does
+    # the full slice → enqueue-print → state-update chain. SliceJob lifecycle
+    # drives the existing progress toast.
+    orchestrate = _make_orchestration_callable(
+        run_id=run.id,
+        pipeline_id=pipeline.id,
+        src_kind=src_kind,
+        src_id=src_id,
+        src_filename=src_filename,
+        src_path=src_path,
+        target_printer_id=pipeline.target_printer_id,
+        creator_user_id=current_user.id if current_user else None,
+    )
+    slice_job = await slice_dispatch.enqueue(
+        kind="library_file" if src_kind == "library_file" else "archive",
+        source_id=src_id,
+        source_name=src_filename,
+        run=orchestrate,
+    )
+
+    run.slice_job_id = slice_job.id
+    await db.commit()
+    await db.refresh(run)
+
+    return await _materialise_run(db, run)
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/runs  + /pipeline-runs/{id}  + cancel
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
+async def list_runs_for_pipeline(
+    pipeline_id: int,
+    limit: int = 10,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    limit = max(1, min(limit, 100))
+    rows = (
+        (
+            await db.execute(
+                select(PipelineRun)
+                .where(PipelineRun.pipeline_id == pipeline_id)
+                .order_by(PipelineRun.id.desc())
+                .limit(limit)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    return PipelineRunListResponse(runs=[await _materialise_run(db, r) for r in rows])
+
+
+@pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
+async def get_run(
+    run_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+    return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/cancel", response_model=PipelineRunResponse)
+async def cancel_run(
+    run_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Cancel a queued / in-flight run. Pre-slice or post-slice — semantics
+    are described on the original PR B route."""
+    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+
+    if run.status in ("completed", "failed", "cancelled"):
+        return await _materialise_run(db, run)
+
+    run.status = "cancelled"
+    run.completed_at = datetime.now(timezone.utc)
+    if not run.error_message:
+        run.error_message = "Cancelled by user"
+
+    job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
+    for job in job_rows:
+        if job.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+            ).scalar_one_or_none()
+            if queue_entry is not None and queue_entry.status in (
+                "pending",
+                "queued",
+            ):
+                queue_entry.status = "cancelled"
+        if job.status not in ("completed", "failed", "cancelled"):
+            job.status = "cancelled"
+            job.completed_at = datetime.now(timezone.utc)
+
+    await db.commit()
+    await db.refresh(run)
+    return await _materialise_run(db, run)

+ 16 - 0
backend/app/api/routes/slicer_pipelines.py

@@ -151,6 +151,22 @@ async def update_pipeline(
     if data.bed_type is not None:
         row.bed_type = data.bed_type
 
+    # PR B target binding. The schema accepts ``target_kind=specific_printer``
+    # without ``target_printer_id`` (operator may be saving the kind first),
+    # but a 'specific_printer' kind with a printer_id of 0 is rejected since
+    # printer ids are always positive — guard against the JSON-coerced
+    # empty-string case from the frontend.
+    if data.target_kind is not None:
+        row.target_kind = data.target_kind
+    if data.target_printer_id is not None:
+        # ``target_printer_id=0`` from the frontend means "clear the target"
+        # (the <option value=""> case). Anything positive must reference an
+        # actual printer row.
+        if data.target_printer_id == 0:
+            row.target_printer_id = None
+        else:
+            row.target_printer_id = data.target_printer_id
+
     await db.commit()
     await db.refresh(row)
     return _to_response(row)

+ 10 - 0
backend/app/core/database.py

@@ -189,6 +189,7 @@ async def init_db():
         oidc_provider,
         orca_base_cache,
         pending_upload,
+        pipeline_run,
         print_batch,
         print_log,
         print_queue,
@@ -669,6 +670,15 @@ async def run_migrations(conn):
     """
     from sqlalchemy import text
 
+    # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
+    # Allows a pipeline run to source from an archive's source 3MF in addition
+    # to a library file. Idempotent — _safe_execute swallows the "already exists"
+    # case on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN source_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL",
+    )
+
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 

+ 3 - 0
backend/app/main.py

@@ -49,6 +49,7 @@ from backend.app.api.routes import (
     obico,
     orca_cloud,
     pending_uploads,
+    pipeline_runs,
     print_log,
     print_queue,
     printer_sensor_history,
@@ -6752,6 +6753,8 @@ app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
+app.include_router(pipeline_runs.pipeline_run_create_router, prefix=app_settings.api_prefix)
+app.include_router(pipeline_runs.pipeline_run_router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)

+ 3 - 0
backend/app/models/__init__.py

@@ -18,6 +18,7 @@ from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
@@ -70,6 +71,8 @@ __all__ = [
     "OIDCProvider",
     "UserOIDCLink",
     "OrcaBaseProfile",
+    "PipelineJob",
+    "PipelineRun",
     "SlicerPipeline",
     "Spool",
     "SpoolKProfile",

+ 105 - 0
backend/app/models/pipeline_run.py

@@ -0,0 +1,105 @@
+"""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"))
+
+    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")

+ 124 - 0
backend/app/schemas/pipeline_run.py

@@ -0,0 +1,124 @@
+"""Pydantic schemas for PipelineRun + eligibility (#1425 PR B)."""
+
+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",
+    ]
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+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: bool
+    target_printer_id: int | None = None
+    target_printer_name: str | None = None
+    issues: list[EligibilityIssueResponse] = []
+
+
+class CheckEligibilityRequest(BaseModel):
+    """Exactly one of ``source_library_file_id`` / ``source_archive_id`` must
+    be set. The eligibility matcher itself doesn't read the source — it only
+    needs the pipeline + live AMS state — but the route validates source
+    existence here so the same modal can pre-flight both archive and library
+    sources without growing a second endpoint.
+    """
+
+    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):
+    """Same XOR shape as CheckEligibilityRequest — see that schema's docstring."""
+
+    source_library_file_id: int | None = None
+    source_archive_id: int | None = None
+    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 so the audit trail shows which runs bypassed pre-flight.",
+    )
+
+    @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
+    copies: int
+    status: Literal[
+        "queued",
+        "slicing",
+        "dispatching",
+        "in_progress",
+        "completed",
+        "failed",
+        "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] = []
+
+
+class PipelineRunListResponse(BaseModel):
+    runs: list[PipelineRunResponse] = []

+ 7 - 0
backend/app/schemas/slicer_pipeline.py

@@ -47,6 +47,13 @@ class SlicerPipelineUpdate(BaseModel):
     filament_presets: list[PresetRef] | None = Field(default=None, min_length=1)
     bed_type: str | None = Field(default=None, max_length=64)
 
+    # PR B target binding. ``target_kind='specific_printer'`` requires
+    # ``target_printer_id`` to be set OR cleared in the same payload (route
+    # handler enforces). ``target_kind='printer_class'`` is the PR A default;
+    # PR B leaves the class column unwired (PR C wires it).
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+
 
 class SlicerPipelineResponse(SlicerPipelineBase):
     """A single pipeline as returned by the API."""

+ 263 - 0
backend/app/services/pipeline_eligibility.py

@@ -0,0 +1,263 @@
+"""Eligibility matcher for Slicer Pipeline runs (#1425 PR B).
+
+Given a pipeline + the user's pinned target printer, this returns a structured
+report of issues the operator should resolve before running. The frontend
+displays the report; the user can ``Run anyway`` to proceed (lenient policy —
+the print may still fail at the printer, but Bambuddy isn't going to refuse
+the click).
+
+Issue kinds (pinned for tests + i18n keys):
+  - printer_not_set         — pipeline has no target_printer_id
+  - printer_not_found       — target_printer_id points at a deleted/missing row
+  - printer_disabled        — Printer.is_active is False (#1476)
+  - printer_offline         — MQTT not connected
+  - filament_type_mismatch  — AMS slot loaded with wrong filament type
+  - filament_color_mismatch — type matches, colour differs
+  - ams_slot_missing        — pipeline expects N filament slots but AMS exposes fewer
+  - filament_unverified     — pipeline filament preset is a non-local tier we
+                              can't statically read (cloud / orca_cloud / standard);
+                              the run will proceed, but the operator should
+                              double-check
+
+The matcher is a pure-ish function over (pipeline, printer row, live AMS state,
+local-preset dict) so unit tests can drive it with fixtures without spinning up
+MQTT. The route handler is the only place that talks to ``printer_manager``.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from typing import Literal
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.printer import Printer
+from backend.app.models.slicer_pipeline import SlicerPipeline
+
+IssueKind = Literal[
+    "printer_not_set",
+    "printer_not_found",
+    "printer_disabled",
+    "printer_offline",
+    "filament_type_mismatch",
+    "filament_color_mismatch",
+    "ams_slot_missing",
+    "filament_unverified",
+]
+
+
+@dataclass(frozen=True)
+class EligibilityIssue:
+    kind: IssueKind
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+@dataclass(frozen=True)
+class EligibilityReport:
+    ok: bool
+    target_printer_id: int | None
+    target_printer_name: str | None
+    issues: tuple[EligibilityIssue, ...]
+
+
+# Same equivalence map as print_scheduler._canonical_filament_type but kept
+# local so this module has no upward dependency on the scheduler. Mirrors the
+# scheduler's behaviour: BBL-prefixed product names normalise to the base type
+# (e.g. "PLA Basic" → "PLA"). When the scheduler's map gets a new alias, this
+# one needs the same entry.
+_FILAMENT_EQUIV_MAP = {
+    "PLA": "PLA",
+    "PLA BASIC": "PLA",
+    "PLA MATTE": "PLA",
+    "PLA SILK": "PLA",
+    "PLA PRO": "PLA",
+    "PLA TOUGH": "PLA",
+    "PETG": "PETG",
+    "PETG HF": "PETG",
+    "PETG BASIC": "PETG",
+    "PETG TRANSLUCENT": "PETG",
+    "ABS": "ABS",
+    "ASA": "ASA",
+    "TPU": "TPU",
+    "TPU 95A": "TPU",
+    "PC": "PC",
+    "PA": "PA",
+    "PA-CF": "PA",
+    "PVA": "PVA",
+}
+
+
+def _canonical(ftype: str) -> str:
+    upper = (ftype or "").strip().upper()
+    return _FILAMENT_EQUIV_MAP.get(upper, upper)
+
+
+def _normalise_colour(colour: str | None) -> str:
+    if not colour:
+        return ""
+    return colour.replace("#", "").lower()[:6]
+
+
+def _ams_slots(raw_data: dict) -> list[tuple[str, str]]:
+    """Flatten AMS + external spool into ``[(type, colour_hex6), ...]`` in slot
+    order. Uses the same field shape as print_scheduler._check_required_filaments.
+    """
+    out: list[tuple[str, str]] = []
+    for ams_unit in raw_data.get("ams") or []:
+        for tray in ams_unit.get("tray") or []:
+            tray_type = tray.get("tray_type") or ""
+            tray_colour = tray.get("tray_color") or ""
+            out.append((_canonical(tray_type), _normalise_colour(tray_colour)))
+    for vt in raw_data.get("vt_tray") or []:
+        vt_type = vt.get("tray_type") or ""
+        vt_colour = vt.get("tray_color") or ""
+        out.append((_canonical(vt_type), _normalise_colour(vt_colour)))
+    return out
+
+
+async def _expected_filament(
+    db: AsyncSession,
+    source: str,
+    preset_id: str,
+) -> tuple[str | None, str | None]:
+    """Return ``(canonical_type, normalised_colour)`` for a pipeline filament
+    slot's PresetRef, or ``(None, None)`` when the preset can't be resolved
+    statically (cloud / orca_cloud / standard — read at slice time, not here).
+    """
+    if source != "local":
+        # Cloud / orca_cloud / standard: surface as ``filament_unverified``
+        # in the report, the matcher decides.
+        return (None, None)
+    try:
+        local_id = int(preset_id)
+    except (TypeError, ValueError):
+        return (None, None)
+    row = (await db.execute(select(LocalPreset).where(LocalPreset.id == local_id))).scalar_one_or_none()
+    if row is None:
+        return (None, None)
+    return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
+
+
+async def check_pipeline_eligibility(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    printer_raw_status: dict | None,
+) -> EligibilityReport:
+    """Build the report. ``printer_raw_status`` is the live ``PrinterState``
+    serialised to a dict (``connected``, ``raw_data``), or ``None`` when the
+    target printer has no MQTT client. The route handler does the
+    ``printer_manager.get_status`` lookup so this stays unit-testable.
+    """
+    issues: list[EligibilityIssue] = []
+
+    # 1. Target printer set?
+    if pipeline.target_printer_id is None:
+        issues.append(EligibilityIssue(kind="printer_not_set"))
+        return EligibilityReport(
+            ok=False,
+            target_printer_id=None,
+            target_printer_name=None,
+            issues=tuple(issues),
+        )
+
+    printer = (await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))).scalar_one_or_none()
+    if printer is None:
+        issues.append(EligibilityIssue(kind="printer_not_found"))
+        return EligibilityReport(
+            ok=False,
+            target_printer_id=pipeline.target_printer_id,
+            target_printer_name=None,
+            issues=tuple(issues),
+        )
+
+    target_name = printer.name
+
+    # 2. Disabled?
+    if not printer.is_active:
+        issues.append(EligibilityIssue(kind="printer_disabled"))
+
+    # 3. Offline?
+    if not printer_raw_status or not printer_raw_status.get("connected"):
+        issues.append(EligibilityIssue(kind="printer_offline"))
+        # Without live AMS state, skip slot checks — would surface as a
+        # cascade of misleading mismatches.
+        return EligibilityReport(
+            ok=not issues,
+            target_printer_id=printer.id,
+            target_printer_name=target_name,
+            issues=tuple(issues),
+        )
+
+    # 4. Per-slot filament match.
+    try:
+        filament_refs = json.loads(pipeline.filament_presets_json or "[]")
+    except (json.JSONDecodeError, TypeError):
+        filament_refs = []
+
+    ams_slots = _ams_slots(printer_raw_status.get("raw_data") or {})
+
+    for slot_index, ref in enumerate(filament_refs):
+        if not isinstance(ref, dict):
+            continue
+        source = ref.get("source", "")
+        preset_id = ref.get("id", "")
+        expected_type, expected_colour = await _expected_filament(db, source, str(preset_id))
+
+        if expected_type is None:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_unverified",
+                    slot_index=slot_index,
+                    expected=f"{source}:{preset_id}",
+                )
+            )
+            continue
+
+        if slot_index >= len(ams_slots):
+            issues.append(
+                EligibilityIssue(
+                    kind="ams_slot_missing",
+                    slot_index=slot_index,
+                    expected=expected_type,
+                )
+            )
+            continue
+
+        actual_type, actual_colour = ams_slots[slot_index]
+        if expected_type and actual_type and expected_type != actual_type:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_type_mismatch",
+                    slot_index=slot_index,
+                    expected=expected_type,
+                    actual=actual_type or "(empty)",
+                )
+            )
+            continue
+        if expected_colour and actual_colour and expected_colour != actual_colour:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_color_mismatch",
+                    slot_index=slot_index,
+                    expected=expected_colour,
+                    actual=actual_colour,
+                )
+            )
+
+    # Unverified filament refs are surfaced as INFO — they don't flip ok=False.
+    # The user is told what we couldn't verify so they can sanity-check
+    # before pulling the trigger, but the lenient policy doesn't refuse to
+    # let them run.
+    blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
+
+    return EligibilityReport(
+        ok=not blocking_issues,
+        target_printer_id=printer.id,
+        target_printer_name=target_name,
+        issues=tuple(issues),
+    )

+ 529 - 0
backend/tests/integration/test_pipeline_runs_api.py

@@ -0,0 +1,529 @@
+"""Integration tests for Slicer Pipeline runs (#1425 PR B).
+
+Slicing itself is a network call to the slicer sidecar — these tests
+stub ``slice_and_persist`` so the orchestration logic is exercised without
+needing a live sidecar in CI.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+def _pipeline_payload(**overrides) -> dict:
+    payload = {
+        "name": "Production Batch",
+        "description": None,
+        "printer_preset": {"source": "local", "id": "1"},
+        "process_preset": {"source": "local", "id": "2"},
+        "filament_presets": [{"source": "local", "id": "3"}],
+        "bed_type": None,
+    }
+    payload.update(overrides)
+    return payload
+
+
+@pytest.fixture
+async def pipeline_factory(async_client: AsyncClient):
+    """Create pipelines via the API + optionally set a target printer."""
+
+    async def _make(target_printer_id: int | None = None, **overrides) -> dict:
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=_pipeline_payload(**overrides))
+        assert resp.status_code == 201, resp.text
+        pipeline = resp.json()
+        if target_printer_id is not None:
+            put_resp = await async_client.put(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}",
+                json={"target_kind": "specific_printer", "target_printer_id": target_printer_id},
+            )
+            assert put_resp.status_code == 200, put_resp.text
+            pipeline = put_resp.json()
+        return pipeline
+
+    return _make
+
+
+@pytest.fixture
+async def printer_factory(db_session):
+    """Insert a Printer row for tests that need a target_printer_id."""
+    from backend.app.models.printer import Printer
+
+    counter = [0]
+
+    async def _make(**overrides) -> Printer:
+        counter[0] += 1
+        defaults = {
+            "name": f"X1C #{counter[0]}",
+            "serial_number": f"SERIAL{counter[0]:04d}",
+            "ip_address": "192.0.2.1",
+            "access_code": "ABCD1234",
+            "model": "Bambu Lab X1 Carbon",
+            "is_active": True,
+        }
+        defaults.update(overrides)
+        printer = Printer(**defaults)
+        db_session.add(printer)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        return printer
+
+    return _make
+
+
+@pytest.fixture
+async def library_file_factory(db_session):
+    """Insert a LibraryFile row for tests that need a source_library_file_id."""
+    from pathlib import Path
+
+    from backend.app.core.config import settings as app_settings
+    from backend.app.models.library import LibraryFile
+
+    counter = [0]
+
+    async def _make(**overrides) -> LibraryFile:
+        counter[0] += 1
+        # Materialise an empty file on disk so the orchestration's path-exists
+        # guard passes when tests reach it.
+        rel = f"test_pipeline_run_{counter[0]}.3mf"
+        abs_path = Path(app_settings.base_dir) / rel
+        abs_path.parent.mkdir(parents=True, exist_ok=True)
+        abs_path.write_bytes(b"")
+        defaults = {
+            "filename": f"cube_{counter[0]}.3mf",
+            "file_path": rel,
+            "file_type": "3mf",
+            "file_size": 0,
+            "file_hash": f"hash_{counter[0]}",
+            "source_type": "uploaded",
+        }
+        defaults.update(overrides)
+        row = LibraryFile(**defaults)
+        db_session.add(row)
+        await db_session.commit()
+        await db_session.refresh(row)
+        return row
+
+    return _make
+
+
+class TestSlicerPipelineTarget:
+    """PUT /slicer-pipelines/{id} accepts the new target fields."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_writes_target(self, async_client: AsyncClient, pipeline_factory, printer_factory):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory()
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={"target_kind": "specific_printer", "target_printer_id": printer.id},
+        )
+        assert resp.status_code == 200, resp.text
+        updated = resp.json()
+        assert updated["target_kind"] == "specific_printer"
+        assert updated["target_printer_id"] == printer.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_target_printer_id_zero_clears(
+        self, async_client: AsyncClient, pipeline_factory, printer_factory
+    ):
+        """Empty-select dropdown sends target_printer_id=0 → backend treats
+        as 'clear' rather than referencing printer #0 (which doesn't exist)."""
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={"target_printer_id": 0},
+        )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["target_printer_id"] is None
+
+
+class TestCheckEligibility:
+    """POST /slicer-pipelines/{id}/check-eligibility surfaces structured issues."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_target_set(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        pipeline = await pipeline_factory()  # no target set
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["ok"] is False
+        kinds = [i["kind"] for i in body["issues"]]
+        assert "printer_not_set" in kinds
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_printer_disabled(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory(is_active=False)
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        with patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=None)):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 200
+        body = resp.json()
+        kinds = [i["kind"] for i in body["issues"]]
+        assert "printer_disabled" in kinds
+        # printer_offline also fires because get_status returns None — both
+        # issues are expected and both block.
+        assert "printer_offline" in kinds
+        assert body["ok"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_online_match_clears_issues(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """Patch printer_manager so AMS slot 0 carries the same canonical
+        type the pipeline's local-tier filament preset declares."""
+        from backend.app.models.local_preset import LocalPreset
+
+        preset = LocalPreset(
+            name="My PLA",
+            preset_type="filament",
+            source="manual",
+            setting="{}",
+            filament_type="PLA",
+            default_filament_colour="#FFFFFF",
+        )
+        db_session.add(preset)
+        await db_session.commit()
+        await db_session.refresh(preset)
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(
+            target_printer_id=printer.id,
+            filament_presets=[{"source": "local", "id": str(preset.id)}],
+        )
+        src = await library_file_factory()
+
+        live_status = {
+            "connected": True,
+            "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
+        }
+        with patch(
+            "backend.app.api.routes.pipeline_runs._load_printer_status",
+            new=AsyncMock(return_value=live_status),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["ok"] is True
+        assert body["issues"] == []
+        assert body["target_printer_name"] == printer.name
+
+
+class TestRunPipeline:
+    """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_with_issues_and_no_force_returns_409(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        pipeline = await pipeline_factory()  # no target set
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 409
+        # Eligibility report rides in detail.
+        detail = resp.json()["detail"]
+        assert detail["ok"] is False
+        assert any(i["kind"] == "printer_not_set" for i in detail["issues"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_force_with_no_target_still_400(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        """``force=True`` bypasses the 409 but the run endpoint still needs a
+        target to enqueue against — the second guard returns 400."""
+        pipeline = await pipeline_factory()
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "force": True},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_creates_run_and_job(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        # AMS empty → eligibility surfaces filament_unverified (non-blocking)
+        # for the standard-tier filament refs the default factory uses; report
+        # is ok=True so no force needed.
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 9001
+
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["pipeline_id"] == pipeline["id"]
+        assert body["source_library_file_id"] == src.id
+        assert body["copies"] == 1
+        assert body["status"] == "queued"
+        assert len(body["jobs"]) == 1
+        assert body["jobs"][0]["copy_index"] == 0
+        assert body["eligibility_overridden"] is False
+        # slice_job_id rides on the response so the frontend can call
+        # trackJob and render the progress toast.
+        assert body["slice_job_id"] == 9001
+
+
+class TestRunListAndGet:
+    """Run history surfaces."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_runs_empty(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+    ):
+        pipeline = await pipeline_factory()
+        resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
+        assert resp.status_code == 200
+        assert resp.json() == {"runs": []}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_run_404(
+        self,
+        async_client: AsyncClient,
+    ):
+        resp = await async_client.get("/api/v1/pipeline-runs/99999")
+        assert resp.status_code == 404
+
+
+class TestCancelRun:
+    """Cancellation marks the run + linked queue entry."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_unknown_run_404(self, async_client: AsyncClient):
+        resp = await async_client.post("/api/v1/pipeline-runs/99999/cancel")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_marks_queued_run(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 9001
+
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            run_resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id},
+            )
+        run_id = run_resp.json()["id"]
+        cancel_resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
+        assert cancel_resp.status_code == 200
+        assert cancel_resp.json()["status"] == "cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_accepts_archive_source(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        db_session,
+    ):
+        """``source_archive_id`` is accepted in place of source_library_file_id."""
+        from pathlib import Path
+
+        from backend.app.core.config import settings as app_settings
+        from backend.app.models.archive import PrintArchive
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+
+        rel = "test_pipeline_archive_source.3mf"
+        (Path(app_settings.base_dir) / rel).write_bytes(b"")
+        archive = PrintArchive(
+            printer_id=printer.id,
+            filename="Archive Source.3mf",
+            file_path=rel,
+            file_size=0,
+            source_3mf_path=rel,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 7777
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_archive_id": archive.id},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["source_library_file_id"] is None
+        assert body["source_archive_id"] == archive.id
+        assert body["slice_job_id"] == 7777
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_rejects_no_source(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_rejects_both_sources(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "source_archive_id": 99},
+        )
+        assert resp.status_code == 422
+
+
+class TestCancelTerminal:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_terminal_run_is_idempotent(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        run = PipelineRun(
+            pipeline_id=pipeline["id"],
+            source_library_file_id=src.id,
+            copies=1,
+            status="completed",
+        )
+        db_session.add(run)
+        await db_session.commit()
+        await db_session.refresh(run)
+        resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "completed"  # unchanged

+ 1 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -219,7 +219,7 @@ const IT_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
-  'Pipeline',  // #1425 — Slicer Pipelines (cognate in IT)
+  'Pipeline', 'slicing',  // #1425 — Slicer Pipelines (cognate in IT)
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',

+ 208 - 0
frontend/src/__tests__/components/RunWithPipelineModal.test.tsx

@@ -0,0 +1,208 @@
+/**
+ * Tests for RunWithPipelineModal (#1425 PR B).
+ *
+ * Pin the two-step flow: pick → optional confirmation → run. Verify the
+ * fast path skips the modal step and the slow path renders the eligibility
+ * issues with the "Run anyway" button.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { RunWithPipelineModal } from '../../components/RunWithPipelineModal';
+import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
+import { api, type Printer, type SlicerPipeline } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    listSlicerPipelines: vi.fn(),
+    getPrinters: vi.fn(),
+    checkPipelineEligibility: vi.fn(),
+    runPipeline: vi.fn(),
+    // ThemeContext / AuthContext bootstrap touches these on mount.
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+const mockApi = api as unknown as {
+  listSlicerPipelines: ReturnType<typeof vi.fn>;
+  getPrinters: ReturnType<typeof vi.fn>;
+  checkPipelineEligibility: ReturnType<typeof vi.fn>;
+  runPipeline: ReturnType<typeof vi.fn>;
+};
+
+function makePipeline(overrides: Partial<SlicerPipeline> = {}): SlicerPipeline {
+  return {
+    id: 1,
+    name: 'Production Batch',
+    description: null,
+    printer_preset: { source: 'local', id: '1' },
+    process_preset: { source: 'local', id: '2' },
+    filament_presets: [{ source: 'local', id: '3' }],
+    bed_type: null,
+    target_kind: 'specific_printer',
+    target_printer_id: 42,
+    target_model_class: null,
+    fanout_strategy: 'max_parallel',
+    created_by: null,
+    created_at: '2026-06-27T00:00:00Z',
+    updated_at: '2026-06-27T00:00:00Z',
+    ...overrides,
+  };
+}
+
+function makePrinter(overrides: Partial<Printer> = {}): Printer {
+  return {
+    id: 42,
+    name: 'X1C #2',
+    serial_number: 'TEST',
+    ip_address: '192.0.2.1',
+    access_code: '****',
+    model: 'X1C',
+    location: null,
+    is_active: true,
+    nozzle_count: 1,
+    ...overrides,
+  } as unknown as Printer;
+}
+
+describe('RunWithPipelineModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getPrinters.mockResolvedValue([makePrinter()]);
+  });
+
+  it('renders the empty state when no pipelines exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => {
+      expect(screen.getByText(/No pipelines saved yet/i)).toBeInTheDocument();
+    });
+  });
+
+  it('disables pipelines with no target printer', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({
+      pipelines: [makePipeline({ target_printer_id: null })],
+    });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => {
+      expect(screen.getByText(/No target printer set/i)).toBeInTheDocument();
+    });
+    const button = screen.getByRole('button', { name: /Production Batch/i });
+    expect((button as HTMLButtonElement).disabled).toBe(true);
+  });
+
+  it('fires the run immediately when eligibility is ok (fast path)', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: true,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [],
+    });
+    mockApi.runPipeline.mockResolvedValue({});
+    const onClose = vi.fn();
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={onClose} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+
+    await waitFor(() => {
+      expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
+        kind: 'libraryFile',
+        id: 99,
+      });
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(
+        1,
+        { kind: 'libraryFile', id: 99 },
+        false,
+      );
+      expect(onClose).toHaveBeenCalled();
+    });
+  });
+
+  it('threads source kind="archive" through eligibility + run', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: true,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [],
+    });
+    mockApi.runPipeline.mockResolvedValue({ slice_job_id: 9001 });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal
+          source={{ kind: 'archive', id: 7, filename: 'archive.3mf' }}
+          onClose={vi.fn()}
+        />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+    await waitFor(() => {
+      expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
+        kind: 'archive',
+        id: 7,
+      });
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(
+        1,
+        { kind: 'archive', id: 7 },
+        false,
+      );
+    });
+  });
+
+  it('shows the eligibility report + Run-anyway button when issues exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: false,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [
+        {
+          kind: 'filament_type_mismatch',
+          slot_index: 0,
+          expected: 'PLA',
+          actual: 'PETG',
+        },
+      ],
+    });
+    mockApi.runPipeline.mockResolvedValue({});
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+
+    // Confirmation view appears with the issue listed.
+    await waitFor(() => {
+      expect(screen.getByText(/expected PLA/i)).toBeInTheDocument();
+    });
+    // Clicking Run anyway fires the run with force=true and the new source-kind shape.
+    await user.click(screen.getByRole('button', { name: /Run anyway/i }));
+    await waitFor(() => {
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true);
+    });
+  });
+});

+ 112 - 1
frontend/src/api/client.ts

@@ -1530,11 +1530,87 @@ export interface SlicerPipelineCreateRequest {
   filament_presets: PresetRef[];
   bed_type?: string | null;
 }
-export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest>;
+export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest> & {
+  target_kind?: 'specific_printer' | 'printer_class';
+  // ``target_printer_id: 0`` means "clear the target" — the backend maps that
+  // to null. Use null in TypeScript for the same intent.
+  target_printer_id?: number | null;
+};
 export interface SlicerPipelinesListResponse {
   pipelines: SlicerPipeline[];
 }
 
+// Slicer Pipeline runs (#1425 PR B)
+export type PipelineEligibilityKind =
+  | 'printer_not_set'
+  | 'printer_not_found'
+  | 'printer_disabled'
+  | 'printer_offline'
+  | 'filament_type_mismatch'
+  | 'filament_color_mismatch'
+  | 'ams_slot_missing'
+  | 'filament_unverified';
+export interface PipelineEligibilityIssue {
+  kind: PipelineEligibilityKind;
+  slot_index: number | null;
+  expected: string | null;
+  actual: string | null;
+}
+export interface PipelineEligibilityReport {
+  ok: boolean;
+  target_printer_id: number | null;
+  target_printer_name: string | null;
+  issues: PipelineEligibilityIssue[];
+}
+export interface PipelineJob {
+  id: number;
+  pipeline_run_id: number;
+  copy_index: number;
+  assigned_printer_id: number | null;
+  assigned_printer_name: string | null;
+  queue_entry_id: number | null;
+  status:
+    | 'pending'
+    | 'awaiting_printer'
+    | 'queued'
+    | 'printing'
+    | 'completed'
+    | 'failed'
+    | 'cancelled';
+  error_message: string | null;
+  dispatched_at: string | null;
+  completed_at: string | null;
+}
+export interface PipelineRun {
+  id: number;
+  pipeline_id: number | null;
+  pipeline_name: string | null;
+  source_library_file_id: number | null;
+  source_archive_id: number | null;
+  source_filename: string | null;
+  copies: number;
+  status:
+    | 'queued'
+    | 'slicing'
+    | 'dispatching'
+    | 'in_progress'
+    | 'completed'
+    | 'failed'
+    | 'cancelled';
+  slice_job_id: number | null;
+  sliced_library_file_id: number | null;
+  eligibility_overridden: boolean;
+  error_message: string | null;
+  created_by: number | null;
+  created_at: string;
+  started_at: string | null;
+  completed_at: string | null;
+  jobs: PipelineJob[];
+}
+export interface PipelineRunListResponse {
+  runs: PipelineRun[];
+}
+
 export interface SliceResponse {
   library_file_id: number;
   name: string;
@@ -3035,6 +3111,7 @@ export type Permission =
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
+  | 'pipelines:read' | 'pipelines:write' | 'pipelines:run'
   | 'websocket:connect';
 
 // Group types
@@ -6250,6 +6327,40 @@ export const api = {
     }),
   deleteSlicerPipeline: (id: number) =>
     request<void>(`/slicer-pipelines/${id}`, { method: 'DELETE' }),
+  checkPipelineEligibility: (
+    pipelineId: number,
+    source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
+  ) =>
+    request<PipelineEligibilityReport>(`/slicer-pipelines/${pipelineId}/check-eligibility`, {
+      method: 'POST',
+      body: JSON.stringify(
+        source.kind === 'libraryFile'
+          ? { source_library_file_id: source.id }
+          : { source_archive_id: source.id },
+      ),
+    }),
+  runPipeline: (
+    pipelineId: number,
+    source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
+    force = false,
+  ) =>
+    request<PipelineRun>(`/slicer-pipelines/${pipelineId}/run`, {
+      method: 'POST',
+      body: JSON.stringify({
+        ...(source.kind === 'libraryFile'
+          ? { source_library_file_id: source.id }
+          : { source_archive_id: source.id }),
+        force,
+      }),
+    }),
+  listPipelineRuns: (pipelineId: number, limit = 5) =>
+    request<PipelineRunListResponse>(
+      `/slicer-pipelines/${pipelineId}/runs?limit=${limit}`,
+    ),
+  getPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}`),
+  cancelPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}/cancel`, { method: 'POST' }),
 
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the

+ 362 - 0
frontend/src/components/RunWithPipelineModal.tsx

@@ -0,0 +1,362 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { AlertTriangle, Cog, Loader2, Play, Printer as PrinterIcon, X } from 'lucide-react';
+import {
+  api,
+  type PipelineEligibilityReport,
+  type Printer as PrinterType,
+  type SlicerPipeline,
+} from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
+
+// Same source-kind shape SliceModal uses, so the same library-file vs archive
+// distinction flows through eligibility-check, run dispatch, AND the progress
+// toast tracker.
+export type RunPipelineSource =
+  | { kind: 'libraryFile'; id: number; filename: string }
+  | { kind: 'archive'; id: number; filename: string };
+
+export interface RunWithPipelineModalProps {
+  source: RunPipelineSource;
+  onClose: () => void;
+}
+
+// Two-step modal. Step 1: pick a pipeline. Step 2: confirm eligibility
+// (skipped when ok=true) and run. Lives in two views in the same modal so
+// the user keeps context — most production runs hit the green path and
+// never see step 2.
+export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [picked, setPicked] = useState<SlicerPipeline | null>(null);
+  const [report, setReport] = useState<PipelineEligibilityReport | null>(null);
+  const { trackJob } = useSliceJobTracker();
+
+  const { data: list, isLoading: pipelinesLoading } = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+  });
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
+  const sourceRef = { kind: source.kind, id: source.id } as const;
+  const checkMutation = useMutation({
+    mutationFn: (pipelineId: number) =>
+      api.checkPipelineEligibility(pipelineId, sourceRef),
+  });
+
+  const runMutation = useMutation({
+    mutationFn: ({ pipelineId, force }: { pipelineId: number; force: boolean }) =>
+      api.runPipeline(pipelineId, sourceRef, force),
+    onSuccess: (run) => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
+      queryClient.invalidateQueries({ queryKey: ['pipeline-runs'] });
+      // Hand the slice job off to the existing tracker so the same persistent
+      // progress toast renders for pipeline runs as for manual SliceModal
+      // slices — no separate notification surface.
+      if (run.slice_job_id) {
+        trackJob(run.slice_job_id, source.kind, source.filename);
+      }
+      showToast(t('library.runWithPipeline.toast.started', 'Pipeline run started'), 'success');
+      onClose();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('library.runWithPipeline.toast.failed', 'Could not start run'), 'error');
+    },
+  });
+
+  const pipelines = list?.pipelines ?? [];
+  const printerById: Record<number, PrinterType> = (printers ?? []).reduce((acc, p) => {
+    acc[p.id] = p;
+    return acc;
+  }, {} as Record<number, PrinterType>);
+
+  const handlePick = async (pipeline: SlicerPipeline) => {
+    if (!pipeline.target_printer_id) {
+      showToast(
+        t('library.runWithPipeline.noTargetMessage', 'This pipeline has no target printer set. Open it in Settings to pick one.'),
+        'error',
+      );
+      return;
+    }
+    setPicked(pipeline);
+    try {
+      const result = await checkMutation.mutateAsync(pipeline.id);
+      setReport(result);
+      if (result.ok) {
+        runMutation.mutate({ pipelineId: pipeline.id, force: false });
+      }
+    } catch {
+      // Network error — keep the user on step 1 so they can retry.
+      setPicked(null);
+    }
+  };
+
+  const handleConfirm = () => {
+    if (!picked) return;
+    runMutation.mutate({ pipelineId: picked.id, force: true });
+  };
+
+  const handleBack = () => {
+    setPicked(null);
+    setReport(null);
+  };
+
+  return (
+    <div
+      className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
+      onClick={onClose}
+      role="dialog"
+      aria-modal="true"
+      aria-label={t('library.runWithPipeline.modalTitle', 'Run with pipeline')}
+    >
+      <div
+        className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col overflow-hidden"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
+          <h3 className="text-sm font-semibold text-white flex items-center gap-2">
+            <Play className="w-4 h-4 text-bambu-green" />
+            {picked && report
+              ? t('library.runWithPipeline.confirmTitle', 'Confirm run')
+              : t('library.runWithPipeline.modalTitle', 'Run with pipeline')}
+          </h3>
+          <button
+            type="button"
+            onClick={onClose}
+            aria-label={t('common.close', 'Close')}
+            className="text-bambu-gray hover:text-white"
+          >
+            <X className="w-4 h-4" />
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-y-auto p-4 space-y-3">
+          {picked && report ? (
+            <ConfirmStep
+              pipeline={picked}
+              report={report}
+              source={source}
+              onBack={handleBack}
+              onConfirm={handleConfirm}
+              running={runMutation.isPending}
+            />
+          ) : (
+            <PickStep
+              source={source}
+              pipelines={pipelines}
+              printerById={printerById}
+              loading={pipelinesLoading || checkMutation.isPending}
+              onPick={handlePick}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function PickStep({
+  source,
+  pipelines,
+  printerById,
+  loading,
+  onPick,
+}: {
+  source: { filename: string };
+  pipelines: SlicerPipeline[];
+  printerById: Record<number, { name: string }>;
+  loading: boolean;
+  onPick: (p: SlicerPipeline) => void;
+}) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <p className="text-xs text-bambu-gray">
+        {t('library.runWithPipeline.sourceHint', 'Source')}:{' '}
+        <span className="text-white">{source.filename}</span>
+      </p>
+      {loading && (
+        <div className="flex items-center gap-2 text-sm text-bambu-gray">
+          <Loader2 className="w-4 h-4 animate-spin" />
+          {t('library.runWithPipeline.loading', 'Loading…')}
+        </div>
+      )}
+      {!loading && pipelines.length === 0 && (
+        <p className="text-sm text-bambu-gray">
+          {t(
+            'library.runWithPipeline.empty',
+            'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.',
+          )}
+        </p>
+      )}
+      {!loading && pipelines.length > 0 && (
+        <ul className="space-y-1.5" aria-label={t('library.runWithPipeline.pipelineListAria', 'Available pipelines')}>
+          {pipelines.map((p) => {
+            const targetName = p.target_printer_id ? printerById[p.target_printer_id]?.name : null;
+            return (
+              <li key={p.id}>
+                <button
+                  type="button"
+                  onClick={() => onPick(p)}
+                  disabled={!p.target_printer_id}
+                  className="w-full text-left px-3 py-2 rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+                >
+                  <div className="flex items-center gap-2">
+                    <Cog className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
+                    <span className="text-sm font-medium text-white truncate">{p.name}</span>
+                  </div>
+                  <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
+                    <PrinterIcon className="w-3 h-3" />
+                    {targetName ? (
+                      <span>{targetName}</span>
+                    ) : (
+                      <span className="text-amber-400">
+                        {t('library.runWithPipeline.noTarget', 'No target printer set')}
+                      </span>
+                    )}
+                  </div>
+                </button>
+              </li>
+            );
+          })}
+        </ul>
+      )}
+    </>
+  );
+}
+
+function ConfirmStep({
+  pipeline,
+  report,
+  source,
+  onBack,
+  onConfirm,
+  running,
+}: {
+  pipeline: SlicerPipeline;
+  report: PipelineEligibilityReport;
+  source: { filename: string };
+  onBack: () => void;
+  onConfirm: () => void;
+  running: boolean;
+}) {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <div className="text-xs text-bambu-gray">
+        <p>
+          {t('library.runWithPipeline.confirmIntro', 'Pre-flight found issues with this run')}:
+        </p>
+        <p className="mt-1">
+          <span className="text-bambu-gray/70">{t('library.runWithPipeline.sourceHint', 'Source')}: </span>
+          <span className="text-white">{source.filename}</span>
+        </p>
+        <p>
+          <span className="text-bambu-gray/70">{t('library.runWithPipeline.pipelineHint', 'Pipeline')}: </span>
+          <span className="text-white">{pipeline.name}</span>
+        </p>
+        {report.target_printer_name && (
+          <p>
+            <span className="text-bambu-gray/70">{t('library.runWithPipeline.targetHint', 'Target')}: </span>
+            <span className="text-white">{report.target_printer_name}</span>
+          </p>
+        )}
+      </div>
+
+      <ul className="space-y-1.5">
+        {report.issues.map((issue, idx) => (
+          <li key={idx} className="flex items-start gap-2 text-xs">
+            <AlertTriangle className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
+            <span className="text-bambu-gray">
+              <IssueText issue={issue} />
+            </span>
+          </li>
+        ))}
+      </ul>
+
+      <div className="flex items-center justify-end gap-2 pt-2 border-t border-bambu-dark-tertiary">
+        <button
+          type="button"
+          onClick={onBack}
+          disabled={running}
+          className="px-3 py-1.5 text-xs text-bambu-gray hover:text-white"
+        >
+          {t('common.back', 'Back')}
+        </button>
+        <button
+          type="button"
+          onClick={onConfirm}
+          disabled={running}
+          className="px-3 py-1.5 text-xs bg-amber-500 hover:bg-amber-600 text-white rounded disabled:opacity-50 flex items-center gap-1"
+        >
+          {running ? (
+            <Loader2 className="w-3 h-3 animate-spin" />
+          ) : (
+            <Play className="w-3 h-3" />
+          )}
+          {t('library.runWithPipeline.runAnyway', 'Run anyway')}
+        </button>
+      </div>
+    </>
+  );
+}
+
+function IssueText({ issue }: { issue: PipelineEligibilityReport['issues'][number] }) {
+  const { t } = useTranslation();
+  switch (issue.kind) {
+    case 'printer_not_set':
+      return <>{t('library.runWithPipeline.issue.printerNotSet', 'No target printer set on this pipeline.')}</>;
+    case 'printer_not_found':
+      return <>{t('library.runWithPipeline.issue.printerNotFound', 'Target printer no longer exists.')}</>;
+    case 'printer_disabled':
+      return <>{t('library.runWithPipeline.issue.printerDisabled', 'Target printer is disabled.')}</>;
+    case 'printer_offline':
+      return <>{t('library.runWithPipeline.issue.printerOffline', 'Target printer is offline.')}</>;
+    case 'filament_type_mismatch':
+      return (
+        <>
+          {t('library.runWithPipeline.issue.filamentType', 'Filament slot {{slot}}: expected {{expected}}, AMS has {{actual}}', {
+            slot: (issue.slot_index ?? 0) + 1,
+            expected: issue.expected ?? '?',
+            actual: issue.actual ?? '?',
+          })}
+        </>
+      );
+    case 'filament_color_mismatch':
+      return (
+        <>
+          {t('library.runWithPipeline.issue.filamentColor', 'Filament slot {{slot}}: colour differs (expected {{expected}}, AMS has {{actual}})', {
+            slot: (issue.slot_index ?? 0) + 1,
+            expected: issue.expected ?? '?',
+            actual: issue.actual ?? '?',
+          })}
+        </>
+      );
+    case 'ams_slot_missing':
+      return (
+        <>
+          {t('library.runWithPipeline.issue.amsSlotMissing', 'AMS slot {{slot}} not available on this printer', {
+            slot: (issue.slot_index ?? 0) + 1,
+          })}
+        </>
+      );
+    case 'filament_unverified':
+      return (
+        <>
+          {t('library.runWithPipeline.issue.filamentUnverified', 'Filament slot {{slot}} comes from a cloud / standard preset and could not be statically verified.', {
+            slot: (issue.slot_index ?? 0) + 1,
+          })}
+        </>
+      );
+    default:
+      return <>{issue.kind}</>;
+  }
+}

+ 136 - 7
frontend/src/components/SlicerPipelinesPanel.tsx

@@ -1,11 +1,13 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { AlertTriangle, Check, Edit2, Loader2, Trash2, Workflow, X } from 'lucide-react';
+import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Trash2, Workflow, X } from 'lucide-react';
 import {
   api,
+  type PipelineRun,
   type PresetRef,
   type PresetSource,
+  type Printer as PrinterType,
   type SlicerPipeline,
   type UnifiedPresetsResponse,
 } from '../api/client';
@@ -47,9 +49,27 @@ export function SlicerPipelinesPanel() {
     queryFn: () => api.getSlicerPresets(),
   });
 
+  // Printers list for the target picker (PR B).
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
   const updateMutation = useMutation({
-    mutationFn: ({ id, name, description }: { id: number; name?: string; description?: string | null }) =>
-      api.updateSlicerPipeline(id, { name, description }),
+    mutationFn: ({
+      id,
+      name,
+      description,
+      target_printer_id,
+      target_kind,
+    }: {
+      id: number;
+      name?: string;
+      description?: string | null;
+      target_printer_id?: number | null;
+      target_kind?: 'specific_printer' | 'printer_class';
+    }) =>
+      api.updateSlicerPipeline(id, { name, description, target_printer_id, target_kind }),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
       showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
@@ -116,7 +136,8 @@ export function SlicerPipelinesPanel() {
                 key={p.id}
                 pipeline={p}
                 presets={presets}
-                onRename={(name, description) => updateMutation.mutate({ id: p.id, name, description })}
+                printers={printers ?? []}
+                onSave={(payload) => updateMutation.mutate({ id: p.id, ...payload })}
                 onDelete={() => {
                   if (confirm(t('settings.pipelines.confirmDelete', 'Delete this pipeline? This cannot be undone.'))) {
                     deleteMutation.mutate(p.id);
@@ -136,14 +157,21 @@ export function SlicerPipelinesPanel() {
 function PipelineRow({
   pipeline,
   presets,
-  onRename,
+  printers,
+  onSave,
   onDelete,
   saving,
   deleting,
 }: {
   pipeline: SlicerPipeline;
   presets: UnifiedPresetsResponse | undefined;
-  onRename: (name: string, description: string | null) => void;
+  printers: PrinterType[];
+  onSave: (payload: {
+    name?: string;
+    description?: string | null;
+    target_printer_id?: number | null;
+    target_kind?: 'specific_printer' | 'printer_class';
+  }) => void;
   onDelete: () => void;
   saving: boolean;
   deleting: boolean;
@@ -152,6 +180,19 @@ function PipelineRow({
   const [editing, setEditing] = useState(false);
   const [draftName, setDraftName] = useState(pipeline.name);
   const [draftDescription, setDraftDescription] = useState(pipeline.description ?? '');
+  const [draftTargetPrinterId, setDraftTargetPrinterId] = useState<number | null>(
+    pipeline.target_printer_id,
+  );
+
+  // Recent runs for the inline last-run summary. ``enabled: editing === false``
+  // avoids re-querying every keystroke while the editor is open.
+  const { data: runsList } = useQuery({
+    queryKey: ['pipeline-runs', pipeline.id],
+    queryFn: () => api.listPipelineRuns(pipeline.id, 1),
+    enabled: !editing,
+    refetchInterval: 15_000,
+  });
+  const lastRun: PipelineRun | undefined = runsList?.runs?.[0];
 
   const printerName = resolveName(presets, 'printer', pipeline.printer_preset);
   const processName = resolveName(presets, 'process', pipeline.process_preset);
@@ -159,17 +200,28 @@ function PipelineRow({
   const hasStaleRef =
     presets !== undefined &&
     (printerName === null || processName === null || filamentResolutions.some((n) => n === null));
+  const targetPrinter = pipeline.target_printer_id
+    ? printers.find((p) => p.id === pipeline.target_printer_id)
+    : undefined;
+  const needsTarget = pipeline.target_printer_id === null;
 
   const handleSave = () => {
     const trimmedName = draftName.trim();
     if (!trimmedName) return;
-    onRename(trimmedName, draftDescription.trim() || null);
+    onSave({
+      name: trimmedName,
+      description: draftDescription.trim() || null,
+      target_kind: 'specific_printer',
+      // Backend treats 0 as "clear"; null in TS maps to that intent.
+      target_printer_id: draftTargetPrinterId ?? 0,
+    });
     setEditing(false);
   };
 
   const handleCancel = () => {
     setDraftName(pipeline.name);
     setDraftDescription(pipeline.description ?? '');
+    setDraftTargetPrinterId(pipeline.target_printer_id);
     setEditing(false);
   };
 
@@ -194,6 +246,28 @@ function PipelineRow({
                 rows={2}
                 className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
               />
+              <div>
+                <label className="text-xs text-bambu-gray block mb-1">
+                  {t('settings.pipelines.field.targetPrinter', 'Target printer')}
+                </label>
+                <select
+                  value={draftTargetPrinterId ?? ''}
+                  onChange={(e) =>
+                    setDraftTargetPrinterId(e.target.value ? parseInt(e.target.value, 10) : null)
+                  }
+                  aria-label={t('settings.pipelines.field.targetPrinter', 'Target printer')}
+                  className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                >
+                  <option value="">
+                    {t('settings.pipelines.field.noTarget', '— No target —')}
+                  </option>
+                  {printers.map((p) => (
+                    <option key={p.id} value={p.id}>
+                      {p.name}
+                    </option>
+                  ))}
+                </select>
+              </div>
             </div>
           ) : (
             <>
@@ -277,6 +351,43 @@ function PipelineRow({
               <span className="text-white">{pipeline.bed_type}</span>
             </div>
           )}
+          <div className="text-bambu-gray flex items-center gap-1">
+            <PrinterIcon className="w-3 h-3" />
+            <span className="font-medium text-bambu-gray/80">
+              {t('settings.pipelines.field.targetPrinter', 'Target printer')}:
+            </span>{' '}
+            {targetPrinter ? (
+              <span className="text-white">{targetPrinter.name}</span>
+            ) : (
+              <span className="text-amber-400">
+                {t('settings.pipelines.noTargetHint', 'Set a target printer to run this')}
+              </span>
+            )}
+          </div>
+        </div>
+      )}
+
+      {!editing && lastRun && (
+        <div className="mt-1.5 text-xs text-bambu-gray flex items-center gap-1">
+          <span className="font-medium text-bambu-gray/80">
+            {t('settings.pipelines.runs.lastRun', 'Last run')}:
+          </span>{' '}
+          <RunStatusBadge status={lastRun.status} />
+          {lastRun.created_at && (
+            <span className="text-bambu-gray/60">
+              · {new Date(lastRun.created_at).toLocaleString()}
+            </span>
+          )}
+        </div>
+      )}
+
+      {needsTarget && !editing && (
+        <div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400">
+          <AlertTriangle className="w-3.5 h-3.5" />
+          {t(
+            'settings.pipelines.noTargetWarning',
+            'Set a target printer before running this pipeline.',
+          )}
         </div>
       )}
 
@@ -293,6 +404,24 @@ function PipelineRow({
   );
 }
 
+function RunStatusBadge({ status }: { status: PipelineRun['status'] }) {
+  const { t } = useTranslation();
+  const colourClass: Record<PipelineRun['status'], string> = {
+    queued: 'text-bambu-gray',
+    slicing: 'text-blue-400',
+    dispatching: 'text-blue-400',
+    in_progress: 'text-bambu-green',
+    completed: 'text-bambu-green',
+    failed: 'text-red-400',
+    cancelled: 'text-bambu-gray',
+  };
+  return (
+    <span className={colourClass[status]}>
+      {t(`settings.pipelines.runs.status.${status}`, status)}
+    </span>
+  );
+}
+
 function PresetLine({
   label,
   ref,

+ 46 - 0
frontend/src/i18n/locales/de.ts

@@ -2570,6 +2570,8 @@ export default {
       field: {
         name: 'Pipeline-Name',
         description: 'Beschreibung',
+        targetPrinter: 'Zieldrucker',
+        noTarget: '— Kein Ziel —',
       },
       action: {
         save: 'Speichern',
@@ -2590,6 +2592,20 @@ export default {
         deleted: 'Pipeline gelöscht',
         deleteFailed: 'Löschen fehlgeschlagen',
       },
+      noTargetHint: 'Lege einen Zieldrucker fest, um diese auszuführen',
+      noTargetWarning: 'Lege einen Zieldrucker fest, bevor du diese Pipeline ausführst.',
+      runs: {
+        lastRun: 'Letzter Lauf',
+        status: {
+          queued: 'in Warteschlange',
+          slicing: 'wird geschnitten',
+          dispatching: 'wird gesendet',
+          in_progress: 'druckt',
+          completed: 'abgeschlossen',
+          failed: 'fehlgeschlagen',
+          cancelled: 'abgebrochen',
+        },
+      },
     },
   },
 
@@ -3820,6 +3836,36 @@ export default {
     deleteConfirm: 'Möchten Sie dieses Filament wirklich löschen?',
     importFromPrinter: 'Vom Drucker importieren',
     exportToFile: 'In Datei exportieren',
+    runWithPipeline: {
+      actionLabel: 'Mit Pipeline ausführen',
+      noPermission: 'Du hast keine Berechtigung, Pipelines auszuführen',
+      modalTitle: 'Mit Pipeline ausführen',
+      confirmTitle: 'Lauf bestätigen',
+      confirmIntro: 'Pre-Flight hat Probleme mit diesem Lauf gefunden',
+      sourceHint: 'Quelle',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Ziel',
+      pipelineListAria: 'Verfügbare Pipelines',
+      runAnyway: 'Trotzdem ausführen',
+      loading: 'Wird geladen…',
+      empty: 'Noch keine Pipelines gespeichert. Öffne den Slice-Dialog und klicke „Als Pipeline speichern“, um eine zu erstellen.',
+      noTarget: 'Kein Zieldrucker festgelegt',
+      noTargetMessage: 'Diese Pipeline hat keinen Zieldrucker. Öffne sie in den Einstellungen, um einen festzulegen.',
+      toast: {
+        started: 'Pipeline-Lauf gestartet',
+        failed: 'Lauf konnte nicht gestartet werden',
+      },
+      issue: {
+        printerNotSet: 'Kein Zieldrucker für diese Pipeline festgelegt.',
+        printerNotFound: 'Zieldrucker existiert nicht mehr.',
+        printerDisabled: 'Zieldrucker ist deaktiviert.',
+        printerOffline: 'Zieldrucker ist offline.',
+        filamentType: 'Filament-Slot {{slot}}: erwartet {{expected}}, AMS hat {{actual}}',
+        filamentColor: 'Filament-Slot {{slot}}: Farbe weicht ab (erwartet {{expected}}, AMS hat {{actual}})',
+        amsSlotMissing: 'AMS-Slot {{slot}} ist auf diesem Drucker nicht verfügbar',
+        filamentUnverified: 'Filament-Slot {{slot}} stammt aus einem Cloud-/Standard-Preset und konnte nicht statisch verifiziert werden.',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 50 - 0
frontend/src/i18n/locales/en.ts

@@ -2586,6 +2586,8 @@ export default {
       field: {
         name: 'Pipeline name',
         description: 'Description',
+        targetPrinter: 'Target printer',
+        noTarget: '— No target —',
       },
       action: {
         save: 'Save',
@@ -2606,6 +2608,21 @@ export default {
         deleted: 'Pipeline deleted',
         deleteFailed: 'Delete failed',
       },
+      // PR B target binding + last-run summary
+      noTargetHint: 'Set a target printer to run this',
+      noTargetWarning: 'Set a target printer before running this pipeline.',
+      runs: {
+        lastRun: 'Last run',
+        status: {
+          queued: 'queued',
+          slicing: 'slicing',
+          dispatching: 'dispatching',
+          in_progress: 'printing',
+          completed: 'completed',
+          failed: 'failed',
+          cancelled: 'cancelled',
+        },
+      },
     },
   },
 
@@ -3836,6 +3853,39 @@ export default {
     deleteConfirm: 'Are you sure you want to delete this filament?',
     importFromPrinter: 'Import from Printer',
     exportToFile: 'Export to File',
+    // Slicer Pipelines run from the File Manager (#1425 PR B). The Run-with-pipeline
+    // modal is a two-step dialog: pick a pipeline, then either fire (eligibility ok)
+    // or confirm-and-fire (eligibility report shown). Lives in components/RunWithPipelineModal.tsx.
+    runWithPipeline: {
+      actionLabel: 'Run with pipeline',
+      noPermission: 'You do not have permission to run pipelines',
+      modalTitle: 'Run with pipeline',
+      confirmTitle: 'Confirm run',
+      confirmIntro: 'Pre-flight found issues with this run',
+      sourceHint: 'Source',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Target',
+      pipelineListAria: 'Available pipelines',
+      runAnyway: 'Run anyway',
+      loading: 'Loading…',
+      empty: 'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.',
+      noTarget: 'No target printer set',
+      noTargetMessage: 'This pipeline has no target printer set. Open it in Settings to pick one.',
+      toast: {
+        started: 'Pipeline run started',
+        failed: 'Could not start run',
+      },
+      issue: {
+        printerNotSet: 'No target printer set on this pipeline.',
+        printerNotFound: 'Target printer no longer exists.',
+        printerDisabled: 'Target printer is disabled.',
+        printerOffline: 'Target printer is offline.',
+        filamentType: 'Filament slot {{slot}}: expected {{expected}}, AMS has {{actual}}',
+        filamentColor: 'Filament slot {{slot}}: colour differs (expected {{expected}}, AMS has {{actual}})',
+        amsSlotMissing: 'AMS slot {{slot}} not available on this printer',
+        filamentUnverified: 'Filament slot {{slot}} comes from a cloud / standard preset and could not be statically verified.',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 46 - 0
frontend/src/i18n/locales/es.ts

@@ -2573,6 +2573,8 @@ export default {
       field: {
         name: 'Nombre de la pipeline',
         description: 'Descripción',
+        targetPrinter: 'Impresora de destino',
+        noTarget: '— Sin destino —',
       },
       action: {
         save: 'Guardar',
@@ -2593,6 +2595,20 @@ export default {
         deleted: 'Pipeline eliminada',
         deleteFailed: 'Error al eliminar',
       },
+      noTargetHint: 'Establece una impresora de destino para ejecutar',
+      noTargetWarning: 'Establece una impresora de destino antes de ejecutar esta pipeline.',
+      runs: {
+        lastRun: 'Última ejecución',
+        status: {
+          queued: 'en cola',
+          slicing: 'cortando',
+          dispatching: 'enviando',
+          in_progress: 'imprimiendo',
+          completed: 'completada',
+          failed: 'fallida',
+          cancelled: 'cancelada',
+        },
+      },
     },
   },
 
@@ -3823,6 +3839,36 @@ export default {
     deleteConfirm: '¿Está seguro de que desea eliminar este filamento?',
     importFromPrinter: 'Importar desde la impresora',
     exportToFile: 'Exportar a un archivo',
+    runWithPipeline: {
+      actionLabel: 'Ejecutar con pipeline',
+      noPermission: 'No tienes permiso para ejecutar pipelines',
+      modalTitle: 'Ejecutar con pipeline',
+      confirmTitle: 'Confirmar ejecución',
+      confirmIntro: 'El pre-vuelo encontró problemas con esta ejecución',
+      sourceHint: 'Origen',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destino',
+      pipelineListAria: 'Pipelines disponibles',
+      runAnyway: 'Ejecutar de todos modos',
+      loading: 'Cargando…',
+      empty: 'Aún no hay pipelines guardadas. Abre el diálogo Cortar y haz clic en "Guardar como pipeline" para crear una.',
+      noTarget: 'Sin impresora de destino',
+      noTargetMessage: 'Esta pipeline no tiene impresora de destino. Ábrela en Ajustes para elegir una.',
+      toast: {
+        started: 'Ejecución de pipeline iniciada',
+        failed: 'No se pudo iniciar la ejecución',
+      },
+      issue: {
+        printerNotSet: 'Sin impresora de destino en esta pipeline.',
+        printerNotFound: 'La impresora de destino ya no existe.',
+        printerDisabled: 'La impresora de destino está deshabilitada.',
+        printerOffline: 'La impresora de destino está desconectada.',
+        filamentType: 'Slot de filamento {{slot}}: esperado {{expected}}, AMS tiene {{actual}}',
+        filamentColor: 'Slot de filamento {{slot}}: color distinto (esperado {{expected}}, AMS tiene {{actual}})',
+        amsSlotMissing: 'Slot AMS {{slot}} no disponible en esta impresora',
+        filamentUnverified: 'Slot de filamento {{slot}} proviene de un preset de nube / estándar y no se pudo verificar estáticamente.',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 46 - 0
frontend/src/i18n/locales/fr.ts

@@ -2559,6 +2559,8 @@ export default {
       field: {
         name: 'Nom du pipeline',
         description: 'Description',
+        targetPrinter: 'Imprimante cible',
+        noTarget: '— Aucune cible —',
       },
       action: {
         save: 'Enregistrer',
@@ -2579,6 +2581,20 @@ export default {
         deleted: 'Pipeline supprimé',
         deleteFailed: 'Échec de la suppression',
       },
+      noTargetHint: 'Définissez une imprimante cible pour exécuter',
+      noTargetWarning: 'Définissez une imprimante cible avant d\'exécuter ce pipeline.',
+      runs: {
+        lastRun: 'Dernière exécution',
+        status: {
+          queued: 'en file',
+          slicing: 'tranchage',
+          dispatching: 'envoi',
+          in_progress: 'impression',
+          completed: 'terminé',
+          failed: 'échec',
+          cancelled: 'annulé',
+        },
+      },
     },
   },
 
@@ -3809,6 +3825,36 @@ export default {
     deleteConfirm: 'Supprimer ce filament ?',
     importFromPrinter: 'Importer de l\'imprimante',
     exportToFile: 'Exporter vers fichier',
+    runWithPipeline: {
+      actionLabel: 'Exécuter avec un pipeline',
+      noPermission: 'Vous n\'avez pas la permission d\'exécuter des pipelines',
+      modalTitle: 'Exécuter avec un pipeline',
+      confirmTitle: 'Confirmer l\'exécution',
+      confirmIntro: 'Le pré-vol a trouvé des problèmes avec cette exécution',
+      sourceHint: 'Source',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Cible',
+      pipelineListAria: 'Pipelines disponibles',
+      runAnyway: 'Exécuter quand même',
+      loading: 'Chargement…',
+      empty: 'Aucun pipeline enregistré pour le moment. Ouvrez le dialogue Trancher et cliquez sur « Enregistrer comme pipeline » pour en créer un.',
+      noTarget: 'Aucune imprimante cible définie',
+      noTargetMessage: 'Ce pipeline n\'a pas d\'imprimante cible. Ouvrez-le dans les Paramètres pour en choisir une.',
+      toast: {
+        started: 'Exécution de pipeline démarrée',
+        failed: 'Impossible de démarrer l\'exécution',
+      },
+      issue: {
+        printerNotSet: 'Aucune imprimante cible définie pour ce pipeline.',
+        printerNotFound: 'L\'imprimante cible n\'existe plus.',
+        printerDisabled: 'L\'imprimante cible est désactivée.',
+        printerOffline: 'L\'imprimante cible est hors ligne.',
+        filamentType: 'Emplacement filament {{slot}} : attendu {{expected}}, AMS a {{actual}}',
+        filamentColor: 'Emplacement filament {{slot}} : couleur différente (attendu {{expected}}, AMS a {{actual}})',
+        amsSlotMissing: 'Emplacement AMS {{slot}} indisponible sur cette imprimante',
+        filamentUnverified: 'L\'emplacement filament {{slot}} provient d\'un préréglage cloud / standard et n\'a pas pu être vérifié statiquement.',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 46 - 0
frontend/src/i18n/locales/it.ts

@@ -2558,6 +2558,8 @@ export default {
       field: {
         name: 'Nome pipeline',
         description: 'Descrizione',
+        targetPrinter: 'Stampante di destinazione',
+        noTarget: '— Nessuna destinazione —',
       },
       action: {
         save: 'Salva',
@@ -2578,6 +2580,20 @@ export default {
         deleted: 'Pipeline eliminata',
         deleteFailed: 'Eliminazione non riuscita',
       },
+      noTargetHint: 'Imposta una stampante di destinazione per eseguire',
+      noTargetWarning: 'Imposta una stampante di destinazione prima di eseguire questa pipeline.',
+      runs: {
+        lastRun: 'Ultima esecuzione',
+        status: {
+          queued: 'in coda',
+          slicing: 'slicing',
+          dispatching: 'invio',
+          in_progress: 'stampa',
+          completed: 'completata',
+          failed: 'fallita',
+          cancelled: 'annullata',
+        },
+      },
     },
   },
 
@@ -3808,6 +3824,36 @@ export default {
     deleteConfirm: 'Sei sicuro di voler eliminare questo filamento?',
     importFromPrinter: 'Importa da stampante',
     exportToFile: 'Esporta su file',
+    runWithPipeline: {
+      actionLabel: 'Esegui con pipeline',
+      noPermission: 'Non hai il permesso di eseguire pipeline',
+      modalTitle: 'Esegui con pipeline',
+      confirmTitle: 'Conferma esecuzione',
+      confirmIntro: 'Il pre-volo ha trovato problemi con questa esecuzione',
+      sourceHint: 'Sorgente',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destinazione',
+      pipelineListAria: 'Pipeline disponibili',
+      runAnyway: 'Esegui comunque',
+      loading: 'Caricamento…',
+      empty: 'Nessuna pipeline salvata. Apri il dialogo Slice e fai clic su "Salva come pipeline" per crearne una.',
+      noTarget: 'Nessuna stampante di destinazione',
+      noTargetMessage: 'Questa pipeline non ha una stampante di destinazione. Aprila nelle Impostazioni per sceglierne una.',
+      toast: {
+        started: 'Esecuzione della pipeline avviata',
+        failed: 'Impossibile avviare l\'esecuzione',
+      },
+      issue: {
+        printerNotSet: 'Nessuna stampante di destinazione su questa pipeline.',
+        printerNotFound: 'La stampante di destinazione non esiste più.',
+        printerDisabled: 'La stampante di destinazione è disabilitata.',
+        printerOffline: 'La stampante di destinazione è offline.',
+        filamentType: 'Slot filamento {{slot}}: atteso {{expected}}, AMS ha {{actual}}',
+        filamentColor: 'Slot filamento {{slot}}: colore differente (atteso {{expected}}, AMS ha {{actual}})',
+        amsSlotMissing: 'Slot AMS {{slot}} non disponibile su questa stampante',
+        filamentUnverified: 'Lo slot filamento {{slot}} proviene da un preset cloud / standard e non può essere verificato staticamente.',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 46 - 0
frontend/src/i18n/locales/ja.ts

@@ -2570,6 +2570,8 @@ export default {
       field: {
         name: 'パイプライン名',
         description: '説明',
+        targetPrinter: '対象プリンター',
+        noTarget: '— 対象なし —',
       },
       action: {
         save: '保存',
@@ -2590,6 +2592,20 @@ export default {
         deleted: 'パイプラインを削除しました',
         deleteFailed: '削除に失敗しました',
       },
+      noTargetHint: '実行するには対象プリンターを設定してください',
+      noTargetWarning: 'このパイプラインを実行する前に対象プリンターを設定してください。',
+      runs: {
+        lastRun: '前回の実行',
+        status: {
+          queued: '待機中',
+          slicing: 'スライス中',
+          dispatching: '送信中',
+          in_progress: '印刷中',
+          completed: '完了',
+          failed: '失敗',
+          cancelled: 'キャンセル',
+        },
+      },
     },
   },
 
@@ -3820,6 +3836,36 @@ export default {
     deleteConfirm: 'このフィラメントを削除しますか?',
     importFromPrinter: 'プリンターからインポート',
     exportToFile: 'ファイルにエクスポート',
+    runWithPipeline: {
+      actionLabel: 'パイプラインで実行',
+      noPermission: 'パイプラインを実行する権限がありません',
+      modalTitle: 'パイプラインで実行',
+      confirmTitle: '実行を確認',
+      confirmIntro: 'プリフライトでこの実行に関する問題が見つかりました',
+      sourceHint: 'ソース',
+      pipelineHint: 'パイプライン',
+      targetHint: '対象',
+      pipelineListAria: '利用可能なパイプライン',
+      runAnyway: 'とにかく実行',
+      loading: '読み込み中…',
+      empty: '保存されたパイプラインはまだありません。スライスダイアログを開き「パイプラインとして保存」をクリックして作成してください。',
+      noTarget: '対象プリンターが未設定',
+      noTargetMessage: 'このパイプラインには対象プリンターが設定されていません。設定で開いて選択してください。',
+      toast: {
+        started: 'パイプラインの実行を開始しました',
+        failed: '実行を開始できませんでした',
+      },
+      issue: {
+        printerNotSet: 'このパイプラインに対象プリンターが設定されていません。',
+        printerNotFound: '対象プリンターが存在しません。',
+        printerDisabled: '対象プリンターは無効です。',
+        printerOffline: '対象プリンターはオフラインです。',
+        filamentType: 'フィラメントスロット {{slot}}: 期待 {{expected}}、AMS は {{actual}}',
+        filamentColor: 'フィラメントスロット {{slot}}: 色が異なります(期待 {{expected}}、AMS は {{actual}})',
+        amsSlotMissing: 'AMS スロット {{slot}} はこのプリンターで利用できません',
+        filamentUnverified: 'フィラメントスロット {{slot}} はクラウド/標準プリセットからのため、静的に検証できませんでした。',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 47 - 1
frontend/src/i18n/locales/ko.ts

@@ -2426,6 +2426,8 @@ export default {
       field: {
         name: '파이프라인 이름',
         description: '설명',
+        targetPrinter: '대상 프린터',
+        noTarget: '— 대상 없음 —',
       },
       action: {
         save: '저장',
@@ -2446,6 +2448,20 @@ export default {
         deleted: '파이프라인이 삭제되었습니다',
         deleteFailed: '삭제 실패',
       },
+      noTargetHint: '실행하려면 대상 프린터를 설정하세요',
+      noTargetWarning: '이 파이프라인을 실행하기 전에 대상 프린터를 설정하세요.',
+      runs: {
+        lastRun: '마지막 실행',
+        status: {
+          queued: '대기 중',
+          slicing: '슬라이싱 중',
+          dispatching: '전송 중',
+          in_progress: '인쇄 중',
+          completed: '완료됨',
+          failed: '실패',
+          cancelled: '취소됨',
+        },
+      },
     },
   },
   notification: {
@@ -3614,7 +3630,37 @@ export default {
     noFilaments: '라이브러리에 필라멘트 없음',
     deleteConfirm: '이 필라멘트를 삭제하시겠습니까?',
     importFromPrinter: '프린터에서 가져오기',
-    exportToFile: '파일로 내보내기'
+    exportToFile: '파일로 내보내기',
+    runWithPipeline: {
+      actionLabel: '파이프라인으로 실행',
+      noPermission: '파이프라인을 실행할 권한이 없습니다',
+      modalTitle: '파이프라인으로 실행',
+      confirmTitle: '실행 확인',
+      confirmIntro: '사전 점검에서 이 실행과 관련된 문제를 발견했습니다',
+      sourceHint: '소스',
+      pipelineHint: '파이프라인',
+      targetHint: '대상',
+      pipelineListAria: '사용 가능한 파이프라인',
+      runAnyway: '그래도 실행',
+      loading: '불러오는 중…',
+      empty: '저장된 파이프라인이 없습니다. 슬라이스 대화상자를 열고 "파이프라인으로 저장"을 클릭하여 생성하세요.',
+      noTarget: '대상 프린터가 설정되지 않음',
+      noTargetMessage: '이 파이프라인에는 대상 프린터가 없습니다. 설정에서 열어 선택하세요.',
+      toast: {
+        started: '파이프라인 실행 시작됨',
+        failed: '실행을 시작할 수 없습니다',
+      },
+      issue: {
+        printerNotSet: '이 파이프라인에 대상 프린터가 설정되어 있지 않습니다.',
+        printerNotFound: '대상 프린터가 더 이상 존재하지 않습니다.',
+        printerDisabled: '대상 프린터가 비활성화되어 있습니다.',
+        printerOffline: '대상 프린터가 오프라인입니다.',
+        filamentType: '필라멘트 슬롯 {{slot}}: 예상 {{expected}}, AMS 실제 {{actual}}',
+        filamentColor: '필라멘트 슬롯 {{slot}}: 색상이 다릅니다 (예상 {{expected}}, AMS {{actual}})',
+        amsSlotMissing: 'AMS 슬롯 {{slot}}이(가) 이 프린터에서 사용 불가',
+        filamentUnverified: '필라멘트 슬롯 {{slot}}은(는) 클라우드/표준 프리셋이며 정적으로 검증할 수 없습니다.',
+      },
+    },
   },
   slice: {
     title: '모델 슬라이싱',

+ 46 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2558,6 +2558,8 @@ export default {
       field: {
         name: 'Nome da pipeline',
         description: 'Descrição',
+        targetPrinter: 'Impressora de destino',
+        noTarget: '— Sem destino —',
       },
       action: {
         save: 'Salvar',
@@ -2578,6 +2580,20 @@ export default {
         deleted: 'Pipeline excluída',
         deleteFailed: 'Falha ao excluir',
       },
+      noTargetHint: 'Defina uma impressora de destino para executar',
+      noTargetWarning: 'Defina uma impressora de destino antes de executar esta pipeline.',
+      runs: {
+        lastRun: 'Última execução',
+        status: {
+          queued: 'na fila',
+          slicing: 'cortando',
+          dispatching: 'enviando',
+          in_progress: 'imprimindo',
+          completed: 'concluída',
+          failed: 'falhou',
+          cancelled: 'cancelada',
+        },
+      },
     },
   },
 
@@ -3808,6 +3824,36 @@ export default {
     deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',
     importFromPrinter: 'Importar da Impressora',
     exportToFile: 'Exportar para Arquivo',
+    runWithPipeline: {
+      actionLabel: 'Executar com pipeline',
+      noPermission: 'Você não tem permissão para executar pipelines',
+      modalTitle: 'Executar com pipeline',
+      confirmTitle: 'Confirmar execução',
+      confirmIntro: 'A pré-verificação encontrou problemas com esta execução',
+      sourceHint: 'Origem',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destino',
+      pipelineListAria: 'Pipelines disponíveis',
+      runAnyway: 'Executar mesmo assim',
+      loading: 'Carregando…',
+      empty: 'Ainda não há pipelines salvas. Abra o diálogo Cortar e clique em "Salvar como pipeline" para criar uma.',
+      noTarget: 'Sem impressora de destino',
+      noTargetMessage: 'Esta pipeline não tem impressora de destino. Abra-a em Configurações para escolher uma.',
+      toast: {
+        started: 'Execução da pipeline iniciada',
+        failed: 'Não foi possível iniciar a execução',
+      },
+      issue: {
+        printerNotSet: 'Sem impressora de destino nesta pipeline.',
+        printerNotFound: 'Impressora de destino não existe mais.',
+        printerDisabled: 'Impressora de destino está desativada.',
+        printerOffline: 'Impressora de destino está offline.',
+        filamentType: 'Slot de filamento {{slot}}: esperado {{expected}}, AMS tem {{actual}}',
+        filamentColor: 'Slot de filamento {{slot}}: cor diferente (esperado {{expected}}, AMS tem {{actual}})',
+        amsSlotMissing: 'Slot AMS {{slot}} indisponível nesta impressora',
+        filamentUnverified: 'Slot de filamento {{slot}} vem de uma predefinição da nuvem / padrão e não pôde ser verificado estaticamente.',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 46 - 0
frontend/src/i18n/locales/tr.ts

@@ -2574,6 +2574,8 @@ export default {
       field: {
         name: 'Pipeline adı',
         description: 'Açıklama',
+        targetPrinter: 'Hedef yazıcı',
+        noTarget: '— Hedef yok —',
       },
       action: {
         save: 'Kaydet',
@@ -2594,6 +2596,20 @@ export default {
         deleted: 'Pipeline silindi',
         deleteFailed: 'Silme başarısız',
       },
+      noTargetHint: 'Çalıştırmak için bir hedef yazıcı belirleyin',
+      noTargetWarning: 'Bu pipeline\'ı çalıştırmadan önce bir hedef yazıcı belirleyin.',
+      runs: {
+        lastRun: 'Son çalıştırma',
+        status: {
+          queued: 'kuyrukta',
+          slicing: 'dilimleniyor',
+          dispatching: 'gönderiliyor',
+          in_progress: 'yazdırılıyor',
+          completed: 'tamamlandı',
+          failed: 'başarısız',
+          cancelled: 'iptal edildi',
+        },
+      },
     },
   },
 
@@ -3810,6 +3826,36 @@ export default {
     deleteConfirm: 'Bu filamenti silmek istediğinizden emin misiniz?',
     importFromPrinter: 'Yazıcıdan İçe Aktar',
     exportToFile: 'Dosyaya Dışa Aktar',
+    runWithPipeline: {
+      actionLabel: 'Pipeline ile çalıştır',
+      noPermission: 'Pipeline çalıştırma izniniz yok',
+      modalTitle: 'Pipeline ile çalıştır',
+      confirmTitle: 'Çalıştırmayı onayla',
+      confirmIntro: 'Ön kontrol bu çalıştırmayla ilgili sorunlar buldu',
+      sourceHint: 'Kaynak',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Hedef',
+      pipelineListAria: 'Kullanılabilir pipeline\'lar',
+      runAnyway: 'Yine de çalıştır',
+      loading: 'Yükleniyor…',
+      empty: 'Henüz kayıtlı pipeline yok. Dilimleme diyalogunu açın ve oluşturmak için "Pipeline olarak kaydet"e tıklayın.',
+      noTarget: 'Hedef yazıcı belirlenmemiş',
+      noTargetMessage: 'Bu pipeline\'ın hedef yazıcısı yok. Birini seçmek için Ayarlar\'da açın.',
+      toast: {
+        started: 'Pipeline çalıştırması başladı',
+        failed: 'Çalıştırma başlatılamadı',
+      },
+      issue: {
+        printerNotSet: 'Bu pipeline\'da hedef yazıcı belirlenmemiş.',
+        printerNotFound: 'Hedef yazıcı artık mevcut değil.',
+        printerDisabled: 'Hedef yazıcı devre dışı.',
+        printerOffline: 'Hedef yazıcı çevrimdışı.',
+        filamentType: 'Filament yuvası {{slot}}: beklenen {{expected}}, AMS\'de {{actual}}',
+        filamentColor: 'Filament yuvası {{slot}}: renk farklı (beklenen {{expected}}, AMS\'de {{actual}})',
+        amsSlotMissing: 'AMS yuvası {{slot}} bu yazıcıda yok',
+        filamentUnverified: 'Filament yuvası {{slot}} bulut / standart ön ayardan geldiği için statik olarak doğrulanamadı.',
+      },
+    },
   },
 
   // Dilimle (SliceModal ile slicer-API entegrasyonu)

+ 46 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2558,6 +2558,8 @@ export default {
       field: {
         name: '流水线名称',
         description: '描述',
+        targetPrinter: '目标打印机',
+        noTarget: '— 无目标 —',
       },
       action: {
         save: '保存',
@@ -2578,6 +2580,20 @@ export default {
         deleted: '流水线已删除',
         deleteFailed: '删除失败',
       },
+      noTargetHint: '设置目标打印机以运行',
+      noTargetWarning: '运行此流水线前请先设置目标打印机。',
+      runs: {
+        lastRun: '上次运行',
+        status: {
+          queued: '排队中',
+          slicing: '切片中',
+          dispatching: '发送中',
+          in_progress: '打印中',
+          completed: '已完成',
+          failed: '失败',
+          cancelled: '已取消',
+        },
+      },
     },
   },
 
@@ -3808,6 +3824,36 @@ export default {
     deleteConfirm: '确定要删除此耗材吗?',
     importFromPrinter: '从打印机导入',
     exportToFile: '导出到文件',
+    runWithPipeline: {
+      actionLabel: '用流水线运行',
+      noPermission: '您没有运行流水线的权限',
+      modalTitle: '用流水线运行',
+      confirmTitle: '确认运行',
+      confirmIntro: '预检发现此次运行存在问题',
+      sourceHint: '来源',
+      pipelineHint: '流水线',
+      targetHint: '目标',
+      pipelineListAria: '可用流水线',
+      runAnyway: '仍然运行',
+      loading: '加载中…',
+      empty: '尚未保存流水线。请打开切片对话框并点击"另存为流水线"创建一个。',
+      noTarget: '未设置目标打印机',
+      noTargetMessage: '此流水线没有目标打印机。请在设置中打开并选择一个。',
+      toast: {
+        started: '流水线运行已开始',
+        failed: '无法启动运行',
+      },
+      issue: {
+        printerNotSet: '此流水线未设置目标打印机。',
+        printerNotFound: '目标打印机已不存在。',
+        printerDisabled: '目标打印机已禁用。',
+        printerOffline: '目标打印机已离线。',
+        filamentType: '耗材槽 {{slot}}:期望 {{expected}},AMS 实际 {{actual}}',
+        filamentColor: '耗材槽 {{slot}}:颜色不同(期望 {{expected}},AMS 实际 {{actual}})',
+        amsSlotMissing: '此打印机上没有 AMS 槽 {{slot}}',
+        filamentUnverified: '耗材槽 {{slot}} 来自云端 / 标准预设,无法静态验证。',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 46 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2558,6 +2558,8 @@ export default {
       field: {
         name: '管線名稱',
         description: '描述',
+        targetPrinter: '目標印表機',
+        noTarget: '— 無目標 —',
       },
       action: {
         save: '儲存',
@@ -2578,6 +2580,20 @@ export default {
         deleted: '管線已刪除',
         deleteFailed: '刪除失敗',
       },
+      noTargetHint: '設定目標印表機以執行',
+      noTargetWarning: '執行此管線前請先設定目標印表機。',
+      runs: {
+        lastRun: '上次執行',
+        status: {
+          queued: '排隊中',
+          slicing: '切片中',
+          dispatching: '傳送中',
+          in_progress: '列印中',
+          completed: '已完成',
+          failed: '失敗',
+          cancelled: '已取消',
+        },
+      },
     },
   },
 
@@ -3808,6 +3824,36 @@ export default {
     deleteConfirm: '確定要刪除此耗材嗎?',
     importFromPrinter: '從印表機匯入',
     exportToFile: '匯出到檔案',
+    runWithPipeline: {
+      actionLabel: '以管線執行',
+      noPermission: '您沒有執行管線的權限',
+      modalTitle: '以管線執行',
+      confirmTitle: '確認執行',
+      confirmIntro: '預檢發現此次執行存在問題',
+      sourceHint: '來源',
+      pipelineHint: '管線',
+      targetHint: '目標',
+      pipelineListAria: '可用管線',
+      runAnyway: '仍然執行',
+      loading: '載入中…',
+      empty: '尚未儲存管線。請開啟切片對話框並點擊「另存為管線」建立一個。',
+      noTarget: '未設定目標印表機',
+      noTargetMessage: '此管線沒有目標印表機。請在設定中開啟並選擇一個。',
+      toast: {
+        started: '管線執行已開始',
+        failed: '無法啟動執行',
+      },
+      issue: {
+        printerNotSet: '此管線未設定目標印表機。',
+        printerNotFound: '目標印表機已不存在。',
+        printerDisabled: '目標印表機已停用。',
+        printerOffline: '目標印表機已離線。',
+        filamentType: '耗材槽 {{slot}}:預期 {{expected}},AMS 實際 {{actual}}',
+        filamentColor: '耗材槽 {{slot}}:顏色不同(預期 {{expected}},AMS 實際 {{actual}})',
+        amsSlotMissing: '此印表機上沒有 AMS 槽 {{slot}}',
+        filamentUnverified: '耗材槽 {{slot}} 來自雲端 / 標準預設,無法靜態驗證。',
+      },
+    },
   },
 
   // Slice (slicer-API integration via SliceModal)

+ 47 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -59,6 +59,7 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
+import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
@@ -183,6 +184,7 @@ function ArchiveCard({
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
+  const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   // #1343: when true, the delete also drops the row from Quick Stats. Default
   // off — soft delete preserves the archive's filament/time/cost contribution.
@@ -438,6 +440,19 @@ function ArchiveCard({
           }
         },
       },
+      // Run-with-pipeline (#1425 PR B follow-up). Sources from archive's
+      // source 3MF (or file_path fallback). Only when slicer-api is on.
+      ...(useSlicerApi
+        ? [{
+            label: t('library.runWithPipeline.actionLabel'),
+            icon: <Play className="w-4 h-4" />,
+            onClick: () => setShowRunPipeline(true),
+            disabled: !hasPermission('pipelines:run'),
+            title: !hasPermission('pipelines:run')
+              ? t('library.runWithPipeline.noPermission')
+              : undefined,
+          }]
+        : []),
     ]),
     {
       label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),
@@ -1277,6 +1292,15 @@ function ArchiveCard({
         />
       )}
 
+      {/* Run-with-Pipeline Modal (#1425 PR B). Sources from archive — backend
+          reads source_3mf_path, falls back to file_path. */}
+      {showRunPipeline && (
+        <RunWithPipelineModal
+          source={{ kind: 'archive', id: archive.id, filename: archive.print_name || archive.filename || 'model' }}
+          onClose={() => setShowRunPipeline(false)}
+        />
+      )}
+
       {/* Delete Confirmation */}
       {showDeleteConfirm && (
         <ConfirmModal
@@ -1582,6 +1606,7 @@ function ArchiveListRow({
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
+  const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
@@ -1797,6 +1822,19 @@ function ArchiveListRow({
           }
         },
       },
+      // Run-with-pipeline (#1425 PR B follow-up). Sources from archive's
+      // source 3MF (or file_path fallback). Only when slicer-api is on.
+      ...(useSlicerApi
+        ? [{
+            label: t('library.runWithPipeline.actionLabel'),
+            icon: <Play className="w-4 h-4" />,
+            onClick: () => setShowRunPipeline(true),
+            disabled: !hasPermission('pipelines:run'),
+            title: !hasPermission('pipelines:run')
+              ? t('library.runWithPipeline.noPermission')
+              : undefined,
+          }]
+        : []),
     ]),
     {
       label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),
@@ -2280,6 +2318,15 @@ function ArchiveListRow({
         />
       )}
 
+      {/* Run-with-Pipeline Modal (#1425 PR B). Sources from archive — backend
+          reads source_3mf_path, falls back to file_path. */}
+      {showRunPipeline && (
+        <RunWithPipelineModal
+          source={{ kind: 'archive', id: archive.id, filename: archive.print_name || archive.filename || 'model' }}
+          onClose={() => setShowRunPipeline(false)}
+        />
+      )}
+
       {/* Delete Confirmation */}
       {showDeleteConfirm && (
         <ConfirmModal

+ 41 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -32,6 +32,7 @@ import {
   Archive as ArchiveIcon,
   Briefcase,
   Cog,
+  Play,
   Printer,
   Pencil,
   Image,
@@ -58,6 +59,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
+import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
 import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FolderReadmePanel } from '../components/FolderReadmePanel';
@@ -729,6 +731,7 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
+  onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
@@ -741,7 +744,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -864,6 +867,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('slice.action')}
                 </button>
               )}
+              {onRunPipeline && useSlicerApi && isSliceableFilename(file.filename) && (
+                <button
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('pipelines:run') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('pipelines:run')) { onRunPipeline(file); setShowActions(false); } }}
+                  disabled={!hasPermission('pipelines:run')}
+                  title={!hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined}
+                >
+                  <Play className="w-3.5 h-3.5" />
+                  {t('library.runWithPipeline.actionLabel')}
+                </button>
+              )}
               {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf') && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
@@ -978,6 +994,8 @@ export function FileManagerPage() {
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
+  // Slicer Pipelines (#1425 PR B) — file gets "Run with pipeline" action.
+  const [runPipelineFile, setRunPipelineFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
@@ -2265,6 +2283,7 @@ export function FileManagerPage() {
                     onDownload={handleDownload}
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
+                    onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     onPreview3d={(f) => {
                       // Sliced files (.gcode / .gcode.3mf) open the same
@@ -2446,6 +2465,20 @@ export function FileManagerPage() {
                           <Cog className="w-4 h-4" />
                         </button>
                       )}
+                      {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+                        <button
+                          onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('pipelines:run')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('pipelines:run') ? t('library.runWithPipeline.actionLabel', 'Run with pipeline') : t('library.runWithPipeline.noPermission', 'You do not have permission to run pipelines')}
+                          disabled={!hasPermission('pipelines:run')}
+                        >
+                          <Play className="w-4 h-4" />
+                        </button>
+                      )}
                       {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl') && (
                         <button
                           onClick={() => {
@@ -2648,6 +2681,13 @@ export function FileManagerPage() {
         />
       )}
 
+      {runPipelineFile && (
+        <RunWithPipelineModal
+          source={{ kind: 'libraryFile', id: runPipelineFile.id, filename: runPipelineFile.filename }}
+          onClose={() => setRunPipelineFile(null)}
+        />
+      )}
+
       {viewerFile && (
         <ModelViewerModal
           libraryFileId={viewerFile.id}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-Bydn78aP.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-CEw4Iy3m.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D51oc-O3.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CcI7-4jx.css">
+    <script type="module" crossorigin src="/assets/index-CEw4Iy3m.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bydn78aP.css">
   </head>
   <body>
     <div id="root"></div>

+ 0 - 0
test_pipeline_archive_source.3mf


+ 0 - 0
test_pipeline_run_1.3mf


이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.