Kaynağa Gözat

fix(pipelines): a pipeline run remembers how each copy ended

A dispatched pipeline job kept status 'queued' and showed its outcome only
through its queue entry. Once the entry was deleted (queue page, archive or
user delete) the job lost its link, the run stayed 'dispatching' forever,
and retry could not tell a printed copy from one that never ran.

Session hooks now store a job's final status when its queue entry ends:
on every flush (status changes, deletes of a pending entry = cancelled) and
for bulk statements on print_queue run through a session. A start-up
backfill copies the final status of entries that ended before. Jobs whose
entry was already gone read as 'unknown': never retried, counted as finished
by the run, and left alone by cancelling the run. A guard test refuses raw
SQL that ends or deletes queue entries past the hooks.
maziggy 1 hafta önce
ebeveyn
işleme
176161f0d2
100 değiştirilmiş dosya ile 362 ekleme ve 26 silme
  1. 1 0
      CHANGELOG.md
  2. 13 13
      backend/app/api/routes/pipeline_runs.py
  3. 30 0
      backend/app/core/database.py
  4. 2 0
      backend/app/schemas/pipeline_run.py
  5. 120 0
      backend/app/services/pipeline_job_status.py
  6. 143 8
      backend/tests/integration/test_pipeline_runs_api.py
  7. 32 0
      backend/tests/unit/test_pipeline_job_status_writers.py
  8. 2 1
      frontend/src/api/client.ts
  9. 1 0
      frontend/src/i18n/locales/de.ts
  10. 1 0
      frontend/src/i18n/locales/en.ts
  11. 1 0
      frontend/src/i18n/locales/es.ts
  12. 1 0
      frontend/src/i18n/locales/fr.ts
  13. 1 0
      frontend/src/i18n/locales/it.ts
  14. 1 0
      frontend/src/i18n/locales/ja.ts
  15. 1 0
      frontend/src/i18n/locales/ko.ts
  16. 1 0
      frontend/src/i18n/locales/nl.ts
  17. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  18. 1 0
      frontend/src/i18n/locales/ru.ts
  19. 1 0
      frontend/src/i18n/locales/sv.ts
  20. 1 0
      frontend/src/i18n/locales/tr.ts
  21. 1 0
      frontend/src/i18n/locales/uk.ts
  22. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  23. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  24. 2 0
      frontend/src/pages/PipelineRunsPage.tsx
  25. 0 0
      static/assets/AdditionalSection-CKb2QQ7-.js
  26. 0 0
      static/assets/AmsUnitCard-BUTwT-XE.js
  27. 0 0
      static/assets/ArchivesPage-BujYrEEh.js
  28. 0 0
      static/assets/AssignToAmsModal-BaJqc7a9.js
  29. 0 0
      static/assets/CamWallPage-kRqq-Ujp.js
  30. 0 0
      static/assets/CameraDiagnoseModal-D3YfMVPd.js
  31. 0 0
      static/assets/CameraPage-VZFR8_sZ.js
  32. 0 0
      static/assets/CameraWall-DrtBX8Nc.js
  33. 0 0
      static/assets/ColumnConfigModal-Bs3BdJSs.js
  34. 0 0
      static/assets/ConfigureAmsSlotModal-CbYR9_gx.js
  35. 0 0
      static/assets/ConnectAuthorizePage-D3YD8i3Z.js
  36. 0 0
      static/assets/ContextMenu-DQbfTewg.js
  37. 0 0
      static/assets/ExternalLinkPage-Bixo5RD_.js
  38. 0 1
      static/assets/FileManagerPage-DvpS59zr.js
  39. 0 0
      static/assets/FileUploadModal-CXOoCHS7.js
  40. 0 0
      static/assets/FinancePage-C0DFTRbF.js
  41. 0 0
      static/assets/GCodeViewerPage-EYXu3SyN.js
  42. 0 0
      static/assets/GroupEditPage-rQW_7dIh.js
  43. 0 0
      static/assets/ImagePreviewModal-BC6UREVq.js
  44. 0 0
      static/assets/InventoryPage-CDwWYGug.js
  45. 0 0
      static/assets/LibraryTrashPage-DCACCkns.js
  46. 0 0
      static/assets/LoginPage-3PdA1NFP.js
  47. 0 0
      static/assets/MaintenancePage-BB81OD-q.js
  48. 0 0
      static/assets/ModelSourcesPage-DWXCR7l3.js
  49. 0 0
      static/assets/NotificationsPage-Ct5p7cJD.js
  50. 0 1
      static/assets/PdfPreviewModal-CeIGF6wf.js
  51. 0 0
      static/assets/PhotoGalleryModal-MIqnTLam.js
  52. 0 0
      static/assets/PrinterLocationsPage-BiYcufzQ.js
  53. 0 0
      static/assets/PrintersPage-COR7t_n6.js
  54. 0 0
      static/assets/ProfilesPage-DOr9l3Tk.js
  55. 0 0
      static/assets/ProjectDetailPage-CGfWKFeR.js
  56. 0 0
      static/assets/ProjectsPage-D8zg3xKL.js
  57. 0 0
      static/assets/QueuePage-8IEf7lrE.js
  58. 0 0
      static/assets/RichTextEditor-PvbGmr5k.js
  59. 0 0
      static/assets/SettingsPage-BYlaMdns.js
  60. 0 0
      static/assets/SetupPage-DCw0wHvR.js
  61. 0 0
      static/assets/SliceModal-D3LCVlza.js
  62. 0 0
      static/assets/SpoolBuddyAmsPage-DtAjS6FB.js
  63. 0 0
      static/assets/SpoolBuddyCalibrationPage-ksQikQgv.js
  64. 0 0
      static/assets/SpoolBuddyDashboard-xAO7kLkr.js
  65. 0 0
      static/assets/SpoolBuddyInventoryPage-Uamhs8Nc.js
  66. 1 1
      static/assets/SpoolBuddySettingsPage-C06A-KQV.js
  67. 0 0
      static/assets/SpoolBuddyWriteTagPage-CHgIgsZt.js
  68. 0 0
      static/assets/SpreadsheetPreviewModal-MiB9z69Z.js
  69. 0 0
      static/assets/StatsPage-BBxSHKvN.js
  70. 0 0
      static/assets/StreamOverlayPage-B_kK9Vav.js
  71. 0 0
      static/assets/SystemInfoPage-C1wx-z2r.js
  72. 0 0
      static/assets/de-MhLckxCK.js
  73. 0 0
      static/assets/de-bIen8vBa.js
  74. 0 0
      static/assets/es-D3Lxzz5P.js
  75. 0 0
      static/assets/es-OQ0M1RNP.js
  76. 0 0
      static/assets/fr-B4qii4Uk.js
  77. 0 0
      static/assets/fr-Om92FA6_.js
  78. 0 0
      static/assets/index-DcFCRD_h.js
  79. 1 1
      static/assets/inventorySearch-B-l6AAPT.js
  80. 0 0
      static/assets/it-BnbUTPSA.js
  81. 0 0
      static/assets/it-BvW9M3Vz.js
  82. 0 0
      static/assets/ja-ChUAPWxI.js
  83. 0 0
      static/assets/ja-D2EOAyJD.js
  84. 0 0
      static/assets/ko-B-_J2Kfz.js
  85. 0 0
      static/assets/ko-BY8XJxha.js
  86. 0 0
      static/assets/locationSensorDefaults-BU7B25Qv.js
  87. 0 0
      static/assets/nl-Bv9vDy4k.js
  88. 0 0
      static/assets/nl-wF3bqIc7.js
  89. 0 0
      static/assets/pdf-BrWwR2uA.js
  90. 0 0
      static/assets/pt-BR-831utWem.js
  91. 0 0
      static/assets/pt-BR-92EhOKVO.js
  92. 0 0
      static/assets/ru-CD4S12VG.js
  93. 0 0
      static/assets/ru-D-8vaLZZ.js
  94. 0 0
      static/assets/sv-C3HTHiY0.js
  95. 0 0
      static/assets/sv-s25PQYIG.js
  96. 0 0
      static/assets/tr-lkcLBFlT.js
  97. 0 0
      static/assets/tr-zuS-RAgq.js
  98. 0 0
      static/assets/uk-BaXFeBlZ.js
  99. 0 0
      static/assets/uk-DtWUCD87.js
  100. 0 0
      static/assets/utils-CECEIY96.js

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A pipeline run remembers how each copy ended** — A copy a slicer pipeline sent to the queue only showed its outcome while its queue entry existed. Once the entry was deleted (from the queue page, with its archive, or with its user) the run could no longer tell a printed copy from one that never ran: it stayed **dispatching** forever, and **Retry failed** could not tell what to retry. Each copy now keeps its final state (completed, failed or cancelled) when its queue entry ends, and removing a copy that hadn't printed yet counts as cancelled. Copies whose entry was already gone before this update show as **unknown**: never retried, and counted as finished, so the run completes.
 - **Deleting a spool keeps its usage history, and a new spool no longer takes over a deleted one's** — On PostgreSQL, deleting a spool deleted its usage history, so **Recalculate costs** replaced the real spool cost of its prints with an estimate. On SQLite the history stayed, but the next spool created could get the deleted spool's number and then showed its prints and consumption as its own. The usage history now stays on both databases without a spool, the prints keep their spool cost, and spool numbers are never given out again on SQLite. A Git backup restores such history as well. Supplier and material statistics leave out usage of deleted spools, as there is no spool left to count it for.
 - **Archives a spool was used for can be deleted on PostgreSQL** — With PostgreSQL, deleting an archive for good (deleting it with **purge from statistics**, the automatic archive purge with that option, or deleting a printer together with its archives) failed with a server error when a spool from the inventory had been used for that print. The spool's usage record now stays and only loses its link to the deleted archive, on both databases; on SQLite, usage records still pointing at an archive deleted earlier are cleared of that link at the next start. **Recalculate costs** no longer charges usage records without an archive link to archives by their print name: that added the spool cost of a deleted print, of a print made with archiving off, or of every earlier run to each archive with the same name. Archives without linked usage get the estimate from the filament price instead, as they already did when no such record existed (usage recorded before 0.2.1, or with archiving off).
 - **An id of 0 no longer slips past the checks when queueing, batching or creating a project** — Adding to the queue with an archive id of `0` next to a library file, creating a batch with an archive or library file id of `0`, and creating a project with parent `0` skipped the check that the archive, file or project exists and saved `0` as its id: a server error on PostgreSQL, a link to nothing on SQLite. `0` now means "none" there, as it already did when editing a project. Creating a batch also checks that its archive or library file exists, and refuses an unknown one.

+ 13 - 13
backend/app/api/routes/pipeline_runs.py

@@ -188,7 +188,10 @@ def _compute_job_status(
     if persisted in ("failed", "cancelled", "completed"):
         return persisted
     if queue_entry is None:
-        return persisted
+        # Dispatched, but its queue entry is gone without a final status stored
+        # (deleted before jobs stored it, or removed by the database itself):
+        # printed or not cannot be told, so neither failed nor cancelled.
+        return "unknown" if persisted in ("queued", "printing") else persisted
     qs = queue_entry.status
     if qs == "completed":
         return "completed"
@@ -221,19 +224,21 @@ def _roll_up_run_status(
         return persisted
 
     completed = sum(1 for s in job_statuses if s == "completed")
+    # A copy whose outcome is unknown ended without failing anything to retry.
+    unknown = sum(1 for s in job_statuses if s == "unknown")
     failed = sum(1 for s in job_statuses if s == "failed")
     cancelled = sum(1 for s in job_statuses if s == "cancelled")
     in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
     total = len(job_statuses)
 
-    if completed == total:
+    if completed + unknown == total:
         return "completed"
     if in_flight > 0:
         return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
     # All copies are in terminal states.
     if failed == 0 and cancelled == total:
         return "cancelled"
-    if completed > 0 and (failed > 0 or cancelled > 0):
+    if completed + unknown > 0 and (failed > 0 or cancelled > 0):
         return "partial_failure"
     if failed > 0:
         return "failed"
@@ -294,16 +299,6 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
             printer_name = p.name if p else None
 
         live_job_status = _compute_job_status(job.status, queue_entry)
-        # If the job WAS dispatched (had a queue_entry_id) but the entry has
-        # since been deleted from the queue page, the user's intent was
-        # cancellation. Otherwise the run would stay forever showing as
-        # ``queued`` because the persisted job.status hasn't been updated.
-        if (
-            job.queue_entry_id is not None
-            and queue_entry is None
-            and live_job_status not in ("completed", "failed", "cancelled")
-        ):
-            live_job_status = "cancelled"
         job_live_statuses.append(live_job_status)
         job_responses.append(
             PipelineJobResponse(
@@ -998,12 +993,17 @@ async def cancel_run(
 
     job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
     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()
             if queue_entry is not None and queue_entry.status in ("pending", "queued"):
                 queue_entry.status = "cancelled"
+        if job.status in ("queued", "printing") and queue_entry is None:
+            # Dispatched, its entry gone: whether it printed is unknown, and
+            # calling it cancelled would make retry print it again.
+            continue
         if job.status not in ("completed", "failed", "cancelled"):
             job.status = "cancelled"
             job.completed_at = datetime.now(timezone.utc)

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

@@ -5805,6 +5805,7 @@ async def _run_migrations(conn):
     await _migrate_notification_providers_go_with_their_printer(conn)
     await _migrate_spool_usage_outlives_its_archive(conn)
     await _migrate_spool_usage_outlives_its_spool(conn)
+    await _backfill_pipeline_job_final_status(conn)
     await _migrate_pg_column_types(conn)
     await _migrate_pg_columns_to_model(conn)
 
@@ -6115,6 +6116,32 @@ async def _migrate_spool_usage_outlives_its_spool(conn) -> None:
     await _pg_set_fk_ondelete(conn, "spool_usage_history", "spool_id", "spool", "SET NULL")
 
 
+async def _backfill_pipeline_job_final_status(conn) -> None:
+    """Give dispatched pipeline jobs the final status of their queue entry.
+
+    Jobs store it when the entry ends (services/pipeline_job_status); entries
+    that ended before that keep it only on the entry, which can be deleted
+    later. Not gated: it only matches jobs not yet ended whose entry has, so
+    a second run changes nothing. Jobs whose entry is already gone stay
+    unknown.
+    """
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        result = await conn.execute(
+            text(
+                "UPDATE pipeline_jobs SET status = CASE "
+                "(SELECT q.status FROM print_queue q WHERE q.id = pipeline_jobs.queue_entry_id) "
+                "WHEN 'completed' THEN 'completed' WHEN 'cancelled' THEN 'cancelled' ELSE 'failed' END "
+                "WHERE status NOT IN ('completed', 'failed', 'cancelled') "
+                "AND queue_entry_id IN (SELECT id FROM print_queue "
+                "WHERE status IN ('completed', 'failed', 'aborted', 'cancelled'))"
+            )
+        )
+    if result.rowcount:
+        logger.info("Stored the final status of %d pipeline job(s) from their queue entry", result.rowcount)
+
+
 _PG_ON_DELETE_CODES = {"CASCADE": "c", "SET NULL": "n"}
 
 
@@ -7711,3 +7738,6 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
 
 # The ON DELETE rules SQLite doesn't apply itself: hooks on every ORM delete.
 from backend.app.core import sqlite_fk  # noqa: E402, F401
+
+# A pipeline job stores its queue entry's final status: hooks on every flush.
+from backend.app.services import pipeline_job_status  # noqa: E402, F401

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

@@ -120,6 +120,8 @@ class PipelineJobResponse(BaseModel):
         "completed",
         "failed",
         "cancelled",
+        # Dispatched, its queue entry gone without a final status stored.
+        "unknown",
     ]
     error_message: str | None = None
     dispatched_at: datetime | None = None

+ 120 - 0
backend/app/services/pipeline_job_status.py

@@ -0,0 +1,120 @@
+"""A pipeline job keeps the final status of its queue entry.
+
+A dispatched job's own status stays ``queued``; while its queue entry exists
+the run view reads the entry's status. The entry can be deleted later (queue
+page, archive delete, user delete), which clears the job's link, and the job
+could then not tell a printed copy from one that never ran. So when the entry
+ends -- reaches a final status, or is deleted before that (a cancellation) --
+the job stores that status itself.
+
+Hooks on every session flush (status changes, ORM deletes) and on statements
+run through a session (bulk deletes and status updates of print_queue). A
+delete the database cascades on its own (a printer or batch removed with its
+pending entries) is not seen: such a job reads as unknown, never as failed.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+
+from sqlalchemy import Table, event, select
+from sqlalchemy.engine import Connection
+from sqlalchemy.orm import ORMExecuteState, Session, attributes
+from sqlalchemy.sql.dml import Delete, Update
+
+from backend.app.models.pipeline_run import PipelineJob
+from backend.app.models.print_queue import PrintQueueItem
+
+# Final job statuses, and the queue entry statuses that end a job.
+JOB_FINAL_STATUSES = ("completed", "failed", "cancelled")
+_FINAL_FROM_QUEUE = {"completed": "completed", "failed": "failed", "aborted": "failed", "cancelled": "cancelled"}
+
+_queue_table: Table = PrintQueueItem.__table__  # type: ignore[assignment]
+_job_table: Table = PipelineJob.__table__  # type: ignore[assignment]
+
+
+def final_job_status(queue_status: str | None, *, entry_deleted: bool) -> str | None:
+    """The status a job ends with when its entry has ``queue_status``, or None if it doesn't end."""
+    final = _FINAL_FROM_QUEUE.get(queue_status or "")
+    if final is not None:
+        return final
+    # Deleted while still waiting or printing: the copy was cancelled.
+    return "cancelled" if entry_deleted else None
+
+
+def _store(connection: Connection, entries: dict[int, str]) -> None:
+    """Give the not yet ended jobs of each queue entry id its final status."""
+    now = datetime.now(timezone.utc).replace(tzinfo=None)
+    for status in set(entries.values()):
+        ids = [entry_id for entry_id, final in entries.items() if final == status]
+        connection.execute(
+            _job_table.update()
+            .where(_job_table.c.queue_entry_id.in_(ids), _job_table.c.status.not_in(JOB_FINAL_STATUSES))
+            .values(status=status, completed_at=now)
+        )
+
+
+@event.listens_for(Session, "before_flush")
+def _store_on_flush(session: Session, flush_context: Any, instances: Any) -> None:
+    entries: dict[int, str] = {}
+    for obj in session.dirty:
+        if isinstance(obj, PrintQueueItem) and obj.id is not None:
+            if attributes.get_history(obj, "status").has_changes():
+                final = final_job_status(obj.status, entry_deleted=False)
+                if final is not None:
+                    entries[obj.id] = final
+    for obj in session.deleted:
+        if isinstance(obj, PrintQueueItem) and obj.id is not None:
+            final = final_job_status(obj.status, entry_deleted=True)
+            if final is not None:
+                entries[obj.id] = final
+    if entries:
+        _store(session.connection(), entries)
+
+
+def _is_queue_table(table: Any) -> bool:
+    # ORM statements carry an annotated copy of the table, so compare by name.
+    return getattr(table, "name", None) == _queue_table.name
+
+
+def _set_status(statement: Update) -> str | None:
+    """The literal status a bulk update writes into print_queue, if any."""
+    for column, value in (statement._values or {}).items():  # noqa: SLF001
+        key = column if isinstance(column, str) else getattr(column, "key", None)
+        if key == "status":
+            literal = getattr(value, "value", value)
+            return literal if isinstance(literal, str) else None
+    return None
+
+
+# First among the statement hooks: core/sqlite_fk clears the jobs' link to a
+# deleted entry in its own hook, after which they could not be found.
+@event.listens_for(Session, "do_orm_execute", insert=True)
+def _store_on_bulk(state: ORMExecuteState) -> None:
+    statement = state.statement
+    if state.is_delete and isinstance(statement, Delete):
+        if not _is_queue_table(statement.table):
+            return
+        new_status = None
+        entry_deleted = True
+    elif state.is_update and isinstance(statement, Update):
+        if not _is_queue_table(statement.table):
+            return
+        new_status = _set_status(statement)
+        if final_job_status(new_status, entry_deleted=False) is None:
+            return
+        entry_deleted = False
+    else:
+        return
+    connection = state.session.connection()
+    query = select(_queue_table.c.id, _queue_table.c.status)
+    if statement.whereclause is not None:
+        query = query.where(statement.whereclause)
+    entries: dict[int, str] = {}
+    for entry_id, status in connection.execute(query):
+        final = final_job_status(new_status or status, entry_deleted=entry_deleted)
+        if final is not None:
+            entries[entry_id] = final
+    if entries:
+        _store(connection, entries)

+ 143 - 8
backend/tests/integration/test_pipeline_runs_api.py

@@ -929,10 +929,6 @@ class TestPolishFollowUp:
         await db_session.commit()
         return run.id, item.id, src.id
 
-    @pytest.mark.xfail(
-        strict=True,
-        reason="A job keeps status 'queued' after dispatch; once its queue entry is deleted, a pending copy cannot be told from a printed one. Needs the job's final status stored when its entry ends.",
-    )
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_deleted_queue_entry_rolls_up_as_cancelled(
@@ -986,10 +982,6 @@ class TestPolishFollowUp:
 
         assert body["jobs"][0]["status"] == "pending"
 
-    @pytest.mark.xfail(
-        strict=True,
-        reason="A job keeps status 'queued' after dispatch; once its queue entry is deleted, a pending copy cannot be told from a printed one. Needs the job's final status stored when its entry ends.",
-    )
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_retry_counts_a_deleted_queue_entry_as_cancelled(
@@ -1266,3 +1258,146 @@ class TestRunViaApiKey:
 
         assert resp.status_code == 202, resp.text
         assert enqueue.await_args.kwargs["owner_id"] is None
+
+
+class TestJobKeepsItsFinalStatus:
+    """A dispatched job stores its queue entry's final status, so deleting the entry
+    later cannot turn a printed copy into one that looks pending (or retryable)."""
+
+    @staticmethod
+    async def _job(db_session, run_id: int):
+        from sqlalchemy import select
+
+        from backend.app.models.pipeline_run import PipelineJob
+
+        db_session.expire_all()
+        return (await db_session.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run_id))).scalar_one()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_printed_copy_stays_completed_after_its_entry_is_deleted(
+        self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
+    ):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
+            db_session, pipeline_factory, printer_factory, library_file_factory
+        )
+        item = await db_session.get(PrintQueueItem, item_id)
+        item.status = "completed"
+        await db_session.commit()
+
+        assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
+        body = (await async_client.get(f"/api/v1/pipeline-runs/{run_id}")).json()
+        retry = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
+
+        assert (body["jobs"][0]["status"], body["status"]) == ("completed", "completed")
+        assert retry.status_code == 400, retry.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("bulk", "expected"), [("cancel", "cancelled"), ("delete", "cancelled"), ("fail", "failed")]
+    )
+    async def test_bulk_statements_store_the_final_status(
+        self, pipeline_factory, printer_factory, library_file_factory, db_session, bulk, expected
+    ):
+        from sqlalchemy import delete, update
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
+            db_session, pipeline_factory, printer_factory, library_file_factory
+        )
+        if bulk == "delete":
+            statement = delete(PrintQueueItem).where(PrintQueueItem.id == item_id)
+        elif bulk == "cancel":
+            # As the library trash cancels the jobs of a deleted file: on the table itself.
+            statement = PrintQueueItem.__table__.update().where(PrintQueueItem.id == item_id).values(status="cancelled")
+        else:
+            statement = update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(status="failed")
+        await db_session.execute(statement)
+        await db_session.commit()
+
+        assert (await self._job(db_session, run_id)).status == expected
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_job_whose_entry_vanished_earlier_is_unknown_not_retried(
+        self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
+    ):
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="dispatching")
+        db_session.add(run)
+        await db_session.flush()
+        # Dispatched before jobs stored a final status; the entry is gone since.
+        db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, queue_entry_id=None, status="queued"))
+        await db_session.commit()
+
+        body = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}")).json()
+        retry = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/retry-failed")
+
+        assert (body["jobs"][0]["status"], body["status"]) == ("unknown", "completed")
+        assert retry.status_code == 400, retry.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_startup_stores_the_status_of_entries_that_ended_before(
+        self, pipeline_factory, printer_factory, library_file_factory, db_session
+    ):
+        from backend.app.core.database import _backfill_pipeline_job_final_status
+
+        run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
+            db_session, pipeline_factory, printer_factory, library_file_factory
+        )
+        connection = await db_session.connection()
+        # Ended before jobs stored it: written past the session hooks.
+        await connection.exec_driver_sql(f"UPDATE print_queue SET status = 'aborted' WHERE id = {int(item_id)}")
+        await db_session.commit()
+
+        await _backfill_pipeline_job_final_status(await db_session.connection())
+        await _backfill_pipeline_job_final_status(await db_session.connection())  # a restart changes nothing
+        await db_session.commit()
+
+        assert (await self._job(db_session, run_id)).status == "failed"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancelling_a_run_leaves_an_unknown_copy_unknown(
+        self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
+    ):
+        """Cancelled would make retry print a copy that may have printed already."""
+        from dataclasses import dataclass
+
+        from backend.app.models.pipeline_run import PipelineJob
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 6668
+
+        run_id, _, _ = await TestPolishFollowUp._dispatched_run(
+            db_session, pipeline_factory, printer_factory, library_file_factory
+        )
+        # A second copy, dispatched before jobs stored a final status; its entry is gone.
+        db_session.add(PipelineJob(pipeline_run_id=run_id, copy_index=1, queue_entry_id=None, status="queued"))
+        await db_session.commit()
+
+        cancelled = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
+        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()),
+            ),
+        ):
+            retry = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
+
+        assert cancelled.status_code == 200, cancelled.text
+        assert sorted(job["status"] for job in cancelled.json()["jobs"]) == ["cancelled", "unknown"]
+        assert retry.status_code == 202, retry.text
+        assert retry.json()["copies"] == 1

+ 32 - 0
backend/tests/unit/test_pipeline_job_status_writers.py

@@ -0,0 +1,32 @@
+"""Queue status changes stay visible to the pipeline job hooks.
+
+services/pipeline_job_status stores a job's final status from flushes and from
+statements run through a session. A status written or a row deleted as raw SQL
+text would skip both, and the job would forget whether its copy printed.
+"""
+
+import re
+from pathlib import Path
+
+APP = Path(__file__).resolve().parents[2] / "app"
+
+# Matched across lines: SQL is usually split into string pieces.
+RAW_WRITES = re.compile(
+    r"UPDATE\s+print_queue\b[\s\"']*SET\b[^;)]*?\bstatus\s*=|DELETE\s+FROM\s+print_queue\b",
+    re.IGNORECASE,
+)
+
+# Raw writes that cannot end a job, with why.
+ALLOWED = {
+    # The #1667 migration resets skipped items to pending: not a final status.
+    "core/database.py": 1,
+}
+
+
+def test_no_raw_sql_ends_or_deletes_a_queue_entry():
+    found: dict[str, int] = {}
+    for path in APP.rglob("*.py"):
+        count = len(RAW_WRITES.findall(path.read_text(encoding="utf-8")))
+        if count:
+            found[str(path.relative_to(APP))] = count
+    assert found == ALLOWED

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

@@ -2142,7 +2142,8 @@ export interface PipelineJob {
     | 'printing'
     | 'completed'
     | 'failed'
-    | 'cancelled';
+    | 'cancelled'
+    | 'unknown';
   error_message: string | null;
   dispatched_at: string | null;
   completed_at: string | null;

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

@@ -1384,6 +1384,7 @@ export default {
       completed: 'abgeschlossen',
       failed: 'fehlgeschlagen',
       cancelled: 'abgebrochen',
+      unknown: 'unbekannt',
     },
     cancelledByUser: 'Vom Benutzer abgebrochen',
   },

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

@@ -1397,6 +1397,7 @@ export default {
       completed: 'completed',
       failed: 'failed',
       cancelled: 'cancelled',
+      unknown: 'unknown',
     },
   },
 

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

@@ -1384,6 +1384,7 @@ export default {
       completed: 'completada',
       failed: 'fallida',
       cancelled: 'cancelada',
+      unknown: 'desconocido',
     },
     cancelledByUser: 'Cancelado por el usuario',
   },

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

@@ -1384,6 +1384,7 @@ export default {
       completed: 'terminée',
       failed: 'échouée',
       cancelled: 'annulée',
+      unknown: 'inconnu',
     },
     cancelledByUser: 'Annulé par l\'utilisateur',
   },

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

@@ -1384,6 +1384,7 @@ export default {
       completed: 'completata',
       failed: 'fallita',
       cancelled: 'annullata',
+      unknown: 'sconosciuto',
     },
     cancelledByUser: 'Annullato dall\'utente',
   },

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

@@ -1383,6 +1383,7 @@ export default {
       completed: '完了',
       failed: '失敗',
       cancelled: 'キャンセル',
+      unknown: '不明',
     },
     cancelledByUser: 'ユーザーによりキャンセル',
   },

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

@@ -1337,6 +1337,7 @@ export default {
       completed: '완료됨',
       failed: '실패',
       cancelled: '취소됨',
+      unknown: '알 수 없음',
     },
     cancelledByUser: '사용자에 의해 취소됨',
   },

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -1395,6 +1395,7 @@ export default {
       completed: 'voltooid',
       failed: 'mislukt',
       cancelled: 'geannuleerd',
+      unknown: 'onbekend',
     },
   },
 

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

@@ -1384,6 +1384,7 @@ export default {
       completed: 'concluída',
       failed: 'falhou',
       cancelled: 'cancelada',
+      unknown: 'desconhecido',
     },
     cancelledByUser: 'Cancelado pelo usuário',
   },

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -1336,6 +1336,7 @@ export default {
       completed: "завершено",
       failed: "ошибка",
       cancelled: "отменено",
+      unknown: "неизвестно",
     },
   },
   queue: {

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -1395,6 +1395,7 @@ export default {
       completed: 'slutförd',
       failed: 'misslyckad',
       cancelled: 'avbruten',
+      unknown: 'okänt',
     },
   },
 

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

@@ -1385,6 +1385,7 @@ export default {
       completed: 'tamamlandı',
       failed: 'başarısız',
       cancelled: 'iptal edildi',
+      unknown: 'bilinmiyor',
     },
     cancelledByUser: 'Kullanıcı tarafından iptal edildi',
   },

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -1394,6 +1394,7 @@ export default {
       completed: "завершено",
       failed: "не вдалося",
       cancelled: "скасовано",
+      unknown: "невідомо",
     },
   },
 

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

@@ -1384,6 +1384,7 @@ export default {
       completed: '已完成',
       failed: '失败',
       cancelled: '已取消',
+      unknown: '未知',
     },
     cancelledByUser: '用户已取消',
   },

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

@@ -1384,6 +1384,7 @@ export default {
       completed: '已完成',
       failed: '失敗',
       cancelled: '已取消',
+      unknown: '未知',
     },
     cancelledByUser: '使用者已取消',
   },

+ 2 - 0
frontend/src/pages/PipelineRunsPage.tsx

@@ -707,6 +707,8 @@ function JobStatusChip({ status }: { status: PipelineRun['jobs'][number]['status
     completed: 'bg-emerald-700 text-emerald-100',
     failed: 'bg-red-700 text-red-100',
     cancelled: 'bg-rose-900 text-rose-200',
+    // Dispatched; whether it printed is not known.
+    unknown: 'bg-slate-700 text-slate-300',
   };
   return (
     <span className={`px-1.5 py-0.5 rounded text-[10px] uppercase font-semibold tracking-wide ${colours[status]}`}>

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/AdditionalSection-CKb2QQ7-.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/AmsUnitCard-BUTwT-XE.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ArchivesPage-BujYrEEh.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/AssignToAmsModal-BaJqc7a9.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/CamWallPage-kRqq-Ujp.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/CameraDiagnoseModal-D3YfMVPd.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/CameraPage-VZFR8_sZ.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/CameraWall-DrtBX8Nc.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ColumnConfigModal-Bs3BdJSs.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CbYR9_gx.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ConnectAuthorizePage-D3YD8i3Z.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ContextMenu-DQbfTewg.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ExternalLinkPage-Bixo5RD_.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
static/assets/FileManagerPage-DvpS59zr.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/FileUploadModal-CXOoCHS7.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/FinancePage-C0DFTRbF.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/GCodeViewerPage-EYXu3SyN.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/GroupEditPage-rQW_7dIh.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ImagePreviewModal-BC6UREVq.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/InventoryPage-CDwWYGug.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/LibraryTrashPage-DCACCkns.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/LoginPage-3PdA1NFP.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/MaintenancePage-BB81OD-q.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ModelSourcesPage-DWXCR7l3.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/NotificationsPage-Ct5p7cJD.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
static/assets/PdfPreviewModal-CeIGF6wf.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/PhotoGalleryModal-MIqnTLam.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/PrinterLocationsPage-BiYcufzQ.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/PrintersPage-COR7t_n6.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ProfilesPage-DOr9l3Tk.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ProjectDetailPage-CGfWKFeR.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ProjectsPage-D8zg3xKL.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/QueuePage-8IEf7lrE.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/RichTextEditor-PvbGmr5k.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SettingsPage-BYlaMdns.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SetupPage-DCw0wHvR.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SliceModal-D3LCVlza.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyAmsPage-DtAjS6FB.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-ksQikQgv.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyDashboard-xAO7kLkr.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-Uamhs8Nc.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 1
static/assets/SpoolBuddySettingsPage-C06A-KQV.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-CHgIgsZt.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpreadsheetPreviewModal-MiB9z69Z.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/StatsPage-BBxSHKvN.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/StreamOverlayPage-B_kK9Vav.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SystemInfoPage-C1wx-z2r.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/de-MhLckxCK.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/de-bIen8vBa.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/es-D3Lxzz5P.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/es-OQ0M1RNP.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/fr-B4qii4Uk.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/fr-Om92FA6_.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-DcFCRD_h.js


+ 1 - 1
static/assets/inventorySearch-Btiv8N-6.js → static/assets/inventorySearch-B-l6AAPT.js

@@ -1 +1 @@
-import{ft as e}from"./index-5pmk7Nlq.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-DcFCRD_h.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/it-BnbUTPSA.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/it-BvW9M3Vz.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ja-ChUAPWxI.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ja-D2EOAyJD.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ko-B-_J2Kfz.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ko-BY8XJxha.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/locationSensorDefaults-BU7B25Qv.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/nl-Bv9vDy4k.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/nl-wF3bqIc7.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/pdf-BrWwR2uA.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/pt-BR-831utWem.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/pt-BR-92EhOKVO.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ru-CD4S12VG.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ru-D-8vaLZZ.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/sv-C3HTHiY0.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/sv-s25PQYIG.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/tr-lkcLBFlT.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/tr-zuS-RAgq.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/uk-BaXFeBlZ.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/uk-DtWUCD87.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/utils-CECEIY96.js


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor