Przeglądaj źródła

Let API keys read and run slicer pipelines (#1425 follow-up)

    Every pipeline endpoint answered 403 for API keys whatever scopes the key
    carried. PR A parked all three permissions on the admin denylist until the
    run dispatch existed to decide about; it landed in PR C and the parking was
    never revisited.

    PIPELINES_READ now rides can_read_status. PIPELINES_RUN requires
    can_queue AND can_manage_library together, so the allowlist gained tuple
    values: a run slices into the library and then queues prints, and mapping
    it to either flag alone would hand that flag the other one's authority.
    The 403 names every flag the key is short of. PIPELINES_WRITE stays
    admin-only -- a key can run the recipe, not rewrite it or clear the log.

    Opening the run route also needed the cloud-owner fallback the direct
    slice route makes: a pipeline can carry Bambu/Orca Cloud presets, and
    resolving those reads a token off a user record that an API-keyed request
    does not have. retry_failed forwards the new dependency explicitly,
    since a direct call receives the Depends marker rather than None.
maziggy 3 tygodni temu
rodzic
commit
2d324dc7d0
29 zmienionych plików z 735 dodań i 97 usunięć
  1. 28 5
      backend/app/api/routes/pipeline_runs.py
  2. 62 19
      backend/app/core/auth.py
  3. 43 0
      backend/tests/_fixtures/background_tasks.py
  4. 237 4
      backend/tests/integration/test_auth_apikey_rbac.py
  5. 209 0
      backend/tests/integration/test_pipeline_runs_api.py
  6. 2 1
      backend/tests/integration/test_scheduler_budget_reservation.py
  7. 2 1
      backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py
  8. 5 9
      backend/tests/unit/test_printer_offline_notification.py
  9. 2 1
      backend/tests/unit/test_scheduler_busy_defer_2598.py
  10. 2 1
      backend/tests/unit/test_scheduler_cancel_race.py
  11. 2 1
      backend/tests/unit/test_scheduler_cleanup_library.py
  12. 2 1
      backend/tests/unit/test_scheduler_nozzle_mismatch.py
  13. 2 1
      backend/tests/unit/test_scheduler_release_conn_before_ftp_2572.py
  14. 84 0
      backend/tests/unit/test_spawn_patch_does_not_leak_coroutines.py
  15. 4 4
      frontend/src/i18n/locales/de.ts
  16. 4 4
      frontend/src/i18n/locales/en.ts
  17. 4 4
      frontend/src/i18n/locales/es.ts
  18. 4 4
      frontend/src/i18n/locales/fr.ts
  19. 4 4
      frontend/src/i18n/locales/it.ts
  20. 4 4
      frontend/src/i18n/locales/ja.ts
  21. 4 4
      frontend/src/i18n/locales/ko.ts
  22. 4 4
      frontend/src/i18n/locales/pt-BR.ts
  23. 4 4
      frontend/src/i18n/locales/ru.ts
  24. 4 4
      frontend/src/i18n/locales/tr.ts
  25. 4 4
      frontend/src/i18n/locales/uk.ts
  26. 4 4
      frontend/src/i18n/locales/zh-CN.ts
  27. 4 4
      frontend/src/i18n/locales/zh-TW.ts
  28. 0 0
      static/assets/index-DwuX91sA.js
  29. 1 1
      static/index.html

+ 28 - 5
backend/app/api/routes/pipeline_runs.py

@@ -33,6 +33,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 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
@@ -663,12 +664,16 @@ async def run_pipeline(
     pipeline_id: int,
     body: PipelineRunCreateRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     db: AsyncSession = Depends(get_db),
 ):
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_dispatch import slice_dispatch
 
     pipeline = await _load_pipeline(db, pipeline_id)
+    # ``user=current_user`` deliberately, not the cloud owner below: an API-key
+    # caller has no per-row identity and must keep can_read_all, the same as
+    # every other read helper.
     src_kind, src_id, src_filename, src_path = await _resolve_source(
         db,
         library_file_id=body.source_library_file_id,
@@ -676,6 +681,14 @@ async def run_pipeline(
         user=current_user,
     )
 
+    # The permission gate answers an API-keyed request with current_user=None,
+    # so a pipeline built on Bambu/Orca Cloud presets would have nobody whose
+    # stored cloud token could resolve them. Fall back to the key's owner, the
+    # same fallback POST /library/files/{id}/slice makes (#1182 follow-up).
+    # Only keys with the cloud scope resolve to an owner here; everything else
+    # stays None and slices against local presets exactly as before.
+    creator = current_user or api_key_cloud_owner
+
     # Cap copies against the configured ceiling.
     raw_cap = await get_setting(db, "pipeline_max_copies")
     try:
@@ -712,7 +725,7 @@ async def run_pipeline(
         copies=body.copies,
         status="queued",
         eligibility_overridden=(not report.ok and body.force),
-        created_by=current_user.id if current_user else None,
+        created_by=creator.id if creator else None,
     )
     db.add(run)
     await db.flush()
@@ -737,14 +750,14 @@ async def run_pipeline(
         src_id=src_id,
         src_filename=src_filename,
         src_path=src_path,
-        creator_user_id=current_user.id if current_user else None,
+        creator_user_id=creator.id if creator else None,
         copies=body.copies,
     )
     slice_job = await slice_dispatch.enqueue(
         kind="library_file" if src_kind == "library_file" else "archive",
         source_id=src_id,
         source_name=src_filename,
-        owner_id=current_user.id if current_user else None,
+        owner_id=creator.id if creator else None,
         run=orchestrate,
     )
 
@@ -915,6 +928,7 @@ async def cancel_run(
 async def retry_failed(
     run_id: int,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     db: AsyncSession = Depends(get_db),
 ):
     """Create a new run with copies = (failed + cancelled count) from the
@@ -955,8 +969,17 @@ async def retry_failed(
     )
 
     # Reuse the run_pipeline route logic via a direct call — keeps the
-    # orchestration single-sourced. The result inherits parent_run_id.
-    new_run_response = await run_pipeline(parent.pipeline_id, body, current_user=current_user, db=db)
+    # orchestration single-sourced. The result inherits parent_run_id. Every
+    # dependency it declares has to be forwarded explicitly: FastAPI resolves
+    # those only for a routed request, so an omitted one would arrive as the
+    # Depends() marker object itself rather than as None.
+    new_run_response = await run_pipeline(
+        parent.pipeline_id,
+        body,
+        current_user=current_user,
+        api_key_cloud_owner=api_key_cloud_owner,
+        db=db,
+    )
 
     # Stamp parent_run_id on the freshly-created run.
     new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()

+ 62 - 19
backend/app/core/auth.py

@@ -68,7 +68,13 @@ logger = logging.getLogger(__name__)
 #                           delete of admin resources, settings writes, user/
 #                           group/api-key/backup admin ops, discovery scan,
 #                           cloud auth, library ALL-ownership perms, purges
-_APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
+#
+# A value may be a tuple of scope flags, in which case ALL of them must be True
+# on the key. That is for the rare permission whose route spans two trust
+# dimensions the operator toggles separately — see ``PIPELINES_RUN`` below.
+# Prefer a single flag; a tuple is a statement that neither flag alone
+# authorises what the route does.
+_APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     # can_read_status — read-only access to status, history, and configuration
     Permission.PRINTERS_READ: "can_read_status",
     # Legacy flat permissions retained for back-compat with custom API keys —
@@ -114,6 +120,10 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     # working (they need the UI-language setting via API key).
     Permission.SETTINGS_READ: "can_read_status",
     Permission.MAKERWORLD_VIEW: "can_read_status",
+    # Pipeline definitions and run history are configuration + status: listing
+    # pipelines, reading a run, and the (write-free) POST check-eligibility
+    # pre-flight. Authoring stays admin-only under PIPELINES_WRITE.
+    Permission.PIPELINES_READ: "can_read_status",
     Permission.WEBSOCKET_CONNECT: "can_read_status",
     # can_queue — queue write ops + reprint (which enqueues an existing archive)
     Permission.QUEUE_CREATE: "can_queue",
@@ -194,6 +204,17 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.PROJECTS_CREATE: "can_manage_projects",
     Permission.PROJECTS_UPDATE: "can_manage_projects",
     Permission.PROJECTS_DELETE: "can_manage_projects",
+    # can_queue AND can_manage_library — running a pipeline does two things a
+    # key is separately trusted with. It slices the source into a new library
+    # file (``slice_and_persist``, the same write the direct
+    # ``POST /library/files/{id}/slice`` route gates on LIBRARY_UPLOAD →
+    # can_manage_library), then creates one PrintQueueItem per copy for the
+    # scheduler to dispatch (can_queue). Mapping it to either flag alone would
+    # hand that flag the other one's authority, so both are required. Cancelling
+    # a run is the same permission — whoever may start one may stop it. PR A
+    # parked all three pipeline permissions on the denylist "until the run
+    # dispatch lands"; it landed in PR C (#1425) and this is that follow-up.
+    Permission.PIPELINES_RUN: ("can_queue", "can_manage_library"),
     # can_access_cloud — narrow opt-in scope, gated by the router-level
     # ``_cloud_api_key_gate`` and additionally enforced here so the route-
     # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
@@ -285,27 +306,34 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.SMART_PLUGS_DELETE,
         # Network scanning — operator only (no API-key scope for this).
         Permission.DISCOVERY_SCAN,
-        # Slicer Pipelines (#1425) — admin authoring + the print-spending Run
-        # action. PR A only ships CRUD; PR B / PR C may move PIPELINES_RUN onto
-        # `can_queue` (it queues prints) once the run dispatch lands. PR A keeps
-        # all three denied so they fail closed for any API-key surface.
-        Permission.PIPELINES_READ,
+        # Slicer Pipelines (#1425) — authoring only. PIPELINES_READ and
+        # PIPELINES_RUN moved to the allowlist once PR C landed the run
+        # dispatch; PIPELINES_WRITE stays denied because it creates/edits/
+        # deletes the pipeline definition (slicer settings, target printer,
+        # fanout strategy) and, via `POST /pipeline-runs/clear`, drops run
+        # history. That is admin authoring, matching the other resource-CRUD
+        # entries here — a key that may run a pipeline cannot rewrite what it
+        # does.
         Permission.PIPELINES_WRITE,
-        Permission.PIPELINES_RUN,
     }
 )
 
 
-def _resolve_apikey_scope(perm_string: str) -> str | None:
-    """Return the scope-flag attribute name gating ``perm_string`` for API keys.
+def _required_apikey_scopes(perm_string: str) -> tuple[str, ...] | None:
+    """Return every scope flag a key must hold to exercise ``perm_string``.
 
-    None when the permission is unmapped (= admin-only / not API-key-usable).
+    None when the permission is unmapped (= admin-only / not API-key-usable),
+    which is distinct from an empty tuple — the latter would read as "no flags
+    needed" and must never be produced.
     """
     try:
         perm = Permission(perm_string)
     except ValueError:
         return None
-    return _APIKEY_SCOPE_BY_PERMISSION.get(perm)
+    scopes = _APIKEY_SCOPE_BY_PERMISSION.get(perm)
+    if scopes is None:
+        return None
+    return (scopes,) if isinstance(scopes, str) else tuple(scopes)
 
 
 def apikey_effective_permissions(api_key: APIKey, owner: User | None = None) -> list[str]:
@@ -321,10 +349,20 @@ def apikey_effective_permissions(api_key: APIKey, owner: User | None = None) ->
     an owned key must pass the owner, or ``/auth/me`` will over-report and drift
     from the gate, which is the defect #1894 was about.
     """
+
+    def _granted(perm: Permission) -> bool:
+        scopes = _required_apikey_scopes(perm.value)
+        # An unmapped permission cannot occur here (we iterate the mapping
+        # itself), but treat it as denied rather than as "no flags to satisfy",
+        # which ``all(())`` would otherwise report as granted.
+        if not scopes:
+            return False
+        return all(getattr(api_key, flag, False) for flag in scopes)
+
     return sorted(
         perm.value
-        for perm, scope_attr in _APIKEY_SCOPE_BY_PERMISSION.items()
-        if getattr(api_key, scope_attr, False) and (owner is None or owner.has_permission(perm.value))
+        for perm in _APIKEY_SCOPE_BY_PERMISSION
+        if _granted(perm) and (owner is None or owner.has_permission(perm.value))
     )
 
 
@@ -379,8 +417,9 @@ def _check_apikey_permissions(
     """Raise 403 unless ``api_key`` is allowed to use ``perm_strings``.
 
     Allowlist semantics: every requested permission MUST be present in
-    ``_APIKEY_SCOPE_BY_PERMISSION`` AND its scope flag must be True on
-    ``api_key``. Unmapped permissions = administrative = 403.
+    ``_APIKEY_SCOPE_BY_PERMISSION`` AND every scope flag it maps to must be
+    True on ``api_key`` (most map to one; a few require several). Unmapped
+    permissions = administrative = 403.
 
     A key must not out-rank the user it belongs to, so when ``owner`` is given
     the permission must additionally be one the owner holds. Scope flags are
@@ -407,16 +446,20 @@ def _check_apikey_permissions(
 
     last_failure: HTTPException | None = None
     for perm_str in perm_strings:
-        scope_attr = _resolve_apikey_scope(perm_str)
-        if scope_attr is None:
+        scopes = _required_apikey_scopes(perm_str)
+        missing = [flag for flag in scopes or () if not getattr(api_key, flag, False)]
+        if not scopes:
             failure = HTTPException(
                 status_code=status.HTTP_403_FORBIDDEN,
                 detail="API keys cannot be used for administrative operations",
             )
-        elif not getattr(api_key, scope_attr, False):
+        elif missing:
+            # Name every flag the key is short of, not just the first: a
+            # permission requiring two scopes would otherwise send the operator
+            # round the loop twice, ticking one box per 403.
             failure = HTTPException(
                 status_code=status.HTTP_403_FORBIDDEN,
-                detail=f"API key does not have '{scope_attr}' permission",
+                detail=f"API key does not have {' and '.join(repr(flag) for flag in missing)} permission",
             )
         elif owner is not None and not owner.has_permission(perm_str):
             failure = HTTPException(

+ 43 - 0
backend/tests/_fixtures/background_tasks.py

@@ -0,0 +1,43 @@
+"""Patching ``spawn_background_task`` without leaking the coroutine.
+
+Every caller builds its coroutine as a *call argument*::
+
+    spawn_background_task(self._watchdog_print_start(...), name=...)
+
+so the coroutine object is constructed whether or not the replacement ever
+schedules it. A bare ``MagicMock`` then parks it in ``call_args`` and it is
+finalised, never awaited, during some *later* test's garbage collection —
+surfacing as a ``PytestUnraisableExceptionWarning`` attributed to whichever
+unrelated test happened to be running at the time. That makes the report
+useless for finding the leak and, because it depends on GC timing and test
+order, it appears and disappears between runs of the same suite.
+
+Closing the coroutine mirrors what the real helper does — take ownership of it
+— while still keeping the work from running.
+
+``DEFAULT`` rather than the ``close()`` return: the mock's return value stands
+in for the ``asyncio.Task``, and the queue-pool path in ``_process_queue``
+calls ``task.add_done_callback(...)`` on it. Returning ``None`` from the
+side-effect would replace the usual ``MagicMock`` return with ``None`` and
+break that caller.
+"""
+
+from unittest.mock import DEFAULT, patch
+
+SCHEDULER_TARGET = "backend.app.services.print_scheduler.spawn_background_task"
+MAIN_TARGET = "backend.app.main.spawn_background_task"
+
+
+def close_and_default(coro, **kwargs):
+    """Take ownership of ``coro`` the way the real helper would, then stand down."""
+    coro.close()
+    return DEFAULT
+
+
+def discarding_spawn_patch(target: str = SCHEDULER_TARGET):
+    """``patch`` for ``spawn_background_task`` that closes what it is handed.
+
+    A drop-in for ``patch(target, MagicMock())`` — still a mock, so call
+    assertions work — that does not leave an un-awaited coroutine behind.
+    """
+    return patch(target, side_effect=close_and_default)

+ 237 - 4
backend/tests/integration/test_auth_apikey_rbac.py

@@ -178,6 +178,23 @@ class TestApiKeyDenylistIntegrity:
         assert not incorrectly_denied, f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"
 
 
+def _flags_in_use() -> set[str]:
+    """Every scope flag named anywhere in the allowlist.
+
+    A mapping value is normally one flag but may be a tuple of flags that must
+    all be held (PIPELINES_RUN). Reading ``.values()`` directly would put that
+    tuple into the set and make both drift checks below wrong in opposite
+    directions: an unknown-flag alarm for the tuple, and a false "dead flag"
+    for whichever flags only appear inside one.
+    """
+    from backend.app.core.auth import _APIKEY_SCOPE_BY_PERMISSION
+
+    flags: set[str] = set()
+    for value in _APIKEY_SCOPE_BY_PERMISSION.values():
+        flags.update((value,) if isinstance(value, str) else value)
+    return flags
+
+
 class TestApiKeyScopeAllowlist:
     """GHSA-r2qv-8222-hqg3 (CVSS 9.9) — allowlist-based scope enforcement.
 
@@ -233,7 +250,7 @@ class TestApiKeyScopeAllowlist:
             "can_manage_projects",
             "can_access_cloud",
         }
-        used_flags = set(_APIKEY_SCOPE_BY_PERMISSION.values())
+        used_flags = _flags_in_use()
         assert used_flags <= valid_flags, f"Unknown scope flags in mapping: {used_flags - valid_flags}"
         # And every flag must actually exist on the model.
         for flag in valid_flags:
@@ -265,9 +282,7 @@ class TestApiKeyScopeAllowlist:
     )
     def test_each_scope_flag_has_at_least_one_permission(self, scope_flag):
         """If a scope flag has no permissions, it's dead code — fail loudly."""
-        from backend.app.core.auth import _APIKEY_SCOPE_BY_PERMISSION
-
-        assert scope_flag in _APIKEY_SCOPE_BY_PERMISSION.values(), (
+        assert scope_flag in _flags_in_use(), (
             f"No permission maps to {scope_flag} — either remove the flag or classify a permission under it."
         )
 
@@ -361,6 +376,10 @@ class TestCheckApiKeyPermissionsMatrix:
         ("PROJECTS_CREATE", "can_manage_projects", "create a project"),
         ("PROJECTS_UPDATE", "can_manage_projects", "update a project / add archives"),
         ("PROJECTS_DELETE", "can_manage_projects", "delete a project"),
+        # Pipeline definitions and run history read as status/config, so they
+        # ride can_read_status. PIPELINES_RUN needs two flags and has its own
+        # class below; PIPELINES_WRITE stays admin-only (see _ADMIN_CASES).
+        ("PIPELINES_READ", "can_read_status", "list pipelines / read run history"),
     ]
 
     _ADMIN_CASES = [
@@ -381,6 +400,11 @@ class TestCheckApiKeyPermissionsMatrix:
         # print's stats contribution, mirroring LIBRARY_PURGE.
         "ARCHIVES_PURGE",
         "DISCOVERY_SCAN",
+        # PIPELINES_READ / PIPELINES_RUN became key-usable once PR C landed the
+        # run dispatch (#1425). Authoring did not: PIPELINES_WRITE rewrites the
+        # slicer settings and target printer a run then acts on, and clears run
+        # history.
+        "PIPELINES_WRITE",
     ]
 
     @pytest.mark.parametrize("perm_name,required_flag,_descr", _SCOPE_CASES)
@@ -500,3 +524,212 @@ class TestCheckApiKeyPermissionsMatrix:
                 [Permission.QUEUE_CREATE.value, Permission.PRINTERS_CONTROL.value],
             )
         assert exc.value.status_code == 403
+
+
+class TestMultiScopePermissions:
+    """A permission may require several scope flags at once (#1425 follow-up).
+
+    Running a slicer pipeline slices the source into a new library file and
+    then queues one print per copy. Those are two things an operator ticks
+    separately when minting a key, so PIPELINES_RUN maps to both
+    ``can_queue`` and ``can_manage_library`` — mapping it to either alone
+    would quietly hand that flag the other one's authority.
+    """
+
+    def _run_perm(self):
+        from backend.app.core.permissions import Permission
+
+        return Permission.PIPELINES_RUN.value
+
+    def test_both_flags_pass(self):
+        from backend.app.core.auth import _check_apikey_permissions
+
+        _check_apikey_permissions(_FakeApiKey(can_queue=True, can_manage_library=True), [self._run_perm()])
+
+    @pytest.mark.parametrize(
+        "flags",
+        [
+            {},
+            {"can_queue": True},
+            {"can_manage_library": True},
+            # Neither of the two required flags, however generous the rest.
+            {"can_read_status": True, "can_control_printer": True, "can_manage_projects": True},
+        ],
+    )
+    def test_a_partial_key_is_refused(self, flags):
+        """Half the authority is not authority. A queue-only key must not be
+        able to write into the library through a pipeline, and a library-only
+        key must not be able to spend filament through one."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(_FakeApiKey(**flags), [self._run_perm()])
+        assert exc.value.status_code == 403
+
+    def test_the_403_names_every_missing_flag(self):
+        """Reporting only the first would send the operator round the loop
+        twice, ticking one box per refusal with no hint a second is needed."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(_FakeApiKey(), [self._run_perm()])
+        assert "can_queue" in exc.value.detail
+        assert "can_manage_library" in exc.value.detail
+
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(_FakeApiKey(can_queue=True), [self._run_perm()])
+        assert "can_manage_library" in exc.value.detail
+        assert "can_queue" not in exc.value.detail
+
+    def test_single_scope_message_is_unchanged(self):
+        """Existing keys' 403 text is documented in the wiki and matched by
+        other tests; multi-scope support must not reword the common case."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(_FakeApiKey(), [Permission.QUEUE_CREATE.value])
+        assert exc.value.detail == "API key does not have 'can_queue' permission"
+
+    def test_require_any_still_passes_on_a_different_permission(self):
+        """An any-of route must not be blocked by the multi-scope member when
+        the key satisfies one of the others."""
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        _check_apikey_permissions(
+            _FakeApiKey(can_read_status=True),
+            [self._run_perm(), Permission.PRINTERS_READ.value],
+            require_any=True,
+        )
+
+    def test_effective_permissions_agree_with_the_gate(self):
+        """``/auth/me`` reports what a key can do by walking the same mapping.
+        If it ignored the second flag it would advertise pipelines:run to a
+        key the gate then refuses — the drift #1894 was about."""
+        from backend.app.core.auth import apikey_effective_permissions
+
+        assert self._run_perm() not in apikey_effective_permissions(_FakeApiKey(can_queue=True))
+        assert self._run_perm() not in apikey_effective_permissions(_FakeApiKey(can_manage_library=True))
+        assert self._run_perm() in apikey_effective_permissions(_FakeApiKey(can_queue=True, can_manage_library=True))
+
+    def test_effective_permissions_still_narrow_to_the_owner(self):
+        """A multi-scope permission is no exception to owner narrowing: both
+        flags set is still capped by what the key's owner may do."""
+
+        class _Owner:
+            def __init__(self, holds):
+                self._holds = holds
+
+            def has_permission(self, perm):
+                return perm in self._holds
+
+        from backend.app.core.auth import apikey_effective_permissions
+
+        key = _FakeApiKey(can_queue=True, can_manage_library=True)
+        assert self._run_perm() not in apikey_effective_permissions(key, _Owner(set()))
+        assert self._run_perm() in apikey_effective_permissions(key, _Owner({self._run_perm()}))
+
+
+class TestPipelineRoutesAcceptApiKeys:
+    """End-to-end: the routes themselves, not just the mapping.
+
+    Before this fix every pipeline endpoint answered 403 "API keys cannot be
+    used for administrative operations", because PR A parked all three
+    permissions on the denylist until the run dispatch landed. It landed in
+    PR C.
+    """
+
+    @pytest.fixture
+    async def auth_on(self, db_session):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="auth_enabled", value="true"))
+        await db_session.commit()
+
+    async def _key(self, db_session, **flags):
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name="pipeline-key",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                enabled=True,
+                **{"can_read_status": False, "can_queue": False, "can_manage_library": False, **flags},
+            )
+        )
+        await db_session.commit()
+        return full_key
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_read_key_can_list_pipelines(self, async_client: AsyncClient, db_session, auth_on):
+        key = await self._key(db_session, can_read_status=True)
+
+        resp = await async_client.get("/api/v1/slicer-pipelines/", headers={"X-API-Key": key})
+
+        assert resp.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_read_key_can_list_runs(self, async_client: AsyncClient, db_session, auth_on):
+        key = await self._key(db_session, can_read_status=True)
+
+        resp = await async_client.get("/api/v1/pipeline-runs", headers={"X-API-Key": key})
+
+        assert resp.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_read_key_cannot_author_a_pipeline(self, async_client: AsyncClient, db_session, auth_on):
+        """PIPELINES_WRITE stays admin-only — reading pipelines must not imply
+        rewriting the slicer settings a run will act on."""
+        key = await self._key(db_session, can_read_status=True, can_queue=True, can_manage_library=True)
+
+        resp = await async_client.post(
+            "/api/v1/slicer-pipelines/",
+            json={"name": "x"},
+            headers={"X-API-Key": key},
+        )
+
+        assert resp.status_code == 403
+        assert "administrative operations" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_queue_only_key_cannot_run_a_pipeline(self, async_client: AsyncClient, db_session, auth_on):
+        key = await self._key(db_session, can_read_status=True, can_queue=True)
+
+        resp = await async_client.post(
+            "/api/v1/slicer-pipelines/1/run",
+            json={"source_library_file_id": 1},
+            headers={"X-API-Key": key},
+        )
+
+        assert resp.status_code == 403
+        assert "can_manage_library" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_fully_scoped_key_gets_past_the_gate(self, async_client: AsyncClient, db_session, auth_on):
+        """404 for the missing pipeline, not 403 — the permission check is
+        what this asserts, and only a request that cleared it reaches the
+        lookup."""
+        key = await self._key(db_session, can_read_status=True, can_queue=True, can_manage_library=True)
+
+        resp = await async_client.post(
+            "/api/v1/slicer-pipelines/999999/run",
+            json={"source_library_file_id": 1},
+            headers={"X-API-Key": key},
+        )
+
+        assert resp.status_code == 404

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

