You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by ah...@apache.org on 2026/05/12 11:11:22 UTC

(causeway) branch main updated: CAUSEWAY-3998: replay refinement (forward porting)

This is an automated email from the ASF dual-hosted git repository.

andi-huber pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/causeway.git


The following commit(s) were added to refs/heads/main by this push:
     new 4341a4e6286 CAUSEWAY-3998: replay refinement (forward porting)
4341a4e6286 is described below

commit 4341a4e6286ce47a4d9f0b4c967af7d24474542f
Author: andi-huber <ah...@apache.org>
AuthorDate: Tue May 12 13:11:12 2026 +0200

    CAUSEWAY-3998: replay refinement (forward porting)
---
 .../applib/CausewayModuleExtCommandLogApplib.java  |   4 +-
 .../applib/dom/replay/CommandExportManager.java    |  36 +++---
 .../CommandExportManager.layout.fallback.xml       |   6 +-
 .../applib/dom/replay/CommandReplayManager.java    | 124 +++++++++++++++------
 .../CommandReplayManager.layout.fallback.xml       |   6 +-
 .../applib/dom/replay/ReplayableCommand.java       |  25 +++++
 .../replay/ReplayableCommand.layout.fallback.xml   |   1 +
 7 files changed, 142 insertions(+), 60 deletions(-)

diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
index 8cdf6f7877e..76435d8babd 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
@@ -72,12 +72,12 @@
         ReplayableCommand_replayOrRetry.class,
         ReplayableCommand_excludeFromReplay.class,
         ReplayableCommand_delete.class,
-        CommandExportManager.changeSince.class,
+        CommandExportManager.changeBaseline.class,
         CommandExportManager.previousHour.class,
         CommandExportManager.nextHour.class,
         CommandExportManager.exportSelected.class,
         CommandExportManager.makeSelectedExportable.class,
-        CommandReplayManager.changeSince.class,
+        CommandReplayManager.changeBaseline.class,
         CommandExportManager.previousHour.class,
         CommandReplayManager.nextHour.class,
         CommandReplayManager.importCommands.class,
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
index 9b9e8a30b90..275aee2c82a 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
@@ -59,8 +59,8 @@
 @Named(CommandExportManager.LOGICAL_TYPE_NAME)
 public record CommandExportManager(
         @Property
-        @PropertyLayout(describedAs = "Only commands since this timestamp are available for export")
-        java.sql.Timestamp since,
+        @PropertyLayout(describedAs = "Only commands after this timestamp are available for export")
+        java.sql.Timestamp baseline,
         ReplayContext replayContext) implements ViewModel {
 
 	public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandExportManager";
@@ -86,14 +86,14 @@ public CommandExportManager(
             domainEvent = previousHour.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            associateWith = "since", sequence = "1",
+            associateWith = "baseline", sequence = "1",
             named = "Previous",
             position = ActionLayout.Position.PANEL,
             describedAs = "Move back one hour")
     public class previousHour {
         public class DomainEvent extends ActionDomainEvent<previousHour> { }
         @MemberSupport public CommandExportManager act() {
-            return new CommandExportManager(addSeconds(since, -3600), replayContext);
+            return new CommandExportManager(addSeconds(baseline, -3600), replayContext);
         }
     }
 
@@ -103,14 +103,14 @@ public class DomainEvent extends ActionDomainEvent<previousHour> { }
             domainEvent = nextHour.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            associateWith = "since", sequence = "3",
+            associateWith = "baseline", sequence = "3",
             named = "Next",
             position = ActionLayout.Position.PANEL,
             describedAs = "Move forward one hour")
     public class nextHour {
         public class DomainEvent extends ActionDomainEvent<nextHour> { }
         @MemberSupport public CommandExportManager act() {
-            return new CommandExportManager(addSeconds(since, +3600), replayContext);
+            return new CommandExportManager(addSeconds(baseline, +3600), replayContext);
         }
     }
 
@@ -118,19 +118,19 @@ public class DomainEvent extends ActionDomainEvent<nextHour> { }
             restrictTo = RestrictTo.PROTOTYPING,
             semantics = SemanticsOf.SAFE,
             commandPublishing = Publishing.DISABLED,
-            domainEvent = changeSince.DomainEvent.class,
+            domainEvent = changeBaseline.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            associateWith = "since", sequence = "2",
+            associateWith = "baseline", sequence = "2",
             named = "Change",
             position = ActionLayout.Position.PANEL)
