diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatter.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatter.java
new file mode 100644
index 000000000..0092f781f
--- /dev/null
+++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatter.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.internal.common.core.response.data;
+
+import io.flamingock.internal.common.core.recovery.RecoveryIssue;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Canonical renderer for {@link ExecuteResponseData}. Two outputs:
+ *
+ *
+ * - {@link #summary(ExecuteResponseData)} — single-line, log-aggregator-friendly.
+ * Used by {@code ExecuteOperationException#getMessage()}.
+ * - {@link #report(ExecuteResponseData)} — multi-line, fixed-width block intended for SLF4J
+ * streams and {@code ExecuteOperationException#toString()}.
+ *
+ *
+ * Both methods are fully defensive: null result, empty stages, missing per-stage state, and
+ * partial data are tolerated; the formatter never throws. The default event listener and the
+ * exception {@code toString()} both depend on this contract.
+ */
+public final class ExecutionReportFormatter {
+
+ private static final String LINE = "========================================================================";
+ private static final String NEWLINE = System.lineSeparator();
+
+ private ExecutionReportFormatter() {
+ }
+
+ public static String summary(ExecuteResponseData result) {
+ if (result == null) {
+ return "Flamingock execution: no execution data available";
+ }
+
+ List stages = nonNullStages(result);
+ List failedStageNames = failedStageNames(stages);
+ List miChangeIds = manualInterventionChangeIds(stages);
+
+ String headline;
+ if (isFailedStatus(result.getStatus()) || !failedStageNames.isEmpty()) {
+ headline = String.format(
+ "Flamingock execution failed: %d of %d stage(s) failed [%s]",
+ result.getFailedStages(),
+ result.getTotalStages(),
+ String.join(", ", failedStageNames));
+ } else {
+ headline = String.format(
+ "Flamingock execution completed: %d stage(s)",
+ result.getTotalStages());
+ }
+
+ String counts = String.format(
+ "; changes applied=%d, failed=%d, skipped=%d; duration=%dms",
+ result.getAppliedChanges(),
+ result.getFailedChanges(),
+ result.getSkippedChanges(),
+ result.getTotalDurationMs());
+
+ StringBuilder sb = new StringBuilder(headline).append(counts);
+ if (!miChangeIds.isEmpty()) {
+ sb.append("; manual intervention required for change(s): ")
+ .append(String.join(", ", miChangeIds));
+ }
+ return sb.toString();
+ }
+
+ public static String report(ExecuteResponseData result) {
+ if (result == null) {
+ return banner("Flamingock execution report — NO DATA");
+ }
+
+ StringBuilder sb = new StringBuilder();
+ String statusLabel = statusLabel(result);
+ sb.append(LINE).append(NEWLINE)
+ .append(" Flamingock execution report — ").append(statusLabel).append(NEWLINE)
+ .append(LINE).append(NEWLINE);
+
+ appendTimeBlock(sb, result);
+ sb.append(NEWLINE);
+
+ sb.append(" Stages: ")
+ .append(result.getTotalStages()).append(" total — ")
+ .append(result.getCompletedStages()).append(" completed, ")
+ .append(result.getFailedStages()).append(" failed")
+ .append(NEWLINE);
+ sb.append(" Changes: ")
+ .append(result.getTotalChanges()).append(" total — ")
+ .append(result.getAppliedChanges()).append(" applied, ")
+ .append(result.getSkippedChanges()).append(" skipped, ")
+ .append(result.getFailedChanges()).append(" failed")
+ .append(NEWLINE);
+
+ List stages = nonNullStages(result);
+ if (!stages.isEmpty()) {
+ sb.append(NEWLINE).append(" Per-stage breakdown:").append(NEWLINE).append(NEWLINE);
+ for (StageResult stage : stages) {
+ appendStageBlock(sb, stage);
+ sb.append(NEWLINE);
+ }
+ }
+
+ sb.append(LINE);
+ return sb.toString();
+ }
+
+ private static void appendTimeBlock(StringBuilder sb, ExecuteResponseData result) {
+ Instant start = result.getStartTime();
+ Instant end = result.getEndTime();
+ sb.append(" Started: ").append(start != null ? start.toString() : "n/a").append(NEWLINE);
+ sb.append(" Finished: ").append(end != null ? end.toString() : "n/a").append(NEWLINE);
+ sb.append(" Duration: ").append(result.getTotalDurationMs()).append(" ms").append(NEWLINE);
+ }
+
+ private static void appendStageBlock(StringBuilder sb, StageResult stage) {
+ String label = stageLabel(stage);
+ String name = stage.getStageName() != null ? stage.getStageName() : "(unnamed)";
+ sb.append(" ").append(label).append(' ').append(name)
+ .append(" (").append(stage.getDurationMs()).append(" ms)").append(NEWLINE);
+
+ List changes = stage.getChanges() != null ? stage.getChanges() : Collections.emptyList();
+ int applied = (int) changes.stream().filter(c -> c != null && c.isApplied()).count();
+ int skipped = (int) changes.stream().filter(c -> c != null && c.isAlreadyApplied()).count();
+ int failed = (int) changes.stream().filter(c -> c != null && c.isFailed()).count();
+ sb.append(" changes: ")
+ .append(applied).append(" applied, ")
+ .append(skipped).append(" skipped, ")
+ .append(failed).append(" failed")
+ .append(NEWLINE);
+
+ StageState state = stage.getState();
+ if (state == null) {
+ return;
+ }
+ if (state.isBlockedForManualIntervention()) {
+ List ids = changeIdsFromRecoveryIssues(state.getRecoveryIssues());
+ if (!ids.isEmpty()) {
+ sb.append(" change(s) requiring intervention: ")
+ .append(String.join(", ", ids)).append(NEWLINE);
+ }
+ } else if (state.isFailed()) {
+ state.getErrorInfo().ifPresent(err -> appendErrorBlock(sb, err));
+ List failedChangeIds = failedChangeIds(stage);
+ if (!failedChangeIds.isEmpty()) {
+ sb.append(" failed change(s): ")
+ .append(String.join(", ", failedChangeIds)).append(NEWLINE);
+ }
+ }
+ }
+
+ private static void appendErrorBlock(StringBuilder sb, ErrorInfo err) {
+ String errorType = err.getErrorType() != null ? err.getErrorType() : "Error";
+ sb.append(" error: ").append(errorType).append(NEWLINE);
+ String message = err.getMessage();
+ if (message != null && !message.isEmpty()) {
+ // Indent continuation lines so multi-line messages stay aligned with the "error:" column.
+ String[] lines = message.split("\\R");
+ for (String line : lines) {
+ sb.append(" ").append(line).append(NEWLINE);
+ }
+ }
+ }
+
+ private static String stageLabel(StageResult stage) {
+ StageState state = stage.getState();
+ if (state == null) {
+ return "[UNKNOWN] ";
+ }
+ if (state.isBlockedForManualIntervention()) {
+ return "[BLOCKED — manual intervention required]";
+ }
+ if (state.isFailed()) {
+ return "[FAILED] ";
+ }
+ if (state.isCompleted()) {
+ return "[COMPLETED]";
+ }
+ if (state.isStarted()) {
+ return "[STARTED] ";
+ }
+ if (state.isNotStarted()) {
+ return "[PENDING] ";
+ }
+ return "[UNKNOWN] ";
+ }
+
+ private static String statusLabel(ExecuteResponseData result) {
+ ExecutionStatus status = result.getStatus();
+ if (status == null) {
+ return "UNKNOWN";
+ }
+ return status.name();
+ }
+
+ private static boolean isFailedStatus(ExecutionStatus status) {
+ return status == ExecutionStatus.FAILED;
+ }
+
+ private static List nonNullStages(ExecuteResponseData result) {
+ List raw = result.getStages();
+ if (raw == null) {
+ return Collections.emptyList();
+ }
+ List filtered = new ArrayList<>(raw.size());
+ for (StageResult s : raw) {
+ if (s != null) {
+ filtered.add(s);
+ }
+ }
+ return filtered;
+ }
+
+ private static List failedStageNames(List stages) {
+ return stages.stream()
+ .filter(s -> s.getState() != null && s.getState().isFailed())
+ .map(s -> s.getStageName() != null ? s.getStageName() : "(unnamed)")
+ .collect(Collectors.toList());
+ }
+
+ private static List manualInterventionChangeIds(List stages) {
+ List ids = new ArrayList<>();
+ for (StageResult s : stages) {
+ StageState state = s.getState();
+ if (state != null && state.isBlockedForManualIntervention()) {
+ ids.addAll(changeIdsFromRecoveryIssues(state.getRecoveryIssues()));
+ }
+ }
+ return ids;
+ }
+
+ private static List changeIdsFromRecoveryIssues(List issues) {
+ if (issues == null) {
+ return Collections.emptyList();
+ }
+ return issues.stream()
+ .filter(i -> i != null && i.getChangeId() != null)
+ .map(RecoveryIssue::getChangeId)
+ .collect(Collectors.toList());
+ }
+
+ private static List failedChangeIds(StageResult stage) {
+ List changes = stage.getChanges();
+ if (changes == null) {
+ return Collections.emptyList();
+ }
+ return changes.stream()
+ .filter(c -> c != null && c.isFailed() && c.getChangeId() != null)
+ .map(ChangeResult::getChangeId)
+ .collect(Collectors.toList());
+ }
+
+ private static String banner(String headline) {
+ return LINE + NEWLINE + " " + headline + NEWLINE + LINE;
+ }
+}
diff --git a/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatterTest.java b/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatterTest.java
new file mode 100644
index 000000000..b73bdf514
--- /dev/null
+++ b/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/response/data/ExecutionReportFormatterTest.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.internal.common.core.response.data;
+
+import io.flamingock.internal.common.core.recovery.RecoveryIssue;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ExecutionReportFormatterTest {
+
+ @Test
+ void summaryOnNullReturnsSafeFallback() {
+ String out = ExecutionReportFormatter.summary(null);
+ assertTrue(out.contains("no execution data"));
+ }
+
+ @Test
+ void summaryOnSuccess() {
+ ExecuteResponseData result = ExecuteResponseData.builder()
+ .status(ExecutionStatus.SUCCESS)
+ .totalStages(2)
+ .completedStages(2)
+ .failedStages(0)
+ .totalChanges(3)
+ .appliedChanges(3)
+ .skippedChanges(0)
+ .failedChanges(0)
+ .totalDurationMs(120)
+ .build();
+
+ String out = ExecutionReportFormatter.summary(result);
+ assertTrue(out.startsWith("Flamingock execution completed:"), out);
+ assertTrue(out.contains("applied=3"), out);
+ assertTrue(out.contains("duration=120ms"), out);
+ assertFalse(out.contains("manual intervention"), out);
+ }
+
+ @Test
+ void summaryOnStageFailureListsFailedStageNames() {
+ StageResult completed = StageResult.builder()
+ .stageId("ok").stageName("ok-stage").state(StageState.COMPLETED).durationMs(50)
+ .build();
+ StageResult failed = StageResult.builder()
+ .stageId("bad").stageName("bad-stage")
+ .state(StageState.failed(new ErrorInfo("Boom", "kaboom", Collections.singletonList("c1"), "bad-stage")))
+ .durationMs(70)
+ .changes(Collections.singletonList(failedChange("c1", "Boom", "kaboom")))
+ .build();
+
+ ExecuteResponseData result = ExecuteResponseData.builder()
+ .status(ExecutionStatus.FAILED)
+ .totalStages(2).completedStages(1).failedStages(1)
+ .totalChanges(2).appliedChanges(1).failedChanges(1)
+ .totalDurationMs(120)
+ .stages(Arrays.asList(completed, failed))
+ .build();
+
+ String out = ExecutionReportFormatter.summary(result);
+ assertTrue(out.startsWith("Flamingock execution failed: 1 of 2 stage(s) failed [bad-stage]"), out);
+ assertTrue(out.contains("failed=1"), out);
+ }
+
+ @Test
+ void summaryAppendsManualInterventionChangeIds() {
+ StageResult blocked = StageResult.builder()
+ .stageId("mi").stageName("mi-stage")
+ .state(StageState.blockedManualIntervention("mi-stage",
+ Arrays.asList(new RecoveryIssue("change-a"), new RecoveryIssue("change-b"))))
+ .build();
+
+ ExecuteResponseData result = ExecuteResponseData.builder()
+ .status(ExecutionStatus.FAILED)
+ .totalStages(1).failedStages(1)
+ .totalChanges(2).failedChanges(0)
+ .totalDurationMs(10)
+ .stages(Collections.singletonList(blocked))
+ .build();
+
+ String out = ExecutionReportFormatter.summary(result);
+ assertTrue(out.contains("manual intervention required for change(s): change-a, change-b"), out);
+ }
+
+ @Test
+ void reportSuccessHeadlineAndPerStageBlock() {
+ StageResult done = StageResult.builder()
+ .stageId("only").stageName("only-stage")
+ .state(StageState.COMPLETED).durationMs(75)
+ .changes(Collections.singletonList(appliedChange("c1")))
+ .build();
+
+ ExecuteResponseData result = ExecuteResponseData.builder()
+ .status(ExecutionStatus.SUCCESS)
+ .startTime(Instant.parse("2026-05-15T08:00:00Z"))
+ .endTime(Instant.parse("2026-05-15T08:00:00.075Z"))
+ .totalDurationMs(75)
+ .totalStages(1).completedStages(1).failedStages(0)
+ .totalChanges(1).appliedChanges(1)
+ .stages(Collections.singletonList(done))
+ .build();
+
+ String out = ExecutionReportFormatter.report(result);
+ assertTrue(out.contains("Flamingock execution report — SUCCESS"), out);
+ assertTrue(out.contains("Stages: 1 total — 1 completed, 0 failed"), out);
+ assertTrue(out.contains("[COMPLETED]"), out);
+ assertTrue(out.contains("only-stage"), out);
+ assertTrue(out.contains("1 applied, 0 skipped, 0 failed"), out);
+ }
+
+ @Test
+ void reportFailureIncludesErrorBlockAndFailedChangeIds() {
+ StageResult failed = StageResult.builder()
+ .stageId("bad").stageName("bad-stage")
+ .state(StageState.failed(new ErrorInfo("Boom", "kaboom\non two lines", Collections.singletonList("c1"), "bad-stage")))
+ .durationMs(42)
+ .changes(Collections.singletonList(failedChange("c1", "Boom", "kaboom")))
+ .build();
+
+ ExecuteResponseData result = ExecuteResponseData.builder()
+ .status(ExecutionStatus.FAILED)
+ .totalStages(1).failedStages(1)
+ .totalChanges(1).failedChanges(1)
+ .totalDurationMs(42)
+ .stages(Collections.singletonList(failed))
+ .build();
+
+ String out = ExecutionReportFormatter.report(result);
+ assertTrue(out.contains("Flamingock execution report — FAILED"), out);
+ assertTrue(out.contains("[FAILED]"), out);
+ assertTrue(out.contains("error: Boom"), out);
+ assertTrue(out.contains("kaboom"), out);
+ assertTrue(out.contains("on two lines"), out);
+ assertTrue(out.contains("failed change(s): c1"), out);
+ }
+
+ @Test
+ void reportBlockedForMIShowsChangeIdsAndDistinctLabel() {
+ StageResult blocked = StageResult.builder()
+ .stageId("mi").stageName("mi-stage")
+ .state(StageState.blockedManualIntervention("mi-stage",
+ Arrays.asList(new RecoveryIssue("change-a"), new RecoveryIssue("change-b"))))
+ .build();
+
+ ExecuteResponseData result = ExecuteResponseData.builder()
+ .status(ExecutionStatus.FAILED)
+ .totalStages(1).failedStages(1)
+ .stages(Collections.singletonList(blocked))
+ .build();
+
+ String out = ExecutionReportFormatter.report(result);
+ assertTrue(out.contains("[BLOCKED — manual intervention required]"), out);
+ assertTrue(out.contains("change(s) requiring intervention: change-a, change-b"), out);
+ }
+
+ @Test
+ void reportOnNullReturnsSafeBanner() {
+ String out = ExecutionReportFormatter.report(null);
+ assertNotNull(out);
+ assertTrue(out.contains("NO DATA"), out);
+ }
+
+ @Test
+ void reportToleratesNullStagesAndPartialData() {
+ ExecuteResponseData result = new ExecuteResponseData();
+ result.setStages(null);
+ // Should not throw.
+ String out = ExecutionReportFormatter.report(result);
+ assertNotNull(out);
+ }
+
+ @Test
+ void reportToleratesStageWithNullStateAndNullChanges() {
+ StageResult naked = new StageResult();
+ naked.setStageName(null);
+ naked.setState(null);
+ naked.setChanges(null);
+
+ ExecuteResponseData result = ExecuteResponseData.builder()
+ .status(ExecutionStatus.SUCCESS)
+ .stages(Collections.singletonList(naked))
+ .build();
+
+ // Should not throw despite null state/name/changes.
+ String out = ExecutionReportFormatter.report(result);
+ assertNotNull(out);
+ assertTrue(out.contains("(unnamed)"), out);
+ }
+
+ private static ChangeResult appliedChange(String id) {
+ return ChangeResult.builder().changeId(id).status(ChangeStatus.APPLIED).build();
+ }
+
+ private static ChangeResult failedChange(String id, String errorType, String errorMessage) {
+ return ChangeResult.builder()
+ .changeId(id)
+ .status(ChangeStatus.FAILED)
+ .errorType(errorType)
+ .errorMessage(errorMessage)
+ .build();
+ }
+}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractBuilder.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractBuilder.java
index 5dce1754a..b70d28030 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractBuilder.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractBuilder.java
@@ -147,6 +147,17 @@ public HOLDER setEnabled(boolean enabled) {
return getSelf();
}
+ @Override
+ public HOLDER setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport) {
+ coreConfiguration.setEnableDefaultExecutionReport(enableDefaultExecutionReport);
+ return getSelf();
+ }
+
+ @Override
+ public boolean isEnableDefaultExecutionReport() {
+ return coreConfiguration.isEnableDefaultExecutionReport();
+ }
+
@Override
public HOLDER setServiceIdentifier(String serviceIdentifier) {
coreConfiguration.setServiceIdentifier(serviceIdentifier);
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java
index 3d888e3dd..08257f822 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/builder/AbstractChangeRunnerBuilder.java
@@ -36,6 +36,8 @@
import io.flamingock.internal.core.event.CompositeEventPublisher;
import io.flamingock.internal.core.event.EventPublisher;
import io.flamingock.internal.core.event.SimpleEventPublisher;
+import io.flamingock.internal.core.event.listener.DefaultPipelineCompletedReportListener;
+import io.flamingock.internal.core.event.listener.DefaultPipelineFailedReportListener;
import io.flamingock.internal.core.event.model.IPipelineCompletedEvent;
import io.flamingock.internal.core.event.model.IPipelineFailedEvent;
import io.flamingock.internal.core.event.model.IPipelineIgnoredEvent;
@@ -307,9 +309,9 @@ private EventPublisher buildEventPublisher() {
SimpleEventPublisher simpleEventPublisher = new SimpleEventPublisher()
//pipeline events
.addListener(IPipelineStartedEvent.class, getPipelineStartedListener())
- .addListener(IPipelineCompletedEvent.class, getPipelineCompletedListener())
+ .addListener(IPipelineCompletedEvent.class, composedPipelineCompletedListener())
.addListener(IPipelineIgnoredEvent.class, getPipelineIgnoredListener())
- .addListener(IPipelineFailedEvent.class, getPipelineFailureListener())
+ .addListener(IPipelineFailedEvent.class, composedPipelineFailedListener())
//stage events
.addListener(IStageStartedEvent.class, getStageStartedListener())
.addListener(IStageCompletedEvent.class, getStageCompletedListener())
@@ -326,6 +328,29 @@ private EventPublisher buildEventPublisher() {
return new CompositeEventPublisher(eventPublishersFromPlugins);
}
+ /**
+ * Composes the default execution-report listener with the user-supplied listener when
+ * {@code enableDefaultExecutionReport} is on. Order is deterministic: default fires first,
+ * user fires after — so user code sees a stable log state when it runs.
+ */
+ private Consumer composedPipelineCompletedListener() {
+ Consumer userListener = getPipelineCompletedListener();
+ if (!coreConfiguration.isEnableDefaultExecutionReport()) {
+ return userListener;
+ }
+ Consumer defaultListener = new DefaultPipelineCompletedReportListener();
+ return userListener != null ? defaultListener.andThen(userListener) : defaultListener;
+ }
+
+ private Consumer composedPipelineFailedListener() {
+ Consumer userListener = getPipelineFailureListener();
+ if (!coreConfiguration.isEnableDefaultExecutionReport()) {
+ return userListener;
+ }
+ Consumer defaultListener = new DefaultPipelineFailedReportListener();
+ return userListener != null ? defaultListener.andThen(userListener) : defaultListener;
+ }
+
///////////////////////////////////////////////////////////////////////////////////
// CORE
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java
index 7cdc18423..85b40ac45 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurable.java
@@ -42,6 +42,10 @@ public interface CoreConfigurable {
boolean isValidationOnly();
+ void setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport);
+
+ boolean isEnableDefaultExecutionReport();
+
void setServiceIdentifier(String serviceIdentifier);
void setMetadata(Map metadata);
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java
index 3e7ca8bed..1818f2030 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfiguration.java
@@ -42,6 +42,15 @@ public class CoreConfiguration implements CoreConfigurable {
*/
private boolean validationOnly = false;
+ /**
+ * If true, Flamingock registers the default execution-report listener that writes the
+ * canonical multi-line report to SLF4J under the {@code FK-Report} logger on pipeline
+ * completion or failure. Set to false to opt out of the report entirely; SLF4J-level
+ * silencing (e.g. {@code }) is the recommended
+ * fine-grained control. Default true.
+ */
+ private boolean enableDefaultExecutionReport = true;
+
/**
* Service identifier.
*/
@@ -103,6 +112,16 @@ public void setValidationOnly(boolean validationOnly) {
this.validationOnly = validationOnly;
}
+ @Override
+ public void setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport) {
+ this.enableDefaultExecutionReport = enableDefaultExecutionReport;
+ }
+
+ @Override
+ public boolean isEnableDefaultExecutionReport() {
+ return enableDefaultExecutionReport;
+ }
+
@Override
public void setServiceIdentifier(String serviceIdentifier) {
this.serviceIdentifier = serviceIdentifier;
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurator.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurator.java
index 5389bf091..b4ab546b7 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurator.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/configuration/core/CoreConfigurator.java
@@ -32,6 +32,10 @@ public interface CoreConfigurator {
HOLDER setEnabled(boolean enabled);
+ HOLDER setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport);
+
+ boolean isEnableDefaultExecutionReport();
+
HOLDER setServiceIdentifier(String serviceIdentifier);
HOLDER setMetadata(Map metadata);
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListener.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListener.java
new file mode 100644
index 000000000..f6e7749a6
--- /dev/null
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListener.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.internal.core.event.listener;
+
+import io.flamingock.internal.common.core.response.data.ExecutionReportFormatter;
+import io.flamingock.internal.core.event.model.IPipelineCompletedEvent;
+import io.flamingock.internal.util.log.FlamingockLoggerFactory;
+import org.slf4j.Logger;
+
+import java.util.function.Consumer;
+
+/**
+ * Writes the canonical execution report at {@code INFO} on the {@code FK-Report} logger when a
+ * pipeline completes successfully. Registered by the builder unless
+ * {@code enableDefaultExecutionReport(false)} is set.
+ *
+ * Defensive: must never throw. A failure inside the formatter is reported as a single
+ * fallback {@code ERROR} line and swallowed so it cannot mask the run outcome.
+ */
+public final class DefaultPipelineCompletedReportListener implements Consumer {
+
+ public static final String LOGGER_NAME = "FK-Report";
+
+ private static final Logger logger = FlamingockLoggerFactory.getLogger(LOGGER_NAME);
+
+ @Override
+ public void accept(IPipelineCompletedEvent event) {
+ try {
+ String report = ExecutionReportFormatter.report(event != null ? event.getResult() : null);
+ logger.info("{}{}", System.lineSeparator(), report);
+ } catch (Throwable t) {
+ logger.error("FK-Report rendering failed: {}", t.toString(), t);
+ }
+ }
+}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListener.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListener.java
new file mode 100644
index 000000000..50ed6dc00
--- /dev/null
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListener.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.internal.core.event.listener;
+
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
+import io.flamingock.internal.common.core.response.data.ExecutionReportFormatter;
+import io.flamingock.internal.core.event.model.IPipelineFailedEvent;
+import io.flamingock.internal.util.log.FlamingockLoggerFactory;
+import org.slf4j.Logger;
+
+import java.util.function.Consumer;
+
+/**
+ * Writes the canonical execution report at {@code ERROR} on the {@code FK-Report} logger when a
+ * pipeline fails. Registered by the builder unless {@code enableDefaultExecutionReport(false)}
+ * is set.
+ *
+ * Defensive: must never throw. If the event carries no response data we fall back to logging
+ * the carried exception only; if the formatter itself fails we emit a single fallback line and
+ * swallow the throwable.
+ */
+public final class DefaultPipelineFailedReportListener implements Consumer {
+
+ public static final String LOGGER_NAME = "FK-Report";
+
+ private static final Logger logger = FlamingockLoggerFactory.getLogger(LOGGER_NAME);
+
+ @Override
+ public void accept(IPipelineFailedEvent event) {
+ try {
+ ExecuteResponseData result = event != null ? event.getResult() : null;
+ Exception cause = event != null ? event.getException() : null;
+ if (result == null) {
+ logger.error("Flamingock execution failed (no execution data available)", cause);
+ return;
+ }
+ logger.error("{}{}", System.lineSeparator(), ExecutionReportFormatter.report(result));
+ } catch (Throwable t) {
+ logger.error("FK-Report rendering failed: {}", t.toString(), t);
+ }
+ }
+}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineCompletedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineCompletedEvent.java
index 2e553b25d..9cd06520b 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineCompletedEvent.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineCompletedEvent.java
@@ -15,6 +15,10 @@
*/
package io.flamingock.internal.core.event.model;
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
+
public interface IPipelineCompletedEvent extends Event {
+ ExecuteResponseData getResult();
+
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineFailedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineFailedEvent.java
index c2b40b530..129f4ac45 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineFailedEvent.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IPipelineFailedEvent.java
@@ -15,10 +15,12 @@
*/
package io.flamingock.internal.core.event.model;
-public interface IPipelineFailedEvent extends Event {
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
+public interface IPipelineFailedEvent extends Event {
Exception getException();
+ ExecuteResponseData getResult();
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageCompletedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageCompletedEvent.java
index 06457111d..ef61fe16a 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageCompletedEvent.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageCompletedEvent.java
@@ -15,7 +15,9 @@
*/
package io.flamingock.internal.core.event.model;
+import io.flamingock.internal.common.core.response.data.StageResult;
+
public interface IStageCompletedEvent extends Event {
- Object getResult();
+ StageResult getResult();
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageFailedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageFailedEvent.java
index e9db7986d..920dbea5a 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageFailedEvent.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/IStageFailedEvent.java
@@ -15,10 +15,12 @@
*/
package io.flamingock.internal.core.event.model;
-public interface IStageFailedEvent extends Event {
+import io.flamingock.internal.common.core.response.data.StageResult;
+public interface IStageFailedEvent extends Event {
Exception getException();
+ StageResult getResult();
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineCompletedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineCompletedEvent.java
index e2438f1a1..f1b57b2c2 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineCompletedEvent.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineCompletedEvent.java
@@ -15,12 +15,19 @@
*/
package io.flamingock.internal.core.event.model.impl;
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
import io.flamingock.internal.core.event.model.IPipelineCompletedEvent;
public class PipelineCompletedEvent implements IPipelineCompletedEvent {
+ private final ExecuteResponseData result;
- public PipelineCompletedEvent() {
+ public PipelineCompletedEvent(ExecuteResponseData result) {
+ this.result = result;
}
+ @Override
+ public ExecuteResponseData getResult() {
+ return result;
+ }
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineFailedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineFailedEvent.java
index 5dbfe218d..a95a2ca21 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineFailedEvent.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/PipelineFailedEvent.java
@@ -15,18 +15,26 @@
*/
package io.flamingock.internal.core.event.model.impl;
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
import io.flamingock.internal.core.event.model.IPipelineFailedEvent;
public class PipelineFailedEvent implements IPipelineFailedEvent {
private final Exception throwable;
+ private final ExecuteResponseData result;
- public PipelineFailedEvent(Exception throwable) {
+ public PipelineFailedEvent(Exception throwable, ExecuteResponseData result) {
this.throwable = throwable;
+ this.result = result;
}
@Override
public Exception getException() {
return throwable;
}
+
+ @Override
+ public ExecuteResponseData getResult() {
+ return result;
+ }
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageCompletedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageCompletedEvent.java
index 2ac3901cb..913fd232f 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageCompletedEvent.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageCompletedEvent.java
@@ -15,17 +15,19 @@
*/
package io.flamingock.internal.core.event.model.impl;
+import io.flamingock.internal.common.core.response.data.StageResult;
import io.flamingock.internal.core.event.model.IStageCompletedEvent;
public class StageCompletedEvent implements IStageCompletedEvent {
- private final Object result;
+ private final StageResult result;
- public StageCompletedEvent(Object result) {
+ public StageCompletedEvent(StageResult result) {
this.result = result;
}
+
@Override
- public Object getResult() {
+ public StageResult getResult() {
return result;
}
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageFailedEvent.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageFailedEvent.java
index 3df76f851..480f59bca 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageFailedEvent.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/event/model/impl/StageFailedEvent.java
@@ -16,18 +16,26 @@
package io.flamingock.internal.core.event.model.impl;
+import io.flamingock.internal.common.core.response.data.StageResult;
import io.flamingock.internal.core.event.model.IStageFailedEvent;
public class StageFailedEvent implements IStageFailedEvent {
private final Exception throwable;
+ private final StageResult result;
- public StageFailedEvent(Exception throwable) {
+ public StageFailedEvent(Exception throwable, StageResult result) {
this.throwable = throwable;
+ this.result = result;
}
@Override
public Exception getException() {
return throwable;
}
+
+ @Override
+ public StageResult getResult() {
+ return result;
+ }
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java
index 5e5e58e50..52380ad1e 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/AbstractPipelineTraverseOperation.java
@@ -160,7 +160,7 @@ private ExecuteResult execute(LoadedPipeline pipeline) {
// response reflects the pipeline-wide error carried in pipelineLevelError.
result.setStatus(ExecutionStatus.FAILED);
logger.debug("Error executing the process. ABORTED OPERATION", pipelineLevelError);
- eventPublisher.publish(new PipelineFailedEvent(toException(pipelineLevelError)));
+ eventPublisher.publish(new PipelineFailedEvent(toException(pipelineLevelError), result));
if (throwPipelineLevelError) {
throw ExecuteOperationException.fromExisting(pipelineLevelError, result);
}
@@ -168,17 +168,12 @@ private ExecuteResult execute(LoadedPipeline pipeline) {
}
if (hasAnyFailedStage(pipelineRun)) {
- logger.info("Flamingock execution finished with stage failures [duration={}ms applied={} skipped={} failed={}]",
- result.getTotalDurationMs(), result.getAppliedChanges(), result.getSkippedChanges(), result.getFailedChanges());
StagedExecuteOperationException stagedException = new StagedExecuteOperationException(result);
- eventPublisher.publish(new PipelineFailedEvent(stagedException));
+ eventPublisher.publish(new PipelineFailedEvent(stagedException, result));
throw stagedException;
}
- logger.info("Flamingock execution completed [duration={}ms applied={} skipped={}]",
- result.getTotalDurationMs(), result.getAppliedChanges(), result.getSkippedChanges());
-
- eventPublisher.publish(new PipelineCompletedEvent());
+ eventPublisher.publish(new PipelineCompletedEvent(result));
return new ExecuteResult(result);
}
@@ -193,7 +188,7 @@ private void runStage(String executionId, Lock lock, ExecutableStage executableS
pipelineRun.markStageStarted(stageName);
eventPublisher.publish(new StageStartedEvent());
pipelineRun.markStageBlockedFromMI(stageName, miException.getConflictingChanges());
- eventPublisher.publish(new StageFailedEvent(miException));
+ eventPublisher.publish(new StageFailedEvent(miException, pipelineRun.getStageRun(stageName).getResult()));
return;
}
@@ -201,10 +196,10 @@ private void runStage(String executionId, Lock lock, ExecutableStage executableS
startStage(executionId, lock, executableStage, pipelineRun);
} catch (StageExecutionException exception) {
pipelineRun.markStageFailed(stageName, exception);
- eventPublisher.publish(new StageFailedEvent(exception));
+ eventPublisher.publish(new StageFailedEvent(exception, pipelineRun.getStageRun(stageName).getResult()));
} catch (Throwable generalException) {
pipelineRun.markStageFailed(stageName, generalException);
- eventPublisher.publish(new StageFailedEvent(toException(generalException)));
+ eventPublisher.publish(new StageFailedEvent(toException(generalException), pipelineRun.getStageRun(stageName).getResult()));
}
}
@@ -216,7 +211,7 @@ private void startStage(String executionId, Lock lock, ExecutableStage executabl
ExecutionContext executionContext = new ExecutionContext(executionId, orphanExecutionContext.getHostname(), orphanExecutionContext.getMetadata());
StageExecutor.Output executionOutput = stageExecutor.executeStage(executableStage, executionContext, lock);
pipelineRun.markStageCompleted(executableStage.getName(), executionOutput.getResult());
- eventPublisher.publish(new StageCompletedEvent(executionOutput));
+ eventPublisher.publish(new StageCompletedEvent(executionOutput.getResult()));
}
private static boolean hasAnyFailedStage(PipelineRun pipelineRun) {
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/PipelineExecuteOperationException.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/PipelineExecuteOperationException.java
index f3fc8135d..31c7acbe0 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/PipelineExecuteOperationException.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/PipelineExecuteOperationException.java
@@ -16,6 +16,7 @@
package io.flamingock.internal.core.operation;
import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
+import io.flamingock.internal.common.core.response.data.ExecutionReportFormatter;
/**
* Thrown when a pipeline-wide error breaks iteration (e.g. {@code LockException}, planner abort,
@@ -31,4 +32,10 @@ public PipelineExecuteOperationException(Throwable cause, ExecuteResponseData re
public PipelineExecuteOperationException(String message, Throwable cause, ExecuteResponseData result) {
super(message, cause, result);
}
+
+ // Rich multi-line report; getMessage() stays as Throwable's default (cause-derived) for log aggregators.
+ @Override
+ public String toString() {
+ return ExecutionReportFormatter.report(getResult());
+ }
}
diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/StagedExecuteOperationException.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/StagedExecuteOperationException.java
index 2a49f0613..b4d8a02ac 100644
--- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/StagedExecuteOperationException.java
+++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/operation/StagedExecuteOperationException.java
@@ -16,39 +16,27 @@
package io.flamingock.internal.core.operation;
import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
-import io.flamingock.internal.common.core.response.data.StageResult;
-
-import java.util.stream.Collectors;
+import io.flamingock.internal.common.core.response.data.ExecutionReportFormatter;
/**
* Thrown when one or more stages ended in a failed state ({@code Failed} or
* {@code BlockedForMI}) but the pipeline iteration completed. The per-stage error details live
* in {@code getResult().getStages()}.
*
- * The exception message is a single-line, log-aggregator-friendly summary built from the
- * carried {@link ExecuteResponseData}: failed stage count + names, change counts, and run
- * duration. Multi-line / per-stage detail rendering is deferred to a future {@code toString()}
- * override and a default event listener (see {@code docs/ERROR_REPORTING_PROPOSAL.md}).
+ *
The {@link #getMessage()} is a single-line, log-aggregator-friendly summary (failed stage
+ * count + names, change counts, run duration, and the IDs of any change requiring manual
+ * intervention). The full multi-line per-stage report is available via {@link #toString()} —
+ * see {@code docs/ERROR_REPORTING_PROPOSAL.md} for why the two intentionally differ.
*/
public class StagedExecuteOperationException extends ExecuteOperationException {
public StagedExecuteOperationException(ExecuteResponseData result) {
- super(buildMessage(result), result);
+ super(ExecutionReportFormatter.summary(result), result);
}
- private static String buildMessage(ExecuteResponseData result) {
- String failedStageNames = result.getStages().stream()
- .filter(s -> s.getState().isFailed())
- .map(StageResult::getStageName)
- .collect(Collectors.joining(", "));
- return String.format(
- "Flamingock execution failed: %d of %d stage(s) failed [%s]; changes applied=%d, failed=%d, skipped=%d; duration=%dms",
- result.getFailedStages(),
- result.getTotalStages(),
- failedStageNames,
- result.getAppliedChanges(),
- result.getFailedChanges(),
- result.getSkippedChanges(),
- result.getTotalDurationMs());
+ // Rich multi-line report; getMessage() stays one-line for log aggregators.
+ @Override
+ public String toString() {
+ return ExecutionReportFormatter.report(getResult());
}
}
diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/configuration/core/EnableDefaultExecutionReportFlagTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/configuration/core/EnableDefaultExecutionReportFlagTest.java
new file mode 100644
index 000000000..01872ec95
--- /dev/null
+++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/configuration/core/EnableDefaultExecutionReportFlagTest.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.internal.core.configuration.core;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class EnableDefaultExecutionReportFlagTest {
+
+ @Test
+ void defaultIsTrue() {
+ CoreConfiguration cfg = new CoreConfiguration();
+ assertTrue(cfg.isEnableDefaultExecutionReport(),
+ "Default execution report listener must be enabled by default — opt-out, not opt-in");
+ }
+
+ @Test
+ void setterTogglesValue() {
+ CoreConfiguration cfg = new CoreConfiguration();
+ cfg.setEnableDefaultExecutionReport(false);
+ assertFalse(cfg.isEnableDefaultExecutionReport());
+ cfg.setEnableDefaultExecutionReport(true);
+ assertTrue(cfg.isEnableDefaultExecutionReport());
+ }
+}
diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListenerTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListenerTest.java
new file mode 100644
index 000000000..eeee1e7a3
--- /dev/null
+++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineCompletedReportListenerTest.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.internal.core.event.listener;
+
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
+import io.flamingock.internal.common.core.response.data.ExecutionStatus;
+import io.flamingock.internal.core.event.model.IPipelineCompletedEvent;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+class DefaultPipelineCompletedReportListenerTest {
+
+ private final DefaultPipelineCompletedReportListener listener = new DefaultPipelineCompletedReportListener();
+
+ @Test
+ void acceptsNormalEventWithoutThrowing() {
+ ExecuteResponseData data = ExecuteResponseData.builder()
+ .status(ExecutionStatus.SUCCESS)
+ .totalStages(1).completedStages(1)
+ .totalDurationMs(50)
+ .build();
+ assertDoesNotThrow(() -> listener.accept(() -> data));
+ }
+
+ @Test
+ void acceptsNullEventWithoutThrowing() {
+ assertDoesNotThrow(() -> listener.accept(null));
+ }
+
+ @Test
+ void acceptsEventWithNullResultWithoutThrowing() {
+ assertDoesNotThrow(() -> listener.accept(() -> null));
+ }
+
+ @Test
+ void swallowsThrowableFromPoisonedEvent() {
+ IPipelineCompletedEvent poisoned = () -> { throw new IllegalStateException("blew up"); };
+ assertDoesNotThrow(() -> listener.accept(poisoned));
+ }
+}
diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListenerTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListenerTest.java
new file mode 100644
index 000000000..006743df2
--- /dev/null
+++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/event/listener/DefaultPipelineFailedReportListenerTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.internal.core.event.listener;
+
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
+import io.flamingock.internal.common.core.response.data.ExecutionStatus;
+import io.flamingock.internal.core.event.model.IPipelineFailedEvent;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+class DefaultPipelineFailedReportListenerTest {
+
+ private final DefaultPipelineFailedReportListener listener = new DefaultPipelineFailedReportListener();
+
+ @Test
+ void acceptsNormalEventWithoutThrowing() {
+ ExecuteResponseData data = ExecuteResponseData.builder()
+ .status(ExecutionStatus.FAILED)
+ .totalStages(1).failedStages(1)
+ .totalDurationMs(50)
+ .build();
+ Exception cause = new RuntimeException("boom");
+ assertDoesNotThrow(() -> listener.accept(event(cause, data)));
+ }
+
+ @Test
+ void acceptsNullEventWithoutThrowing() {
+ assertDoesNotThrow(() -> listener.accept(null));
+ }
+
+ @Test
+ void acceptsEventWithNullResultWithoutThrowing() {
+ assertDoesNotThrow(() -> listener.accept(event(new RuntimeException("boom"), null)));
+ }
+
+ @Test
+ void swallowsThrowableFromPoisonedEvent() {
+ IPipelineFailedEvent poisoned = new IPipelineFailedEvent() {
+ @Override
+ public Exception getException() {
+ throw new IllegalStateException("getException blew up");
+ }
+
+ @Override
+ public ExecuteResponseData getResult() {
+ throw new IllegalStateException("getResult blew up");
+ }
+ };
+ // Contract: defensive — must never propagate.
+ assertDoesNotThrow(() -> listener.accept(poisoned));
+ }
+
+ private static IPipelineFailedEvent event(Exception cause, ExecuteResponseData result) {
+ return new IPipelineFailedEvent() {
+ @Override
+ public Exception getException() {
+ return cause;
+ }
+
+ @Override
+ public ExecuteResponseData getResult() {
+ return result;
+ }
+ };
+ }
+}
diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/StagedExecuteOperationExceptionTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/StagedExecuteOperationExceptionTest.java
new file mode 100644
index 000000000..dc87dac36
--- /dev/null
+++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/operation/StagedExecuteOperationExceptionTest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.internal.core.operation;
+
+import io.flamingock.internal.common.core.recovery.RecoveryIssue;
+import io.flamingock.internal.common.core.response.data.ErrorInfo;
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
+import io.flamingock.internal.common.core.response.data.ExecutionStatus;
+import io.flamingock.internal.common.core.response.data.StageResult;
+import io.flamingock.internal.common.core.response.data.StageState;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StagedExecuteOperationExceptionTest {
+
+ @Test
+ void getMessageIsSingleLineAndContainsFailedStageNames() {
+ ExecuteResponseData result = failureResult();
+ StagedExecuteOperationException ex = new StagedExecuteOperationException(result);
+
+ String msg = ex.getMessage();
+ assertFalse(msg.contains(System.lineSeparator()), "getMessage must remain single-line: " + msg);
+ assertTrue(msg.startsWith("Flamingock execution failed:"), msg);
+ assertTrue(msg.contains("bad-stage"), msg);
+ assertTrue(msg.contains("failed=1"), msg);
+ }
+
+ @Test
+ void getMessageAppendsManualInterventionChangeIds() {
+ StageResult blocked = StageResult.builder()
+ .stageId("mi").stageName("mi-stage")
+ .state(StageState.blockedManualIntervention("mi-stage",
+ Arrays.asList(new RecoveryIssue("change-a"), new RecoveryIssue("change-b"))))
+ .build();
+ ExecuteResponseData result = ExecuteResponseData.builder()
+ .status(ExecutionStatus.FAILED)
+ .totalStages(1).failedStages(1)
+ .stages(Collections.singletonList(blocked))
+ .build();
+
+ StagedExecuteOperationException ex = new StagedExecuteOperationException(result);
+ assertTrue(ex.getMessage().contains("manual intervention required for change(s): change-a, change-b"),
+ ex.getMessage());
+ }
+
+ @Test
+ void toStringReturnsMultiLineReport() {
+ ExecuteResponseData result = failureResult();
+ StagedExecuteOperationException ex = new StagedExecuteOperationException(result);
+
+ String rendered = ex.toString();
+ assertTrue(rendered.contains(System.lineSeparator()),
+ "toString must be multi-line: " + rendered);
+ assertTrue(rendered.contains("Flamingock execution report"), rendered);
+ assertTrue(rendered.contains("[FAILED]"), rendered);
+ }
+
+ private static ExecuteResponseData failureResult() {
+ StageResult failed = StageResult.builder()
+ .stageId("bad").stageName("bad-stage")
+ .state(StageState.failed(new ErrorInfo("Boom", "kaboom", Collections.singletonList("c1"), "bad-stage")))
+ .durationMs(70)
+ .build();
+ return ExecuteResponseData.builder()
+ .status(ExecutionStatus.FAILED)
+ .totalStages(1).failedStages(1)
+ .totalChanges(1).failedChanges(1)
+ .totalDurationMs(120)
+ .stages(Collections.singletonList(failed))
+ .build();
+ }
+}
diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/inmemory/InMemoryFlamingockBuilder.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/inmemory/InMemoryFlamingockBuilder.java
index d278c5a01..d945da23f 100644
--- a/core/flamingock-test-support/src/main/java/io/flamingock/support/inmemory/InMemoryFlamingockBuilder.java
+++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/inmemory/InMemoryFlamingockBuilder.java
@@ -47,13 +47,17 @@ public class InMemoryFlamingockBuilder extends CommunityChangeRunnerBuilder {
public static InMemoryFlamingockBuilder create() {
- return new InMemoryFlamingockBuilder(
+ InMemoryFlamingockBuilder builder = new InMemoryFlamingockBuilder(
new CoreConfiguration(),
new CommunityConfiguration(),
new SimpleContext(),
new DefaultPluginManager(),
InMemoryAuditStore.create()
);
+ // Keep unit-test logs clean: opt out of the default FK-Report listener by default.
+ // Tests verifying report content can re-enable via setEnableDefaultExecutionReport(true).
+ builder.setEnableDefaultExecutionReport(false);
+ return builder;
}
private InMemoryFlamingockBuilder(CoreConfiguration coreConfiguration,
diff --git a/docs/ERROR_REPORTING_PROPOSAL.md b/docs/ERROR_REPORTING_PROPOSAL.md
index cbacf6353..f01ce6379 100644
--- a/docs/ERROR_REPORTING_PROPOSAL.md
+++ b/docs/ERROR_REPORTING_PROPOSAL.md
@@ -1,11 +1,11 @@
# Error reporting proposal — rich execution report via event listener
-**Status:** partially implemented.
-- **Step 1 (single-line `getMessage()` summary) — DONE** on `StagedExecuteOperationException`. The message now reads e.g. `"Flamingock execution failed: 1 of 3 stage(s) failed [flamingock-system-stage]; changes applied=3, failed=1, skipped=0; duration=312ms"`. The message-building helper is inlined as a `private static String buildMessage(ExecuteResponseData)` inside the exception class — natural seed to extract into a reusable `ExecutionReportFormatter.summary(...)` when step 2 lands.
-- **Step 2 (`toString()` override with rich multi-line report) — DEFERRED.**
-- **Step 3 (default event listener writing via SLF4J + builder opt-out) — DEFERRED.**
+**Status:** implemented (2026-05-24).
+- **Step 1 (single-line `getMessage()` summary) — DONE.** `StagedExecuteOperationException.getMessage()` delegates to `ExecutionReportFormatter.summary(...)`. MI change IDs are appended when any stage is blocked.
+- **Step 2 (`toString()` override with rich multi-line report) — DONE.** Both `StagedExecuteOperationException` and `PipelineExecuteOperationException` override `toString()` to return `ExecutionReportFormatter.report(...)`.
+- **Step 3 (default event listener writing via SLF4J + builder opt-out) — DONE.** Two separate listeners — `DefaultPipelineCompletedReportListener` (INFO) and `DefaultPipelineFailedReportListener` (ERROR) — both writing under the `FK-Report` logger. Composed with any user listener via `Consumer.andThen` when `enableDefaultExecutionReport(true)` (default). Inline summary `logger.info(...)` calls in `AbstractPipelineTraverseOperation` removed in favor of the listener.
-**Related code:** `core/flamingock-core/.../operation/AbstractPipelineTraverseOperation.java`, `ExecuteOperationException` / `StagedExecuteOperationException` / `PipelineExecuteOperationException`, `core/flamingock-core-commons/.../response/data/ExecuteResponseData.java`, `core/flamingock-core/.../event/`.
+**Related code:** `core/flamingock-core/.../operation/AbstractPipelineTraverseOperation.java`, `ExecuteOperationException` / `StagedExecuteOperationException` / `PipelineExecuteOperationException`, `core/flamingock-core-commons/.../response/data/{ExecuteResponseData,ExecutionReportFormatter}.java`, `core/flamingock-core/.../event/listener/`, `core/flamingock-core/.../builder/AbstractChangeRunnerBuilder.java`, `core/flamingock-core/.../configuration/core/CoreConfiguration.java`. Spring Boot wrappers under `platform-plugins/flamingock-springboot-integration/.../event/` pass `getResult()` through (and the four `SpringStage*Event` classes were re-typed to implement their correct `IStage*Event` interfaces — they were previously cross-wired to `IPipeline*Event`).
## Context
diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java
index 9466dc3c6..eae960204 100644
--- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java
+++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProperties.java
@@ -100,6 +100,16 @@ public void setValidationOnly(boolean validationOnly) {
coreConfiguration.setValidationOnly(validationOnly);
}
+ @Override
+ public void setEnableDefaultExecutionReport(boolean enableDefaultExecutionReport) {
+ coreConfiguration.setEnableDefaultExecutionReport(enableDefaultExecutionReport);
+ }
+
+ @Override
+ public boolean isEnableDefaultExecutionReport() {
+ return coreConfiguration.isEnableDefaultExecutionReport();
+ }
+
@Override
public void setServiceIdentifier(String serviceIdentifier) {
coreConfiguration.setServiceIdentifier(serviceIdentifier);
diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineCompletedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineCompletedEvent.java
index 44403c47d..824d36a4e 100644
--- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineCompletedEvent.java
+++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineCompletedEvent.java
@@ -16,6 +16,7 @@
package io.flamingock.springboot.event;
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
import io.flamingock.internal.core.event.model.IPipelineCompletedEvent;
import org.springframework.context.ApplicationEvent;
@@ -34,6 +35,11 @@ public SpringPipelineCompletedEvent(Object source, IPipelineCompletedEvent event
this.event = event;
}
+ @Override
+ public ExecuteResponseData getResult() {
+ return event.getResult();
+ }
+
@Override
public String toString() {
return "SpringPipelineCompletedEvent{" +
diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineFailedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineFailedEvent.java
index a0545bb1f..e928ac50b 100644
--- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineFailedEvent.java
+++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringPipelineFailedEvent.java
@@ -15,6 +15,7 @@
*/
package io.flamingock.springboot.event;
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
import io.flamingock.internal.core.event.model.IPipelineFailedEvent;
import org.springframework.context.ApplicationEvent;
@@ -37,7 +38,10 @@ public Exception getException() {
return event.getException();
}
-
+ @Override
+ public ExecuteResponseData getResult() {
+ return event.getResult();
+ }
@Override
public String toString() {
diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageCompletedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageCompletedEvent.java
index e2ab02e10..ff6432d61 100644
--- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageCompletedEvent.java
+++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageCompletedEvent.java
@@ -16,11 +16,11 @@
package io.flamingock.springboot.event;
-import io.flamingock.internal.core.event.model.IPipelineCompletedEvent;
+import io.flamingock.internal.common.core.response.data.StageResult;
import io.flamingock.internal.core.event.model.IStageCompletedEvent;
import org.springframework.context.ApplicationEvent;
-public class SpringStageCompletedEvent extends ApplicationEvent implements IPipelineCompletedEvent {
+public class SpringStageCompletedEvent extends ApplicationEvent implements IStageCompletedEvent {
private final IStageCompletedEvent event;
@@ -35,10 +35,14 @@ public SpringStageCompletedEvent(Object source, IStageCompletedEvent event) {
this.event = event;
}
+ @Override
+ public StageResult getResult() {
+ return event.getResult();
+ }
@Override
public String toString() {
- return "SpringPipelineCompletedEvent{" +
+ return "SpringStageCompletedEvent{" +
"event=" + event +
", source=" + source +
'}';
diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageFailedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageFailedEvent.java
index 266c51a01..5d1489092 100644
--- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageFailedEvent.java
+++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageFailedEvent.java
@@ -15,11 +15,11 @@
*/
package io.flamingock.springboot.event;
-import io.flamingock.internal.core.event.model.IPipelineFailedEvent;
+import io.flamingock.internal.common.core.response.data.StageResult;
import io.flamingock.internal.core.event.model.IStageFailedEvent;
import org.springframework.context.ApplicationEvent;
-public class SpringStageFailedEvent extends ApplicationEvent implements IPipelineFailedEvent {
+public class SpringStageFailedEvent extends ApplicationEvent implements IStageFailedEvent {
private final IStageFailedEvent event;
/**
@@ -38,11 +38,14 @@ public Exception getException() {
return event.getException();
}
-
+ @Override
+ public StageResult getResult() {
+ return event.getResult();
+ }
@Override
public String toString() {
- return "SpringPipelineFailedEvent{" +
+ return "SpringStageFailedEvent{" +
"event=" + event +
", source=" + source +
'}';
diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageIgnoredEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageIgnoredEvent.java
index 2a3b163a4..f7dec48c5 100644
--- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageIgnoredEvent.java
+++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageIgnoredEvent.java
@@ -16,11 +16,10 @@
package io.flamingock.springboot.event;
-import io.flamingock.internal.core.event.model.IPipelineIgnoredEvent;
import io.flamingock.internal.core.event.model.IStageIgnoredEvent;
import org.springframework.context.ApplicationEvent;
-public class SpringStageIgnoredEvent extends ApplicationEvent implements IPipelineIgnoredEvent {
+public class SpringStageIgnoredEvent extends ApplicationEvent implements IStageIgnoredEvent {
private final IStageIgnoredEvent event;
@@ -38,7 +37,7 @@ public SpringStageIgnoredEvent(Object source, IStageIgnoredEvent event) {
@Override
public String toString() {
- return "SpringPipelineIgnoredEvent{" +
+ return "SpringStageIgnoredEvent{" +
"event=" + event +
", source=" + source +
'}';
diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageStartedEvent.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageStartedEvent.java
index 7826cc4ab..7c60e6d7a 100644
--- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageStartedEvent.java
+++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/event/SpringStageStartedEvent.java
@@ -15,11 +15,10 @@
*/
package io.flamingock.springboot.event;
-import io.flamingock.internal.core.event.model.IPipelineStartedEvent;
import io.flamingock.internal.core.event.model.IStageStartedEvent;
import org.springframework.context.ApplicationEvent;
-public class SpringStageStartedEvent extends ApplicationEvent implements IPipelineStartedEvent {
+public class SpringStageStartedEvent extends ApplicationEvent implements IStageStartedEvent {
private final IStageStartedEvent event;
@@ -37,7 +36,7 @@ public SpringStageStartedEvent(Object source, IStageStartedEvent event) {
@Override
public String toString() {
- return "SpringPipelineStartedEvent{" +
+ return "SpringStageStartedEvent{" +
"event=" + event +
", source=" + source +
'}';
diff --git a/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/event/SpringEventPayloadPassthroughTest.java b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/event/SpringEventPayloadPassthroughTest.java
new file mode 100644
index 000000000..b229d4d95
--- /dev/null
+++ b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/event/SpringEventPayloadPassthroughTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2026 Flamingock (https://www.flamingock.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.flamingock.springboot.event;
+
+import io.flamingock.internal.common.core.response.data.ExecuteResponseData;
+import io.flamingock.internal.common.core.response.data.ExecutionStatus;
+import io.flamingock.internal.common.core.response.data.StageResult;
+import io.flamingock.internal.common.core.response.data.StageState;
+import io.flamingock.internal.core.event.model.IPipelineCompletedEvent;
+import io.flamingock.internal.core.event.model.IPipelineFailedEvent;
+import io.flamingock.internal.core.event.model.IStageCompletedEvent;
+import io.flamingock.internal.core.event.model.IStageFailedEvent;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+/**
+ * Verifies that the Spring {@code ApplicationEvent} wrappers expose the typed
+ * {@code ExecuteResponseData} / {@code StageResult} payloads from the wrapped core event, so
+ * Spring user code can render the canonical execution report without downcasting.
+ */
+class SpringEventPayloadPassthroughTest {
+
+ private final Object source = new Object();
+
+ @Test
+ void pipelineCompletedWrapperExposesResult() {
+ ExecuteResponseData data = ExecuteResponseData.builder()
+ .status(ExecutionStatus.SUCCESS).build();
+ IPipelineCompletedEvent core = () -> data;
+ SpringPipelineCompletedEvent wrapper = new SpringPipelineCompletedEvent(source, core);
+ assertSame(data, wrapper.getResult());
+ }
+
+ @Test
+ void pipelineFailedWrapperExposesResultAndException() {
+ ExecuteResponseData data = ExecuteResponseData.builder()
+ .status(ExecutionStatus.FAILED).build();
+ Exception cause = new RuntimeException("boom");
+ IPipelineFailedEvent core = new IPipelineFailedEvent() {
+ @Override public Exception getException() { return cause; }
+ @Override public ExecuteResponseData getResult() { return data; }
+ };
+ SpringPipelineFailedEvent wrapper = new SpringPipelineFailedEvent(source, core);
+ assertSame(data, wrapper.getResult());
+ assertSame(cause, wrapper.getException());
+ }
+
+ @Test
+ void stageCompletedWrapperExposesResult() {
+ StageResult stage = StageResult.builder()
+ .stageId("s").stageName("s").state(StageState.COMPLETED).build();
+ IStageCompletedEvent core = () -> stage;
+ SpringStageCompletedEvent wrapper = new SpringStageCompletedEvent(source, core);
+ assertSame(stage, wrapper.getResult());
+ }
+
+ @Test
+ void stageFailedWrapperExposesResultAndException() {
+ StageResult stage = StageResult.builder()
+ .stageId("s").stageName("s")
+ .state(StageState.failed(null)).build();
+ Exception cause = new RuntimeException("boom");
+ IStageFailedEvent core = new IStageFailedEvent() {
+ @Override public Exception getException() { return cause; }
+ @Override public StageResult getResult() { return stage; }
+ };
+ SpringStageFailedEvent wrapper = new SpringStageFailedEvent(source, core);
+ assertSame(stage, wrapper.getResult());
+ assertSame(cause, wrapper.getException());
+ }
+}
diff --git a/utils/test-util/src/main/java/io/flamingock/core/kit/TestKit.java b/utils/test-util/src/main/java/io/flamingock/core/kit/TestKit.java
index b807bf620..6b7ba85de 100644
--- a/utils/test-util/src/main/java/io/flamingock/core/kit/TestKit.java
+++ b/utils/test-util/src/main/java/io/flamingock/core/kit/TestKit.java
@@ -70,12 +70,16 @@ public interface TestKit {
default TestFlamingockBuilder createBuilderWithAuditStore(CommunityAuditStore auditStore) {
- return new TestFlamingockBuilder(
+ TestFlamingockBuilder builder = new TestFlamingockBuilder(
new CoreConfiguration(),
new CommunityConfiguration(),
new SimpleContext(),
new DefaultPluginManager(),
auditStore
);
+ // Keep CI logs clean: opt out of the default FK-Report listener by default.
+ // Tests verifying report content can re-enable via setEnableDefaultExecutionReport(true).
+ builder.setEnableDefaultExecutionReport(false);
+ return builder;
}
}