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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new a5667912eff8 CAMEL-26773: camel-chaos - the journal of an exploration: 
a budget it enforces, the baseline, and the rules tried (#29466)
a5667912eff8 is described below

commit a5667912eff88cdefb1f92362804740fbd62d62d
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 9 14:04:41 2026 +0100

    CAMEL-26773: camel-chaos - the journal of an exploration: a budget it 
enforces, the baseline, and the rules tried (#29466)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/chaos.adoc       |  31 ++++
 components/camel-chaos/src/main/docs/chaos.adoc    |  31 ++++
 .../camel/component/chaos/ChaosExploration.java    | 187 +++++++++++++++++++
 .../apache/camel/component/chaos/ChaosManager.java | 143 ++++++++++++++-
 .../apache/camel/component/chaos/ChaosReport.java  |  15 ++
 .../component/chaos/ChaosExplorationTest.java      | 204 +++++++++++++++++++++
 .../apache/camel/cli/connector/ChaosAction.java    |  13 +-
 .../LocalCliConnectorChaosActionTest.java          |  26 +++
 .../dsl/jbang/core/commands/process/ListChaos.java |  44 +++++
 .../jbang/core/commands/process/ListChaosTest.java |  25 +++
 10 files changed, 715 insertions(+), 4 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
index 4db9c739828f..4d7c00bb6874 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
@@ -328,6 +328,37 @@ adds or changes a rule, or loads a scenario. The 
development MCP tools that `cam
 serves over HTTP have only the list and the report (not the check either: the 
integration parses the rule, and
 `exception=` loads a class; nor what to try or a scenario).
 
+=== An exploration
+
+An agent that tries chaos rules on the integration by itself, for a time it 
was given, runs an exploration. camel-chaos
+keeps its journal, so the report, the CLI and the TUI read it, and it survives 
the agent losing its context. And
+camel-chaos enforces the time, whatever the agent does:
+
+* One exploration runs at a time, with a budget of 10 minutes by default, and 
at most 2 hours.
+* While it runs, a rule ends at the deadline at the latest, and a rule that 
would start after it (`from=`) is refused.
+* At the deadline the rules are removed and the exploration is finished. This 
is checked whenever chaos is used: a
+rule added, or the report read, which the status of the application does every 
second.
+* Chaos counts the exchanges from the start of the exploration, and its first 
rule takes the baseline: the counts per
+route of the run without faults before it, so the failures that were there 
already are not blamed on chaos.
+
+The `chaos` action of the CLI connector starts it (`{"action": "chaos", 
"command": "explore-start", "budget": "10m",
+"focus": "orders"}`) and finishes it before the deadline (`explore-finish`), 
as `ChaosManager.startExploration(budget,
+focus)` and `finishExploration()` do in Java. The report has its journal as 
`exploration`: `running`, `startedAt`,
+`budget`, `deadline`, `timeLeft` while it runs, `focus`, the `rules` added 
while it ran, the `baseline` (per `route` its
+`completed`, `failed`, `failedKeptByConsumer`, `handled` and `redeliveries`) 
and `baselineAt`, and once finished
+`finishedAt`, `finishedBy` (`finished` or `deadline`) and the number of rules 
`removed`. It stays in the report after it
+finished, until the next one starts or the report is reset. A scenario loaded 
while it runs starts the counts again,
+and the baseline is zero then (`baselineReset`).
+
+[source,text]
+----
+$ camel get chaos --report
+...
+  exploration: running, 6m12s left of 10m0s, focus: orders
+    rules tried: r1, r2
+    baseline (before the first rule): orders 30 completed, 0 failed
+----
+
 == Scenarios
 
 A scenario is the rules of an experiment in a file of the project, so it can 
be run again, shared and kept with the
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc 
b/components/camel-chaos/src/main/docs/chaos.adoc
index 4db9c739828f..4d7c00bb6874 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -328,6 +328,37 @@ adds or changes a rule, or loads a scenario. The 
development MCP tools that `cam
 serves over HTTP have only the list and the report (not the check either: the 
integration parses the rule, and
 `exception=` loads a class; nor what to try or a scenario).
 
+=== An exploration
+
+An agent that tries chaos rules on the integration by itself, for a time it 
was given, runs an exploration. camel-chaos
+keeps its journal, so the report, the CLI and the TUI read it, and it survives 
the agent losing its context. And
+camel-chaos enforces the time, whatever the agent does:
+
+* One exploration runs at a time, with a budget of 10 minutes by default, and 
at most 2 hours.
+* While it runs, a rule ends at the deadline at the latest, and a rule that 
would start after it (`from=`) is refused.
+* At the deadline the rules are removed and the exploration is finished. This 
is checked whenever chaos is used: a
+rule added, or the report read, which the status of the application does every 
second.
+* Chaos counts the exchanges from the start of the exploration, and its first 
rule takes the baseline: the counts per
+route of the run without faults before it, so the failures that were there 
already are not blamed on chaos.
+
+The `chaos` action of the CLI connector starts it (`{"action": "chaos", 
"command": "explore-start", "budget": "10m",
+"focus": "orders"}`) and finishes it before the deadline (`explore-finish`), 
as `ChaosManager.startExploration(budget,
+focus)` and `finishExploration()` do in Java. The report has its journal as 
`exploration`: `running`, `startedAt`,
+`budget`, `deadline`, `timeLeft` while it runs, `focus`, the `rules` added 
while it ran, the `baseline` (per `route` its
+`completed`, `failed`, `failedKeptByConsumer`, `handled` and `redeliveries`) 
and `baselineAt`, and once finished
+`finishedAt`, `finishedBy` (`finished` or `deadline`) and the number of rules 
`removed`. It stays in the report after it
+finished, until the next one starts or the report is reset. A scenario loaded 
while it runs starts the counts again,
+and the baseline is zero then (`baselineReset`).
+
+[source,text]
+----
+$ camel get chaos --report
+...
+  exploration: running, 6m12s left of 10m0s, focus: orders
+    rules tried: r1, r2
+    baseline (before the first rule): orders 30 completed, 0 failed
+----
+
 == Scenarios
 
 A scenario is the rules of an experiment in a file of the project, so it can 
be run again, shared and kept with the
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExploration.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExploration.java
new file mode 100644
index 000000000000..89a9e3560e9f
--- /dev/null
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExploration.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.component.chaos;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.StringJoiner;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * The journal of an exploration (CAMEL-26773): an agent tries chaos rules on 
the integration for a time it was given,
+ * and camel-chaos keeps what it did, so the report, the CLI and the TUI read 
it, and it survives the agent losing its
+ * context. camel-chaos also enforces the time: a rule added while it runs 
ends at the deadline at the latest, and at
+ * the deadline the rules are removed and the exploration is finished, 
whatever the agent does.
+ * <p/>
+ * It has the baseline, the counts per route when it started, so the failures 
that were there before are not blamed on
+ * chaos, and the rules added while it ran. The manager guards it with its 
lock.
+ */
+final class ChaosExploration {
+
+    static final long DEFAULT_BUDGET = 10 * 60_000L;
+    static final long MIN_BUDGET = 10_000L;
+    static final long MAX_BUDGET = 2 * 60 * 60_000L;
+
+    /** How an exploration finished. */
+    enum Finish {
+        // finished before the deadline (finishExploration)
+        FINISHED("finished"),
+        // the time was up
+        DEADLINE("deadline");
+
+        final String key;
+
+        Finish(String key) {
+            this.key = key;
+        }
+    }
+
+    final long startedAt;
+    final long budget;
+    final long deadline;
+    final String focus;
+    // the counts per route before its first rule (the run without faults), 
taken when the first rule is added:
+    // completed, failed, kept by the consumer, handled, redeliveries
+    private final Map<String, long[]> baseline = new LinkedHashMap<>();
+    private long baselineAt;
+    // the report was reset while it ran: the counts of the routes started 
again from zero
+    private boolean baselineReset;
+    // the ids of the rules added while it ran (a changed rule keeps its id)
+    private final List<String> rules = new ArrayList<>();
+    private long finishedAt;
+    private Finish finishedBy;
+    private int removed;
+
+    ChaosExploration(long startedAt, long budget, String focus) {
+        this.startedAt = startedAt;
+        this.budget = budget;
+        this.deadline = startedAt + budget;
+        this.focus = focus != null && !focus.isBlank() ? focus.strip() : null;
+    }
+
+    boolean isRunning() {
+        return finishedBy == null;
+    }
+
+    /** A rule added while it runs: the first one takes the baseline, the 
counts of the run without faults. */
+    void ruleAdded(String id, Map<String, ChaosManager.RouteCounts> counts, 
long now) {
+        if (baselineAt == 0) {
+            baselineAt = now;
+            counts.forEach((route, c) -> baseline.put(route, new long[] {
+                    c.completed.sum(), c.failed.sum(), c.failedKept.sum(), 
c.handled.sum(), c.redeliveries.sum() }));
+        }
+        if (!rules.contains(id)) {
+            rules.add(id);
+        }
+    }
+
+    /** The counts of the routes were reset (a scenario was loaded): the 
baseline is zero from now on. */
+    void reportReset() {
+        baseline.clear();
+        baselineReset = true;
+    }
+
+    void finished(long now, Finish by, int removedRules) {
+        finishedAt = now;
+        finishedBy = by;
+        removed = removedRules;
+    }
+
+    JsonObject toJson(long now) {
+        JsonObject jo = new JsonObject();
+        jo.put("running", isRunning());
+        jo.put("startedAt", startedAt);
+        jo.put("budget", budget);
+        jo.put("deadline", deadline);
+        if (isRunning()) {
+            jo.put("timeLeft", Math.max(0, deadline - now));
+        }
+        if (focus != null) {
+            jo.put("focus", focus);
+        }
+        if (baselineAt > 0) {
+            jo.put("baselineAt", baselineAt);
+        }
+        JsonArray list = new JsonArray();
+        baseline.forEach((route, c) -> {
+            JsonObject r = new JsonObject();
+            r.put("route", route);
+            r.put("completed", c[0]);
+            r.put("failed", c[1]);
+            r.put("failedKeptByConsumer", c[2]);
+            r.put("handled", c[3]);
+            r.put("redeliveries", c[4]);
+            list.add(r);
+        });
+        jo.put("baseline", list);
+        if (baselineReset) {
+            jo.put("baselineReset", true);
+        }
+        jo.put("rules", new JsonArray(rules));
+        if (!isRunning()) {
+            jo.put("finishedAt", finishedAt);
+            jo.put("finishedBy", finishedBy.key);
+            jo.put("removed", removed);
+        }
+        return jo;
+    }
+
+    int ruleCount() {
+        return rules.size();
+    }
+
+    String text(long now) {
+        StringBuilder sb = new StringBuilder();
+        appendText(sb, now);
+        return sb.toString();
+    }
+
+    void appendText(StringBuilder sb, long now) {
+        sb.append("\n  exploration: ");
+        if (isRunning()) {
+            sb.append("running, 
").append(ChaosRuleParser.printTime(Math.max(0, deadline - now))).append(" left 
of ")
+                    .append(ChaosRuleParser.printTime(budget));
+        } else {
+            sb.append(finishedBy == Finish.DEADLINE ? "finished at the 
deadline" : "finished")
+                    .append(" after 
").append(ChaosRuleParser.printTime(finishedAt - startedAt)).append(" of ")
+                    .append(ChaosRuleParser.printTime(budget));
+            if (removed > 0) {
+                sb.append(", ").append(removed).append(removed == 1 ? " rule 
removed" : " rules removed");
+            }
+        }
+        if (focus != null) {
+            sb.append(" (focus: ").append(focus).append(')');
+        }
+        sb.append("\n    rules tried: ").append(rules.isEmpty() ? "none yet" : 
String.join(", ", rules));
+        StringJoiner sj = new StringJoiner(", ");
+        baseline.forEach((route, c) -> sj.add(route + " " + c[0] + " 
completed, " + c[1] + " failed"));
+        sb.append("\n    baseline: ");
+        if (baselineAt == 0) {
+            sb.append("taken when the first rule is added");
+        } else {
+            sb.append(sj.length() == 0 ? "no exchanges" : 
sj.toString()).append(" in the ")
+                    .append(ChaosRuleParser.printTime(baselineAt - 
startedAt)).append(" before the first rule");
+        }
+        if (baselineReset) {
+            sb.append(" (the report was reset while it ran)");
+        }
+    }
+}
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
index 3f5ae8f18426..55ca97db689a 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
@@ -43,6 +43,7 @@ import org.apache.camel.component.chaos.ChaosRule.Hook;
 import org.apache.camel.component.chaos.ChaosRule.State;
 import org.apache.camel.support.ScheduledPollConsumer;
 import org.apache.camel.util.TimeSource;
+import org.apache.camel.util.TimeUtils;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
 import org.slf4j.Logger;
@@ -106,6 +107,8 @@ public final class ChaosManager {
     // lock), so the route of such a rule can be checked
     private final ChaosLifecycle lifecycle;
     private boolean routesCreated;
+    // the exploration running now or the last one (CAMEL-26773), guarded by 
the lock
+    private ChaosExploration exploration;
 
     ChaosManager(CamelContext camelContext, long seed, String seedFrom, long 
maxDuration) {
         this.camelContext = camelContext;
@@ -271,6 +274,8 @@ public final class ChaosManager {
             checkRoute(definition);
         }
         long now = TimeSource.currentTimeMillis();
+        // an exploration whose time is up removes its rules first
+        checkDeadline(now);
         if (rules.size() + definitions.size() > MAX_RULES) {
             retireEnded(now);
         }
@@ -279,12 +284,22 @@ public final class ChaosManager {
                     "There are " + rules.size() + " chaos rules already, and 
at most " + MAX_RULES
                                             + "; remove some before adding 
more");
         }
+        ChaosExploration running = exploration != null && 
exploration.isRunning() ? exploration : null;
+        if (running != null) {
+            for (ChaosRule.Builder definition : definitions) {
+                // checked before any is added: all the rules or none
+                checkStartsBeforeDeadline(new ChaosRule(definition, "check", 
seed, now, maxDuration), running);
+            }
+        }
         List<ChaosRule> added = new ArrayList<>();
         for (ChaosRule.Builder definition : definitions) {
             ChaosRule rule = new ChaosRule(definition, "r" + nextId++, seed, 
now, maxDuration);
             rule.recentFaults = recentFaults;
             // a rule added before the context started starts its clock when 
it has started
             rule.clockStarted = camelContext.isStarted();
+            if (running != null) {
+                endByDeadline(rule, running, now);
+            }
             rules.add(rule);
             added.add(rule);
         }
@@ -315,15 +330,21 @@ public final class ChaosManager {
     public int changeRule(String id, String rule) {
         ChaosRule.Builder definition = parse(rule);
         synchronized (lock) {
+            long now = TimeSource.currentTimeMillis();
+            // an exploration whose time is up removes its rules first, so the 
rule may be gone
+            checkDeadline(now);
             int index = indexOf(id);
             if (index < 0) {
                 throw new IllegalArgumentException("There is no chaos rule " + 
id);
             }
             checkRoute(definition);
-            long now = TimeSource.currentTimeMillis();
             ChaosRule changed = new ChaosRule(definition, id, 
rules.get(index).version + 1, seed, now, maxDuration);
             changed.recentFaults = recentFaults;
             changed.clockStarted = camelContext.isStarted();
+            if (exploration != null && exploration.isRunning()) {
+                checkStartsBeforeDeadline(changed, exploration);
+                endByDeadline(changed, exploration, now);
+            }
             ChaosRule old = rules.set(index, changed);
             old.removed(State.REPLACED, now);
             remember(old);
@@ -583,6 +604,12 @@ public final class ChaosManager {
             evictedLostAtDlq = 0;
             routes.clear();
             recentFaults.clear();
+            if (exploration != null && exploration.isRunning()) {
+                // a scenario loaded while it runs: the counts start again 
from zero, and so does its baseline
+                exploration.reportReset();
+            } else {
+                exploration = null;
+            }
             LOG.info("Chaos report reset");
             return count;
         }
@@ -600,7 +627,11 @@ public final class ChaosManager {
         long lost;
         int pending;
         long lostAtDlq;
+        ChaosExploration explored;
         synchronized (lock) {
+            // the status reads the report every second, which finishes an 
exploration whose time is up
+            checkDeadline(TimeSource.currentTimeMillis());
+            explored = exploration;
             current = new ArrayList<>(rules);
             removed = new ArrayList<>(history);
             unmet = evictedUnmet;
@@ -613,9 +644,107 @@ public final class ChaosManager {
         List<ChaosRule> all = new ArrayList<>(current);
         all.addAll(removed);
         List<ChaosAdvisor.RouteTried> tried = ChaosAdvisor.tried(all, 
suggestedKinds(now), now);
-        return new ChaosReport(
+        ChaosReport report = new ChaosReport(
                 seed, seedFrom, now, current, removed, counts, unmet, lost, 
pending,
                 lostAtDlq, recentFaults.latestFirst(), 
ChaosTargets.reach(camelContext), tried);
+        if (explored != null) {
+            synchronized (lock) {
+                report.exploration(explored.toJson(now), explored.text(now));
+            }
+        }
+        return report;
+    }
+
+    /**
+     * Starts an exploration (CAMEL-26773): an agent tries chaos rules for the 
time it was given. While it runs, a rule
+     * ends at the deadline at the latest, and at the deadline the rules are 
removed and the exploration is finished.
+     * The report has its journal: the baseline (the counts per route now) and 
the rules added while it runs.
+     *
+     * @param  budget                   how long, such as 10m (10 minutes when 
empty, at most 2 hours)
+     * @param  focus                    what the agent explores, such as a 
route (optional)
+     * @return                          the exploration, as in the report
+     * @throws IllegalArgumentException when the budget is not a time, or too 
short or too long
+     * @throws IllegalStateException    when an exploration is running
+     */
+    public Map<String, Object> startExploration(String budget, String focus) {
+        long millis;
+        try {
+            millis = budget == null || budget.isBlank()
+                    ? ChaosExploration.DEFAULT_BUDGET : 
TimeUtils.toMilliSeconds(budget.strip());
+        } catch (RuntimeException e) {
+            throw new IllegalArgumentException("The budget of the exploration 
is not a time: " + budget);
+        }
+        if (millis < ChaosExploration.MIN_BUDGET || millis > 
ChaosExploration.MAX_BUDGET) {
+            throw new IllegalArgumentException(
+                    "The budget of the exploration must be between 10s and 2h, 
was: " + budget);
+        }
+        synchronized (lock) {
+            long now = TimeSource.currentTimeMillis();
+            checkDeadline(now);
+            if (exploration != null && exploration.isRunning()) {
+                throw new IllegalStateException(
+                        "An exploration is running, for another " + 
ChaosRuleParser.printTime(exploration.deadline - now)
+                                                + ": finish it first");
+            }
+            exploration = new ChaosExploration(now, millis, focus);
+            update();
+            LOG.info("Chaos exploration started: {}{}", 
ChaosRuleParser.printTime(millis),
+                    exploration.focus != null ? " (focus: " + 
exploration.focus + ")" : "");
+            return exploration.toJson(now);
+        }
+    }
+
+    /**
+     * Finishes the running exploration before its deadline: its rules are 
removed, and its journal stays in the report
+     * until the next one starts or the report is reset.
+     *
+     * @return                       the exploration, as in the report
+     * @throws IllegalStateException when no exploration is running
+     */
+    public Map<String, Object> finishExploration() {
+        synchronized (lock) {
+            long now = TimeSource.currentTimeMillis();
+            checkDeadline(now);
+            if (exploration == null || !exploration.isRunning()) {
+                throw new IllegalStateException("No exploration is running");
+            }
+            finish(now, ChaosExploration.Finish.FINISHED);
+            return exploration.toJson(now);
+        }
+    }
+
+    // finishes the running exploration whose time is up; with the lock
+    private void checkDeadline(long now) {
+        if (exploration != null && exploration.isRunning() && now >= 
exploration.deadline) {
+            finish(now, ChaosExploration.Finish.DEADLINE);
+        }
+    }
+
+    private void finish(long now, ChaosExploration.Finish by) {
+        int removedRules = clearRules();
+        exploration.finished(now, by, removedRules);
+        // no rule is left and the exploration is done: the counting stops
+        update();
+        LOG.info("Chaos exploration {}: {} rules tried, {} removed",
+                by == ChaosExploration.Finish.DEADLINE ? "finished at its 
deadline" : "finished",
+                exploration.ruleCount(), removedRules);
+    }
+
+    private static void checkStartsBeforeDeadline(ChaosRule rule, 
ChaosExploration running) {
+        if (rule.startsAt >= running.deadline) {
+            throw new IllegalArgumentException(
+                    "The chaos rule would start after the end of the 
exploration, in "
+                                               + 
ChaosRuleParser.printTime(running.deadline - rule.addedAt)
+                                               + ": " + rule.text);
+        }
+    }
+
+    // a rule added while an exploration runs ends at its deadline at the 
latest; its first rule takes the baseline
+    private void endByDeadline(ChaosRule rule, ChaosExploration running, long 
now) {
+        if (rule.endsAt > running.deadline) {
+            rule.endsAt = running.deadline;
+        }
+        running.ruleAdded(rule.id, new TreeMap<>(routes), now);
     }
 
     // the kinds of fault the suggestions offer per route, read again at most 
every 10 seconds (the report is read
@@ -639,6 +768,13 @@ public final class ChaosManager {
 
     // -- for the hooks, the event notifier and the plugin
 
+    /** The rules there are now. */
+    List<ChaosRule> rules() {
+        synchronized (lock) {
+            return List.copyOf(rules);
+        }
+    }
+
     RuleSet ruleSet() {
         return ruleSet;
     }
@@ -1016,7 +1152,8 @@ public final class ChaosManager {
                 lifecycle.schedule(rule);
             }
         }
-        boolean any = !rules.isEmpty();
+        // an exploration counts from its start, for the baseline before its 
first rule (CAMEL-26773)
+        boolean any = !rules.isEmpty() || exploration != null && 
exploration.isRunning();
         if (any && !counting) {
             used = true;
             counting = true;
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
index 5371d6c484b6..d2641bf44ba4 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
@@ -72,6 +72,9 @@ public final class ChaosReport {
     private final List<ChaosRecentFaults.Entry> recentFaults;
     // what has been tried per route, and what not yet (CAMEL-26728)
     private final List<ChaosAdvisor.RouteTried> tried;
+    // the exploration running now or the last one (CAMEL-26773), as JSON and 
text, or null
+    private JsonObject exploration;
+    private String explorationText;
 
     ChaosReport(long seed, String seedFrom, long now, List<ChaosRule> current, 
List<ChaosRule> removed,
                 Map<String, RouteCounts> counts, int evictedUnmet, long 
evictedLost, int evictedPending,
@@ -146,6 +149,12 @@ public final class ChaosReport {
         this.pending = anyPending || evictedPending > 0;
     }
 
+    /** The journal of the exploration running now or the last one, which the 
report then has. */
+    void exploration(JsonObject json, String text) {
+        this.exploration = json;
+        this.explorationText = text;
+    }
+
     // the number of an id such as r12, for the order of the rules
     private static int idNumber(String id) {
         try {
@@ -273,6 +282,9 @@ public final class ChaosReport {
             }
             sb.append("), handled ").append(r.handled).append(", redeliveries 
").append(r.redeliveries);
         }
+        if (explorationText != null) {
+            sb.append(explorationText);
+        }
         if (!tried.isEmpty()) {
             sb.append("\n  tried, per route:");
             for (ChaosAdvisor.RouteTried r : tried) {
@@ -469,6 +481,9 @@ public final class ChaosReport {
         root.put("routes", routeArray);
         root.put("recentFaults", recentFaultsJson());
         root.put("tried", triedJson());
+        if (exploration != null) {
+            root.put("exploration", exploration);
+        }
         return root;
     }
 
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExplorationTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExplorationTest.java
new file mode 100644
index 000000000000..5a17ba5fe034
--- /dev/null
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExplorationTest.java
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.component.chaos;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The journal of an exploration (CAMEL-26773): the baseline and the rules 
tried, and a deadline that camel-chaos
+ * enforces, on the clock of TimeSource.
+ */
+class ChaosExplorationTest extends ChaosTestSupport {
+
+    private FakeClock clock;
+
+    @Override
+    protected void setupResources() throws Exception {
+        super.setupResources();
+        clock = FakeClock.install();
+    }
+
+    @Override
+    protected void cleanupResources() throws Exception {
+        clock.close();
+        super.cleanupResources();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:orders").routeId("orders").to("mock:shipped");
+            }
+        };
+    }
+
+    private JsonObject exploration() {
+        return (JsonObject) chaos().report().toJson().get("exploration");
+    }
+
+    private static List<Object> list(JsonObject jo, String key) {
+        return jo.getCollection(key);
+    }
+
+    @Test
+    void theBaselineAndTheRulesTriedUntilTheDeadline() {
+        Map<String, Object> started = chaos().startExploration("5m", "orders");
+        assertThat(started).containsEntry("running", 
true).containsEntry("budget", 300_000L)
+                .containsEntry("focus", "orders").containsEntry("timeLeft", 
300_000L);
+        assertThat(chaos().report().toText()).contains("baseline: taken when 
the first rule is added");
+
+        // the run without faults before the first rule is the baseline, so 
what fails there is not blamed on chaos
+        template.sendBody("direct:orders", "before");
+        clock.advance(30_000);
+        String r1 = chaos().addRule("endpoint=mock:shipped error until=8m");
+        String r2 = chaos().addRule("endpoint=mock:shipped delay 
duration=10ms");
+        // a rule ends at the deadline at the latest
+        long deadline = (long) started.get("deadline");
+        assertThat(chaos().rules()).allMatch(r -> r.endsAt <= deadline);
+
+        clock.advance(120_000);
+        JsonObject running = exploration();
+        assertThat(running.getLong("timeLeft")).isEqualTo(150_000);
+        assertThat(list(running, "rules")).containsExactly(r1, r2);
+        JsonObject orders = (JsonObject) 
running.getCollection("baseline").iterator().next();
+        assertThat(orders.getString("route")).isEqualTo("orders");
+        assertThat(orders.getLong("completed")).isEqualTo(1);
+        assertThat(chaos().report().toText()).contains("exploration: running, 
2m30s left of 5m0s (focus: orders)",
+                "rules tried: r1, r2", "baseline: orders 1 completed, 0 failed 
in the 30s before the first rule");
+
+        // the time is up: the next look at the report finishes it, and the 
rules are removed
+        clock.advance(150_000);
+        JsonObject finished = exploration();
+        assertThat(finished.getBoolean("running")).isFalse();
+        assertThat(finished.getString("finishedBy")).isEqualTo("deadline");
+        assertThat(finished.getLong("removed")).isEqualTo(2);
+        assertThat(finished.containsKey("timeLeft")).isFalse();
+        assertThat(chaos().rules()).isEmpty();
+        assertThat(chaos().report().toText()).contains("exploration: finished 
at the deadline after 5m0s of 5m0s,"
+                                                       + " 2 rules removed");
+    }
+
+    @Test
+    void finishedBeforeTheDeadline() {
+        chaos().startExploration(null, null);
+        chaos().addRule("endpoint=mock:shipped error");
+        clock.advance(60_000);
+
+        Map<String, Object> finished = chaos().finishExploration();
+
+        assertThat(finished).containsEntry("running", 
false).containsEntry("finishedBy", "finished")
+                .containsEntry("budget", 
ChaosExploration.DEFAULT_BUDGET).containsEntry("removed", 1);
+        assertThat(chaos().rules()).isEmpty();
+        // the journal stays in the report, and rules added now are not part 
of it
+        chaos().addRule("endpoint=mock:shipped drop");
+        assertThat(list(exploration(), "rules")).containsExactly("r1");
+        assertThatThrownBy(() -> 
chaos().finishExploration()).isInstanceOf(IllegalStateException.class)
+                .hasMessage("No exploration is running");
+    }
+
+    @Test
+    void oneAtATimeAndWithinItsTime() {
+        assertThatThrownBy(() -> chaos().startExploration("3h", 
null)).isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("between 10s and 2h");
+        assertThatThrownBy(() -> chaos().startExploration("1s", 
null)).hasMessageContaining("between 10s and 2h");
+        assertThatThrownBy(() -> chaos().startExploration("soon", 
null)).hasMessageContaining("not a time: soon");
+
+        chaos().startExploration("10m", null);
+        assertThatThrownBy(() -> chaos().startExploration("10m", 
null)).isInstanceOf(IllegalStateException.class)
+                .hasMessage("An exploration is running, for another 10m0s: 
finish it first");
+        // a rule that would only start after the deadline is refused, and 
none of the rules is added
+        chaos().finishExploration();
+        chaos().startExploration("1m", null);
+        assertThatThrownBy(() -> chaos().addRules("endpoint=mock:shipped 
error; endpoint=mock:shipped drop from=2m"))
+                
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("start after 
the end of the exploration");
+        assertThat(chaos().rules()).isEmpty();
+        // a new exploration once the time of the last one is up
+        clock.advance(60_000);
+        assertThat(chaos().startExploration("1m", 
null)).containsEntry("running", true);
+    }
+
+    @Test
+    void aChangedRuleEndsAtTheDeadlineToo() {
+        chaos().startExploration("2m", null);
+        String id = chaos().addRule("endpoint=mock:shipped error until=1m");
+        clock.advance(90_000);
+
+        chaos().changeRule(id, "endpoint=mock:shipped error until=5m");
+
+        ChaosRule changed = chaos().rules().get(0);
+        
assertThat(changed.endsAt).isEqualTo(exploration().getLong("deadline"));
+        assertThat(list(exploration(), "rules")).containsExactly(id);
+    }
+
+    @Test
+    void aRuleChangedAfterTheDeadlineIsGone() {
+        chaos().startExploration("1m", null);
+        String id = chaos().addRule("endpoint=mock:shipped error");
+        clock.advance(60_000);
+
+        // the change is the first to see the time is up: the exploration 
removes its rules before the change looks
+        assertThatThrownBy(() -> chaos().changeRule(id, "endpoint=mock:shipped 
drop"))
+                
.isInstanceOf(IllegalArgumentException.class).hasMessage("There is no chaos 
rule " + id);
+        
assertThat(exploration().getString("finishedBy")).isEqualTo("deadline");
+    }
+
+    @Test
+    void theCountingStopsWithTheExploration() {
+        chaos().startExploration("1m", null);
+        assertThat(chaos().isCounting()).isTrue();
+        chaos().addRule("endpoint=mock:shipped error");
+
+        chaos().finishExploration();
+        assertThat(chaos().isCounting()).isFalse();
+
+        // and at the deadline
+        chaos().startExploration("1m", null);
+        chaos().addRule("endpoint=mock:shipped error");
+        clock.advance(60_000);
+        chaos().report();
+        assertThat(chaos().isCounting()).isFalse();
+    }
+
+    @Test
+    void aScenarioLoadedWhileItRunsStartsTheCountsAgain() {
+        template.sendBody("direct:orders", "before");
+        chaos().startExploration("10m", null);
+
+        List<String> ids = chaos().loadScenario("outage", 
"endpoint=mock:shipped error until=1m");
+
+        JsonObject running = exploration();
+        assertThat(running.getBoolean("running")).isTrue();
+        assertThat(running.getBoolean("baselineReset")).isTrue();
+        assertThat(list(running, "baseline")).isEmpty();
+        assertThat(list(running, "rules")).containsExactlyElementsOf(ids);
+
+        // a finished exploration goes with a reset of the report
+        chaos().finishExploration();
+        chaos().reset();
+        assertThat(exploration()).isNull();
+    }
+}
diff --git 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
index 07e1fc7fcb2b..554ac4415f43 100644
--- 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
+++ 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
@@ -141,8 +141,19 @@ final class ChaosAction {
                 if (reset) {
                     answer.put("reset", true);
                 }
+            } else if ("explore-start".equals(command)) {
+                // an exploration (CAMEL-26773): camel-chaos keeps its journal 
and ends its rules at the deadline
+                @SuppressWarnings("unchecked")
+                Map<String, Object> started = (Map<String, Object>) call(type, 
chaos, "startExploration",
+                        root.getString("budget"), root.getString("focus"));
+                answer.putAll(started);
+            } else if ("explore-finish".equals(command)) {
+                @SuppressWarnings("unchecked")
+                Map<String, Object> finished = (Map<String, Object>) 
call(type, chaos, "finishExploration");
+                answer.putAll(finished);
             } else {
-                output.error("Unknown chaos command: " + command + " (add, 
check, change, remove, clear, load or suggest)");
+                output.error("Unknown chaos command: " + command
+                             + " (add, check, change, remove, clear, load, 
suggest, explore-start or explore-finish)");
                 return;
             }
         } catch (IllegalArgumentException | IllegalStateException e) {
diff --git 
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
 
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
index 5e5c827e7a8b..73b090e315a0 100644
--- 
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
+++ 
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
@@ -222,6 +222,32 @@ class LocalCliConnectorChaosActionTest {
         assertThat(reportRules()).isEmpty();
     }
 
+    @Test
+    void anExplorationStartsAndFinishes() throws Exception {
+        start(null, Map.of("camel.chaos.enabled", "true"));
+        JsonObject action = new JsonObject();
+        action.put("action", "chaos");
+        action.put("command", "explore-start");
+        action.put("budget", "5m");
+        action.put("focus", "start");
+
+        CliActionOutcome started = CliActionOutcome.dispatch(connector, 
action).get(10, TimeUnit.SECONDS);
+        assertThat(started.error()).isNull();
+        assertThat(started.result().getBoolean("running")).isTrue();
+        assertThat(started.result().getLong("budget")).isEqualTo(300_000L);
+        assertThat(started.result().getString("focus")).isEqualTo("start");
+        // one at a time: the reason goes to the tool
+        assertThat(CliActionOutcome.dispatch(connector, action).get(10, 
TimeUnit.SECONDS).error())
+                .startsWith("An exploration is running");
+
+        assertThat(chaos("add", null, "endpoint=mock:target 
error").error()).isNull();
+        CliActionOutcome finished = chaos("explore-finish", null, null);
+        assertThat(finished.error()).isNull();
+        
assertThat(finished.result().getString("finishedBy")).isEqualTo("finished");
+        assertThat(finished.result().getLong("removed")).isEqualTo(1L);
+        assertThat(chaos("explore-finish", null, null).error()).isEqualTo("No 
exploration is running");
+    }
+
     @Test
     void rulesAreAddedTogetherWithWarningsForTheOnesThatMatchNothing() throws 
Exception {
         start(null, Map.of("camel.chaos.enabled", "true"));
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
index 4a95b0894029..34c710feb859 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
@@ -304,6 +304,50 @@ public class ListChaos extends ProcessWatchCommand {
                             .with(r -> r.getStringOrDefault("exchangeId", 
"")))));
         }
         printTried(report);
+        printExploration(report);
+    }
+
+    /**
+     * The exploration running now or the last one (CAMEL-26773): its time, 
the rules tried, and the baseline before its
+     * first rule.
+     */
+    private void printExploration(JsonObject report) {
+        JsonObject exploration = report.getMap("exploration");
+        if (exploration == null) {
+            return;
+        }
+        StringBuilder sb = new StringBuilder("  exploration: ");
+        long budget = exploration.getLongOrDefault("budget", 0);
+        if (exploration.getBooleanOrDefault("running", false)) {
+            sb.append("running, 
").append(TimeUtils.printDuration(exploration.getLongOrDefault("timeLeft", 0)))
+                    .append(" left of 
").append(TimeUtils.printDuration(budget));
+        } else {
+            sb.append("deadline".equals(exploration.getString("finishedBy")) ? 
"finished at the deadline" : "finished")
+                    .append(" 
(").append(TimeUtils.printDuration(budget)).append(')');
+        }
+        String focus = exploration.getString("focus");
+        if (focus != null) {
+            sb.append(", focus: ").append(focus);
+        }
+        printer().println(sb.toString());
+        List<Object> rules = exploration.getCollection("rules");
+        printer().println("    rules tried: "
+                          + (rules == null || rules.isEmpty()
+                                  ? "none yet" : 
rules.stream().map(String::valueOf).collect(Collectors.joining(", "))));
+        if (!exploration.containsKey("baselineAt")) {
+            printer().println("    baseline: taken when the first rule is 
added");
+            return;
+        }
+        StringJoiner sj = new StringJoiner(", ");
+        List<Object> baseline = exploration.getCollection("baseline");
+        if (baseline != null) {
+            for (Object o : baseline) {
+                JsonObject route = (JsonObject) o;
+                sj.add(route.getString("route") + " " + 
route.getLongOrDefault("completed", 0) + " completed, "
+                       + route.getLongOrDefault("failed", 0) + " failed");
+            }
+        }
+        printer().println("    baseline (before the first rule): " + 
(sj.length() == 0 ? "no exchanges" : sj));
     }
 
     /**
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
index 1bb84ce6b76c..56107e222941 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
@@ -186,6 +186,31 @@ class ListChaosTest extends ProcessCommandTestSupport {
         assertThat(out).contains("a stub does not read a kept message again, 
as the component it stands in for would");
     }
 
+    @Test
+    void theExplorationInTheReport() throws Exception {
+        JsonObject chaos = chaosOn();
+        JsonObject exploration = new JsonObject();
+        exploration.put("running", true);
+        exploration.put("budget", 600_000L);
+        exploration.put("timeLeft", 372_000L);
+        exploration.put("focus", "orders");
+        exploration.put("rules", new JsonArray(List.of("r1", "r2")));
+        exploration.put("baselineAt", 1_700_000_030_000L);
+        JsonObject orders = new JsonObject();
+        orders.put("route", "orders");
+        orders.put("completed", 30L);
+        orders.put("failed", 0L);
+        exploration.put("baseline", new JsonArray(List.of(orders)));
+        ((JsonObject) chaos.get("report")).put("exploration", exploration);
+        writeStatus(chaos);
+
+        assertThat(run("--report")).isZero();
+
+        String out = printer.getStdout();
+        assertThat(out).contains("exploration: running, 6m12s left of 10m0s, 
focus: orders",
+                "rules tried: r1, r2", "baseline (before the first rule): 
orders 30 completed, 0 failed");
+    }
+
     @Test
     void whatHasBeenTriedInTheReport() throws Exception {
         JsonObject chaos = chaosOn();

Reply via email to