-    public class changeSince {
+    public class changeBaseline {
         public class DomainEvent extends ActionDomainEvent<nextHour> { }
-        @MemberSupport public CommandExportManager act(final java.sql.Timestamp since) {
-            return new CommandExportManager(since, replayContext);
+        @MemberSupport public CommandExportManager act(final java.sql.Timestamp baseline) {
+            return new CommandExportManager(baseline, replayContext);
         }
-        @MemberSupport public java.sql.Timestamp defaultSince() {
-            return CommandExportManager.this.since;
+        @MemberSupport public java.sql.Timestamp defaultBaseline() {
+            return CommandExportManager.this.baseline;
         }
     }
 
@@ -140,7 +140,7 @@ public class DomainEvent extends ActionDomainEvent<nextHour> { }
     @CollectionLayout(
             describedAs = "Commands that can be exported")
     public List<ReplayableCommand> getNotYetExported() {
-        return commandLogEntryRepository().findForegroundSinceTimestampAndCanBeExported(since).stream()
+        return commandLogEntryRepository().findForegroundSinceTimestampAndCanBeExported(baseline).stream()
             .map(entry->new ReplayableCommand(
                     entry.getInteractionId(),
                     replayContext))
@@ -231,7 +231,7 @@ public class DomainEvent extends ActionDomainEvent<exportSelected> { }
     @Collection
     @CollectionLayout(describedAs = "Commands that have been exported")
     public List<ReplayableCommand> getExported() {
-        return commandLogEntryRepository().findForegroundSinceTimestampAndHasBeenExported(since).stream()
+        return commandLogEntryRepository().findForegroundSinceTimestampAndHasBeenExported(baseline).stream()
             .map(entry->new ReplayableCommand(
                     entry.getInteractionId(),
                     replayContext))
@@ -271,7 +271,7 @@ public class DomainEvent extends ActionDomainEvent<makeSelectedExportable> { }
 
     @Override
     public String viewModelMemento() {
-        return TimestampMarshallUtil.toString(this.since);
+        return TimestampMarshallUtil.toString(this.baseline);
     }
 
     // -- HELPER
@@ -280,7 +280,7 @@ private CommandLogEntryRepository commandLogEntryRepository() {
         return replayContext.commandLogEntryRepository();
     }
 
-    private static Timestamp addSeconds(final Timestamp since, final int secondsToAdd) {
-        return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd));
+    private static Timestamp addSeconds(final Timestamp ts, final int secondsToAdd) {
+        return Timestamp.from(ts.toInstant().plusSeconds(secondsToAdd));
     }
 }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml
index ba0d2da0ef7..272902d81be 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml
@@ -4,11 +4,11 @@
             <cpt:domainObject/>
         </bs:col>
         <bs:col span="3">
-            <cpt:fieldSet name=" " id="since">
+            <cpt:fieldSet name=" " id="baseline">
                 <cpt:action id="previousHour"/>
-                <cpt:action id="changeSince"/>
+                <cpt:action id="changeBaseline"/>
                 <cpt:action id="nextHour"/>
-                <cpt:property id="since"/>
+                <cpt:property id="baseline"/>
             </cpt:fieldSet>
         </bs:col>
         <bs:col span="3">
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
index e6522bf1d54..20834510232 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
@@ -20,7 +20,9 @@
 
 import java.sql.Timestamp;
 import java.util.List;
+import java.util.Optional;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import jakarta.inject.Inject;
 import jakarta.inject.Named;
@@ -36,11 +38,13 @@
 import org.apache.causeway.applib.annotation.MemberSupport;
 import org.apache.causeway.applib.annotation.ObjectSupport;
 import org.apache.causeway.applib.annotation.Parameter;
+import org.apache.causeway.applib.annotation.ParameterLayout;
 import org.apache.causeway.applib.annotation.Property;
 import org.apache.causeway.applib.annotation.PropertyLayout;
 import org.apache.causeway.applib.annotation.Publishing;
 import org.apache.causeway.applib.annotation.RestrictTo;
 import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.jaxb.JavaSqlXMLGregorianCalendarMarshalling;
 import org.apache.causeway.applib.util.schema.CommandDtoUtils;
 import org.apache.causeway.applib.value.Blob;
 import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
@@ -54,8 +58,8 @@
 @Named(CommandReplayManager.LOGICAL_TYPE_NAME)
 public record CommandReplayManager(
         @Property
-        @PropertyLayout(describedAs = "Only commands since this timestamp are available for export")
-        java.sql.Timestamp since,
+        @PropertyLayout(describedAs = "Only commands after this baseline are listed for replay")
+        java.sql.Timestamp baseline,
         ReplayContext replayContext) implements ViewModel {
 
     public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandReplayManager";
@@ -81,14 +85,14 @@ public CommandReplayManager(
             domainEvent = previousHour.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            associateWith = "since", sequence = "1",
+            associateWith = "baseline", sequence = "1",
             named = "Previous",
             position = ActionLayout.Position.PANEL,
             describedAs = "Move back one hour")
     public class previousHour {
         public class DomainEvent extends ActionDomainEvent<previousHour> { }
         @MemberSupport public CommandReplayManager act() {
-            return new CommandReplayManager(addSeconds(since, -3600), replayContext);
+            return new CommandReplayManager(addSeconds(baseline, -3600), replayContext);
         }
     }
 
@@ -98,14 +102,14 @@ public class DomainEvent extends ActionDomainEvent<previousHour> { }
             domainEvent = nextHour.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            associateWith = "since", sequence = "3",
+            associateWith = "baseline", sequence = "3",
             named = "Next",
             position = ActionLayout.Position.PANEL,
             describedAs = "Move forward one hour")
     public class nextHour {
         public class DomainEvent extends ActionDomainEvent<nextHour> { }
         @MemberSupport public CommandReplayManager act() {
-            return new CommandReplayManager(addSeconds(since, +3600), replayContext);
+            return new CommandReplayManager(addSeconds(baseline, +3600), replayContext);
         }
     }
 
@@ -113,21 +117,21 @@ public class DomainEvent extends ActionDomainEvent<nextHour> { }
             restrictTo = RestrictTo.PROTOTYPING,
             semantics = SemanticsOf.SAFE,
             commandPublishing = Publishing.DISABLED,
-            domainEvent = changeSince.DomainEvent.class,
+            domainEvent = changeBaseline.DomainEvent.class,
             executionPublishing = Publishing.DISABLED
     )
     @ActionLayout(
-            associateWith = "since", sequence = "2",
+            associateWith = "baseline", sequence = "2",
             named = "Change",
             position = ActionLayout.Position.PANEL
     )
-    public class changeSince {
+    public class changeBaseline {
         public class DomainEvent extends ActionDomainEvent<nextHour> { }
         @MemberSupport public CommandReplayManager act(final java.sql.Timestamp since) {
             return new CommandReplayManager(since, replayContext);
         }
-        @MemberSupport public java.sql.Timestamp defaultSince() {
-            return CommandReplayManager.this.since;
+        @MemberSupport public java.sql.Timestamp defaultBaseline() {
+            return CommandReplayManager.this.baseline;
         }
     }
 
@@ -138,21 +142,39 @@ public class DomainEvent extends ActionDomainEvent<nextHour> { }
             domainEvent = importCommands.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            sequence = "0.1",
+            sequence = "1.1",
             associateWith = "pendingOrFailed",
-            cssClass = "btn-primary",
+            cssClass = "btn-secondary",
             describedAs = "Imports commands from yaml format, then persists them with replayState=PENDING.")
     public class importCommands {
         public class DomainEvent extends ActionDomainEvent<importCommands> { }
-        public CommandReplayManager act(
+        @MemberSupport public CommandReplayManager act(
                 @Parameter(fileAccept = ".yml,.yaml")
-                final Blob commandsYaml) {
+                final Blob commandsYaml,
+                @ParameterLayout(describedAs = "Change the baseline to the timestamp of the oldest, so that they are listed at top")
+                final boolean moveBaselineToOldest) {
             var yamlDs = commandsYaml.asDataSource();
 
             final List<CommandDto> commandDtos = CommandDtoUtils.fromYaml(yamlDs);
             commandDtos.forEach(commandLogEntryRepository()::saveForReplay);
 
-            return CommandReplayManager.this;
+            return moveBaselineToOldest
+                ? oldestTimestamp(commandDtos)
+                    .map(timestamp -> new CommandReplayManager(timestamp, replayContext))
+                    .orElse(CommandReplayManager.this)
+                : CommandReplayManager.this;
+        }
+
+        @MemberSupport public boolean defaultMoveBaselineToOldest() {
+            return true;
+        }
+
+        private Optional<Timestamp> oldestTimestamp(final List<CommandDto> commandDtos) {
+            return commandDtos.stream()
+                .map(CommandDto::getTimestamp)
+                .map(JavaSqlXMLGregorianCalendarMarshalling::toTimestamp)
+                .sorted()
+                .findFirst();
         }
     }
 
@@ -160,16 +182,10 @@ public CommandReplayManager act(
 
     @Collection
     @CollectionLayout(
-            describedAs = "Imported Commands that can be either replayed (replayState=PENDING) "
-                    + "or retried (when replayState=FAILED)")
+            describedAs = "Imported Commands that can be either replayed (if PENDING) or retried (if FAILED)")
     public List<ReplayableCommand> getPendingOrFailed() {
-        return commandLogEntryRepository()
-            .findForegroundSinceTimestampAndWithReplayPendingOrFailed(since)
-            .stream()
-            .map(entry->new ReplayableCommand(
-                    entry.getInteractionId(),
-                    replayContext))
-            .collect(Collectors.toList());
+        return streamPendingOrFailed()
+            .toList();
     }
 
     @Action(
@@ -180,9 +196,9 @@ public List<ReplayableCommand> getPendingOrFailed() {
             domainEvent = replayOrRetrySelected.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            associateWith = "pendingOrFailed", sequence = "1.1",
-            cssClassFa = "solid circle-play",
+            associateWith = "pendingOrFailed", sequence = "1.2",
             cssClass = "btn-primary",
+            cssClassFa = "solid circle-play",
             describedAs = "Executes the list of commands in sequence, after having sorted them by their timestamp. "
                     + "If any of the given commands fails, "
                     + "its surrounding transaction is rolled back, but any successful commands so far are marked OK). "
@@ -208,6 +224,34 @@ public class DomainEvent extends ActionDomainEvent<replayOrRetrySelected> { }
         }
     }
 
+    @Action(
+            restrictTo = RestrictTo.PROTOTYPING,
+            choicesFrom = "pendingOrFailed",
+            semantics = SemanticsOf.NON_IDEMPOTENT,
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = replayOrRetrySelected.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED
+    )
+    @ActionLayout(
+            associateWith = "pendingOrFailed", sequence = "1.3",
+            cssClassFa = "solid circle-play",
+            cssClass = "btn-primary",
+            describedAs = "Executes the oldest command.")
+    public class replayOrRetryNext {
+        public class DomainEvent extends ActionDomainEvent<replayOrRetrySelected> { }
+        @MemberSupport public CommandReplayManager act() {
+            var nextIfAny = streamPendingOrFailed().findFirst();
+            // should always be present, due to our guard
+            nextIfAny.ifPresent(ReplayableCommand::tryReplayOrRetry);
+            return CommandReplayManager.this;
+        }
+
+        @MemberSupport
+        public String disableAct() {
+            return sizePendingOrFailed() == 0 ? "No commands in collection" : null;
+        }
+    }
+
     @Action(
             restrictTo = RestrictTo.PROTOTYPING,
             choicesFrom = "pendingOrFailed",
@@ -216,7 +260,8 @@ public class DomainEvent extends ActionDomainEvent<replayOrRetrySelected> { }
             domainEvent = excludeSelectedFromReplay.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            associateWith = "pendingOrFailed", sequence = "1.2",
+            associateWith = "pendingOrFailed", sequence = "1.4",
+            cssClass = "btn-secondary",
             describedAs = "Marks selected Commands to be EXCLUDED from replay")
     public class excludeSelectedFromReplay {
         public class DomainEvent extends ActionDomainEvent<excludeSelectedFromReplay> {}
@@ -226,7 +271,7 @@ public class DomainEvent extends ActionDomainEvent<excludeSelectedFromReplay> {}
             return CommandReplayManager.this;
         }
         @MemberSupport public String disableAct() {
-            return getPendingOrFailed().isEmpty() ? "No commands in collection" : null;
+            return sizePendingOrFailed() == 0  ? "No commands in collection" : null;
         }
         @MemberSupport public String validateSelected(final List<ReplayableCommand> selected) {
             return selected != null
@@ -244,7 +289,8 @@ public class DomainEvent extends ActionDomainEvent<excludeSelectedFromReplay> {}
             domainEvent = deleteSelectedPendingOrFailed.DomainEvent.class,
             executionPublishing = Publishing.DISABLED)
     @ActionLayout(
-            associateWith = "pendingOrFailed", sequence = "1.3",
+            associateWith = "pendingOrFailed", sequence = "1.4",
+            cssClass = "btn-danger",
             named = "Delete Selected",
             describedAs = "Deletes selected Commands (cannot be undone)")
     public class deleteSelectedPendingOrFailed {
@@ -272,7 +318,7 @@ public class DomainEvent extends ActionDomainEvent<deleteSelectedPendingOrFailed
             describedAs = "Imported Commands that were either replayed with success (replayState=OK) "
                     + "or marked to be excluded from replay (replayState=EXCLUDE)")
     public List<ReplayableCommand> getSucceededOrExcluded() {
-        return commandLogEntryRepository().findSinceAndWithReplayOkOrExcluded(since).stream()
+        return commandLogEntryRepository().findSinceAndWithReplayOkOrExcluded(baseline).stream()
             .map(entry->new ReplayableCommand(
                     entry.getInteractionId(),
                     replayContext))
@@ -311,7 +357,7 @@ public class DomainEvent extends ActionDomainEvent<deleteSelectedSucceededOrExcl
 
     @Override
     public String viewModelMemento() {
-        return TimestampMarshallUtil.toString(this.since);
+        return TimestampMarshallUtil.toString(this.baseline);
     }
 
     // -- HELPER
@@ -320,7 +366,17 @@ private CommandLogEntryRepository commandLogEntryRepository() {
         return replayContext.commandLogEntryRepository();
     }
 
-    private static Timestamp addSeconds(final Timestamp since, final int secondsToAdd) {
-        return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd));
+    private static Timestamp addSeconds(final Timestamp ts, final int secondsToAdd) {
+        return Timestamp.from(ts.toInstant().plusSeconds(secondsToAdd));
+    }
+
+    private Stream<ReplayableCommand> streamPendingOrFailed() {
+        return commandLogEntryRepository()
+            .findForegroundSinceTimestampAndWithReplayPendingOrFailed(baseline).stream()
+            .map(entry -> new ReplayableCommand(entry.getInteractionId(), replayContext));
+    }
+
+    private long sizePendingOrFailed() {
+        return streamPendingOrFailed().count();
     }
 }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml
index c7aed354e9f..f10b0445aa8 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml
@@ -4,11 +4,11 @@
             <cpt:domainObject/>
         </bs:col>
         <bs:col span="3">
-            <cpt:fieldSet name=" " id="since">
+            <cpt:fieldSet name=" " id="baseline">
                 <cpt:action id="previousHour"/>
-                <cpt:action id="changeSince"/>
+                <cpt:action id="changeBaseline"/>
                 <cpt:action id="nextHour"/>
-                <cpt:property id="since"/>
+                <cpt:property id="baseline"/>
             </cpt:fieldSet>
         </bs:col>
         <bs:col span="3">
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
index 04e3782ea37..82e249c5948 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
@@ -31,18 +31,23 @@
 import org.springframework.transaction.annotation.Propagation;
 
 import org.apache.causeway.applib.ViewModel;
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
 import org.apache.causeway.applib.annotation.DomainObject;
 import org.apache.causeway.applib.annotation.DomainObjectLayout;
 import org.apache.causeway.applib.annotation.Introspection;
 import org.apache.causeway.applib.annotation.LabelPosition;
+import org.apache.causeway.applib.annotation.MemberSupport;
 import org.apache.causeway.applib.annotation.ObjectSupport;
 import org.apache.causeway.applib.annotation.Programmatic;
 import org.apache.causeway.applib.annotation.Property;
 import org.apache.causeway.applib.annotation.PropertyLayout;
+import org.apache.causeway.applib.annotation.SemanticsOf;
 import org.apache.causeway.applib.annotation.Where;
 import org.apache.causeway.applib.fa.FontAwesomeLayers;
 import org.apache.causeway.applib.jaxb.JavaTimeXMLGregorianCalendarMarshalling;
 import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.bookmark.BookmarkService;
 import org.apache.causeway.applib.services.command.CommandExecutorService.InteractionContextPolicy;
 import org.apache.causeway.commons.functional.Try;
 import org.apache.causeway.commons.internal.base._Refs.ObjectReference;
@@ -193,6 +198,26 @@ public String getTargetId() {
             .orElse(null);
     }
 
+    @Action(semantics = SemanticsOf.SAFE)
+    @ActionLayout(
+            cssClassFa = "fa-bullseye"
+    )
+    public class openTarget {
+        @Inject BookmarkService bookmarkService;
+
+        @MemberSupport public Object act() {
+            return commandLogEntry()
+                .map(CommandLogEntry::getTarget)
+                .flatMap(bookmark -> bookmarkService.lookup(bookmark))
+                .orElse(null);
+        }
+        @MemberSupport public String disableAct() {
+            return commandLogEntry().isPresent()
+                ? null
+                : "Unknown target";
+        }
+    }
+
     @Property
     @PropertyLayout(
             sequence = "3.1",
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
index 331644bba1a..4b3f8a8658d 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
@@ -37,6 +37,7 @@
                 </bs:tab>
             </bs:tabGroup>
             <cpt:fieldSet name="Target" id="target">
+                <cpt:action id="openTarget" position="PANEL"/>
                 <cpt:property id="targetType"/>
                 <cpt:property id="targetId"/>
                 <cpt:property id="member"/>