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.
This commit is contained in:
maziggy
2026-06-27 15:01:14 +02:00
parent d6bdb7e200
commit 4bbf0f031e
34 changed files with 3266 additions and 177 deletions
+112 -1
View File
@@ -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