diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index fb79cbb..a4463b5 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -18,6 +18,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocument import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory; import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult; +import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort; @@ -48,14 +49,19 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; * the {@link GuiManualFileCopyPort} used to manually copy a source file to the target * folder for documents that have not yet been successfully processed, and * the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing - * context for documents that were skipped in the current run, and the resolved application - * version string that the status bar displays at the bottom of the main window. + * context for documents that were skipped in the current run, the resolved application + * version string that the status bar displays at the bottom of the main window, and the + * optional {@link SchedulerControlUseCase} for controlling the automatic scheduler. *

* The optional {@code applicationContextError} carries a human-readable German error * message when the bootstrap-side application run context could not be initialised at * startup (e.g., invalid or incomplete configuration). An empty value signals that the * run context was built successfully and batch runs can be launched immediately. *

+ * The optional {@code schedulerControlUseCase} is present when the automatic scheduler + * was successfully wired at startup. An empty value means scheduler control is not + * available in this startup context (e.g., no valid configuration was loaded at startup). + *

* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to * know about provider-specific HTTP details or adapter wiring. */ @@ -84,7 +90,8 @@ public record GuiStartupContext( GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort, GuiPromptEditorPortFactory promptEditorPortFactory, GuiCreateNewDatabasePort createNewDatabasePort, - Optional applicationContextError) { + Optional applicationContextError, + Optional schedulerControlUseCase) { /** * Creates a fully wired startup context. @@ -167,6 +174,75 @@ public record GuiStartupContext( "promptEditorPortFactory must not be null"); createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort, "createNewDatabasePort must not be null"); + schedulerControlUseCase = schedulerControlUseCase == null ? Optional.empty() : schedulerControlUseCase; + } + + /** + * Backward-compatible constructor that fills {@code schedulerControlUseCase} with + * {@link Optional#empty()}. + *

+ * Preserves existing callers that were written before the scheduler was added. + * + * @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} + * @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null} + * @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null} + * @param resetDocumentStatusPort bridge that resets document status; must not be {@code null} + * @param manualFileRenamePort bridge that renames a target file; must not be {@code null} + * @param manualFileCopyPort bridge that copies a source file; must not be {@code null} + * @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null} + * @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"} + * @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null} + * @param historyOverviewPort bridge for history overview; must not be {@code null} + * @param historyDetailsPort bridge for history details; must not be {@code null} + * @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null} + * @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null} + * @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null} + * @param createNewDatabasePort bridge for new database creation; must not be {@code null} + * @param applicationContextError optional error from context init; {@code null} becomes empty + */ + public GuiStartupContext( + GuiConfigurationEditorState initialState, + Optional startupNotice, + GuiConfigurationFileLoader configurationFileLoader, + GuiConfigurationFileWriter configurationFileWriter, + AiModelCatalogPort modelCatalogPort, + ApiKeyResolutionPort apiKeyResolutionPort, + ProviderTechnicalTestService providerTechnicalTestService, + PathCheckPort pathCheckPort, + TechnicalTestOrchestrator technicalTestOrchestrator, + CorrectionExecutionService correctionExecutionService, + GuiBatchRunLauncher batchRunLauncher, + GuiMiniRunLauncher miniRunLauncher, + GuiResetDocumentStatusPort resetDocumentStatusPort, + GuiManualFileRenamePort manualFileRenamePort, + GuiManualFileCopyPort manualFileCopyPort, + GuiHistoricalDocumentContextPort historicalDocumentContextPort, + String applicationVersion, + GuiPromptEditorPort promptEditorPort, + GuiHistoryOverviewPort historyOverviewPort, + GuiHistoryDetailsPort historyDetailsPort, + GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort, + GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort, + GuiPromptEditorPortFactory promptEditorPortFactory, + GuiCreateNewDatabasePort createNewDatabasePort, + Optional applicationContextError) { + this(initialState, startupNotice, configurationFileLoader, configurationFileWriter, + modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, + technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, + miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort, + historicalDocumentContextPort, applicationVersion, promptEditorPort, + historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort, + deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort, + applicationContextError, Optional.empty()); } /** diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java index 205173d..871f6be 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java @@ -197,6 +197,23 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase { return statusRef.get(); } + /** + * Markiert den Autostart als fehlgeschlagen. + *

+ * Wird von der Bootstrap-Schicht aufgerufen, wenn ein konfigurierter Autostart + * beim Programmstart fehlgeschlagen ist. Alle übrigen Statusfelder bleiben erhalten; + * lediglich {@link SchedulerStatus#autostartFailed()} wird auf {@code true} gesetzt. + *

+ * Diese Methode darf nur aufgerufen werden, wenn der Scheduler noch gestoppt ist + * (unmittelbar nach einem fehlgeschlagenen {@link #start()}). + */ + public void markAutostartFailed() { + statusRef.updateAndGet(s -> new SchedulerStatus( + s.state(), s.lastRunEndedAt(), s.lastRunSummary(), + s.nextTickAt(), s.lastError(), true)); + logger.warn("Scheduler-Status: Autostart als fehlgeschlagen markiert."); + } + // ------------------------------------------------------------------------- // Tick-Wrapper (package-private für Testbarkeit) // ------------------------------------------------------------------------- diff --git a/pdf-umbenenner-bootstrap/pom.xml b/pdf-umbenenner-bootstrap/pom.xml index 81948ae..4ae2e75 100644 --- a/pdf-umbenenner-bootstrap/pom.xml +++ b/pdf-umbenenner-bootstrap/pom.xml @@ -36,6 +36,11 @@ pdf-umbenenner-adapter-out ${project.version} + + de.gecheckt + pdf-umbenenner-adapter-in-scheduler + ${project.version} + diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index d611bf0..c2757ba 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -18,6 +18,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; +import de.gecheckt.pdf.umbenenner.adapter.in.scheduler.FileChannelConfigurationAccessAdapter; +import de.gecheckt.pdf.umbenenner.adapter.in.scheduler.ScheduledExecutorServiceSchedulerAdapter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter; @@ -61,6 +63,8 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfigurat import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; +import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase; +import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException; import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase; import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext; import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest; @@ -73,6 +77,9 @@ import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult; import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalDocumentContextUseCase; import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort; import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity; +import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTrigger; +import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult; +import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary; 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.DatabaseCreationPort; @@ -100,6 +107,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocument import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteHistoryQueryAdapter; import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase; +import de.gecheckt.pdf.umbenenner.application.usecase.DefaultSchedulerControlUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultCreateNewDatabaseUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase; @@ -246,6 +254,17 @@ public class BootstrapRunner { */ private volatile Optional guiApplicationRunContext = Optional.empty(); + /** + * Der Scheduler-Use-Case, der beim GUI-Start mit einer gültigen Konfiguration + * initialisiert wird. Enthält den {@link DefaultSchedulerControlUseCase}, der den + * automatischen Scheduler-Lebenszyklus verwaltet. + *

+ * Wird beim Start via {@link #tryInitializeScheduler(Path)} gesetzt. Leer, wenn + * beim Start keine gültige Konfiguration vorlag oder die Scheduler-Initialisierung + * fehlschlug. {@code volatile} sichert die Sichtbarkeit über Thread-Grenzen hinweg. + */ + private volatile Optional guiSchedulerUseCase = Optional.empty(); + /** * Functional interface encapsulating the legacy configuration migration step. *

@@ -761,9 +780,28 @@ public class BootstrapRunner { } catch (Exception e) { LOG.error("GUI startup failed: {}", e.getMessage(), e); return 1; + } finally { + stopGuiSchedulerIfActive(); } } + /** + * Stoppt den aktiven Scheduler, wenn die GUI beendet wird. + *

+ * Wird im {@code finally}-Block von {@link #startGuiMode(Optional)} aufgerufen, + * damit der Scheduler-Thread ordentlich beendet wird, bevor der JVM-Prozess endet. + * Ist kein Scheduler aktiv oder bereits gestoppt, hat dieser Aufruf keine Wirkung. + */ + private void stopGuiSchedulerIfActive() { + guiSchedulerUseCase.ifPresent(scheduler -> { + if (scheduler.getStatus().state().isActive()) { + LOG.info("GUI-Beendigung: laufender Scheduler wird gestoppt."); + scheduler.stop(); + } + }); + guiSchedulerUseCase = Optional.empty(); + } + /** * Runs the headless batch processing pipeline for the given startup arguments. *

@@ -936,13 +974,19 @@ public class BootstrapRunner { GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort( loadedState.values().promptTemplateFile()); Optional contextError = initializeApplicationRunContext(configPath); + if (contextError.isEmpty()) { + tryInitializeScheduler(configPath); + } + Optional schedulerUseCase = + guiSchedulerUseCase.map(s -> (SchedulerControlUseCase) s); return new GuiStartupContext(loadedState, Optional.empty(), loader, writer, modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetPort, manualRenamePort, manualCopyPort, historicalDocumentContextPort, applicationVersion, promptEditorPort, historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort, - this::buildGuiPromptEditorPort, createNewDatabasePort, contextError); + this::buildGuiPromptEditorPort, createNewDatabasePort, contextError, + schedulerUseCase); } catch (GuiConfigurationLoadException e) { LOG.error("GUI startup: configuration could not be loaded, starting without it: {}", e.getMessage(), e); @@ -975,6 +1019,82 @@ public class BootstrapRunner { } } + /** + * Initialisiert den automatischen Scheduler für den GUI-Betrieb. + *

+ * Erzeugt {@link FileChannelConfigurationAccessAdapter}, {@link ScheduledExecutorServiceSchedulerAdapter} + * und {@link DefaultSchedulerControlUseCase} und speichert den Use Case in + * {@link #guiSchedulerUseCase}. Ist in der Konfiguration {@code scheduler.enabled=true} + * gesetzt, wird der Scheduler sofort gestartet (Autostart). + *

+ * Schlägt die Initialisierung fehl, wird {@link #guiSchedulerUseCase} auf + * {@link Optional#empty()} gesetzt und der Fehler als Warnung geloggt. + * Der GUI-Start wird dadurch nicht abgebrochen. + * + * @param configFilePath Pfad zur Konfigurationsdatei; muss auf Platte existieren + */ + private void tryInitializeScheduler(Path configFilePath) { + try { + FileChannelConfigurationAccessAdapter configAccessAdapter = + new FileChannelConfigurationAccessAdapter(configFilePath); + ScheduledExecutorServiceSchedulerAdapter schedulerAdapter = + new ScheduledExecutorServiceSchedulerAdapter( + result -> LOG.debug("Scheduler-Tick-Ergebnis: {}", + result.getClass().getSimpleName())); + BatchRunTrigger batchRunTrigger = () -> { + Optional ctxOpt = guiApplicationRunContext; + if (ctxOpt.isEmpty()) { + return new BatchRunTriggerResult.Failed( + "Kein Anwendungskontext verfügbar.", + "guiApplicationRunContext ist leer – Scheduler-Tick übersprungen."); + } + ApplicationRunContext ctx = ctxOpt.get(); + try { + RunLockPort runLockPort = runLockPortFactory.create( + resolveLockFilePath(ctx.startConfiguration())); + BatchRunContext runContext = createRunContext(); + BatchRunProcessingUseCase useCase = buildProductionBatchUseCase( + ctx.startConfiguration(), runLockPort, + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(), + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled()); + BatchRunOutcome outcome = useCase.execute(runContext); + runContext.setEndInstant(Instant.now()); + if (outcome.isLockUnavailable()) { + return new BatchRunTriggerResult.SkippedBusy(); + } else if (outcome.isSuccess()) { + return new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp()); + } else { + return new BatchRunTriggerResult.Failed( + "Verarbeitungslauf fehlgeschlagen.", + "BatchRunOutcome: " + outcome); + } + } catch (RuntimeException e) { + LOG.error("Scheduler-Tick: Unerwarteter Fehler: {}", e.getMessage(), e); + return new BatchRunTriggerResult.Failed( + "Unerwarteter Fehler: " + e.getMessage(), + e.getClass().getSimpleName()); + } + }; + DefaultSchedulerControlUseCase schedulerUseCase = new DefaultSchedulerControlUseCase( + schedulerAdapter, configAccessAdapter, configAccessAdapter, batchRunTrigger); + guiSchedulerUseCase = Optional.of(schedulerUseCase); + + if (configAccessAdapter.loadSettings().enabled()) { + try { + schedulerUseCase.start(); + LOG.info("Scheduler: Autostart aktiviert gemäß Konfiguration."); + } catch (SchedulerStartException e) { + LOG.warn("Scheduler: Autostart fehlgeschlagen: {}", e.getMessage()); + schedulerUseCase.markAutostartFailed(); + } + } + } catch (Exception e) { + LOG.warn("Scheduler: Initialisierung fehlgeschlagen – Scheduler nicht verfügbar: {}", + e.getMessage(), e); + guiSchedulerUseCase = Optional.empty(); + } + } + /** * Erzeugt einen vollständig verdrahteten {@link GuiPromptEditorPort} für den angegebenen * Prompt-Dateipfad.