@@ -980,3 +980,212 @@ class TestCancelTerminal:
         resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
         assert resp.status_code == 200
         assert resp.json()["status"] == "completed"  # unchanged
+
+
+class TestRunViaApiKey:
+    """An API key may run a pipeline (#1425 follow-up).
+
+    Every pipeline endpoint used to answer 403 for API keys — the three
+    permissions were parked as administrative in PR A, before the run dispatch
+    existed to decide about. ``pipelines:run`` now needs the key's Manage Queue
+    *and* Manage Library scopes together, because a run slices into the library
+    and then queues prints.
+    """
+
+    async def _admin_and_key(self, async_client: AsyncClient, db_session, **flags):
+        """Enable auth, then mint a key owned by the admin. The owner matters:
+        a key never out-ranks its owner, and only an owned key can stand in for
+        a user when a cloud preset has to be resolved."""
+        from sqlalchemy import select
+
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+        from backend.app.models.user import User
+
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": True, "admin_username": "pipeadmin", "admin_password": "AdminPass1!"},
+        )
+        admin = (await db_session.execute(select(User).where(User.username == "pipeadmin"))).scalar_one()
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name="pipeline-runner",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                user_id=admin.id,
+                enabled=True,
+                **{"can_read_status": False, "can_queue": False, "can_manage_library": False, **flags},
+            )
+        )
+        await db_session.commit()
+        return admin, full_key
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_scoped_key_runs_the_pipeline(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 7777
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Auth goes on only now: the factories above post as an anonymous
+        # caller, which is how every other test in this file works.
+        _, key = await self._admin_and_key(
+            async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
+        )
+
+        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_library_file_id": src.id, "copies": 1, "force": True},
+                headers={"X-API-Key": key},
+            )
+
+        assert resp.status_code == 202, resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_key_without_manage_library_is_refused(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """Queueing prints is only half of what a run does. The refusal names
+        the flag that is missing rather than calling the whole thing
+        administrative."""
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        _, key = await self._admin_and_key(async_client, db_session, can_read_status=True, can_queue=True)
+
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "copies": 1, "force": True},
+            headers={"X-API-Key": key},
+        )
+
+        assert resp.status_code == 403
+        assert "can_manage_library" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_cloud_scoped_key_slices_as_its_owner(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """A pipeline can be built on Bambu/Orca Cloud presets, and resolving
+        those reads a cloud token off a user record. The permission gate hands
+        an API-keyed request ``current_user=None``, so without falling back to
+        the key's owner such a pipeline would have nobody to resolve against
+        and would fail at slice time — the same fallback the direct slice route
+        makes."""
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 7778
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        admin, key = await self._admin_and_key(
+            async_client,
+            db_session,
+            can_read_status=True,
+            can_queue=True,
+            can_manage_library=True,
+            can_access_cloud=True,
+        )
+
+        enqueue = AsyncMock(return_value=_FakeSliceJob())
+        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=enqueue),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id, "copies": 1, "force": True},
+                headers={"X-API-Key": key},
+            )
+
+        assert resp.status_code == 202, resp.text
+        assert enqueue.await_args.kwargs["owner_id"] == admin.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_key_without_cloud_scope_stays_anonymous(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """The fallback is the cloud scope's own opt-in, not a general identity
+        for API keys: a key without it slices unattributed, exactly as before."""
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 7779
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Same owner and same three scopes as the test above — only the cloud
+        # opt-in differs.
+        _, full_key = await self._admin_and_key(
+            async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
+        )
+
+        enqueue = AsyncMock(return_value=_FakeSliceJob())
+        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=enqueue),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id, "copies": 1, "force": True},
+                headers={"X-API-Key": full_key},
+            )
+
+        assert resp.status_code == 202, resp.text
+        assert enqueue.await_args.kwargs["owner_id"] is None

+ 2 - 1
backend/tests/integration/test_scheduler_budget_reservation.py

@@ -21,6 +21,7 @@ from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.services.finance_budget import validate_print_budget
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
 pytestmark = pytest.mark.integration
 
@@ -116,7 +117,7 @@ async def _dispatch(ctx, *, uploaded: bool = True, cancel_during_upload: bool =
         patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
         patch("backend.app.services.print_scheduler.upload_file_async", upload),
         patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
-        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        discarding_spawn_patch(),
         patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
         patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
         patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),

+ 2 - 1
backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py

@@ -33,6 +33,7 @@ from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings  # noqa: F401 - registers the table
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
 pytestmark = pytest.mark.integration
 
@@ -144,7 +145,7 @@ async def _dispatch(ctx, ids, rack_slots):
                 AsyncMock(return_value=(False, 3, 2.0, 30.0)),
             ),
             patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
-            patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+            discarding_spawn_patch(),
             patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
             patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
             patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),

+ 5 - 9
backend/tests/unit/test_printer_offline_notification.py

@@ -21,6 +21,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 from backend.app import main as main_module
+from backend.tests._fixtures.background_tasks import MAIN_TARGET, discarding_spawn_patch
 
 
 def _spawn_patch():
@@ -28,16 +29,11 @@ def _spawn_patch():
 
     `on_printer_status_change` builds `reconcile_stale_active_prints(...)` as
     a call argument, so the coroutine object is constructed whether or not the
-    replacement schedules it. A bare `MagicMock` keeps it alive in `call_args`
-    and it finalises unawaited during some *later* test's GC, surfacing as a
-    `PytestUnraisableExceptionWarning` attributed to an unrelated file.
-    Closing it here mirrors the real helper taking ownership of the coroutine,
-    while still keeping reconciliation from actually running.
+    replacement schedules it — see
+    `backend/tests/_fixtures/background_tasks.py` for what parking it in a
+    mock instead does to an unrelated test.
     """
-    return patch(
-        "backend.app.main.spawn_background_task",
-        side_effect=lambda coro, **kwargs: coro.close(),
-    )
+    return discarding_spawn_patch(MAIN_TARGET)
 
 
 def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:

+ 2 - 1
backend/tests/unit/test_scheduler_busy_defer_2598.py

@@ -28,6 +28,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
 
 @pytest.fixture
@@ -88,7 +89,7 @@ def _base_patches(scheduler, ctx, upload_mock, start_print_mock, get_status):
         patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
         patch("backend.app.services.print_scheduler.upload_file_async", upload_mock),
         patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
-        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        discarding_spawn_patch(),
         patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
         patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
         patch.object(scheduler, "_preheat_and_soak", AsyncMock()),

+ 2 - 1
backend/tests/unit/test_scheduler_cancel_race.py

@@ -32,6 +32,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
 
 @pytest.fixture
@@ -131,7 +132,7 @@ async def _dispatch(ctx, *, upload_side_effect=None):
         patch("backend.app.services.print_scheduler.delete_file_async", ctx.delete_file),
         patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
         patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
-        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        discarding_spawn_patch(),
         patch(
             "backend.app.services.notification_service.notification_service.on_queue_job_started",
             AsyncMock(),

+ 2 - 1
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -15,6 +15,7 @@ from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.printer import Printer
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
 
 @pytest.fixture
@@ -223,7 +224,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
         patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
         patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
         patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
-        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        discarding_spawn_patch(),
         patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
         patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
         patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),

+ 2 - 1
backend/tests/unit/test_scheduler_nozzle_mismatch.py

@@ -28,6 +28,7 @@ from backend.app.services.print_scheduler import (
     _installed_nozzle_diameters,
     _nozzle_mismatch_message,
 )
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
 
 def _state(*diameters: str):
@@ -200,7 +201,7 @@ async def _run_start_print(ctx, *, installed_nozzles):
         patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
         patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
         patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
-        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        discarding_spawn_patch(),
         patch(
             "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
         ),

+ 2 - 1
backend/tests/unit/test_scheduler_release_conn_before_ftp_2572.py

@@ -34,6 +34,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
 
 @pytest.fixture
@@ -107,7 +108,7 @@ async def test_connection_released_before_ftp_upload(dispatch_case):
             patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
             patch("backend.app.services.print_scheduler.upload_file_async", record_txn_state),
             patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
-            patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+            discarding_spawn_patch(),
             patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
             patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
             patch.object(scheduler, "_preheat_and_soak", AsyncMock()),

+ 84 - 0
backend/tests/unit/test_spawn_patch_does_not_leak_coroutines.py

@@ -0,0 +1,84 @@
+"""Patching ``spawn_background_task`` must not leave a coroutine un-awaited.
+
+The scheduler builds its coroutine as a call argument::
+
+    spawn_background_task(self._watchdog_print_start(...), name=...)
+
+so the object exists whether or not the replacement schedules it. A bare
+``MagicMock`` parks it in ``call_args``; when that reference finally drops, the
+coroutine is finalised never having run and CPython emits
+``RuntimeWarning: coroutine ... was never awaited`` from ``__del__``. Because
+that happens during whatever test the garbage collector is running under, it is
+reported as a ``PytestUnraisableExceptionWarning`` against an unrelated file,
+and it comes and goes with test ordering.
+
+The first test below reproduces exactly that, so the second is not merely
+asserting that a fix does nothing.
+"""
+
+import gc
+import warnings
+from unittest.mock import DEFAULT, MagicMock
+
+import pytest
+
+from backend.tests._fixtures.background_tasks import close_and_default, discarding_spawn_patch
+
+pytestmark = pytest.mark.unit
+
+
+async def _work():
+    """Stands in for ``_watchdog_print_start`` — never scheduled by these tests."""
+
+
+def _never_awaited_warnings(replacement) -> list[warnings.WarningMessage]:
+    """Hand ``replacement`` a coroutine, drop every reference, and collect."""
+    with warnings.catch_warnings(record=True) as caught:
+        warnings.simplefilter("always")
+        replacement(_work(), name="watchdog-print-start-1")
+        replacement.reset_mock()  # the last reference, held in call_args
+        gc.collect()
+    return [w for w in caught if "never awaited" in str(w.message)]
+
+
+def test_a_bare_mock_leaks_the_coroutine():
+    """The behaviour being fixed. If this ever stops warning, the second test
+    below is no longer evidence of anything."""
+    assert _never_awaited_warnings(MagicMock())
+
+
+def test_the_shared_patch_does_not():
+    with discarding_spawn_patch() as spawn:
+        assert not _never_awaited_warnings(spawn)
+
+
+def test_it_is_still_a_mock_that_records_its_calls():
+    """It replaces ``patch(target, MagicMock())`` at call sites that may assert
+    the spawn happened, so it has to stay inspectable."""
+    with discarding_spawn_patch() as spawn:
+        from backend.app.services import print_scheduler
+
+        print_scheduler.spawn_background_task(_work(), name="watchdog-print-start-1")
+
+        spawn.assert_called_once()
+        assert spawn.call_args.kwargs["name"] == "watchdog-print-start-1"
+
+
+def test_the_caller_still_gets_a_task_stand_in():
+    """``_process_queue`` calls ``task.add_done_callback(...)`` on the result.
+    Returning ``close()``'s ``None`` instead of ``DEFAULT`` would break it."""
+    with discarding_spawn_patch() as spawn:
+        from backend.app.services import print_scheduler
+
+        task = print_scheduler.spawn_background_task(_work(), name="queue-upload-1")
+
+        assert task is spawn.return_value
+        task.add_done_callback(lambda _t: None)  # must not raise
+
+
+def test_close_and_default_defers_the_return_value_to_the_mock():
+    coro = _work()
+
+    assert close_and_default(coro) is DEFAULT
+
+    coro.close()  # idempotent — proves the helper left it in a closed state

+ 4 - 4
frontend/src/i18n/locales/de.ts

@@ -2178,19 +2178,19 @@ export default {
     keyName: 'Schlüsselname',
     keyNamePlaceholder: 'z.B. Home Assistant, OctoPrint',
     readStatus: 'Status lesen',
-    readStatusDescription: 'Druckerstatus und Warteschlange anzeigen',
+    readStatusDescription: 'Druckerstatus, Warteschlange, Bibliothek, Archive und Slicer-Pipelines anzeigen',
     manageQueue: 'Warteschlange verwalten',
-    manageQueueDescription: 'Elemente zur Druckwarteschlange hinzufügen und entfernen',
+    manageQueueDescription: 'Elemente zur Druckwarteschlange hinzufügen und entfernen. Zusammen mit "Bibliothek verwalten" Slicer-Pipelines ausführen',
     controlPrinter: 'Drucker steuern',
     controlPrinterDescription: 'Drucke pausieren, fortsetzen und stoppen',
     manageLibrary: 'Bibliothek verwalten',
-    manageLibraryDescription: 'Bibliotheksdateien hochladen, umbenennen und löschen; Modelle aus MakerWorld importieren',
+    manageLibraryDescription: 'Bibliotheksdateien hochladen, umbenennen, löschen und slicen; Modelle aus MakerWorld importieren. Zusammen mit "Warteschlange verwalten" Slicer-Pipelines ausführen',
     manageInventory: 'Bestand verwalten',
     manageInventoryDescription: 'Spulen und Bestandseinträge anlegen, ändern und löschen. Erforderlich für SpoolBuddy-Kioske (NFC-Scan, Waagenmessungen, Kiosk-Systembefehle).',
     manageMaintenance: 'Wartung verwalten',
     manageMaintenanceDescription: 'Abgeschlossene Wartungen protokollieren, Zähler zurücksetzen, Intervalle bearbeiten und den Wartungstyp-Katalog verwalten. Geeignet für Home-Assistant-Automatisierungen, die „Düse gereinigt" protokollieren, ohne umfassendere Druckersteuerung zu gewähren.',
     manageArchives: 'Archive verwalten',
-    manageArchivesDescription: 'Druckarchive bearbeiten und löschen, einschließlich des Entfernens alter Drucke. Umfasst nicht das Bereinigen ihres Statistikbeitrags. Geeignet für Automatisierungen, die den Druckverlauf ausdünnen.',
+    manageArchivesDescription: 'Druckarchive bearbeiten und löschen, einschließlich des Entfernens alter Drucke und ihres Statistikbeitrags. Geeignet für Automatisierungen, die den Druckverlauf ausdünnen.',
     manageProjects: 'Projekte verwalten',
     manageProjectsDescription: 'Projekte erstellen, aktualisieren und löschen sowie Archive zu ihnen hinzufügen. Geeignet für Automatisierungen, die Drucke in Projekten organisieren.',
     maintenanceBadge: 'Wartung',

+ 4 - 4
frontend/src/i18n/locales/en.ts

@@ -2197,19 +2197,19 @@ export default {
     keyName: 'Key Name',
     keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',
     readStatus: 'Read Status',
-    readStatusDescription: 'View printer status and queue',
+    readStatusDescription: 'View printer status, queue, library, archives, and slicer pipelines',
     manageQueue: 'Manage Queue',
-    manageQueueDescription: 'Add and remove items from print queue',
+    manageQueueDescription: 'Add and remove items from print queue. With Manage Library, run slicer pipelines',
     controlPrinter: 'Control Printer',
     controlPrinterDescription: 'Pause, resume, and stop prints',
     manageLibrary: 'Manage Library',
-    manageLibraryDescription: 'Upload, rename, and delete library files; import models from MakerWorld',
+    manageLibraryDescription: 'Upload, rename, delete, and slice library files; import models from MakerWorld. With Manage Queue, run slicer pipelines',
     manageInventory: 'Manage Inventory',
     manageInventoryDescription: 'Create, update, and delete spools and inventory records. Required for SpoolBuddy kiosks (NFC scan, scale readings, kiosk system commands).',
     manageMaintenance: 'Manage Maintenance',
     manageMaintenanceDescription: 'Log completed maintenance, reset counters, edit intervals, and manage the maintenance-type catalog. Suited to Home Assistant automations that record "I cleaned the nozzle" without granting broader printer control.',
     manageArchives: 'Manage Archives',
-    manageArchivesDescription: 'Edit and delete print archives, including removing old prints. Does not include purging their statistics contribution. Suited to automations that prune the print history.',
+    manageArchivesDescription: 'Edit and delete print archives, including removing old prints and their contribution to statistics. Suited to automations that prune the print history.',
     manageProjects: 'Manage Projects',
     manageProjectsDescription: 'Create, update, and delete projects, and add archives to them. Suited to automations that organize prints into projects.',
     libraryBadge: 'Library',

+ 4 - 4
frontend/src/i18n/locales/es.ts

@@ -2181,19 +2181,19 @@ export default {
     keyName: 'Nombre de la clave',
     keyNamePlaceholder: 'p. ej., Home Assistant, OctoPrint',
     readStatus: 'Leer estado',
-    readStatusDescription: 'Ver el estado de la impresora y la cola',
+    readStatusDescription: 'Ver el estado de la impresora, la cola, la biblioteca, los archivos de impresión y las pipelines del cortador',
     manageQueue: 'Gestionar la cola',
-    manageQueueDescription: 'Añadir y quitar elementos de la cola de impresión',
+    manageQueueDescription: 'Añadir y quitar elementos de la cola de impresión. Junto con "Gestionar biblioteca", ejecutar pipelines del cortador',
     controlPrinter: 'Controlar la impresora',
     controlPrinterDescription: 'Pausar, reanudar y detener impresiones',
     manageLibrary: 'Gestionar biblioteca',
-    manageLibraryDescription: 'Subir, renombrar y eliminar archivos de la biblioteca; importar modelos desde MakerWorld',
+    manageLibraryDescription: 'Subir, renombrar, eliminar y cortar archivos de la biblioteca; importar modelos desde MakerWorld. Junto con "Gestionar la cola", ejecutar pipelines del cortador',
     manageInventory: 'Gestionar inventario',
     manageInventoryDescription: 'Crear, actualizar y eliminar bobinas y registros de inventario. Necesario para los quioscos SpoolBuddy (escaneo NFC, lecturas de balanza, comandos del sistema del quiosco).',
     manageMaintenance: 'Gestionar mantenimiento',
     manageMaintenanceDescription: 'Registrar mantenimientos completados, restablecer contadores, editar intervalos y gestionar el catálogo de tipos de mantenimiento. Ideal para automatizaciones de Home Assistant que registran "limpié la boquilla" sin conceder un control más amplio de la impresora.',
     manageArchives: 'Gestionar archivos de impresión',
-    manageArchivesDescription: 'Editar y eliminar archivos de impresión, incluida la eliminación de impresiones antiguas. No incluye la purga de su contribución a las estadísticas. Ideal para automatizaciones que depuran el historial de impresión.',
+    manageArchivesDescription: 'Editar y eliminar archivos de impresión, incluida la eliminación de impresiones antiguas y de su contribución a las estadísticas. Ideal para automatizaciones que depuran el historial de impresión.',
     manageProjects: 'Gestionar proyectos',
     manageProjectsDescription: 'Crear, actualizar y eliminar proyectos, y añadirles archivos de impresión. Ideal para automatizaciones que organizan las impresiones en proyectos.',
     maintenanceBadge: 'Mantenimiento',

+ 4 - 4
frontend/src/i18n/locales/fr.ts

@@ -2134,19 +2134,19 @@ export default {
     keyName: 'Nom de la clé',
     keyNamePlaceholder: 'ex: Home Assistant, OctoPrint',
     readStatus: 'Lire le statut',
-    readStatusDescription: 'Voir les imprimantes et la file',
+    readStatusDescription: 'Voir l\'état des imprimantes, la file, la bibliothèque, les archives et les pipelines du trancheur',
     manageQueue: 'Gérer la file',
-    manageQueueDescription: 'Ajouter/retirer des éléments',
+    manageQueueDescription: 'Ajouter/retirer des éléments. Avec "Gérer la bibliothèque", exécuter des pipelines du trancheur',
     controlPrinter: 'Contrôler l\'imprimante',
     controlPrinterDescription: 'Pause, reprise, arrêt',
     manageLibrary: 'Gérer la bibliothèque',
-    manageLibraryDescription: 'Téléverser, renommer et supprimer des fichiers de la bibliothèque ; importer des modèles depuis MakerWorld',
+    manageLibraryDescription: 'Téléverser, renommer, supprimer et trancher des fichiers de la bibliothèque ; importer des modèles depuis MakerWorld. Avec "Gérer la file", exécuter des pipelines du trancheur',
     manageInventory: 'Gérer l\'inventaire',
     manageInventoryDescription: 'Créer, modifier et supprimer des bobines et des entrées d\'inventaire. Requis pour les bornes SpoolBuddy (scan NFC, lectures de balance, commandes système de la borne).',
     manageMaintenance: 'Gérer la maintenance',
     manageMaintenanceDescription: 'Enregistrer les maintenances effectuées, réinitialiser les compteurs, modifier les intervalles et gérer le catalogue des types de maintenance. Adapté aux automatisations Home Assistant qui consignent "J\'ai nettoyé la buse" sans accorder un contrôle plus large de l\'imprimante.',
     manageArchives: 'Gérer les archives',
-    manageArchivesDescription: 'Modifier et supprimer les archives d\'impression, y compris la suppression des anciennes impressions. N\'inclut pas la purge de leur contribution aux statistiques. Adapté aux automatisations qui élaguent l\'historique d\'impression.',
+    manageArchivesDescription: 'Modifier et supprimer les archives d\'impression, y compris la suppression des anciennes impressions et de leur contribution aux statistiques. Adapté aux automatisations qui élaguent l\'historique d\'impression.',
     manageProjects: 'Gérer les projets',
     manageProjectsDescription: 'Créer, modifier et supprimer des projets, et y ajouter des archives. Adapté aux automatisations qui organisent les impressions en projets.',
     maintenanceBadge: 'Maintenance',

+ 4 - 4
frontend/src/i18n/locales/it.ts

@@ -2134,19 +2134,19 @@ export default {
     keyName: 'Nome chiave',
     keyNamePlaceholder: 'es., Home Assistant, OctoPrint',
     readStatus: 'Leggi stato',
-    readStatusDescription: 'Visualizza stato stampante e coda',
+    readStatusDescription: 'Visualizza stato stampante, coda, libreria, archivi e pipeline dello slicer',
     manageQueue: 'Gestisci coda',
-    manageQueueDescription: 'Aggiungi e rimuovi elementi dalla coda di stampa',
+    manageQueueDescription: 'Aggiungi e rimuovi elementi dalla coda di stampa. Insieme a "Gestisci libreria", esegui pipeline dello slicer',
     controlPrinter: 'Controlla stampante',
     controlPrinterDescription: 'Metti in pausa, riprendi e ferma stampe',
     manageLibrary: 'Gestisci libreria',
-    manageLibraryDescription: 'Carica, rinomina ed elimina file della libreria; importa modelli da MakerWorld',
+    manageLibraryDescription: 'Carica, rinomina, elimina ed esegui lo slicing dei file della libreria; importa modelli da MakerWorld. Insieme a "Gestisci coda", esegui pipeline dello slicer',
     manageInventory: 'Gestisci inventario',
     manageInventoryDescription: 'Crea, aggiorna ed elimina bobine e voci di inventario. Necessario per i chioschi SpoolBuddy (scansione NFC, letture della bilancia, comandi di sistema del chiosco).',
     manageMaintenance: 'Gestisci manutenzione',
     manageMaintenanceDescription: 'Registra le manutenzioni completate, azzera i contatori, modifica gli intervalli e gestisci il catalogo dei tipi di manutenzione. Adatto alle automazioni Home Assistant che registrano "ho pulito l\'ugello" senza concedere un controllo più ampio della stampante.',
     manageArchives: 'Gestisci archivi',
-    manageArchivesDescription: 'Modifica ed elimina gli archivi di stampa, inclusa la rimozione delle vecchie stampe. Non include l\'eliminazione del loro contributo alle statistiche. Adatto alle automazioni che sfoltiscono la cronologia di stampa.',
+    manageArchivesDescription: 'Modifica ed elimina gli archivi di stampa, inclusa la rimozione delle vecchie stampe e del loro contributo alle statistiche. Adatto alle automazioni che sfoltiscono la cronologia di stampa.',
     manageProjects: 'Gestisci progetti',
     manageProjectsDescription: 'Crea, aggiorna ed elimina progetti e aggiungi archivi ad essi. Adatto alle automazioni che organizzano le stampe in progetti.',
     maintenanceBadge: 'Manutenzione',

+ 4 - 4
frontend/src/i18n/locales/ja.ts

@@ -2177,19 +2177,19 @@ export default {
     keyName: 'キー名',
     keyNamePlaceholder: '例: Home Assistant, OctoPrint',
     readStatus: 'ステータスの読み取り',
-    readStatusDescription: 'プリンターのステータスとキューを表示',
+    readStatusDescription: 'プリンターのステータス、キュー、ライブラリ、アーカイブ、スライサーパイプラインを表示',
     manageQueue: 'キューの管理',
-    manageQueueDescription: '印刷キューへのアイテムの追加と削除',
+    manageQueueDescription: '印刷キューへのアイテムの追加と削除。「ライブラリの管理」と併せてスライサーパイプラインを実行',
     controlPrinter: 'プリンターの制御',
     controlPrinterDescription: '印刷の一時停止、再開、停止',
     manageLibrary: 'ライブラリの管理',
-    manageLibraryDescription: 'ライブラリファイルのアップロード、名前変更、削除。MakerWorld からのモデルインポート。',
+    manageLibraryDescription: 'ライブラリファイルのアップロード、名前変更、削除、スライス。MakerWorld からのモデルインポート。「キューの管理」と併せてスライサーパイプラインを実行。',
     manageInventory: '在庫の管理',
     manageInventoryDescription: 'スプールと在庫レコードの作成、更新、削除。SpoolBuddy キオスク(NFC スキャン、はかり読み取り、キオスクのシステムコマンド)に必要です。',
     manageMaintenance: 'メンテナンスの管理',
     manageMaintenanceDescription: '完了したメンテナンスの記録、カウンターのリセット、間隔の編集、メンテナンス種類カタログの管理。より広範なプリンター制御を許可することなく「ノズルをクリーニングした」を記録する Home Assistant オートメーションに適しています。',
     manageArchives: 'アーカイブの管理',
-    manageArchivesDescription: '古い印刷の削除を含む、印刷アーカイブの編集と削除。統計への寄与の削除は含まれません。印刷履歴を整理するオートメーションに適しています。',
+    manageArchivesDescription: '古い印刷とその統計への寄与の削除を含む、印刷アーカイブの編集と削除。印刷履歴を整理するオートメーションに適しています。',
     manageProjects: 'プロジェクトの管理',
     manageProjectsDescription: 'プロジェクトの作成、更新、削除、およびプロジェクトへのアーカイブの追加。印刷をプロジェクトに整理するオートメーションに適しています。',
     maintenanceBadge: 'メンテナンス',

+ 4 - 4
frontend/src/i18n/locales/ko.ts

@@ -2062,19 +2062,19 @@ export default {
     keyName: '키 이름',
     keyNamePlaceholder: '예: Home Assistant, OctoPrint',
     readStatus: '상태 읽기',
-    readStatusDescription: '프린터 상태 및 대기열 보기',
+    readStatusDescription: '프린터 상태, 대기열, 라이브러리, 아카이브 및 슬라이서 파이프라인 보기',
     manageQueue: '대기열 관리',
-    manageQueueDescription: '인쇄 대기열에서 항목 추가 및 제거',
+    manageQueueDescription: '인쇄 대기열에서 항목 추가 및 제거. "라이브러리 관리"와 함께 슬라이서 파이프라인 실행',
     controlPrinter: '프린터 제어',
     controlPrinterDescription: '인쇄 일시정지, 재개, 정지',
     manageLibrary: '라이브러리 관리',
-    manageLibraryDescription: '라이브러리 파일 업로드, 이름 변경 및 삭제; MakerWorld에서 모델 가져오기',
+    manageLibraryDescription: '라이브러리 파일 업로드, 이름 변경, 삭제 및 슬라이스; MakerWorld에서 모델 가져오기. "대기열 관리"와 함께 슬라이서 파이프라인 실행',
     manageInventory: '재고 관리',
     manageInventoryDescription: '스풀 및 재고 레코드 생성, 업데이트 및 삭제. SpoolBuddy 키오스크(NFC 스캔, 저울 측정값, 키오스크 시스템 명령)에 필요합니다.',
     manageMaintenance: '유지보수 관리',
     manageMaintenanceDescription: '완료된 유지보수 기록, 카운터 재설정, 주기 편집, 유지보수 유형 카탈로그 관리. 프린터 제어를 더 광범위하게 허용하지 않고 "노즐을 청소했다"를 기록하는 Home Assistant 자동화에 적합합니다.',
     manageArchives: '아카이브 관리',
-    manageArchivesDescription: '오래된 출력물 제거를 포함하여 출력 아카이브를 편집하고 삭제합니다. 통계 기여 항목의 제거는 포함되지 않습니다. 출력 기록을 정리하는 자동화에 적합합니다.',
+    manageArchivesDescription: '오래된 출력물과 통계 기여 항목의 제거를 포함하여 출력 아카이브를 편집하고 삭제합니다. 출력 기록을 정리하는 자동화에 적합합니다.',
     manageProjects: '프로젝트 관리',
     manageProjectsDescription: '프로젝트를 생성, 업데이트, 삭제하고 프로젝트에 아카이브를 추가합니다. 출력물을 프로젝트로 정리하는 자동화에 적합합니다.',
     maintenanceBadge: '유지보수',

+ 4 - 4
frontend/src/i18n/locales/pt-BR.ts

@@ -2134,19 +2134,19 @@ export default {
     keyName: 'Nome da Chave',
     keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',
     readStatus: 'Status de Leitura',
-    readStatusDescription: 'Visualizar status da impressora e fila',
+    readStatusDescription: 'Visualizar status da impressora, fila, biblioteca, arquivos de impressão e pipelines do slicer',
     manageQueue: 'Gerenciar Fila',
-    manageQueueDescription: 'Adicionar e remover itens da fila de impressão',
+    manageQueueDescription: 'Adicionar e remover itens da fila de impressão. Junto com "Gerenciar biblioteca", executar pipelines do slicer',
     controlPrinter: 'Controlar Impressora',
     controlPrinterDescription: 'Pausar, retomar e parar impressões',
     manageLibrary: 'Gerenciar biblioteca',
-    manageLibraryDescription: 'Enviar, renomear e excluir arquivos da biblioteca; importar modelos do MakerWorld',
+    manageLibraryDescription: 'Enviar, renomear, excluir e fatiar arquivos da biblioteca; importar modelos do MakerWorld. Junto com "Gerenciar Fila", executar pipelines do slicer',
     manageInventory: 'Gerenciar estoque',
     manageInventoryDescription: 'Criar, atualizar e excluir bobinas e registros de estoque. Necessário para quiosques SpoolBuddy (escaneamento NFC, leituras da balança, comandos de sistema do quiosque).',
     manageMaintenance: 'Gerenciar manutenção',
     manageMaintenanceDescription: 'Registrar manutenções concluídas, redefinir contadores, editar intervalos e gerenciar o catálogo de tipos de manutenção. Adequado a automações do Home Assistant que registram "limpei o bico" sem conceder controle mais amplo da impressora.',
     manageArchives: 'Gerenciar arquivos de impressão',
-    manageArchivesDescription: 'Editar e excluir arquivos de impressão, incluindo a remoção de impressões antigas. Não inclui a limpeza da contribuição deles para as estatísticas. Adequado a automações que reduzem o histórico de impressão.',
+    manageArchivesDescription: 'Editar e excluir arquivos de impressão, incluindo a remoção de impressões antigas e da contribuição delas para as estatísticas. Adequado a automações que reduzem o histórico de impressão.',
     manageProjects: 'Gerenciar projetos',
     manageProjectsDescription: 'Criar, atualizar e excluir projetos e adicionar arquivos de impressão a eles. Adequado a automações que organizam as impressões em projetos.',
     maintenanceBadge: 'Manutenção',

+ 4 - 4
frontend/src/i18n/locales/ru.ts

@@ -2059,19 +2059,19 @@ export default {
     keyName: "Название ключа",
     keyNamePlaceholder: "например, Home Assistant или OctoPrint",
     readStatus: "Чтение состояния",
-    readStatusDescription: "Просмотр состояния принтеров и очереди",
+    readStatusDescription: "Просмотр состояния принтеров, очереди, библиотеки, архива и конвейеров слайсера",
     manageQueue: "Управление очередью",
-    manageQueueDescription: "Добавление и удаление заданий из очереди печати",
+    manageQueueDescription: "Добавление и удаление заданий из очереди печати. Вместе с «Управление библиотекой» — запуск конвейеров слайсера",
     controlPrinter: "Управление принтером",
     controlPrinterDescription: "Пауза, продолжение и остановка печати",
     manageLibrary: "Управление библиотекой",
-    manageLibraryDescription: "Загрузка, переименование и удаление файлов библиотеки; импорт моделей из MakerWorld",
+    manageLibraryDescription: "Загрузка, переименование, удаление и слайсинг файлов библиотеки; импорт моделей из MakerWorld. Вместе с «Управление очередью» — запуск конвейеров слайсера",
     manageInventory: "Управление запасами",
     manageInventoryDescription: "Создание, изменение и удаление катушек и записей учёта. Требуется для киосков SpoolBuddy: сканирование NFC, показания весов и системные команды киоска.",
     manageMaintenance: "Управление обслуживанием",
     manageMaintenanceDescription: "Регистрация выполненного обслуживания, сброс счётчиков, изменение интервалов и управление каталогом видов обслуживания. Подходит для автоматизаций Home Assistant, которые отмечают, например, «сопло очищено», без предоставления более широких прав управления принтером.",
     manageArchives: "Управление архивом",
-    manageArchivesDescription: "Изменение и удаление записей архива печати, включая старые задания. Не включает удаление их вклада в статистику. Подходит для автоматизаций очистки истории печати.",
+    manageArchivesDescription: "Изменение и удаление записей архива печати, включая старые задания и их вклад в статистику. Подходит для автоматизаций очистки истории печати.",
     manageProjects: "Управление проектами",
     manageProjectsDescription: "Создание, изменение и удаление проектов, а также добавление в них архивных заданий. Подходит для автоматизаций, распределяющих печати по проектам.",
     libraryBadge: "Библиотека",

+ 4 - 4
frontend/src/i18n/locales/tr.ts

@@ -2182,19 +2182,19 @@ export default {
     keyName: 'Anahtar Adı',
     keyNamePlaceholder: 'örn., Home Assistant, OctoPrint',
     readStatus: 'Durumu Oku',
-    readStatusDescription: 'Yazıcı durumunu ve kuyruğu görüntüle',
+    readStatusDescription: 'Yazıcı durumunu, kuyruğu, kütüphaneyi, arşivleri ve dilimleyici pipeline\'larını görüntüle',
     manageQueue: 'Kuyruğu Yönet',
-    manageQueueDescription: 'Baskı kuyruğundan öğe ekle ve kaldır',
+    manageQueueDescription: 'Baskı kuyruğundan öğe ekle ve kaldır. "Kütüphaneyi Yönet" ile birlikte dilimleyici pipeline\'ları çalıştır',
     controlPrinter: 'Yazıcıyı Kontrol Et',
     controlPrinterDescription: 'Baskıları duraklat, devam ettir ve durdur',
     manageLibrary: 'Kütüphaneyi Yönet',
-    manageLibraryDescription: "Kütüphane dosyalarını yükle, yeniden adlandır ve sil; MakerWorld'den model içe aktar",
+    manageLibraryDescription: "Kütüphane dosyalarını yükle, yeniden adlandır, sil ve dilimle; MakerWorld'den model içe aktar. 'Kuyruğu Yönet' ile birlikte dilimleyici pipeline'ları çalıştır",
     manageInventory: 'Envanteri Yönet',
     manageInventoryDescription: 'Makaraları ve envanter kayıtlarını oluştur, güncelle ve sil. SpoolBuddy kiosklar (NFC tarama, tartı okumaları, kiosk sistem komutları) için gereklidir.',
     manageMaintenance: 'Bakımı yönet',
     manageMaintenanceDescription: 'Tamamlanan bakımları kaydet, sayaçları sıfırla, aralıkları düzenle ve bakım türü kataloğunu yönet. Daha geniş yazıcı kontrolü vermeden "nozulu temizledim"i kaydeden Home Assistant otomasyonlarına uygundur.',
     manageArchives: 'Arşivleri yönet',
-    manageArchivesDescription: 'Eski baskıların kaldırılması dahil olmak üzere baskı arşivlerini düzenleyin ve silin. İstatistik katkılarının temizlenmesini içermez. Baskı geçmişini budayan otomasyonlar için uygundur.',
+    manageArchivesDescription: 'Eski baskıların ve istatistik katkılarının kaldırılması dahil olmak üzere baskı arşivlerini düzenleyin ve silin. Baskı geçmişini budayan otomasyonlar için uygundur.',
     manageProjects: 'Projeleri yönet',
     manageProjectsDescription: 'Projeleri oluşturun, güncelleyin ve silin, ayrıca bunlara arşiv ekleyin. Baskıları projeler halinde düzenleyen otomasyonlar için uygundur.',
     maintenanceBadge: 'Bakım',

+ 4 - 4
frontend/src/i18n/locales/uk.ts

@@ -2197,19 +2197,19 @@ export default {
     keyName: "Назва ключа",
     keyNamePlaceholder: "наприклад, Home Assistant, OctoPrint",
     readStatus: "Читати статус",
-    readStatusDescription: "Перегляд стану принтера та черги",
+    readStatusDescription: "Перегляд стану принтера, черги, бібліотеки, архівів і конвеєрів слайсера",
     manageQueue: "Керувати чергою",
-    manageQueueDescription: "Додавання та видалення елементів із черги друку",
+    manageQueueDescription: "Додавання та видалення елементів із черги друку. Разом із «Керувати бібліотекою» — запуск конвеєрів слайсера",
     controlPrinter: "Керування принтером",
     controlPrinterDescription: "Призупинення, відновлення та зупинка друку",
     manageLibrary: "Керувати бібліотекою",
-    manageLibraryDescription: "Вивантажувати, перейменовувати й видаляти файли бібліотеки; імпортувати моделі з MakerWorld",
+    manageLibraryDescription: "Вивантажувати, перейменовувати, видаляти та слайсити файли бібліотеки; імпортувати моделі з MakerWorld. Разом із «Керувати чергою» — запуск конвеєрів слайсера",
     manageInventory: "Керувати інвентарем",
     manageInventoryDescription: "Створення, оновлення та видалення котушок і записів інвентарю. Потрібно для кіосків SpoolBuddy: сканування NFC, покази ваг і системні команди кіоску.",
     manageMaintenance: "Керувати технічним обслуговуванням",
     manageMaintenanceDescription: "Реєстрація завершеного технічного обслуговування, скидання лічильників, редагування інтервалів і керування каталогом технічного обслуговування. Підходить для автоматизації Home Assistant, яка записує «Я очистив сопло», не надаючи ширшого контролю над принтером.",
     manageArchives: "Керувати архівами",
-    manageArchivesDescription: "Редагувати та видаляти архіви друку, у тому числі видаляти старі друки. Не включає очищення їхнього статистичного внеску. Підходить для автоматизації, яка обрізає історію друку.",
+    manageArchivesDescription: "Редагувати та видаляти архіви друку, у тому числі старі друки та їхній статистичний внесок. Підходить для автоматизації, яка обрізає історію друку.",
     manageProjects: "Керувати проєктами",
     manageProjectsDescription: "Створюйте, оновлюйте та видаляйте проєкти, додавайте до них архіви. Підходить для автоматизації, яка організовує друк у проєкти.",
     libraryBadge: "Бібліотека",

+ 4 - 4
frontend/src/i18n/locales/zh-CN.ts

@@ -2179,19 +2179,19 @@ export default {
     keyName: '密钥名称',
     keyNamePlaceholder: '例如:Home Assistant、OctoPrint',
     readStatus: '读取状态',
-    readStatusDescription: '查看打印机状态和队列',
+    readStatusDescription: '查看打印机状态、队列、资料库、存档和切片机流水线',
     manageQueue: '管理队列',
-    manageQueueDescription: '添加和移除打印队列中的项目',
+    manageQueueDescription: '添加和移除打印队列中的项目。与“管理资料库”配合可运行切片机流水线',
     controlPrinter: '控制打印机',
     controlPrinterDescription: '暂停、继续和停止打印',
     manageLibrary: '管理资料库',
-    manageLibraryDescription: '上传、重命名和删除资料库文件;从 MakerWorld 导入模型',
+    manageLibraryDescription: '上传、重命名、删除和切片资料库文件;从 MakerWorld 导入模型。与“管理队列”配合可运行切片机流水线',
     manageInventory: '管理库存',
     manageInventoryDescription: '创建、更新和删除耗材盘以及库存记录。SpoolBuddy 终端(NFC 扫描、秤读取、终端系统命令)需要此权限。',
     manageMaintenance: '管理维护',
     manageMaintenanceDescription: '记录已完成的维护、重置计数器、编辑间隔并管理维护类型目录。适用于在不授予更广泛打印机控制权限的情况下记录"我清洁了喷嘴"的 Home Assistant 自动化。',
     manageArchives: '管理打印存档',
-    manageArchivesDescription: '编辑和删除打印存档,包括移除旧的打印。不包括清除其统计数据贡献。适用于精简打印历史的自动化。',
+    manageArchivesDescription: '编辑和删除打印存档,包括移除旧的打印其统计数据贡献。适用于精简打印历史的自动化。',
     manageProjects: '管理项目',
     manageProjectsDescription: '创建、更新和删除项目,并向其添加存档。适用于将打印整理到项目中的自动化。',
     maintenanceBadge: '维护',

+ 4 - 4
frontend/src/i18n/locales/zh-TW.ts

@@ -2179,19 +2179,19 @@ export default {
     keyName: '金鑰名稱',
     keyNamePlaceholder: '例如:Home Assistant、OctoPrint',
     readStatus: '讀取狀態',
-    readStatusDescription: '檢視印表機狀態和佇列',
+    readStatusDescription: '檢視印表機狀態、佇列、資料庫、封存和切片機管線',
     manageQueue: '管理佇列',
-    manageQueueDescription: '新增和移除列印佇列中的項目',
+    manageQueueDescription: '新增和移除列印佇列中的項目。搭配「管理資料庫」可執行切片機管線',
     controlPrinter: '控制印表機',
     controlPrinterDescription: '暫停、繼續和停止列印',
     manageLibrary: '管理資料庫',
-    manageLibraryDescription: '上傳、重新命名與刪除資料庫檔案;從 MakerWorld 匯入模型',
+    manageLibraryDescription: '上傳、重新命名、刪除與切片資料庫檔案;從 MakerWorld 匯入模型。搭配「管理佇列」可執行切片機管線',
     manageInventory: '管理庫存',
     manageInventoryDescription: '建立、更新與刪除耗材盤與庫存記錄。SpoolBuddy 終端(NFC 掃描、秤讀取、終端系統指令)需要此權限。',
     manageMaintenance: '管理維護',
     manageMaintenanceDescription: '記錄已完成的維護、重設計數器、編輯間隔並管理維護類型目錄。適用於在不授予更廣泛印表機控制權限的情況下記錄「我清潔了噴嘴」的 Home Assistant 自動化。',
     manageArchives: '管理列印封存',
-    manageArchivesDescription: '編輯和刪除列印封存,包括移除舊的列印。不包括清除其統計資料貢獻。適用於精簡列印歷史的自動化。',
+    manageArchivesDescription: '編輯和刪除列印封存,包括移除舊的列印其統計資料貢獻。適用於精簡列印歷史的自動化。',
     manageProjects: '管理專案',
     manageProjectsDescription: '建立、更新和刪除專案,並向其新增封存。適用於將列印整理到專案中的自動化。',
     maintenanceBadge: '維護',

Plik diff jest za duży
+ 0 - 0
static/assets/index-DwuX91sA.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-xpKc9yRt.js"></script>
+    <script type="module" crossorigin src="/assets/index-DwuX91sA.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-1Ya6fAmN.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików