diff --git a/docs/betrieb.md b/docs/betrieb.md index ebaf7cb..8327e53 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -54,17 +54,24 @@ Windows Server-Betrieb geeignet. Gemappte Netzlaufwerke wie `S:\` oder `H:\` werden ausdrücklich unterstützt. Eine Ablehnung solcher Pfade allein wegen eines dahinterliegenden UNC-Pfads ist unzulässig. -### Umfang der V2.0-GUI +### Umfang der GUI -Die GUI in V2.0 dient ausschließlich als: +Die GUI enthält zwei Tabs: -- **Konfigurationseditor** für die `.properties`-Datei -- **Validierungsoberfläche** (automatische und explizite Prüfung des Konfigurationsstands) -- **Technische Testoberfläche** (Erreichbarkeit des Providers, Pfade, SQLite-Datei, Prompt-Datei) +- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für + die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei, + Prompt-Datei). +- **Tab „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit + Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument. Der Lauf verwendet den + zuletzt gespeicherten Stand der `.properties`-Datei; ungespeicherte Änderungen im + Editor fließen nicht in den Lauf ein. Ein **Soft-Stop** über den Abbrechen-Knopf + beendet den Lauf nach Abschluss der gerade bearbeiteten Datei. Während eines + laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin. -Die GUI enthält in V2.0 **keinen** manuellen Verarbeitungslauf. Das Starten eines Batch-Laufs -aus der GUI ist erst ab V2.1+ vorgesehen. Der headless Betrieb über den Windows Task Scheduler -bleibt der einzige Weg, PDF-Dateien automatisiert zu verarbeiten. +Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und +kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau +ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf +wird jedoch nicht technisch erkannt oder blockiert. --- diff --git a/docs/gui-bedienanleitung.md b/docs/gui-bedienanleitung.md index e39a4da..2fa2f8b 100644 --- a/docs/gui-bedienanleitung.md +++ b/docs/gui-bedienanleitung.md @@ -6,18 +6,17 @@ verwalten und technisch prüfen möchten. --- -## 1. Zweck und Scope der GUI in V2.0 +## 1. Zweck und Scope der GUI -Die GUI dient in V2.0 ausschließlich als: +Die GUI gliedert sich in zwei feste Tabs: -- **Konfigurationseditor** für die `.properties`-Datei -- **Validierungsoberfläche** für den aktuellen Konfigurationsstand -- **Technische Test- und Diagnoseoberfläche** für Erreichbarkeit des Providers, - Pfadprüfungen und Ressourcenverfügbarkeit +- **Tab 1 „Konfiguration"** – Editor, Validierungsoberfläche und technische + Test-/Diagnoseoberfläche für die `.properties`-Datei. +- **Tab 2 „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit + Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13). -Die GUI enthält in V2.0 **keinen** manuellen Verarbeitungslauf. Das Starten eines -Batch-Laufs aus der GUI ist erst ab einer späteren Ausbaustufe vorgesehen. -Ebenso gibt es keinen Historien-Tab, keine Datenbankansicht und kein Kosten-Tracking. +Weiterhin **nicht** enthalten sind ein Historien-Tab, eine Datenbankansicht und ein +Kosten-Tracking — diese Ausbauten sind für spätere Stufen vorbehalten. Der headless Batch-/Scheduler-Betrieb über `--headless` bleibt der einzige Weg, PDF-Dateien automatisiert zu verarbeiten. @@ -441,13 +440,77 @@ Die GUI wird offiziell nur unter **Windows** unterstützt. --- -## 13. Bekannte Einschränkungen V2.0 +## 13. Tab „Verarbeitungslauf" (live-Verfolgung) + +Der zweite Tab „Verarbeitungslauf" startet einen Batch-Lauf direkt aus der GUI und +zeigt dessen Fortschritt in Echtzeit an. + +### Layout +- **Fortschrittsbalken** mit Zähler (`n / m Dateien`) im Kopfbereich +- **Ergebnisliste** (scrollbar) mit einer Zeile pro abgeschlossener Datei +- **Seitenbereich** rechts neben der Liste für die KI-Begründung +- **Meldungs- und Zusammenfassungsbereich** unter der Liste +- Aktionsknöpfe **Starten** und **Abbrechen** + +### Konfigurationsquelle +Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der +`.properties`-Datei. Ungespeicherte Änderungen im Konfigurations-Editor fließen **nicht** +in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein. + +### Start und Verlauf +- Beim Start wird die Dateimenge **einmalig** bestimmt; der Nenner des Fortschrittsbalkens + bleibt während des Laufs konstant. +- Nach jeder abgeschlossenen Datei erscheint ohne manuellen Refresh eine neue Zeile mit + den fünf Spalten **Status-Icon**, **Originaldateiname**, **Neuer Dateiname**, **Datum** + und **Dauer**. +- Für Fehler- und Übersprungen-Fälle wird bei den Spalten „Neuer Dateiname" und „Datum" + ein Gedankenstrich `—` eingetragen. +- Die Status-Icons folgen: ✅ erfolgreich, ⚠️ fehlgeschlagen (retryable), + ❌ fehlgeschlagen (permanent), ⏭️ übersprungen. +- Ein Klick auf eine Zeile zeigt die KI-Begründung im Seitenbereich. Liegt keine + Begründung vor, erscheint der Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning + vor.". +- Nach Laufende erscheint die Zusammenfassung `X erfolgreich, Y fehlgeschlagen, + Z übersprungen` im Meldungs- und Zusammenfassungsbereich. + +### Soft-Stop +Der Knopf **Abbrechen** löst einen **Soft-Stop** aus: die aktuell in Bearbeitung +befindliche Datei wird vollständig fertig verarbeitet, anschließend wird der Lauf sauber +beendet — keine halbfertigen Zustände in der SQLite-Datenbank. + +### Sperre von Tab 1 während eines Laufs +Während eines laufenden Verarbeitungslaufs ist Tab 1 „Konfiguration" gesperrt. Ein +sichtbarer Hinweis erinnert daran, dass die Konfiguration während des Laufs nicht +editierbar ist. Nach Abschluss, Abbruch oder einer unerwarteten Ausnahme wird Tab 1 +automatisch wieder freigegeben. + +### Fenster schließen während eines Laufs +Versucht der Benutzer das Fenster zu schließen, während ein Lauf aktiv ist, erscheint ein +Hinweisdialog mit zwei Optionen: +- **Nicht schließen** – der Lauf läuft unverändert weiter +- **Lauf beenden und schließen** – ein Soft-Stop wird ausgelöst; nach Abschluss der + aktuellen Datei schließt die Anwendung + +### Grenzen und Hinweise +- Pro Anwendungsinstanz ist genau **ein** Verarbeitungslauf gleichzeitig zulässig. Ein + zweiter Startversuch während eines laufenden Laufs wird mit der Meldung „Ein + Verarbeitungslauf ist bereits aktiv." verweigert. +- Ein **gleichzeitiger externer headless Lauf** (Windows Task Scheduler) wird weder + aktiv erkannt noch technisch geblockt. Der Benutzer ist selbst verantwortlich, + parallele Läufe zu vermeiden. +- Startet der Lauf mit einem leeren Quellordner, erscheint der Hinweis „Keine + verarbeitbaren Dateien im Quellordner gefunden" und die Zusammenfassung + `0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen` wird eingetragen. + +--- + +## 14. Bekannte Einschränkungen V2.x | Einschränkung | Erläuterung | |---|---| -| Kein manueller Verarbeitungslauf | Das Starten eines Batch-Laufs aus der GUI ist erst ab V2.1+ vorgesehen | | Kein Historien-Tab | Eine Ansicht der SQLite-Datenbank und Verarbeitungshistorie ist für spätere Ausbaustufen vorbehalten | | Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten | | Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand | -| Keine Koordination mit parallelen headless Läufen | Läuft gleichzeitig ein headless Batch-Lauf, koordinieren sich GUI und headless Betrieb nicht. Schreibkonflikte können entstehen, wenn dieselbe `.properties`-Datei gleichzeitig über die GUI gespeichert und vom headless Lauf gelesen wird | +| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird | | GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet | +| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer | diff --git a/docs/specs/V2_7_-_Spezifikation.md b/docs/specs/V2_7_-_Spezifikation.md new file mode 100644 index 0000000..996eabb --- /dev/null +++ b/docs/specs/V2_7_-_Spezifikation.md @@ -0,0 +1,297 @@ +# V2.7 – GUI-Verarbeitungslauf mit Live-Verfolgung + +**Status:** Freigegeben +**Erstellt:** 2026-04-22 +**Überarbeitet:** 2026-04-22 (nach Review, finale Version) +**Autor:** Marcus (mit Claude als Mentor) + +--- + +## Ziel + +V2.7 erweitert die JavaFX-GUI um einen zweiten Tab „Verarbeitungslauf", über den der Benutzer +einen Batch-Lauf direkt aus der GUI starten und dessen Fortschritt in Echtzeit verfolgen kann. +Der bestehende headless-Betrieb über den Windows Task Scheduler bleibt unverändert erhalten. + +--- + +## Hintergrund + +### Bisheriger Zustand +- Die GUI dient in V2.0–V2.6 ausschließlich der Konfiguration und technischen Validierung +- Ein Verarbeitungslauf kann nur über die Kommandozeile bzw. eine Batch-Datei gestartet werden +- Es gibt keine Möglichkeit, den Fortschritt eines laufenden Batches live zu beobachten + +### Motivation +- Der manuelle Kommandozeilenstart ist für den Alltagsbetrieb umständlich +- Ohne Live-Anzeige ist unklar, ob und wie schnell die Verarbeitung voranschreitet +- Eine einzelne Datei wird schnell verarbeitet – eine Gesamtfortschrittsanzeige ist daher + sinnvoller als eine dateiweise Einzelanzeige + +--- + +## Zielbild + +Nach Abschluss von V2.7 kann der Benutzer: + +1. Im neuen Tab „Verarbeitungslauf" einen Batch-Lauf starten +2. Den Gesamtfortschritt über alle Dateien live verfolgen +3. Jede abgeschlossene Datei mit Ergebnis in einer Liste sehen +4. Das KI-Reasoning zu einer Datei per Klick im Seitenbereich einsehen +5. Den laufenden Batch per Soft-Stop sauber abbrechen + +--- + +## Fachliche Anforderungen + +### Neuer Tab „Verarbeitungslauf" + +- Der bestehende Tab „Konfiguration" bleibt Tab 1 – unverändert +- Tab 2 heißt **„Verarbeitungslauf"** +- Tab-Struktur war in V2.0 bereits vorbereitet + +--- + +### Layout Tab 2 + +``` +┌─────────────────────────────────────────────────────────┐ +│ [Fortschrittsbalken] 12 / 47 Dateien │ +├──────────────────────────────────┬──────────────────────┤ +│ Ergebnisliste │ Seitenbereich │ +│ (scrollbar) │ (KI-Reasoning) │ +│ │ │ +│ │ │ +├──────────────────────────────────┴──────────────────────┤ +│ Meldungs- und Zusammenfassungsbereich │ +├─────────────────────────────────────────────────────────┤ +│ [Starten] [Abbrechen] │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +### Meldungs- und Zusammenfassungsbereich + +Der untere Bereich des Tab 2 dient als **einheitlicher Meldungs- und Zusammenfassungsbereich**. +Er übernimmt zwei Rollen: + +- **Meldungsbereich** – zeigt Startfehler, Hinweise (z. B. 0 Dateien) und technische Exceptions +- **Zusammenfassung** – zeigt nach Laufende: `{X} erfolgreich, {X} fehlgeschlagen, {X} übersprungen` + +Während des Laufs ist der Bereich leer oder zeigt den letzten Statushinweis. +Es gibt in Tab 2 keinen separaten zweiten Meldungsbereich. + +--- + +### Konfigurationsquelle beim Start + +- Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der `.properties`-Datei +- Ungespeicherte Änderungen im Konfigurationseditor (Tab 1) fließen **nicht** in den Lauf ein +- Der Starten-Button prüft vor dem Lauf, ob die gespeicherte Konfiguration lauffähig ist – + nicht den aktuellen Editorzustand + +--- + +### Startvoraussetzungen und Startfehler + +Ein Lauf startet nur, wenn alle folgenden Voraussetzungen erfüllt sind: + +| Voraussetzung | Verhalten bei Fehler | +|---|---| +| Gespeicherte Konfiguration vorhanden und lauffähig | Fehlermeldung, kein Lauf | +| Quellordner vorhanden und lesbar | Fehlermeldung, kein Lauf | +| Zielordner vorhanden oder anlegbar | Fehlermeldung, kein Lauf | +| SQLite-Datei nutzbar | Fehlermeldung, kein Lauf | +| API-Key vorhanden | Fehlermeldung, kein Lauf | +| Kein anderer Verarbeitungslauf in dieser Anwendungsinstanz aktiv | Fehlermeldung, kein Lauf | + +Bei einem Startfehler: +- Erscheint eine klare Fehlermeldung im Meldungs- und Zusammenfassungsbereich +- Fortschrittsbalken und Ergebnisliste bleiben unverändert +- Starten-Button bleibt aktiv, Abbrechen-Button bleibt deaktiviert + +--- + +### Verhalten bei 0 verarbeitbaren Dateien + +- Kein technischer Fehler +- Kein Lauf im eigentlichen Sinne +- Hinweis im Meldungs- und Zusammenfassungsbereich: „Keine verarbeitbaren Dateien im Quellordner gefunden" +- Zusammenfassung: `0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen` + +--- + +### Fortschrittsbalken + +- Die zu verarbeitende Dateimenge wird **einmalig beim Start** bestimmt +- Der Nenner bleibt für den gesamten Lauf **konstant** – Dateien die während des Laufs + im Quellordner auftauchen oder verschwinden, werden nicht berücksichtigt +- Gezählt werden **alle abgeschlossenen** Dateien: erfolgreich + fehlgeschlagen + übersprungen +- Daneben wird der Zählerstand angezeigt, z. B. „12 / 47 Dateien" +- Vor dem ersten Start: leer / 0 % + +--- + +### Statusmodell + +Jede Datei erhält nach Abschluss genau einen der folgenden Status: + +| Status | Icon | Bedeutung | +|---|---|---| +| Erfolgreich | ✅ | Datei wurde umbenannt, Zieldatei erzeugt | +| Fehlgeschlagen (retryable) | ⚠️ | Transienter Fehler, wird beim nächsten Lauf erneut versucht | +| Fehlgeschlagen (permanent) | ❌ | Inhaltsfehler, kein weiterer Retry | +| Übersprungen | ⏭️ | Datei war bereits verarbeitet oder wurde bewusst ausgelassen | + +Alle vier Status zählen als **abgeschlossen** im Sinne des Fortschrittsbalkens. + +--- + +### Ergebnisliste + +Jede abgeschlossene Datei erscheint als neue Zeile in der Liste. +Nach Abschluss jeder Datei erscheint **ohne manuellen Refresh** ein neuer Eintrag. +Die Liste wächst während des Laufs von oben nach unten. + +| Spalte | Erfolg | Fehler / Übersprungen | +|---|---|---| +| Status-Icon | ✅ / ⚠️ / ❌ / ⏭️ | wie links | +| Originaldateiname | Quelldateiname | Quelldateiname | +| Neuer Dateiname | Finaler Zieldateiname | `—` | +| Datum | Ermitteltes Datum | `—` | +| Dauer | Verarbeitungszeit in Sekunden | Verarbeitungszeit in Sekunden | + +- Klick auf eine Zeile zeigt Details im **Seitenbereich** +- Die Liste ist scrollbar +- Die Liste ist **nicht persistent**: bleibt nur für die Dauer des aktuellen Programmstarts +- Bei einem neuen Lauf innerhalb desselben Programmstarts wird die Liste geleert +- Nach Programmstart ist die Liste leer + +--- + +### Seitenbereich (KI-Reasoning) + +- Rechts neben der Ergebnisliste, fest im Layout verankert (kein Popup, kein Dialog) +- Zeigt nach Klick auf eine Zeile: + - Originaldateiname + - Ermittelter Titel + - Ermitteltes Datum + - KI-Reasoning (Volltext) +- Liegt für einen Eintrag kein KI-Reasoning vor (Fehler vor KI-Aufruf, übersprungen), + erscheint der Hinweistext: „Für diesen Eintrag liegt kein KI-Reasoning vor." +- Vor dem ersten Klick: Hinweistext „Datei auswählen für Details" +- Bei neuem Lauf wird der Seitenbereich geleert + +--- + +### Starten-Button + +- Startet den Verarbeitungslauf über alle Dateien im konfigurierten Quellordner +- Verwendet die **gespeicherte** Konfiguration – nicht den aktuellen Editorzustand +- Gleiches fachliches Batch-Verhalten wie der headless-Betrieb: + gleiche Anwendungslogik, gleicher Use Case, nur andere Präsentationsschicht +- Keine Dateiauswahl – alle Dateien werden verarbeitet +- Während des Laufs: deaktiviert +- Nach Abschluss oder Abbruch: wieder aktiv + +--- + +### Abbrechen-Button + +- Nur während eines laufenden Batches aktiv, sonst deaktiviert +- Verhalten: **Soft-Stop** + - Die aktuell in Bearbeitung befindliche Datei wird vollständig fertig verarbeitet + - Das Stop-Flag wird nach Abschluss jeder Datei und vor Start der nächsten Datei geprüft – + niemals mitten in einer atomaren Persistenzoperation + - Danach wird der Lauf sauber beendet, keine halbfertigen Zustände in der SQLite-Datenbank +- Nach dem Soft-Stop erscheint die Zusammenfassung im Meldungs- und Zusammenfassungsbereich + +--- + +### Konfiguration während des Laufs + +- Tab 1 „Konfiguration" wird während eines laufenden Verarbeitungslaufs **gesperrt** +- Im Konfiguration-Tab erscheint ein sichtbarer Hinweis: + „Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar" +- Nach Abschluss, Abbruch oder unerwarteter Exception wird Tab 1 wieder freigegeben + +--- + +### Verhalten bei unerwarteter technischer Exception + +Tritt während des Laufs eine unerwartete Exception auf: + +- Die GUI wechselt in einen definierten terminalen Zustand: + - Starten-Button: aktiv + - Abbrechen-Button: deaktiviert + - Tab 1: entsperrt + - Meldungs- und Zusammenfassungsbereich: Fehlermeldung sichtbar +- Es entsteht kein „hängender" UI-Zustand + +--- + +### Fenster schließen während eines laufenden Laufs + +- Schließt der Benutzer das Fenster während ein Lauf aktiv ist, + wird der Close-Request abgefangen +- Es erscheint ein Hinweisdialog mit zwei Optionen: + - **„Nicht schließen"** – Lauf läuft weiter + - **„Lauf beenden und schließen"** – Soft-Stop wird ausgelöst, + nach Abschluss der aktuellen Datei schließt die Anwendung +- Kein Hard-Abbruch ohne Benutzerentscheidung + +--- + +### Parallele Läufe + +- Pro Anwendungsinstanz ist **nur ein Verarbeitungslauf gleichzeitig** zulässig +- Ein zweiter Startversuch während ein Lauf aktiv ist wird verweigert mit der Meldung: + „Ein Verarbeitungslauf ist bereits aktiv." +- **Bekannte Einschränkung:** Ein gleichzeitiger externer headless-Lauf (Windows Task Scheduler) + wird von der GUI nicht aktiv erkannt und nicht technisch geblockt. + Der Benutzer ist selbst verantwortlich, parallele Läufe zu vermeiden. + Diese Einschränkung ist seit V2.0 dokumentiert und bleibt in V2.7 unverändert bestehen. + +--- + +## Nicht in V2.7 enthalten + +- Dateiauswahl (welche Dateien verarbeitet werden sollen) +- Einzeldatei-Fortschrittsanzeige +- Historien-Tab / SQLite-Ansicht +- Kosten-Tracking +- Automatischer Neustart nach Abschluss +- Benachrichtigungen (Windows-Tray, Toast) +- Parallelverarbeitung mehrerer Dateien +- Technisches Locking gegen externe headless-Läufe + +--- + +## Abnahmekriterien + +- [ ] Tab 2 „Verarbeitungslauf" ist in der GUI vorhanden und erreichbar +- [ ] Starten-Button verwendet ausschließlich die gespeicherte Konfiguration +- [ ] Starten-Button startet den Batch-Lauf über alle Dateien im Quellordner +- [ ] Die Dateimenge wird beim Start einmalig bestimmt; der Nenner des Fortschrittsbalkens bleibt während des gesamten Laufs konstant +- [ ] Fortschrittsbalken zählt alle abgeschlossenen Dateien (erfolgreich + fehlgeschlagen + übersprungen) +- [ ] Nach Abschluss jeder Datei erscheint ohne manuellen Refresh ein neuer Eintrag in der Ergebnisliste +- [ ] Alle fünf Spalten der Ergebnisliste sind für Erfolgsfälle korrekt befüllt +- [ ] Spalte „Neuer Dateiname" und „Datum" zeigen `—` für Fehler- und Übersprungen-Fälle +- [ ] Alle vier Status-Icons sind korrekt: ✅ ⚠️ ❌ ⏭️ +- [ ] Klick auf Zeile zeigt KI-Reasoning im Seitenbereich +- [ ] Einträge ohne KI-Reasoning zeigen den definierten Hinweistext im Seitenbereich +- [ ] Seitenbereich zeigt vor erstem Klick den Hinweistext „Datei auswählen für Details" +- [ ] Soft-Stop beendet den Lauf nach Abschluss der aktuellen Datei; keine weitere Datei wird begonnen +- [ ] Meldungs- und Zusammenfassungsbereich zeigt nach Laufende die Zusammenfassung mit korrekten Zählern +- [ ] Tab 1 ist während des Laufs gesperrt, Hinweis ist sichtbar +- [ ] Tab 1 wird nach Abschluss, Abbruch oder Exception wieder entsperrt +- [ ] Bei unerwarteter Exception wechselt die GUI in den definierten terminalen Zustand +- [ ] Ergebnisliste und Seitenbereich sind nach Programmstart leer +- [ ] Ergebnisliste und Seitenbereich werden bei neuem Lauf geleert +- [ ] Start mit nicht lauffähiger Konfiguration wird verweigert; Fehlermeldung erscheint im Meldungs- und Zusammenfassungsbereich +- [ ] Start bei leerem Quellordner erzeugt keinen Fehler; Hinweis erscheint im Meldungs- und Zusammenfassungsbereich +- [ ] Zweiter Startversuch während laufendem Lauf wird verweigert; Meldung erscheint +- [ ] Close-Request während Lauf öffnet Hinweisdialog mit zwei Optionen +- [ ] headless-Betrieb ist unverändert funktionsfähig +- [ ] `mvn clean verify` ist grün diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 3f93f0c..af6a888 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -14,6 +14,8 @@ import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiApiKeyMerger; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState; @@ -341,6 +343,33 @@ public final class GuiConfigurationEditorWorkspace { private final EditorConfigurationValidator editorValidator = new EditorConfigurationValidator(); private final ApiKeyResolutionPort apiKeyResolutionPort; + /** + * Launcher used by the processing-run tab to execute a batch run against the saved + * configuration file. Supplied by Bootstrap via the startup context. + */ + private final GuiBatchRunLauncher batchRunLauncher; + + /** + * Second main tab of the window that drives the live processing-run view. Created + * during workspace construction and wired into the shared {@link #tabPane} alongside + * the existing configuration tab. + */ + private final GuiBatchRunTab batchRunTab; + + /** + * Hint banner shown at the top of the configuration tab while a processing run is + * active. Visible + managed state are flipped from the batch run tab's listener when + * the running flag toggles. + */ + final Label configurationLockBanner = new Label( + "Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar"); + + /** + * Reference to the configuration tab so the running-state listener can disable its + * content while a batch run is active. + */ + Tab configurationTab; + /** * Creates a new workspace with the unloaded start state. * @@ -391,12 +420,72 @@ public final class GuiConfigurationEditorWorkspace { this.unsavedChangesGuard = new GuiUnsavedChangesGuard( triggerLabel -> showUnsavedChangesDialog(triggerLabel)); + this.batchRunLauncher = effectiveContext.batchRunLauncher(); + this.batchRunTab = new GuiBatchRunTab( + () -> this.batchRunLauncher, + this::loadedConfigurationPath, + this::isSavedConfigurationReady, + this::applyBatchRunLockState); + configureRoot(); configureHeader(effectiveContext.startupNotice()); configureTabs(); configureActionBar(); configureActions(); refreshView(); + applyBatchRunLockState(); + } + + /** + * Returns the processing-run tab; package-private so smoke tests can drive start/cancel + * actions and read the result table. + */ + GuiBatchRunTab batchRunTab() { + return batchRunTab; + } + + /** + * Returns the currently loaded configuration file path, or {@code null} when the + * editor has never loaded a file from disk. The processing-run tab uses this value to + * derive the path a run should execute against. + */ + private Path loadedConfigurationPath() { + return editorState.loadedFileSnapshot() + .map(GuiConfigurationFileSnapshot::filePath) + .orElse(null); + } + + /** + * Returns whether the editor currently has a saved configuration that can be used as + * the source of a processing run. + *
+ * A configuration is considered run-ready when the editor was loaded from disk (i.e. + * a file snapshot exists). Unsaved editor changes are intentionally ignored — the + * run always uses the last saved state of the {@code .properties} file as required by + * the specification. + */ + private boolean isSavedConfigurationReady() { + return editorState.hasLoadedFileSnapshot(); + } + + /** + * Applies the "batch run active" UI lock state to the configuration tab and the + * action bar. + *
+ * While a run is active the configuration editor is made non-interactive, the lock
+ * banner is shown at the top of Tab 1, and the main action buttons (Neu, Öffnen,
+ * Speichern, Speichern unter) are disabled. When the run ends, the locks are
+ * released and the editor returns to its normal state.
+ */
+ void applyBatchRunLockState() {
+ boolean running = batchRunTab != null && batchRunTab.isRunning();
+ configurationLockBanner.setVisible(running);
+ configurationLockBanner.setManaged(running);
+ sectionsBox.setDisable(running);
+ newButton.setDisable(running);
+ openButton.setDisable(running);
+ saveButton.setDisable(running);
+ saveAsButton.setDisable(running);
}
/**
@@ -411,6 +500,11 @@ public final class GuiConfigurationEditorWorkspace {
*/
public void installCloseRequestHandler(Stage stage) {
stage.setOnCloseRequest(event -> {
+ if (batchRunTab != null && batchRunTab.isRunning()) {
+ event.consume();
+ handleCloseWhileRunRunning(stage);
+ return;
+ }
if (!editorState.isDirty()) {
return;
}
@@ -425,6 +519,59 @@ public final class GuiConfigurationEditorWorkspace {
});
}
+ /**
+ * Supplier for the "Lauf läuft noch" dialog invoked by
+ * {@link #handleCloseWhileRunRunning(Stage)}. Package-private so tests can substitute
+ * a deterministic choice without showing a native dialog. The default implementation
+ * displays a blocking confirmation alert with the two options mandated by the spec.
+ */
+ Supplier
+ * Preserves existing callers that were written before the processing-run tab was added.
+ * The no-op launcher rejects every start request with a clear German message so the
+ * UI never enters an unsafe state in legacy test wiring.
+ *
+ * @param initialState initial editor state; must not be {@code null}
+ * @param startupNotice optional startup notice; {@code null} becomes empty
+ * @param configurationFileLoader file-loading callback; must not be {@code null}
+ * @param configurationFileWriter file-writing callback; must not be {@code null}
+ * @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
+ * @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
+ * @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
+ * @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
+ * @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
+ * @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
+ */
+ public GuiStartupContext(
+ GuiConfigurationEditorState initialState,
+ Optional
+ * The coordinator owns the background worker thread that executes the run, maintains the
+ * cancellation flag, and translates the
+ * {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
+ * callbacks into a GUI-friendly event stream on the JavaFX Application Thread.
+ *
+ *
+ * Tests use this constructor to execute batches synchronously or to verify which
+ * thread UI callbacks run on, without depending on an actual JavaFX runtime being
+ * initialised.
+ *
+ * @param launcher bridge to Bootstrap; must not be null
+ * @param threadFactory factory returning a ready-to-start worker thread; must not
+ * be null
+ * @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
+ * Thread; must not be null
+ * @param listener GUI listener; must not be null
+ */
+ public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
+ Function
+ * Immediately returns once the worker thread has been started. All further progress
+ * is communicated through the configured {@link Listener} on the JavaFX Application
+ * Thread. An attempt to start a new run while another is still active is rejected
+ * with {@code false} and leaves the currently running batch untouched.
+ *
+ * @param configFilePath the configuration file the run shall read from; must not be
+ * {@code null}
+ * @return {@code true} when a new worker thread was started, {@code false} when a run
+ * was already in progress
+ * @throws NullPointerException if {@code configFilePath} is {@code null}
+ */
+ public boolean start(Path configFilePath) {
+ Objects.requireNonNull(configFilePath, "configFilePath must not be null");
+ if (isRunning()) {
+ return false;
+ }
+ cancellationRequested.set(false);
+ Runnable task = () -> executeRun(configFilePath);
+ Thread worker = threadFactory.apply(task);
+ Objects.requireNonNull(worker, "threadFactory must not return null");
+ activeWorker.set(worker);
+ worker.start();
+ return true;
+ }
+
+ /**
+ * Requests soft-stop cancellation of the currently running batch.
+ *
+ * The flag is honoured between candidates — the candidate that is currently being
+ * processed is always completed in full and persisted before the run ends. Calling
+ * this method when no run is active has no effect.
+ */
+ public void requestCancellation() {
+ if (isRunning()) {
+ cancellationRequested.set(true);
+ }
+ }
+
+ /**
+ * Returns whether cancellation has been requested for the current (or last) run.
+ *
+ * @return {@code true} when a cancellation request is pending or was pending when
+ * the last run ended; {@code false} before the first run
+ */
+ public boolean isCancellationRequested() {
+ return cancellationRequested.get();
+ }
+
+ private void executeRun(Path configFilePath) {
+ LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
+ configFilePath);
+ BatchRunProgressObserver observer = buildDispatchingObserver();
+ BatchRunCancellationToken token = cancellationRequested::get;
+ GuiBatchRunLaunchOutcome outcome;
+ try {
+ outcome = launcher.launch(configFilePath, observer, token);
+ if (outcome == null) {
+ outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
+ "Launcher hat kein Ergebnis geliefert.");
+ }
+ } catch (RuntimeException e) {
+ LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
+ e.getMessage(), e);
+ outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
+ "Unerwarteter technischer Fehler: "
+ + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
+ }
+ RunSummary summary = observerSummary.get();
+ if (summary == null) {
+ summary = new RunSummary(0, 0, 0);
+ }
+ GuiBatchRunLaunchOutcome finalOutcome = outcome;
+ RunSummary finalSummary = summary;
+ activeWorker.set(null);
+ fxDispatcher.accept(() -> listener.onRunEnded(finalSummary, finalOutcome));
+ LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
+ }
+
+ /**
+ * Captures the final summary supplied by the application layer. Written on the
+ * worker thread; read only after the run has ended.
+ */
+ private final AtomicReference
+ * The outcome reports to the tab whether the run finished normally, could not even be
+ * started (hard failure), or ended because of an unexpected exception. The GUI uses this
+ * to transition between its "laufend" and "bereit"/"Fehler" states.
+ *
+ *
+ * The launcher performs the complete headless startup sequence (legacy migration, config
+ * loading, validation, SQLite schema initialisation, run-lock, use-case wiring, execution)
+ * for the supplied configuration path while forwarding progress callbacks and honouring
+ * the supplied cancellation token. It reuses the very same application ports and
+ * persistence pipeline as a Task-Scheduler-triggered headless run; only the presentation
+ * side (the GUI) differs.
+ *
+ *
+ * Implementations must be safe to call from a non-UI worker thread. They must not touch
+ * the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
+ * caller's concern. The call blocks until the run terminates (normally, after a
+ * cancellation, or after a hard failure).
+ *
+ *
+ * Implementations must not propagate checked exceptions. Unexpected runtime exceptions
+ * should be caught, logged, and returned as a
+ * {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
+ * well-defined terminal state.
+ */
+@FunctionalInterface
+public interface GuiBatchRunLauncher {
+
+ /**
+ * Executes exactly one batch run against the supplied configuration file.
+ *
+ * @param configFilePath path of the {@code .properties} file to run against;
+ * must not be {@code null}; must exist and be readable
+ * @param observer observer receiving start/completion/end callbacks; must
+ * not be {@code null}
+ * @param cancellationToken cancellation token the run polls between candidates; must
+ * not be {@code null}
+ * @return a description of how the run terminated; never {@code null}
+ */
+ GuiBatchRunLaunchOutcome launch(
+ Path configFilePath,
+ BatchRunProgressObserver observer,
+ BatchRunCancellationToken cancellationToken);
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java
new file mode 100644
index 0000000..6845116
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java
@@ -0,0 +1,74 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.Objects;
+import java.util.Optional;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
+
+/**
+ * Immutable view model for a single row in the processing-run result list.
+ *
+ * Each completed candidate becomes exactly one row. The row carries only the information
+ * that is shown in the list and the side panel; it is decoupled from the persistence
+ * model so later GUI layers can render it without reaching back into the application
+ * layer.
+ *
+ * @param originalFileName the source filename as reported by the use case; never
+ * {@code null} or blank
+ * @param status the aggregated completion status; never {@code null}
+ * @param finalFileName the final target filename when the row represents a successful
+ * rename; empty otherwise
+ * @param resolvedDate the resolved document date when the row represents a successful
+ * rename; empty otherwise
+ * @param aiReasoning the AI reasoning shown in the side panel; empty when no
+ * reasoning is available for this row
+ * @param processingDuration wall-clock duration spent on the candidate in this run;
+ * never {@code null} and never negative
+ */
+public record GuiBatchRunResultRow(
+ String originalFileName,
+ DocumentCompletionStatus status,
+ Optional
+ * The tab encapsulates all UI for starting, observing, and stopping a batch run from
+ * inside the GUI. It collaborates with a {@link GuiBatchRunCoordinator} which owns the
+ * background worker thread and forwards progress callbacks here on the JavaFX Application
+ * Thread.
+ *
+ *
+ * All public methods of this class must be invoked on the JavaFX Application Thread. The
+ * class is not thread-safe; the coordinator is responsible for dispatching background
+ * events onto the FX thread before calling back into the tab.
+ */
+public final class GuiBatchRunTab {
+
+ private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class);
+
+ /** Spec: "Datei auswählen für Details". Shown in the detail pane before the first row is selected. */
+ static final String DETAIL_PLACEHOLDER = "Datei auswählen für Details";
+
+ /** Spec: hint shown when no AI reasoning is available for the selected row. */
+ static final String NO_REASONING_TEXT = "Für diesen Eintrag liegt kein KI-Reasoning vor.";
+
+ /** Spec: hint shown when the start button is pressed against an empty source folder. */
+ static final String EMPTY_SOURCE_FOLDER_HINT =
+ "Keine verarbeitbaren Dateien im Quellordner gefunden";
+
+ /** Spec: hint shown when a second start attempt is made while a run is active. */
+ static final String ALREADY_RUNNING_HINT = "Ein Verarbeitungslauf ist bereits aktiv.";
+
+ /** Spec: German startup error shown when the saved configuration is unusable. */
+ static final String NO_SAVED_CONFIGURATION_HINT =
+ "Bitte speichern Sie die Konfiguration, bevor ein Verarbeitungslauf gestartet wird.";
+
+ /** Icon-to-placeholder rendering for empty columns in failure and skip rows. */
+ static final String EMPTY_CELL_TEXT = "\u2014"; // —
+
+ private static final String TAB_TITLE = "Verarbeitungslauf";
+ private static final double PROGRESS_BAR_MAX_WIDTH = Double.MAX_VALUE;
+ private static final double PROGRESS_BAR_PREF_HEIGHT = 20;
+ private static final double DETAIL_PANE_MIN_WIDTH = 280;
+ private static final double LIST_MIN_HEIGHT = 240;
+ private static final double DETAIL_AREA_MIN_HEIGHT = 240;
+ private static final int SECONDARY_SPACING = 12;
+
+ private final Tab tab = new Tab(TAB_TITLE);
+ private final ProgressBar progressBar = new ProgressBar(0);
+ private final Label counterLabel = new Label("0 / 0 Dateien");
+ private final TableView
+ * When no run is active the call has no effect. Cancellation is honoured between
+ * candidates — the currently processed candidate always finishes first.
+ */
+ public void requestCancellation() {
+ coordinator.requestCancellation();
+ cancelButton.setDisable(true);
+ }
+
+ /** Visible for tests. */
+ Button startButton() {
+ return startButton;
+ }
+
+ /** Visible for tests. */
+ Button cancelButton() {
+ return cancelButton;
+ }
+
+ /** Visible for tests. */
+ ProgressBar progressBar() {
+ return progressBar;
+ }
+
+ /** Visible for tests. */
+ TableView
+ * The classes in this package build the second tab of the main window, translate
+ * {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
+ * callbacks into JavaFX UI updates, and manage the worker thread that executes a
+ * single run against a stored {@code .properties} configuration.
+ *
+ *
+ * The batch run itself always executes on a dedicated background worker thread obtained
+ * from {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}.
+ * Every UI mutation (progress bar value, result rows, button states, tab sperre) is
+ * dispatched onto the JavaFX Application Thread via {@code Platform.runLater}. No class
+ * in this package mutates a JavaFX {@code Control} from the worker thread.
+ *
+ *
+ * The coordinator exposes a soft-stop cancellation hook: setting the cancellation flag
+ * causes the use case to stop before starting the next candidate; the candidate
+ * currently being processed is always completed in full so the SQLite persistence remains
+ * consistent.
+ *
+ *
+ * A run is always started against the {@code .properties} file currently on disk (the
+ * last saved state of the editor). Unsaved editor content is intentionally not forwarded
+ * to the launcher — the run must match what a parallel headless launch would see.
+ */
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
index e5b6c64..a3a1e8c 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
@@ -86,10 +86,17 @@ class GuiAdapterSmokeTest {
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch startLatch = new CountDownLatch(1);
- Platform.startup(() -> {
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ startLatch.countDown();
+ });
+ } catch (IllegalStateException alreadyInitialised) {
+ // Another smoke test in the same Surefire fork already started the JavaFX
+ // runtime; treat the toolkit as available and proceed.
PLATFORM_STARTED.set(true);
startLatch.countDown();
- });
+ }
assertTrue(
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless");
@@ -237,14 +244,16 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
- assertEquals(1, workspace.tabPane().getTabs().size(),
- "Exactly one configuration tab must be present");
+ assertEquals(2, workspace.tabPane().getTabs().size(),
+ "Configuration tab and processing-run tab must both be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
- "The single tab must use the configuration label");
+ "The first tab must use the configuration label");
+ assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
+ "The second tab must host the processing-run view");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
- "The single tab must expose the fixed section structure in the documented order");
+ "The configuration tab must expose the fixed section structure in the documented order");
} catch (Throwable t) {
fxError.set(t);
} finally {
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java
new file mode 100644
index 0000000..2f8b94c
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java
@@ -0,0 +1,352 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
+import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
+import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
+import de.gecheckt.pdf.umbenenner.domain.model.RunId;
+
+/**
+ * Synchronous tests for {@link GuiBatchRunCoordinator}.
+ *
+ * These tests substitute the background worker thread and the FX dispatcher with
+ * in-thread runners so behaviour can be verified deterministically without a running
+ * JavaFX runtime.
+ */
+class GuiBatchRunCoordinatorTest {
+
+ private static final Path ANY_CONFIG = Paths.get("ignored.properties");
+
+ @Test
+ void start_withNullPath_throws() {
+ GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
+ (path, observer, token) -> GuiBatchRunLaunchOutcome.completed(),
+ syncThreadFactory(), syncDispatcher(), noOpListener());
+ assertFalse(coordinator.isRunning());
+ try {
+ coordinator.start(null);
+ } catch (NullPointerException expected) {
+ // expected
+ return;
+ }
+ throw new AssertionError("Expected NullPointerException");
+ }
+
+ @Test
+ void completedRun_dispatchesEventsAndSummaryOnFxThread() {
+ List
+ * The application layer consults the token at safe points between candidates to decide
+ * whether the run should stop before starting the next candidate. The current candidate
+ * is always processed to completion before the token is honoured (soft-stop semantics).
+ *
+ * Implementations are typically shared between an inbound adapter (which sets the
+ * cancellation request) and the use case (which polls it). They must be safe to read from
+ * the batch thread while being written concurrently by the adapter thread.
+ *
+ *
+ * Callers that do not need cancellation (e.g. the headless batch entry point) supply
+ * {@link #neverCancelled()} as the token.
+ */
+public interface BatchRunCancellationToken {
+
+ /**
+ * Returns {@code true} if a cancellation has been requested and the batch should
+ * stop before starting the next candidate.
+ *
+ * Must be cheap to call; may be polled repeatedly during a run.
+ *
+ * @return {@code true} if the run should stop as soon as practical, {@code false}
+ * otherwise
+ */
+ boolean isCancellationRequested();
+
+ /**
+ * Returns a singleton token that never reports a cancellation request.
+ *
+ * @return a non-null token that always returns {@code false}
+ */
+ static BatchRunCancellationToken neverCancelled() {
+ return NeverCancelledBatchRunCancellationToken.INSTANCE;
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunProgressObserver.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunProgressObserver.java
new file mode 100644
index 0000000..564aa80
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunProgressObserver.java
@@ -0,0 +1,80 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import de.gecheckt.pdf.umbenenner.domain.model.RunId;
+
+/**
+ * Inbound observer port that receives progress callbacks over the life of a single
+ * batch run.
+ *
+ * The observer is an optional collaborator that an inbound adapter (e.g. a GUI) may
+ * supply to follow a batch run in near real time. The callbacks never carry persistence
+ * details; they only describe observable events at the use-case boundary.
+ *
+ *
+ * For a single run the observer is invoked in this order:
+ *
+ * Callbacks are invoked on the thread executing the batch run. Inbound adapters that
+ * drive a UI must themselves dispatch any UI updates onto the appropriate UI thread
+ * and must not block the reporting thread.
+ *
+ *
+ * Implementations must not throw checked exceptions. Runtime exceptions thrown by an
+ * observer are caught by the application layer and logged; they never affect the batch
+ * run outcome or alter persistence behaviour.
+ */
+public interface BatchRunProgressObserver {
+
+ /**
+ * Invoked once when the run has determined how many candidates will be processed.
+ *
+ * @param runId identifier of the run; never {@code null}
+ * @param totalCandidates total number of candidates detected in the source folder
+ * at scan time; never negative
+ */
+ void onRunStarted(RunId runId, int totalCandidates);
+
+ /**
+ * Invoked once per candidate whose processing reached a terminal resolution.
+ *
+ * The event is emitted after persistence has been attempted for the candidate, so
+ * observers may rely on the reported status matching the persisted attempt status
+ * for that candidate.
+ *
+ * @param event description of the candidate result; never {@code null}
+ */
+ void onDocumentCompleted(DocumentCompletionEvent event);
+
+ /**
+ * Invoked once after the processing loop has finished, regardless of whether the
+ * run completed normally, was cancelled via a {@link BatchRunCancellationToken},
+ * or aborted due to a hard run-level error after the start callback fired.
+ *
+ * @param summary aggregated outcome counts; never {@code null}
+ */
+ void onRunEnded(RunSummary summary);
+
+ /**
+ * Returns a singleton observer that silently ignores all callbacks.
+ *
+ * Used as the default observer for callers that do not need progress notifications
+ * (e.g. the headless batch entry point).
+ *
+ * @return a non-null no-op observer
+ */
+ static BatchRunProgressObserver noOp() {
+ return NoOpBatchRunProgressObserver.INSTANCE;
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java
new file mode 100644
index 0000000..b429a11
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java
@@ -0,0 +1,60 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.Objects;
+
+/**
+ * Immutable event describing the outcome of processing exactly one candidate document.
+ *
+ * Emitted by the application layer at every terminal resolution point of a candidate
+ * (success, retryable failure, permanent failure, skip). Observers may use this event
+ * to update a live progress view, write an audit record, or drive a UI list.
+ *
+ * The event is deliberately decoupled from persistence types: it carries only what an
+ * external observer needs to display or correlate a single candidate result.
+ *
+ * @param originalFileName the source candidate's unique identifier (typically the source
+ * filename); never {@code null} or blank
+ * @param status the aggregated outcome status; never {@code null}
+ * @param finalFileName the final target filename, including any duplicate suffix;
+ * never {@code null} for {@link DocumentCompletionStatus#SUCCESS},
+ * always {@code null} for all other statuses
+ * @param resolvedDate the resolved date of the naming proposal; never {@code null}
+ * for {@link DocumentCompletionStatus#SUCCESS}, always {@code null}
+ * for skip events. May be {@code null} for failure events.
+ * @param aiReasoning the AI reasoning text associated with the naming proposal, if
+ * any is available for this candidate (may be present on success
+ * and on some failure paths where an AI call had previously
+ * produced a reasoning); {@code null} when no reasoning exists
+ * @param processingDuration the wall-clock duration spent on this candidate in the current
+ * run; never {@code null} and never negative
+ */
+public record DocumentCompletionEvent(
+ String originalFileName,
+ DocumentCompletionStatus status,
+ String finalFileName,
+ LocalDate resolvedDate,
+ String aiReasoning,
+ Duration processingDuration) {
+
+ /**
+ * Compact constructor validating mandatory fields.
+ *
+ * @throws NullPointerException if {@code originalFileName}, {@code status} or
+ * {@code processingDuration} is {@code null}
+ * @throws IllegalArgumentException if {@code originalFileName} is blank or
+ * {@code processingDuration} is negative
+ */
+ public DocumentCompletionEvent {
+ Objects.requireNonNull(originalFileName, "originalFileName must not be null");
+ if (originalFileName.isBlank()) {
+ throw new IllegalArgumentException("originalFileName must not be blank");
+ }
+ Objects.requireNonNull(status, "status must not be null");
+ Objects.requireNonNull(processingDuration, "processingDuration must not be null");
+ if (processingDuration.isNegative()) {
+ throw new IllegalArgumentException("processingDuration must not be negative");
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionStatus.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionStatus.java
new file mode 100644
index 0000000..3a31dcb
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionStatus.java
@@ -0,0 +1,43 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+/**
+ * Aggregated status classification reported to
+ * {@link BatchRunProgressObserver#onDocumentCompleted(DocumentCompletionEvent)}
+ * for one processed candidate.
+ *
+ * This enum collapses the finer-grained internal processing status into the four
+ * buckets that an observer (e.g. a GUI progress view) needs to distinguish:
+ * successful completion, retryable failure, permanent failure, and an explicit
+ * skip.
+ *
+ * This classification is purely an observability concern — persistence,
+ * retry decisions, and all other processing rules continue to work against the
+ * detailed internal status.
+ */
+public enum DocumentCompletionStatus {
+
+ /**
+ * The candidate was successfully renamed; the target copy is in place and the
+ * persistence is consistent.
+ */
+ SUCCESS,
+
+ /**
+ * The candidate failed in the current run but will be retried in a later run
+ * (transient technical error, not yet at the retry limit, or a first deterministic
+ * content error).
+ */
+ FAILED_RETRYABLE,
+
+ /**
+ * The candidate failed permanently and will not be retried in later runs
+ * (content error recorded twice, or transient retry budget exhausted).
+ */
+ FAILED_PERMANENT,
+
+ /**
+ * The candidate was skipped because it was already in a terminal state (either
+ * previously successful or previously finally failed).
+ */
+ SKIPPED
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NeverCancelledBatchRunCancellationToken.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NeverCancelledBatchRunCancellationToken.java
new file mode 100644
index 0000000..1b92b76
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NeverCancelledBatchRunCancellationToken.java
@@ -0,0 +1,21 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+/**
+ * Shared singleton token returned by
+ * {@link BatchRunCancellationToken#neverCancelled()}.
+ *
+ * Not intended for direct instantiation by callers.
+ */
+final class NeverCancelledBatchRunCancellationToken implements BatchRunCancellationToken {
+
+ static final NeverCancelledBatchRunCancellationToken INSTANCE =
+ new NeverCancelledBatchRunCancellationToken();
+
+ private NeverCancelledBatchRunCancellationToken() {
+ }
+
+ @Override
+ public boolean isCancellationRequested() {
+ return false;
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NoOpBatchRunProgressObserver.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NoOpBatchRunProgressObserver.java
new file mode 100644
index 0000000..6b1a421
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NoOpBatchRunProgressObserver.java
@@ -0,0 +1,32 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import de.gecheckt.pdf.umbenenner.domain.model.RunId;
+
+/**
+ * Shared singleton no-op implementation of {@link BatchRunProgressObserver}.
+ *
+ * Returned by {@link BatchRunProgressObserver#noOp()}; not intended for direct
+ * instantiation by callers.
+ */
+final class NoOpBatchRunProgressObserver implements BatchRunProgressObserver {
+
+ static final NoOpBatchRunProgressObserver INSTANCE = new NoOpBatchRunProgressObserver();
+
+ private NoOpBatchRunProgressObserver() {
+ }
+
+ @Override
+ public void onRunStarted(RunId runId, int totalCandidates) {
+ // intentionally empty
+ }
+
+ @Override
+ public void onDocumentCompleted(DocumentCompletionEvent event) {
+ // intentionally empty
+ }
+
+ @Override
+ public void onRunEnded(RunSummary summary) {
+ // intentionally empty
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunSummary.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunSummary.java
new file mode 100644
index 0000000..0f06b9c
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunSummary.java
@@ -0,0 +1,42 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+/**
+ * Aggregated outcome counts of a complete batch run, reported once at the end of the run
+ * to {@link BatchRunProgressObserver#onRunEnded(RunSummary)}.
+ *
+ * The three counts are independent, non-negative and sum up to the total number of
+ * candidates that were processed in the run (possibly fewer than the originally detected
+ * candidate count if the run was cancelled mid-way).
+ *
+ * @param successCount number of candidates that completed with
+ * {@link DocumentCompletionStatus#SUCCESS}; must be ≥ 0
+ * @param failedCount number of candidates that completed with either
+ * {@link DocumentCompletionStatus#FAILED_RETRYABLE} or
+ * {@link DocumentCompletionStatus#FAILED_PERMANENT}; must be ≥ 0
+ * @param skippedCount number of candidates that completed with
+ * {@link DocumentCompletionStatus#SKIPPED}; must be ≥ 0
+ */
+public record RunSummary(int successCount, int failedCount, int skippedCount) {
+
+ /**
+ * Compact constructor enforcing non-negative counts.
+ *
+ * @throws IllegalArgumentException if any count is negative
+ */
+ public RunSummary {
+ if (successCount < 0 || failedCount < 0 || skippedCount < 0) {
+ throw new IllegalArgumentException(
+ "RunSummary counts must not be negative; was: "
+ + successCount + "/" + failedCount + "/" + skippedCount);
+ }
+ }
+
+ /**
+ * Returns the total number of candidates reflected in this summary.
+ *
+ * @return {@code successCount + failedCount + skippedCount}; never negative
+ */
+ public int totalProcessed() {
+ return successCount + failedCount + skippedCount;
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java
index 148f1a1..9306486 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java
@@ -16,6 +16,18 @@
* — Structured result of a batch run, designed for exit code mapping
*
*
+ * Progress observation (for interactive inbound adapters):
+ *
* Architecture Rule: Inbound ports are independent of implementation and contain no business logic.
* They define "what can be done to the application". All dependencies point inward;
* adapters depend on ports, not vice versa.
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
index 549bb5f..7a02126 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
@@ -1,10 +1,14 @@
package de.gecheckt.pdf.umbenenner.application.service;
+import java.time.Duration;
import java.time.Instant;
+import java.time.LocalDate;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
@@ -158,6 +162,20 @@ public class DocumentProcessingCoordinator {
private final int maxTitleLength;
private final String activeProviderIdentifier;
+ /**
+ * Optional per-run completion forwarder that is consulted by
+ * {@link #publishCompletion(SourceDocumentCandidate, DocumentCompletionStatus, String,
+ * LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
+ *
+ * Assigned by the inbound use case for the duration of a single run and cleared before the
+ * use case returns. A {@code null} value means no external observer is attached and the
+ * completion event is dropped silently — the default for headless callers.
+ *
+ * Accessed from the batch thread only. Not volatile because installation and read occur
+ * on the same thread (the one executing the batch).
+ */
+ private Consumer
+ * When non-null, the forwarder is consulted at every terminal candidate resolution and
+ * receives a {@link DocumentCompletionEvent} describing the outcome. A {@code null} value
+ * detaches any previously installed forwarder.
+ *
+ * This method is the single seam by which inbound adapters (e.g. the JavaFX GUI) attach a
+ * live-progress observer to the document coordinator without widening the coordinator's
+ * constructor surface. It must only be called from the thread that will drive the batch
+ * run.
+ *
+ * @param forwarder the new forwarder, or {@code null} to detach the current one
+ */
+ public void installCompletionForwarder(Consumer
+ * On successful persistence, a terminal completion event is published to the attached
+ * {@link BatchRunProgressObserver}; the event carries the resolved final filename,
+ * the date and reasoning taken from the authoritative {@code PROPOSAL_READY} attempt.
+ * On persistence failure the completion event is published by
+ * {@link #persistTransientErrorAfterPersistenceFailure}.
*
+ * @param proposalAttempt the authoritative naming-proposal attempt used to populate
+ * the observer event's date and reasoning fields; must not be
+ * {@code null}
* @return true if SUCCESS was persisted; false if persistence itself failed
*/
private boolean persistTargetCopySuccess(
@@ -529,7 +575,8 @@ public class DocumentProcessingCoordinator {
Instant attemptStart,
Instant now,
String resolvedFilename,
- String targetFolderLocator) {
+ String targetFolderLocator,
+ ProcessingAttempt proposalAttempt) {
try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
@@ -550,6 +597,11 @@ public class DocumentProcessingCoordinator {
logger.info("Document '{}' successfully processed. Target: '{}'.",
candidate.uniqueIdentifier(), resolvedFilename);
+ publishCompletion(candidate, DocumentCompletionStatus.SUCCESS,
+ resolvedFilename,
+ proposalAttempt.resolvedDate(),
+ proposalAttempt.aiReasoning(),
+ attemptStart, now);
return true;
} catch (DocumentPersistenceException e) {
@@ -564,7 +616,8 @@ public class DocumentProcessingCoordinator {
candidate, fingerprint, existingRecord, context, attemptStart,
Instant.now(),
"Persistence failed after successful target copy (best-effort rollback attempted): "
- + e.getMessage());
+ + e.getMessage(),
+ proposalAttempt);
return false;
}
}
@@ -628,6 +681,10 @@ public class DocumentProcessingCoordinator {
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
updatedCounters.transientErrorCount(), maxRetriesTransient);
}
+ publishCompletion(candidate,
+ retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
+ : DocumentCompletionStatus.FAILED_PERMANENT,
+ null, null, null, attemptStart, now);
return true;
} catch (DocumentPersistenceException persistEx) {
@@ -654,7 +711,8 @@ public class DocumentProcessingCoordinator {
BatchRunContext context,
Instant attemptStart,
Instant now,
- String errorMessage) {
+ String errorMessage,
+ ProcessingAttempt proposalAttempt) {
ProcessingOutcomeTransition.ProcessingOutcome transition =
ProcessingOutcomeTransition.forKnownDocument(
@@ -664,6 +722,7 @@ public class DocumentProcessingCoordinator {
FailureCounters updatedCounters = transition.counters();
ProcessingStatus errorStatus = transition.overallStatus();
+ boolean secondaryPersisted = false;
try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields(
@@ -679,11 +738,28 @@ public class DocumentProcessingCoordinator {
txOps.saveProcessingAttempt(errorAttempt);
txOps.updateDocumentRecord(errorRecord);
});
+ secondaryPersisted = true;
} catch (DocumentPersistenceException secondaryEx) {
logger.error("Secondary persistence failure for '{}' after target copy rollback: {}",
candidate.uniqueIdentifier(), secondaryEx.getMessage(), secondaryEx);
}
+
+ // Observer notification: even when secondary persistence itself failed the candidate's
+ // terminal resolution in this run is still a copy/persistence failure. Emitting a single
+ // completion event keeps the observer in sync with the user-visible state even though
+ // nothing new was persisted.
+ String reasoning = proposalAttempt != null ? proposalAttempt.aiReasoning() : null;
+ publishCompletion(candidate,
+ transition.retryable()
+ ? DocumentCompletionStatus.FAILED_RETRYABLE
+ : DocumentCompletionStatus.FAILED_PERMANENT,
+ null, null, reasoning, attemptStart, now);
+
+ if (!secondaryPersisted) {
+ logger.debug("Completion for '{}' reported without secondary persistence record.",
+ candidate.uniqueIdentifier());
+ }
}
// =========================================================================
@@ -721,6 +797,8 @@ public class DocumentProcessingCoordinator {
logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
attemptNumber, candidate.uniqueIdentifier(), skipStatus);
+ publishCompletion(candidate, DocumentCompletionStatus.SKIPPED,
+ null, null, null, attemptStart, now);
return true;
} catch (DocumentPersistenceException e) {
@@ -985,6 +1063,13 @@ public class DocumentProcessingCoordinator {
outcome.counters().contentErrorCount(),
outcome.counters().transientErrorCount());
}
+ // Pipeline-path terminal resolutions are reported to the progress observer.
+ // PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes
+ // the actual completion event (SUCCESS or transient-error failure).
+ if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) {
+ publishCompletion(candidate, toCompletionStatus(outcome),
+ null, null, null, attemptStart, now);
+ }
return true;
} catch (DocumentPersistenceException e) {
@@ -1097,4 +1182,74 @@ public class DocumentProcessingCoordinator {
return base + detail;
}
+
+ // =========================================================================
+ // Progress observer dispatch
+ // =========================================================================
+
+ /**
+ * Publishes a single terminal completion event for the candidate to the attached
+ * {@link BatchRunProgressObserver}.
+ *
+ * Must be called exactly once per terminal resolution of a candidate (success, retryable
+ * failure, permanent failure, skip). Intermediate states such as
+ * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY} must not
+ * produce a completion event.
+ *
+ * Any runtime exception thrown by the observer is caught and logged at warn level and must
+ * not affect persistence or batch flow.
+ *
+ * @param candidate the candidate being reported; must not be null
+ * @param status the aggregated completion status; must not be null
+ * @param finalFileName the final target filename on success; {@code null} otherwise
+ * @param resolvedDate the resolved date on success; may be {@code null} otherwise
+ * @param aiReasoning the AI reasoning when one is available for this result;
+ * {@code null} otherwise
+ * @param startInstant the moment processing of the candidate began in this run
+ * @param endInstant the moment the terminal resolution was reached
+ */
+ private void publishCompletion(
+ SourceDocumentCandidate candidate,
+ DocumentCompletionStatus status,
+ String finalFileName,
+ LocalDate resolvedDate,
+ String aiReasoning,
+ Instant startInstant,
+ Instant endInstant) {
+ Consumer
+ * Callers guarantee that the outcome does not represent
+ * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY} — that
+ * intermediate state is never reported as a completion.
+ */
+ private static DocumentCompletionStatus toCompletionStatus(
+ ProcessingOutcomeTransition.ProcessingOutcome outcome) {
+ return outcome.retryable()
+ ? DocumentCompletionStatus.FAILED_RETRYABLE
+ : DocumentCompletionStatus.FAILED_PERMANENT;
+ }
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/CountingCompletionObserver.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/CountingCompletionObserver.java
new file mode 100644
index 0000000..d89043b
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/CountingCompletionObserver.java
@@ -0,0 +1,87 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
+import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
+
+/**
+ * Internal per-run adapter that forwards every
+ * {@link DocumentCompletionEvent} emitted by the
+ * {@link de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator}
+ * to the configured {@link BatchRunProgressObserver}, while accumulating outcome counts
+ * for the run's final {@link RunSummary}.
+ *
+ * Used only by {@link DefaultBatchRunProcessingUseCase} for the lifetime of a single run.
+ * Not thread-safe: all invocations must occur on the batch thread.
+ */
+final class CountingCompletionObserver implements Consumer
+ * The observer is invoked on the batch thread for run start, per-candidate completion,
+ * and run end. The cancellation token is polled between candidates; a requested
+ * cancellation is honoured before starting the next candidate and never interrupts a
+ * candidate that is already being processed (soft-stop).
+ *
+ * @param runtimeConfiguration the runtime configuration; must not be null
+ * @param runLockPort run-lock port; must not be null
+ * @param sourceDocumentCandidatesPort candidate source port; must not be null
+ * @param pdfTextExtractionPort PDF text extraction port; must not be null
+ * @param fingerprintPort fingerprint port; must not be null
+ * @param documentProcessingCoordinator per-document coordinator; must not be null
+ * @param aiNamingService AI naming service; must not be null
+ * @param logger logger; must not be null
+ * @param progressObserver progress observer; must not be null, use
+ * {@link BatchRunProgressObserver#noOp()} when none is
+ * needed
+ * @param cancellationToken cancellation token; must not be null, use
+ * {@link BatchRunCancellationToken#neverCancelled()}
+ * when cancellation is not needed
+ * @throws NullPointerException if any parameter is null
+ */
+ public DefaultBatchRunProcessingUseCase(
+ RuntimeConfiguration runtimeConfiguration,
+ RunLockPort runLockPort,
+ SourceDocumentCandidatesPort sourceDocumentCandidatesPort,
+ PdfTextExtractionPort pdfTextExtractionPort,
+ FingerprintPort fingerprintPort,
+ DocumentProcessingCoordinator documentProcessingCoordinator,
+ AiNamingService aiNamingService,
+ ProcessingLogger logger,
+ BatchRunProgressObserver progressObserver,
+ BatchRunCancellationToken cancellationToken) {
this.runtimeConfiguration = Objects.requireNonNull(runtimeConfiguration, "runtimeConfiguration must not be null");
this.runLockPort = Objects.requireNonNull(runLockPort, "runLockPort must not be null");
this.sourceDocumentCandidatesPort = Objects.requireNonNull(
@@ -123,6 +170,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
documentProcessingCoordinator, "documentProcessingCoordinator must not be null");
this.aiNamingService = Objects.requireNonNull(aiNamingService, "aiNamingService must not be null");
this.logger = Objects.requireNonNull(logger, "logger must not be null");
+ this.progressObserver = Objects.requireNonNull(progressObserver, "progressObserver must not be null");
+ this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
}
@Override
@@ -183,16 +232,60 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
}
logger.info("Found {} PDF candidate(s) in source folder.", candidates.size());
- for (SourceDocumentCandidate candidate : candidates) {
- processCandidate(candidate, context);
+ // Notify observer of the known candidate count up-front so observers can size their
+ // progress bars. The count reflects the source folder at scan time and remains fixed
+ // for the remainder of the run (also when the run is cancelled early).
+ try {
+ progressObserver.onRunStarted(context.runId(), candidates.size());
+ } catch (RuntimeException e) {
+ logger.warn("Progress observer threw on onRunStarted: {}", e.getMessage(), e);
}
- logger.info("Batch run completed. Processed {} candidate(s). RunId: {}",
- candidates.size(), context.runId());
+ // Wrap the user-supplied observer so the per-run summary can be computed by counting
+ // forwarded completion events.
+ CountingCompletionObserver forwardingObserver =
+ new CountingCompletionObserver(progressObserver, logger);
+ documentProcessingCoordinator.installCompletionForwarder(forwardingObserver);
+ try {
+ int processedCount = 0;
+ boolean cancelled = false;
+ for (SourceDocumentCandidate candidate : candidates) {
+ if (cancellationTokenRequested()) {
+ cancelled = true;
+ logger.info("Cancellation requested before processing next candidate. "
+ + "Stopping batch run. RunId: {}, processed {}/{} candidate(s).",
+ context.runId(), processedCount, candidates.size());
+ break;
+ }
+ processCandidate(candidate, context);
+ processedCount++;
+ }
+
+ logger.info("Batch run {}. Processed {} candidate(s). RunId: {}",
+ cancelled ? "cancelled" : "completed",
+ processedCount, context.runId());
+ } finally {
+ documentProcessingCoordinator.installCompletionForwarder(null);
+ try {
+ progressObserver.onRunEnded(forwardingObserver.summary());
+ } catch (RuntimeException e) {
+ logger.warn("Progress observer threw on onRunEnded: {}", e.getMessage(), e);
+ }
+ }
return BatchRunOutcome.SUCCESS;
}
+ private boolean cancellationTokenRequested() {
+ try {
+ return cancellationToken.isCancellationRequested();
+ } catch (RuntimeException e) {
+ logger.warn("Cancellation token threw while being polled; treating as not cancelled: {}",
+ e.getMessage(), e);
+ return false;
+ }
+ }
+
/**
* Releases the run lock if it was previously acquired.
*
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java
new file mode 100644
index 0000000..d71a7ec
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java
@@ -0,0 +1,497 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
+import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
+import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
+import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
+import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
+import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
+import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
+import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
+import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
+import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
+import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
+import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
+import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError;
+import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult;
+import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
+import de.gecheckt.pdf.umbenenner.domain.model.RunId;
+import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
+import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
+
+/**
+ * Focused tests for the progress observer and cancellation token behaviour wired into
+ * {@link DefaultBatchRunProcessingUseCase} and forwarded to
+ * {@link DocumentProcessingCoordinator}.
+ */
+class BatchRunProgressObservationTest {
+
+ private static final int TEST_MAX_TITLE = 60;
+
+ @TempDir
+ Path tempDir;
+
+ // =========================================================================
+ // Value object invariants
+ // =========================================================================
+
+ @Test
+ void documentCompletionEvent_rejectsBlankFilename() {
+ assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
+ " ", DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
+ }
+
+ @Test
+ void documentCompletionEvent_rejectsNegativeDuration() {
+ assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
+ "x.pdf", DocumentCompletionStatus.SUCCESS, null, null, null,
+ Duration.ofSeconds(-1)));
+ }
+
+ @Test
+ void documentCompletionEvent_carriesOptionalFields() {
+ DocumentCompletionEvent event = new DocumentCompletionEvent(
+ "x.pdf", DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf",
+ LocalDate.of(2026, 3, 1), "weil wichtig", Duration.ofMillis(123));
+
+ assertEquals("x.pdf", event.originalFileName());
+ assertEquals(DocumentCompletionStatus.SUCCESS, event.status());
+ assertEquals("2026-03-01 - Titel.pdf", event.finalFileName());
+ assertEquals(LocalDate.of(2026, 3, 1), event.resolvedDate());
+ assertEquals("weil wichtig", event.aiReasoning());
+ assertEquals(Duration.ofMillis(123), event.processingDuration());
+ }
+
+ @Test
+ void runSummary_rejectsNegativeCounts() {
+ assertThrows(IllegalArgumentException.class, () -> new RunSummary(-1, 0, 0));
+ assertThrows(IllegalArgumentException.class, () -> new RunSummary(0, -1, 0));
+ assertThrows(IllegalArgumentException.class, () -> new RunSummary(0, 0, -1));
+ }
+
+ @Test
+ void runSummary_totalProcessedSumsCounts() {
+ RunSummary summary = new RunSummary(2, 3, 4);
+ assertEquals(9, summary.totalProcessed());
+ }
+
+ @Test
+ void noOpObserver_isSingletonAndSilent() {
+ BatchRunProgressObserver a = BatchRunProgressObserver.noOp();
+ BatchRunProgressObserver b = BatchRunProgressObserver.noOp();
+ assertSame(a, b);
+ a.onRunStarted(new RunId("r-1"), 5);
+ a.onDocumentCompleted(new DocumentCompletionEvent(
+ "x.pdf", DocumentCompletionStatus.SKIPPED, null, null, null, Duration.ZERO));
+ a.onRunEnded(new RunSummary(0, 0, 0));
+ }
+
+ @Test
+ void neverCancelledToken_isSingletonAndAlwaysFalse() {
+ BatchRunCancellationToken a = BatchRunCancellationToken.neverCancelled();
+ BatchRunCancellationToken b = BatchRunCancellationToken.neverCancelled();
+ assertSame(a, b);
+ assertFalse(a.isCancellationRequested());
+ }
+
+ // =========================================================================
+ // Use case lifecycle callbacks
+ // =========================================================================
+
+ @Test
+ void useCase_emitsRunStartedAndEndedForEmptyFolder() {
+ RecordingObserver observer = new RecordingObserver();
+ CapturingCoordinator coordinator = new CapturingCoordinator();
+ DefaultBatchRunProcessingUseCase useCase = buildUseCase(
+ new NoOpLock(), new EmptyCandidatesPort(), coordinator, observer,
+ BatchRunCancellationToken.neverCancelled());
+
+ BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("empty"), Instant.now()));
+
+ assertTrue(outcome.isSuccess());
+ assertEquals(List.of("started:0", "ended:0/0/0"), observer.events);
+ }
+
+ @Test
+ void useCase_forwardsCoordinatorCompletionEventsAndCountsThem() {
+ RecordingObserver observer = new RecordingObserver();
+ PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
+ DocumentCompletionStatus.SUCCESS,
+ DocumentCompletionStatus.FAILED_RETRYABLE,
+ DocumentCompletionStatus.FAILED_PERMANENT,
+ DocumentCompletionStatus.SKIPPED));
+ DefaultBatchRunProcessingUseCase useCase = buildUseCase(
+ new NoOpLock(), new FixedCandidatesPort(
+ makeCandidate("a.pdf"),
+ makeCandidate("b.pdf"),
+ makeCandidate("c.pdf"),
+ makeCandidate("d.pdf")),
+ coordinator, observer, BatchRunCancellationToken.neverCancelled());
+
+ BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("mixed"), Instant.now()));
+
+ assertTrue(outcome.isSuccess());
+ // Observer must see exactly one onStarted, 4 completed events, and one onEnded.
+ assertEquals(6, observer.events.size(), () -> observer.events.toString());
+ assertEquals("started:4", observer.events.get(0));
+ assertEquals("ended:1/2/1", observer.events.get(observer.events.size() - 1));
+ }
+
+ @Test
+ void useCase_stopsBeforeNextCandidateWhenCancellationRequested() {
+ RecordingObserver observer = new RecordingObserver();
+ PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
+ DocumentCompletionStatus.SUCCESS,
+ DocumentCompletionStatus.SUCCESS,
+ DocumentCompletionStatus.SUCCESS));
+ ToggleCancellationToken cancellation = new ToggleCancellationToken();
+ // Cancel after the first candidate has been processed.
+ coordinator.onBeforeReturn = () -> {
+ if (coordinator.invocations() == 1) {
+ cancellation.request();
+ }
+ };
+ DefaultBatchRunProcessingUseCase useCase = buildUseCase(
+ new NoOpLock(), new FixedCandidatesPort(
+ makeCandidate("a.pdf"),
+ makeCandidate("b.pdf"),
+ makeCandidate("c.pdf")),
+ coordinator, observer, cancellation);
+
+ BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("cancel"), Instant.now()));
+
+ assertTrue(outcome.isSuccess());
+ // Only the first candidate ran — cancellation is polled before the second.
+ assertEquals(1, coordinator.invocations());
+ // Observer saw the start, one completion, and the run end with (1,0,0).
+ assertEquals(List.of("started:3", "completed:SUCCESS:a.pdf", "ended:1/0/0"),
+ observer.events);
+ }
+
+ @Test
+ void useCase_isSafeWhenObserverThrowsFromCallbacks() {
+ ThrowingObserver observer = new ThrowingObserver();
+ PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
+ DocumentCompletionStatus.SUCCESS));
+ DefaultBatchRunProcessingUseCase useCase = buildUseCase(
+ new NoOpLock(), new FixedCandidatesPort(makeCandidate("x.pdf")),
+ coordinator, observer, BatchRunCancellationToken.neverCancelled());
+
+ // Must not bubble up — thrown runtime exceptions are isolated.
+ BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("throw"), Instant.now()));
+ assertTrue(outcome.isSuccess());
+ assertTrue(observer.startedThrown.get(),
+ "onRunStarted was invoked even though it threw");
+ assertTrue(observer.completedThrown.get(),
+ "onDocumentCompleted was invoked even though it threw");
+ assertTrue(observer.endedThrown.get(),
+ "onRunEnded was invoked even though it threw");
+ }
+
+ @Test
+ void useCase_removesForwarderAfterRun() {
+ RecordingObserver observer = new RecordingObserver();
+ PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
+ DocumentCompletionStatus.SUCCESS));
+ DefaultBatchRunProcessingUseCase useCase = buildUseCase(
+ new NoOpLock(), new FixedCandidatesPort(makeCandidate("x.pdf")),
+ coordinator, observer, BatchRunCancellationToken.neverCancelled());
+
+ useCase.execute(new BatchRunContext(new RunId("clean"), Instant.now()));
+
+ assertNull(coordinator.currentForwarder(),
+ "Forwarder must be removed after the run to avoid leaking observers across runs");
+ }
+
+ // =========================================================================
+ // Helpers / stubs
+ // =========================================================================
+
+ private DefaultBatchRunProcessingUseCase buildUseCase(
+ RunLockPort lock,
+ SourceDocumentCandidatesPort candidates,
+ DocumentProcessingCoordinator coordinator,
+ BatchRunProgressObserver observer,
+ BatchRunCancellationToken token) {
+ RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
+ 3, 3, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
+ return new DefaultBatchRunProcessingUseCase(
+ runtimeConfig,
+ lock,
+ candidates,
+ new PassThroughExtractionPort(),
+ new AlwaysSuccessFingerprintPort(),
+ coordinator,
+ buildStubAiNamingService(),
+ new SilentLogger(),
+ observer,
+ token);
+ }
+
+ private static AiNamingService buildStubAiNamingService() {
+ AiInvocationPort stubAi = req -> {
+ throw new IllegalStateException("AI must not be invoked in these tests");
+ };
+ PromptPort stubPrompt = () -> new PromptLoadingSuccess(
+ new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
+ ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
+ AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
+ return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
+ }
+
+ private static SourceDocumentCandidate makeCandidate(String filename) {
+ return new SourceDocumentCandidate(filename, 1024L,
+ new SourceDocumentLocator("/tmp/" + filename));
+ }
+
+ private static final class NoOpLock implements RunLockPort {
+ @Override public void acquire() { }
+ @Override public void release() { }
+ }
+
+ private static final class EmptyCandidatesPort implements SourceDocumentCandidatesPort {
+ @Override public List
+ * Shared wiring for the headless path (where observer and token default to no-ops) and the
+ * GUI processing-run path (where both carry live callbacks from the UI). The method is
+ * intentionally factored out so a single wiring description serves both entry points.
+ *
+ * @param startConfig validated startup configuration; must not be null
+ * @param runLockPort acquired run-lock port; must not be null
+ * @param progressObserver observer forwarded into the use case; must not be null
+ * @param cancellationToken cancellation token forwarded into the use case; must not be null
+ * @return a fully wired production batch use case; never null
+ */
+ private BatchRunProcessingUseCase buildProductionBatchUseCase(
+ StartConfiguration startConfig,
+ RunLockPort runLockPort,
+ de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
+ de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
+ AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
+ RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
+ startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
+
+ AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
+ ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
+ AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
+
+ String jdbcUrl = buildJdbcUrl(startConfig);
+ FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
+ DocumentRecordRepository documentRecordRepository =
+ new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
+ ProcessingAttemptRepository processingAttemptRepository =
+ new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
+ UnitOfWorkPort unitOfWorkPort =
+ new SqliteUnitOfWorkAdapter(jdbcUrl);
+ ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
+ DocumentProcessingCoordinator.class, aiContentSensitivity);
+ TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
+ TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
+ DocumentProcessingCoordinator documentProcessingCoordinator =
+ new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
+ unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
+ startConfig.maxRetriesTransient(),
+ startConfig.maxTitleLength(),
+ activeFamily.getIdentifier());
+
+ PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
+ ClockPort clockPort = new SystemClockAdapter();
+ AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength());
+ AiNamingService aiNamingService = new AiNamingService(
+ aiInvocationPort,
+ promptPort,
+ aiResponseValidator,
+ providerConfig.model(),
+ startConfig.maxTextCharacters(),
+ startConfig.maxTitleLength());
+
+ ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
+ DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
+ return new DefaultBatchRunProcessingUseCase(
+ runtimeConfig,
+ runLockPort,
+ new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()),
+ new PdfTextExtractionPortAdapter(),
+ fingerprintPort,
+ documentProcessingCoordinator,
+ aiNamingService,
+ useCaseLogger,
+ progressObserver,
+ cancellationToken);
+ }
+
/**
* Creates the BootstrapRunner with custom factories for testing, without a migration step.
*
@@ -639,6 +666,7 @@ public class BootstrapRunner {
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService =
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter());
+ GuiBatchRunLauncher batchRunLauncher = this::launchGuiBatchRun;
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
@@ -651,7 +679,8 @@ public class BootstrapRunner {
providerTechnicalTestService,
pathCheckPort,
technicalTestOrchestrator,
- correctionExecutionService);
+ correctionExecutionService,
+ batchRunLauncher);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -669,7 +698,8 @@ public class BootstrapRunner {
providerTechnicalTestService,
pathCheckPort,
technicalTestOrchestrator,
- correctionExecutionService);
+ correctionExecutionService,
+ batchRunLauncher);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -677,7 +707,7 @@ public class BootstrapRunner {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
- technicalTestOrchestrator, correctionExecutionService);
+ technicalTestOrchestrator, correctionExecutionService, batchRunLauncher);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -691,10 +721,96 @@ public class BootstrapRunner {
providerTechnicalTestService,
pathCheckPort,
technicalTestOrchestrator,
- correctionExecutionService);
+ correctionExecutionService,
+ batchRunLauncher);
}
}
+ /**
+ * Executes exactly one batch run triggered by the GUI's processing-run tab.
+ *
+ * Mirrors the headless bootstrap pipeline: legacy migration, configuration loading and
+ * validation, SQLite schema initialisation, run-lock acquisition, use-case wiring, and
+ * execution. Forwards the supplied observer and cancellation token into the wired use
+ * case so the GUI receives live progress callbacks and can request a soft-stop.
+ *
+ * All known hard startup failures are mapped to
+ * {@link GuiBatchRunLaunchOutcome#rejected(String)}. Unexpected runtime exceptions
+ * that escape after startup are mapped to
+ * {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} so the GUI can reach the
+ * defined terminal state instead of becoming stuck.
+ *
+ * @param configFilePath path to the {@code .properties} configuration; must exist on disk
+ * @param progressObserver observer forwarded into the use case; must not be null
+ * @param cancellationToken token forwarded into the use case; must not be null
+ * @return the outcome for the run; never null
+ */
+ GuiBatchRunLaunchOutcome launchGuiBatchRun(
+ Path configFilePath,
+ de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
+ de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
+ Objects.requireNonNull(configFilePath, "configFilePath must not be null");
+ Objects.requireNonNull(progressObserver, "progressObserver must not be null");
+ Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
+ LOG.info("GUI-Verarbeitungslauf: Startanforderung für Konfiguration {}.", configFilePath);
+ try {
+ if (!Files.exists(configFilePath)) {
+ return GuiBatchRunLaunchOutcome.rejected(
+ "Konfigurationsdatei wurde nicht gefunden: " + configFilePath);
+ }
+ migrateConfigurationIfNeeded(configFilePath);
+ StartConfiguration config = loadAndValidateConfiguration(configFilePath);
+ initializeSchema(config);
+ RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
+ BatchRunContext runContext = createRunContext();
+ BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
+ config, runLockPort, progressObserver, cancellationToken);
+ BatchRunOutcome outcome = useCase.execute(runContext);
+ runContext.setEndInstant(Instant.now());
+ return mapGuiRunOutcome(outcome, runContext);
+ } catch (ConfigurationLoadingException e) {
+ LOG.error("GUI-Verarbeitungslauf: Konfiguration konnte nicht geladen werden: {}",
+ e.getMessage(), e);
+ return GuiBatchRunLaunchOutcome.rejected(
+ "Konfiguration konnte nicht geladen werden: " + e.getMessage());
+ } catch (InvalidStartConfigurationException e) {
+ LOG.error("GUI-Verarbeitungslauf: Konfiguration ist nicht lauffähig: {}", e.getMessage());
+ return GuiBatchRunLaunchOutcome.rejected(
+ "Die gespeicherte Konfiguration ist nicht lauffähig: " + e.getMessage());
+ } catch (DocumentPersistenceException e) {
+ LOG.error("GUI-Verarbeitungslauf: SQLite-Initialisierung fehlgeschlagen: {}",
+ e.getMessage(), e);
+ return GuiBatchRunLaunchOutcome.rejected(
+ "SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
+ } catch (RuntimeException e) {
+ LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
+ return GuiBatchRunLaunchOutcome.failedAfterStart(
+ "Unerwarteter Fehler im Verarbeitungslauf: "
+ + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
+ }
+ }
+
+ private GuiBatchRunLaunchOutcome mapGuiRunOutcome(BatchRunOutcome outcome, BatchRunContext runContext) {
+ return switch (outcome) {
+ case SUCCESS -> {
+ LOG.info("GUI-Verarbeitungslauf: Lauf beendet. RunId: {}", runContext.runId());
+ yield GuiBatchRunLaunchOutcome.completed();
+ }
+ case LOCK_UNAVAILABLE -> {
+ LOG.warn("GUI-Verarbeitungslauf: Laufsperre bereits vergeben. RunId: {}",
+ runContext.runId());
+ yield GuiBatchRunLaunchOutcome.rejected(
+ "Ein anderer Verarbeitungslauf hält bereits die Laufsperre.");
+ }
+ case FAILURE -> {
+ LOG.error("GUI-Verarbeitungslauf: Lauf mit Fehler beendet. RunId: {}",
+ runContext.runId());
+ yield GuiBatchRunLaunchOutcome.failedAfterStart(
+ "Der Verarbeitungslauf konnte nicht erfolgreich abgeschlossen werden.");
+ }
+ };
+ }
+
/**
* Creates the {@link AiModelCatalogPort} dispatcher for use in the GUI startup context.
*
Threading
+ *
+ *
+ *
+ * Lifecycle
+ *
+ *
+ */
+public final class GuiBatchRunCoordinator {
+
+ private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
+ private static final String WORKER_THREAD_NAME = "gui-batch-run";
+
+ /**
+ * Listener interface invoked on the JavaFX Application Thread during a run.
+ */
+ public interface Listener {
+
+ /**
+ * Invoked once, after the batch use case has scanned the source folder and knows
+ * the total candidate count.
+ *
+ * @param runId the identifier of the run; never {@code null}
+ * @param totalCandidates the number of candidates detected in the source folder;
+ * never negative
+ */
+ void onRunStarted(RunId runId, int totalCandidates);
+
+ /**
+ * Invoked once per candidate whose processing reached a terminal resolution.
+ *
+ * @param row the row describing the candidate result; never {@code null}
+ */
+ void onDocumentCompleted(GuiBatchRunResultRow row);
+
+ /**
+ * Invoked once after the run has fully terminated on the worker thread.
+ *
+ * @param summary the final outcome counts; never {@code null}
+ * @param outcome a description of how the run terminated; never {@code null}
+ */
+ void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
+ }
+
+ private final GuiBatchRunLauncher launcher;
+ private final FunctionFields
+ *
+ *
+ */
+public record GuiBatchRunLaunchOutcome(
+ boolean successfullyStarted,
+ boolean batchCompletedNormally,
+ OptionalThreading
+ * Exception contract
+ * Layout
+ *
+ * ┌──────────────────────────────────────────────────────┐
+ * │ [Fortschrittsbalken] 12 / 47 Dateien │
+ * ├──────────────────────────────────┬───────────────────┤
+ * │ Ergebnisliste │ Seitenbereich │
+ * │ (TableView) │ (Reasoning) │
+ * ├──────────────────────────────────┴───────────────────┤
+ * │ Meldungs- und Zusammenfassungsbereich │
+ * ├──────────────────────────────────────────────────────┤
+ * │ [Starten] [Abbrechen] │
+ * └──────────────────────────────────────────────────────┘
+ *
+ *
+ * Threading
+ * Threading contract
+ * Cancellation
+ * Configuration source
+ * Default implementation
+ * Invocation order
+ *
+ *
+ *
+ * Threading
+ * Exception handling
+ *
+ *
+ *