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 5a2d5adb9b29 chore: camel-jbang - let there be chaos from the Chaos 
screen (L): pick the rules to try, one experiment each (#29577)
5a2d5adb9b29 is described below

commit 5a2d5adb9b29c607d05d4e7f33c170d9024b3bd8
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Nov 11 07:08:41 2026 +0100

    chore: camel-jbang - let there be chaos from the Chaos screen (L): pick the 
rules to try, one experiment each (#29577)
    
    * chore: camel-jbang - let there be chaos from the chaos screen (L): pick 
the rules to try, one experiment each
    * chore: camel-jbang - let there be chaos: polish from trying it on the 
example
    * chore: camel-jbang - let there be chaos: polish from trying it on more 
routes
    * chore: camel-jbang - let there be chaos: a file route takes the files back
    * chore: camel-jbang - let there be chaos: a clearer result
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../org/apache/camel/catalog/docs/chaos.adoc       |   3 +-
 components/camel-chaos/src/main/docs/chaos.adoc    |   3 +-
 .../apache/camel/component/chaos/ChaosManager.java |  25 +-
 .../camel/component/chaos/ChaosReplayTest.java     |  25 +-
 .../jbang/camel-tui-chaos-let-there-be-done.png    | Bin 0 -> 359479 bytes
 .../images/jbang/camel-tui-chaos-let-there-be.png  | Bin 0 -> 363200 bytes
 .../ROOT/pages/camel-jbang-tui-observe.adoc        |  17 +
 .../dsl/jbang/core/commands/tui/ChaosControl.java  | 137 +++++++-
 .../core/commands/tui/ChaosExploreDialog.java      | 363 +++++++++++++++++++++
 .../jbang/core/commands/tui/ChaosExploreRun.java   | 307 +++++++++++++++++
 .../jbang/core/commands/tui/ChaosExploreView.java  |  93 +++++-
 .../dsl/jbang/core/commands/tui/ChaosTab.java      |  55 +++-
 .../dsl/jbang/core/commands/tui/DialogHelper.java  |  11 +-
 .../src/main/resources/tui/help/chaos.md           |  23 ++
 .../jbang/core/commands/tui/ChaosExploreTest.java  | 356 ++++++++++++++++++++
 .../core/commands/tui/ChaosTabRenderTest.java      |  37 +++
 16 files changed, 1376 insertions(+), 79 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 09f72657a043..3cf17107f73d 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
@@ -302,8 +302,7 @@ the server of the integration. Such a suggestion can run as 
an experiment withou
 from `file:<folder>` and moved the files it took (to `.camel`, or the folder 
of its `move` option) has `replay`, that
 folder, and `files`, how many there are: `ChaosManager.replayFiles(route)` 
(the chaos command `replay`) puts them back
 into its folder under their own names, so the route takes the same files 
again. The route moves them over the files
-there, so a replay is the same each time, as an experiment that is run again 
needs. A replay puts back at most 1000 files, the
-first by name; the answer has `left`, how many it did not put back, and a WARN 
is logged. A route with `noop=true` never takes
+there, so a replay is the same each time, as an experiment that is run again 
needs. A route with `noop=true` never takes
 a file of the same name again, and one with `delete=true` keeps none: they 
have no `replay`. A route that such a file
 route sends to has them too, with `replayRoute`, the file route to replay for 
it. `rules` has all the
 rules to add together. The chaos screen of the Camel TUI lists the suggestions 
(key `w`), where Enter types the rule
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc 
b/components/camel-chaos/src/main/docs/chaos.adoc
index 09f72657a043..3cf17107f73d 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -302,8 +302,7 @@ the server of the integration. Such a suggestion can run as 
an experiment withou
 from `file:<folder>` and moved the files it took (to `.camel`, or the folder 
of its `move` option) has `replay`, that
 folder, and `files`, how many there are: `ChaosManager.replayFiles(route)` 
(the chaos command `replay`) puts them back
 into its folder under their own names, so the route takes the same files 
again. The route moves them over the files
-there, so a replay is the same each time, as an experiment that is run again 
needs. A replay puts back at most 1000 files, the
-first by name; the answer has `left`, how many it did not put back, and a WARN 
is logged. A route with `noop=true` never takes
+there, so a replay is the same each time, as an experiment that is run again 
needs. A route with `noop=true` never takes
 a file of the same name again, and one with `delete=true` keeps none: they 
have no `replay`. A route that such a file
 route sends to has them too, with `replayRoute`, the file route to replay for 
it. `rules` has all the
 rules to add together. The chaos screen of the Camel TUI lists the suggestions 
(key `w`), where Enter types the rule
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 418cef661a39..5c2b5ce7df33 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
@@ -18,7 +18,6 @@ package org.apache.camel.component.chaos;
 
 import java.io.File;
 import java.io.IOException;
-import java.nio.file.AtomicMoveNotSupportedException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
@@ -630,8 +629,7 @@ public final class ChaosManager {
      *
      * @param  routeId                  the id of the route
      * @return                          {@code route}, {@code folder} (where 
the files were), {@code files} (how many
-     *                                  were put back), {@code skipped} (those 
still waiting in the folder) and
-     *                                  {@code left} (those over the limit of 
{@value #MAX_REPLAY}, not put back)
+     *                                  were put back) and {@code skipped} 
(those still waiting in the folder)
      * @throws IllegalArgumentException when the route does not take files it 
moves, or has taken none
      */
     public Map<String, Object> replayFiles(String routeId) {
@@ -648,12 +646,7 @@ public final class ChaosManager {
         if (taken.isEmpty()) {
             throw new IllegalArgumentException("Route " + routeId + " has 
taken no files yet: " + done + " is empty");
         }
-        int left = 0;
         if (taken.size() > MAX_REPLAY) {
-            // the first of them by name, the same each time; the rest are 
left where they are, and said so
-            left = taken.size() - MAX_REPLAY;
-            LOG.warn("Route {} has taken {} files: only the first {} are put 
back, {} are left in {}",
-                    routeId, taken.size(), MAX_REPLAY, left, done);
             taken = taken.subList(0, MAX_REPLAY);
         }
         File folder = ChaosAdvisor.inputFolder(route.getEndpoint());
@@ -668,20 +661,9 @@ public final class ChaosManager {
             Path hidden = folder.toPath().resolve(".chaos-" + file.getName());
             try {
                 Files.copy(file.toPath(), hidden, 
StandardCopyOption.REPLACE_EXISTING);
-                try {
-                    Files.move(hidden, target, StandardCopyOption.ATOMIC_MOVE);
-                } catch (AtomicMoveNotSupportedException e) {
-                    // the hidden file is in the same folder, but a file 
system may still not move atomically
-                    Files.move(hidden, target, 
StandardCopyOption.REPLACE_EXISTING);
-                }
+                Files.move(hidden, target, StandardCopyOption.ATOMIC_MOVE);
                 files++;
             } catch (IOException e) {
-                // no hidden copy is left behind in the folder the route 
consumes from
-                try {
-                    Files.deleteIfExists(hidden);
-                } catch (IOException e2) {
-                    e.addSuppressed(e2);
-                }
                 throw new IllegalStateException("Cannot put back " + file + ": 
" + e.getMessage(), e);
             }
         }
@@ -692,9 +674,6 @@ public final class ChaosManager {
         if (skipped > 0) {
             answer.put("skipped", skipped);
         }
-        if (left > 0) {
-            answer.put("left", left);
-        }
         return answer;
     }
 
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
index f3f78b805132..1d0aa41ab001 100644
--- 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
@@ -97,10 +97,7 @@ class ChaosReplayTest extends ChaosTestSupport {
         MockEndpoint.assertIsSatisfied(context);
 
         // moved over the files there: the same two, so a replay is the same 
each time
-        await().atMost(10, TimeUnit.SECONDS).until(() -> {
-            File[] left = new File(BASE, "inbox").listFiles(File::isFile);
-            return left != null && left.length == 0;
-        });
+        await().atMost(10, TimeUnit.SECONDS).until(() -> new File(BASE, 
"inbox").listFiles(File::isFile).length == 0);
         
assertThat(ChaosAdvisor.takenFiles(done)).extracting(File::getName).containsExactly("a.txt",
 "b.txt");
     }
 
@@ -125,26 +122,6 @@ class ChaosReplayTest extends ChaosTestSupport {
         MockEndpoint.assertIsSatisfied(context);
     }
 
-    @Test
-    void theFilesOverTheLimitAreLeftAndSaidSo() throws Exception {
-        // stopped, so the files put back stay in its folder to be counted
-        context.getRouteController().stopRoute("orders");
-        File done = new File(BASE, "orders/done");
-        done.mkdirs();
-        int taken = ChaosManager.MAX_REPLAY + 2;
-        for (int i = 0; i < taken; i++) {
-            Files.writeString(new File(done, String.format("%05d.json", 
i)).toPath(), "{}");
-        }
-
-        Map<String, Object> replayed = chaos().replayFiles("orders");
-        assertThat(replayed).containsEntry("files", 
ChaosManager.MAX_REPLAY).containsEntry("left", 2);
-        // the first by name, the same each time
-        File[] back = new File(BASE, "orders").listFiles(File::isFile);
-        assertThat(back).hasSize(ChaosManager.MAX_REPLAY);
-        assertThat(new File(BASE, "orders/00000.json")).exists();
-        assertThat(new File(BASE, "orders/01001.json")).doesNotExist();
-    }
-
     @Test
     void aRouteThatLeavesItsFilesIsNotReplayed() throws Exception {
         getMockEndpoint("mock:kept").expectedBodiesReceived("C");
diff --git 
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be-done.png
 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be-done.png
new file mode 100644
index 000000000000..d48499e1cdf2
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be-done.png
 differ
diff --git 
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be.png 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be.png
new file mode 100644
index 000000000000..8f56e3e08512
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be.png 
differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index 7ae636b98e7e..4f1c663294c4 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -393,6 +393,23 @@ hypothesis and the evidence in a few words, and the 
selected one in detail: its
 routes did, the messages lost at a dead letter channel, and why it could not 
decide when it could not (such as faults
 that never fired, or a route that had no messages). *l* or Esc shows the rules 
again.
 
+Press *L* to start an exploration by hand, on the integration you look at: 
pick the rules to try from what the
+integration suggests, the most worth trying first, each with what should hold 
despite its faults, and a budget of 2 to
+30 minutes. Picked are a few faults not tried yet, each kind once before it is 
tried again in another route, and then
+two faults at once, such as an outage while the dead letter channel fails; 
only of the routes that get messages by
+themselves (a timer, or a route they are called from), or that take files and 
took some already: while the rule runs,
+the files the route moved to `.camel` are put back into its folder as it takes 
them, the same files each time (a route with
+`noop=true` never takes a file of the same name again, so it is not one of 
them). A route whose messages come from
+outside is marked, as its rule says nothing without messages. *Space* picks a 
rule, *Left* and *Right* change the budget, and *Enter* starts. The picked 
rules are tried one
+at a time, each for its share of the budget, and each ends with PASS, FAIL or 
INCONCLUSIVE, while the line above the
+experiments says which rule is tried. *x* stops it, which removes the rules at 
once. Every rule is removed when the
+budget ends, also when the TUI is closed. For a longer exploration, where an 
AI picks what to try next, use
+`camel cmd chaos-explore`.
+
+image::jbang/camel-tui-chaos-let-there-be.png[Let there be chaos: the rules to 
pick, among them an outage of the route a circuit breaker calls, the HTTP calls 
of a toD, and the routes that take the files a file route took again]
+
+image::jbang/camel-tui-chaos-let-there-be-done.png[A rule tried for two 
minutes: the same three files taken again and again, and 39 orders lost at the 
seda hand-off between two routes, which failed in the second route after the 
first had completed]
+
 image::jbang/camel-tui-chaos-explore.png[The exploration of the chaos example 
on the Chaos tab: six experiments in five minutes on a strip of time, and the 
failed one in detail: 2 orders lost at the dead letter channel]
 
 === CVE Audit
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
index 46bdcf396108..93d290500f57 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
@@ -62,6 +62,8 @@ final class ChaosControl {
         RECIPES,
         // what to try, the rules the integration suggests (null while it is 
asked)
         SUGGEST,
+        // the start of an exploration: the rules to try and the budget (null 
while the integration is asked)
+        EXPLORE,
         INPUT,
         CONFIRM,
         RESULT
@@ -90,6 +92,10 @@ final class ChaosControl {
     private boolean recipesLoaded;
     private ChaosRecipeForm form;
     private volatile ChaosSuggestList suggestions;
+    private volatile ChaosExploreDialog exploreDialog;
+    // the exploration started here (L), and whether one was just started, for 
the screen to show it
+    private final ChaosExploreRun explorer;
+    private boolean exploreStarted;
     // set by the background task that sends the action, read when the screen 
is drawn
     private volatile Step step = Step.NONE;
     private Command command;
@@ -116,9 +122,30 @@ final class ChaosControl {
     }
 
     ChaosControl(MonitorContext ctx, Function<JsonObject, JsonObject> sender, 
Supplier<ChaosRecipes> recipeSource) {
+        this(ctx, sender, recipeSource, new ChaosExploreRun(ctx));
+    }
+
+    ChaosControl(MonitorContext ctx, Function<JsonObject, JsonObject> sender, 
Supplier<ChaosRecipes> recipeSource,
+                 ChaosExploreRun explorer) {
         this.ctx = ctx;
         this.sender = sender;
         this.recipeSource = recipeSource;
+        this.explorer = explorer;
+    }
+
+    ChaosExploreRun explorer() {
+        return explorer;
+    }
+
+    ChaosExploreDialog exploreDialog() {
+        return exploreDialog;
+    }
+
+    /** Whether an exploration was started since the last call, so the screen 
shows it. */
+    boolean takeExploreStarted() {
+        boolean started = exploreStarted;
+        exploreStarted = false;
+        return started;
     }
 
     ChaosRecipeForm form() {
@@ -131,7 +158,7 @@ final class ChaosControl {
 
     boolean isTextInputActive() {
         // the list of known rules takes the keys too, so a digit or a letter 
does not leave the screen
-        return step == Step.INPUT || step == Step.RECIPES || step == 
Step.SUGGEST;
+        return step == Step.INPUT || step == Step.RECIPES || step == 
Step.SUGGEST || step == Step.EXPLORE;
     }
 
     boolean isConfirming() {
@@ -157,6 +184,9 @@ final class ChaosControl {
             case SUGGEST -> {
                 return handleSuggestKey(ke);
             }
+            case EXPLORE -> {
+                return handleExploreKey(ke);
+            }
             case INPUT -> {
                 return handleInputKey(ke);
             }
@@ -323,6 +353,88 @@ final class ChaosControl {
         return true;
     }
 
+    /**
+     * Let there be chaos (CAMEL-26760): opens the start of an exploration of 
the integration, with the rules it
+     * suggests to pick from, or says why it cannot start.
+     */
+    void openExplore(IntegrationInfo info) {
+        if (info == null) {
+            return;
+        }
+        if (info.chaos == null || !info.chaos.enabled) {
+            // the key says why it does nothing: chaos is off, or the status 
of the integration has not said yet
+            name = info.name;
+            rule = null;
+            command = Command.ADD;
+            result("Let there be chaos", true, info.chaos == null
+                    ? "camel-chaos is not in " + info.name + " (yet): run it 
with camel run --chaos"
+                    : info.chaos.reason != null ? info.chaos.reason : "Chaos 
is off in " + info.name);
+            return;
+        }
+        pid = info.pid;
+        name = info.name;
+        id = null;
+        rule = null;
+        command = Command.ADD;
+        if (explorer.isRunning(info.pid)
+                || info.chaos.exploration != null && 
info.chaos.exploration.running()) {
+            // one exploration at a time, whoever started it
+            resultLines.clear();
+            result("Let there be chaos", false, "An exploration runs in " + 
name + " already: l shows it");
+            return;
+        }
+        exploreDialog = null;
+        step = Step.EXPLORE;
+        JsonObject request = new JsonObject();
+        request.put("action", "chaos");
+        request.put("command", "suggest");
+        String target = pid;
+        String of = name;
+        Runnable task = () -> {
+            JsonObject reply = sender != null ? sender.apply(request) : 
ctx.executeAction(target, request, 5000);
+            if (step != Step.EXPLORE) {
+                // closed while it was asked
+                return;
+            }
+            if (reply == null || reply.getString("error") != null) {
+                step = Step.NONE;
+                resultLines.clear();
+                result("Let there be chaos", true, reply == null
+                        ? "The integration " + of + " did not answer"
+                        : reply.getString("error"));
+                return;
+            }
+            exploreDialog = new ChaosExploreDialog(of, reply);
+        };
+        if (sender != null) {
+            task.run();
+        } else {
+            ctx.backgroundExecutor.execute(task);
+        }
+    }
+
+    private boolean handleExploreKey(KeyEvent ke) {
+        ChaosExploreDialog dialog = exploreDialog;
+        if (dialog == null) {
+            if (ke.isCancel()) {
+                close();
+            }
+            return true;
+        }
+        switch (dialog.handleKey(ke)) {
+            case CLOSED -> close();
+            case START -> {
+                explorer.start(pid, dialog.picked(), dialog.budget(), 
dialog.budgetMillis());
+                exploreStarted = true;
+                close();
+            }
+            default -> {
+                // the dialog used the key
+            }
+        }
+        return true;
+    }
+
     /** Opens a rule typed as one line, to change before Enter: a rule that 
makes a real error again (CAMEL-26723). */
     void openRule(IntegrationInfo info, String text) {
         if (!ChaosHere.available(info)) {
@@ -462,7 +574,7 @@ final class ChaosControl {
             // the form goes back to the list, the list closes
             return handleRecipeKey(KeyEvent.ofKey(KeyCode.ESCAPE));
         }
-        if (step == Step.SUGGEST) {
+        if (step == Step.SUGGEST || step == Step.EXPLORE) {
             close();
             return true;
         }
@@ -475,6 +587,7 @@ final class ChaosControl {
         input = null;
         form = null;
         suggestions = null;
+        exploreDialog = null;
         describing = null;
     }
 
@@ -603,7 +716,16 @@ final class ChaosControl {
                     list.render(frame, area);
                 } else {
                     DialogHelper.renderConfirm(frame, area, "What to Try", 
"Asking " + name + " what to try...", null,
-                            "Esc", "cancel", null, null);
+                            null, null, null, null);
+                }
+            }
+            case EXPLORE -> {
+                ChaosExploreDialog dialog = exploreDialog;
+                if (dialog != null) {
+                    dialog.render(frame, area);
+                } else {
+                    DialogHelper.renderConfirm(frame, area, "Let there be 
chaos", "Asking " + name + " what to try...",
+                            null, null, null, null, null);
                 }
             }
             case INPUT -> {
@@ -704,6 +826,14 @@ final class ChaosControl {
                     TuiHelper.hint(spans, "Esc", "cancel");
                 }
             }
+            case EXPLORE -> {
+                ChaosExploreDialog dialog = exploreDialog;
+                if (dialog != null) {
+                    dialog.renderFooter(spans);
+                } else {
+                    TuiHelper.hint(spans, "Esc", "cancel");
+                }
+            }
             case INPUT -> {
                 TuiHelper.hint(spans, "Enter",
                         command == Command.DESCRIBE ? "ask the AI" : command 
== Command.SAVE ? "save" : "confirm");
@@ -725,6 +855,7 @@ final class ChaosControl {
                     TuiHelper.hint(spans, "s", "save as scenario");
                 }
                 TuiHelper.hint(spans, "R", "new experiment");
+                TuiHelper.hint(spans, "L", "let there be chaos");
                 if (ctx.askAiCallback != null) {
                     TuiHelper.hint(spans, "d", "describe with AI");
                     if (hasRules) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreDialog.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreDialog.java
new file mode 100644
index 000000000000..6d634cc4044b
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreDialog.java
@@ -0,0 +1,363 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Overflow;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * Let there be chaos ({@code L} on the Chaos screen, CAMEL-26760): the start 
of an exploration of the integration, by
+ * hand. The rules to try are the suggestions of the integration, the most 
worth trying first, each with what should
+ * hold despite its faults; the ones worth trying now are picked. The 
exploration tries the picked rules one at a time,
+ * each for its share of the budget (see {@link ChaosExploreRun}).
+ */
+final class ChaosExploreDialog {
+
+    /** What a key did to the dialog. */
+    enum Outcome {
+        NONE,
+        CLOSED,
+        START
+    }
+
+    /**
+     * A suggestion to try.
+     *
+     * @param rule        its rules, separated by ; for two faults at once
+     * @param expect      what should hold, such as orders:failed<=0
+     * @param fed         whether its route gets messages without a feed (a 
timer, or a route it is called from)
+     * @param replay      how many files the file route took, which are put 
back so it takes them again (0 for none)
+     * @param replayRoute the file route whose files are put back: the route, 
or a file route that sends to it
+     */
+    record Item(String route, String kind, String title, String rule, String 
why, String expect, boolean tried,
+            boolean fed, int replay, String replayRoute) {
+
+        Item(String route, String kind, String title, String rule, String why, 
String expect, boolean tried,
+             boolean fed) {
+            this(route, kind, title, rule, why, expect, tried, fed, 0, null);
+        }
+
+        /** Whether its route gets messages while the rule runs: by itself, or 
from the files it took, put back. */
+        boolean hasMessages() {
+            return fed || replay > 0;
+        }
+
+        /** Whether the files the route took are put back when the rule 
starts. */
+        boolean replays() {
+            return !fed && replay > 0;
+        }
+
+        boolean combined() {
+            return "combined".equals(kind);
+        }
+
+        boolean deadLetter() {
+            return "dead letter channel".equals(kind);
+        }
+    }
+
+    /** The budgets to pick from: an exploration by hand is short, the long 
ones are for camel cmd chaos-explore. */
+    static final List<String> BUDGETS = List.of("2m", "5m", "10m", "15m", 
"30m");
+    // the shortest time a rule is tried, so its faults fire and the routes 
have messages
+    static final long MIN_SHARE = 30_000;
+    // the rules picked when the dialog opens: single faults, then two at once
+    private static final int PICKED_SINGLE = 3;
+    private static final int PICKED_COMBINED = 1;
+    private static final Pattern EXPECT = Pattern.compile("expect=(\\S+)");
+
+    private final String name;
+    private final List<Item> items = new ArrayList<>();
+    private final List<Boolean> picked = new ArrayList<>();
+    private int selected;
+    private int budget = BUDGETS.indexOf("10m");
+
+    ChaosExploreDialog(String name, JsonObject reply) {
+        this.name = name;
+        if (reply != null && reply.getCollection("suggestions") != null) {
+            for (Object o : reply.getCollection("suggestions")) {
+                if (o instanceof JsonObject jo && jo.getString("rule") != 
null) {
+                    // two faults at once (CAMEL-26772): the rules are added 
together, separated by ;
+                    List<Object> rules = jo.getCollection("rules");
+                    String rule = rules != null && !rules.isEmpty()
+                            ? 
rules.stream().map(String::valueOf).collect(Collectors.joining("; "))
+                            : jo.getString("rule");
+                    Matcher m = EXPECT.matcher(rule);
+                    JsonObject feed = jo.getMap("feed");
+                    items.add(new Item(
+                            jo.getString("route"), jo.getString("kind"), 
jo.getString("title"), rule, jo.getString("why"),
+                            m.find() ? m.group(1) : null, 
jo.getBooleanOrDefault("tried", false),
+                            feed != null && feed.getBooleanOrDefault("fed", 
false),
+                            feed != null && feed.getString("replay") != null ? 
feed.getIntegerOrDefault("files", 0) : 0,
+                            feed != null ? 
feed.getStringOrDefault("replayRoute", jo.getString("route")) : null));
+                }
+            }
+        }
+        // the faults not tried yet whose routes get messages by themselves, 
the most worth trying first: a few single
+        // ones, then two at once, as most bugs that lose messages need two 
failures that meet; a route without messages
+        // says nothing, and a dead letter channel fails only when another 
fault sends it a message (the pair does)
+        items.forEach(item -> picked.add(false));
+        // a kind of fault once, before one is tried again in another route: 
the budget asks more questions so
+        pick(true);
+        pick(false);
+    }
+
+    private void pick(boolean distinctKinds) {
+        Set<String> kinds = new HashSet<>();
+        int singles = 0;
+        int combined = 0;
+        for (int i = 0; i < items.size(); i++) {
+            Item item = items.get(i);
+            if (picked.get(i)) {
+                kinds.add(item.kind());
+                if (item.combined()) {
+                    combined++;
+                } else {
+                    singles++;
+                }
+            }
+        }
+        for (int i = 0; i < items.size(); i++) {
+            Item item = items.get(i);
+            boolean worth = !picked.get(i) && !item.tried() && 
item.hasMessages() && item.expect() != null && !item.deadLetter()
+                    && (!distinctKinds || !kinds.contains(item.kind()));
+            if (worth && (item.combined() ? combined < PICKED_COMBINED : 
singles < PICKED_SINGLE)) {
+                picked.set(i, true);
+                kinds.add(item.kind());
+                if (item.combined()) {
+                    combined++;
+                } else {
+                    singles++;
+                }
+            }
+        }
+    }
+
+    List<Item> items() {
+        return items;
+    }
+
+    /** The picked rules, in the order of the list. */
+    List<Item> picked() {
+        List<Item> answer = new ArrayList<>();
+        for (int i = 0; i < items.size(); i++) {
+            if (picked.get(i)) {
+                answer.add(items.get(i));
+            }
+        }
+        return answer;
+    }
+
+    String budget() {
+        return BUDGETS.get(budget);
+    }
+
+    long budgetMillis() {
+        return TimeUtils.toMilliSeconds(budget());
+    }
+
+    /** The time each picked rule is tried, or 0 when none is picked. */
+    long share() {
+        int count = picked().size();
+        return count == 0 ? 0 : budgetMillis() / count;
+    }
+
+    /** Why the exploration cannot start, or null when it can. */
+    String cannotStart() {
+        if (picked().isEmpty()) {
+            return !items.isEmpty() && 
items.stream().noneMatch(Item::hasMessages)
+                    ? "The routes here get their messages from outside: pick a 
rule (Space), and send its route messages"
+                      + " while it runs, or its verdict says nothing"
+                    : "Pick a rule to try (Space)";
+        }
+        if (share() < MIN_SHARE) {
+            return "Too many rules for " + budget() + ": pick fewer, or a 
longer budget";
+        }
+        return null;
+    }
+
+    Outcome handleKey(KeyEvent ke) {
+        if (ke.isCancel()) {
+            return Outcome.CLOSED;
+        }
+        if (ke.isUp() && selected > 0) {
+            selected--;
+        } else if (ke.isDown() && selected < items.size() - 1) {
+            selected++;
+        } else if (ke.isKey(KeyCode.LEFT) && budget > 0) {
+            budget--;
+        } else if (ke.isKey(KeyCode.RIGHT) && budget < BUDGETS.size() - 1) {
+            budget++;
+        } else if (ke.isChar(' ') && !items.isEmpty()) {
+            picked.set(selected, !picked.get(selected));
+        } else if (ke.isConfirm() && cannotStart() == null) {
+            return Outcome.START;
+        }
+        return Outcome.NONE;
+    }
+
+    void render(Frame frame, Rect area) {
+        int width = Math.min(area.width() - 4, 150);
+        int height = Math.min(area.height() - 2, items.isEmpty() ? 5 : 
Math.max(16, items.size() + 10));
+        Rect popup = DialogHelper.centered(area, width, height);
+        frame.renderWidget(Clear.INSTANCE, popup);
+        Block outer = 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).borderStyle(Theme.warning())
+                .title(" Let there be chaos: " + name + " ").build();
+        frame.renderWidget(outer, popup);
+        Rect inner = outer.inner(popup);
+        if (items.isEmpty()) {
+            
frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from(List.of(Line.empty(),
+                    Line.from(Span.styled(" Nothing to try: the routes call 
nothing remote, and have no input from"
+                                          + " outside, dead letter channel or 
hand-off to another route",
+                            Theme.muted())))))
+                    .build(),
+                    inner);
+            return;
+        }
+        List<Rect> rows = Layout.vertical()
+                .constraints(Constraint.length(2), Constraint.fill(), 
Constraint.length(3))
+                .split(inner);
+        renderBudget(frame, rows.get(0));
+        List<Rect> cols = Layout.horizontal()
+                .constraints(Constraint.length(Math.min(76, 
rows.get(1).width() / 2)), Constraint.fill())
+                .split(rows.get(1));
+        renderList(frame, cols.get(0));
+        renderDetail(frame, cols.get(1));
+        String why = cannotStart();
+        
frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from(
+                Line.from(Span.styled(why != null
+                        ? " " + why
+                        : " The rules are tried one at a time on " + name + ", 
each ends with PASS or FAIL. Every rule"
+                          + " is removed when the budget ends, and the routes 
are not changed.",
+                        why != null ? Theme.warning() : Theme.muted()))))
+                
.block(Block.builder().borders(EnumSet.of(Borders.TOP)).borderStyle(Theme.border()).build()).build(),
+                rows.get(2));
+    }
+
+    private void renderBudget(Frame frame, Rect area) {
+        int count = picked().size();
+        List<Span> spans = new ArrayList<>();
+        spans.add(Span.styled(" Budget  ", Theme.label()));
+        spans.add(Span.styled("◂ " + budget() + " ▸", Style.EMPTY.bold()));
+        spans.add(Span.styled(count == 0
+                ? "   no rule picked"
+                : "   " + count + (count == 1 ? " rule" : " rules") + ", " + 
ChaosTab.shortTime(share()) + " each",
+                Theme.muted()));
+        
frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(spans))).build(),
 area);
+    }
+
+    private void renderList(Frame frame, Rect area) {
+        List<Line> lines = new ArrayList<>();
+        lines.add(Line.from(Span.styled(" Rules to try, the most worth trying 
first", Style.EMPTY.bold())));
+        int selectedLine = 0;
+        for (int i = 0; i < items.size(); i++) {
+            Item item = items.get(i);
+            boolean sel = i == selected;
+            if (sel) {
+                selectedLine = lines.size();
+            }
+            Style style = sel ? Theme.selectionBg().bold() : Style.EMPTY;
+            List<Span> spans = new ArrayList<>();
+            spans.add(Span.styled((sel ? " > " : "   ") + (picked.get(i) ? 
"[x] " : "[ ] ") + item.title(), style));
+            spans.add(Span.styled("  " + item.route(), Theme.muted()));
+            if (item.tried()) {
+                spans.add(Span.styled("  tried", Theme.success()));
+            }
+            if (item.replays()) {
+                spans.add(Span.styled("  replays " + item.replay() + 
(item.replay() == 1 ? " file" : " files"),
+                        Theme.muted()));
+            } else if (!item.fed()) {
+                spans.add(Span.styled("  needs messages", Theme.warning()));
+            }
+            lines.add(Line.from(spans));
+        }
+        // keep the selected one in sight
+        int scroll = Math.max(0, selectedLine - (area.height() - 2));
+        
frame.renderWidget(Paragraph.builder().text(Text.from(lines)).scroll(scroll)
+                
.block(Block.builder().borders(EnumSet.of(Borders.RIGHT)).borderStyle(Theme.border()).build()).build(),
 area);
+    }
+
+    private void renderDetail(Frame frame, Rect area) {
+        Item item = items.get(selected);
+        List<Line> lines = new ArrayList<>();
+        lines.add(Line.from(Span.styled(" " + item.title(), 
Style.EMPTY.bold()),
+                Span.styled("  route " + item.route() + " · " + item.kind(), 
Theme.muted())));
+        lines.add(Line.empty());
+        lines.add(Line.from(Span.raw(" " + item.why())));
+        lines.add(Line.empty());
+        lines.add(Line.from(Span.styled(" " + item.rule(), Theme.warning())));
+        if (item.expect() != null) {
+            lines.add(Line.empty());
+            lines.add(Line.from(Span.styled(" Should hold: ", Theme.label()), 
Span.raw(item.expect())));
+        }
+        if (item.tried()) {
+            lines.add(Line.empty());
+            lines.add(Line.from(Span.styled(" A rule of this kind was tried in 
route " + item.route() + " already",
+                    Theme.success())));
+        }
+        if (item.replays()) {
+            lines.add(Line.empty());
+            String who = item.route().equals(item.replayRoute()) ? "it" : 
"route " + item.replayRoute();
+            lines.add(Line.from(Span.styled(" While the rule runs, the " + 
item.replay()
+                                            + (item.replay() == 1 ? " file " : 
" files ") + who + " took are put back into"
+                                            + " its folder as it takes them, 
the same files each time",
+                    Theme.muted())));
+        } else if (!item.fed()) {
+            lines.add(Line.empty());
+            lines.add(Line.from(Span.styled(" Route " + item.route() + " gets 
its messages from outside: send it some"
+                                            + " while the rule runs, or the 
verdict says nothing (INCONCLUSIVE)",
+                    Theme.warning())));
+        }
+        
frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from(lines)).build(),
 area);
+    }
+
+    void renderFooter(List<Span> spans) {
+        if (!items.isEmpty()) {
+            TuiHelper.hint(spans, "↑↓", "choose");
+            TuiHelper.hint(spans, "Space", "pick");
+            TuiHelper.hint(spans, "←→", "budget");
+            if (cannotStart() == null) {
+                TuiHelper.hint(spans, "Enter", "start");
+            }
+        }
+        TuiHelper.hint(spans, "Esc", "cancel");
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreRun.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreRun.java
new file mode 100644
index 000000000000..2a6da6f405cd
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreRun.java
@@ -0,0 +1,307 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.apache.camel.dsl.jbang.core.common.ChaosExperimentRun;
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
+import org.apache.camel.util.json.JsonObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An exploration started from the Chaos screen (let there be chaos, 
CAMEL-26760): the picked rules are tried one at a
+ * time, each as an experiment for its share of the budget, and the 
exploration is finished at the end. It runs on a
+ * thread of its own, as an experiment waits for its verdict; camel-chaos 
enforces the budget, so its rules end at the
+ * deadline also when the TUI is gone. The Explore view shows the experiments 
as the integration reports them, and this
+ * says which rule is being tried.
+ */
+final class ChaosExploreRun {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(ChaosExploreRun.class);
+    private static final Pattern UNTIL = Pattern.compile("\\buntil=\\S+");
+    private static final Pattern WITHIN = Pattern.compile("\\bwithin=\\S+");
+    // the faults that act on the route once, for their duration, and take no 
until=
+    private static final Pattern ONCE = 
Pattern.compile("\\s(stall|lifecycle)\\b");
+    // the order of the verdicts in the summary
+    private static final List<String> VERDICTS = List.of("PASS", "FAIL", 
"INCONCLUSIVE", "REFUSED");
+    // left over at the deadline, for the last experiment to end before 
camel-chaos ends it
+    private static final long MARGIN = 3000;
+    // how much longer than its share an experiment may wait for its verdict
+    private static final long GRACE = 5000;
+
+    // the chaos action and the status of the integration with the pid, for 
the experiments
+    private final Function<String, ChaosExperimentRun.Integration> 
integrations;
+    private Thread thread;
+    // set on the thread of the run, read when the screen is drawn
+    private volatile String pid;
+    private volatile List<ChaosExploreDialog.Item> picks = List.of();
+    private volatile int current;
+    private volatile long share;
+    private volatile long deadline;
+    private volatile long ruleStarted;
+    private volatile boolean running;
+    private volatile boolean stopped;
+    private volatile String outcome;
+    // why the first rule the integration refused was refused
+    private volatile String refusal;
+    private volatile long started;
+    private volatile String budget;
+    private final Map<String, Integer> verdicts = new TreeMap<>(
+            (a, b) -> Integer.compare(order(a), order(b)) != 0 ? 
Integer.compare(order(a), order(b)) : a.compareTo(b));
+
+    ChaosExploreRun(MonitorContext ctx) {
+        this(pid -> integration(ctx, pid));
+    }
+
+    ChaosExploreRun(Function<String, ChaosExperimentRun.Integration> 
integrations) {
+        this.integrations = integrations;
+    }
+
+    /** Whether an exploration started here runs in the integration with the 
pid. */
+    boolean isRunning(String pid) {
+        return running && pid != null && pid.equals(this.pid);
+    }
+
+    /** Starts the exploration: the picked rules one at a time, each for its 
share of the budget. */
+    synchronized void start(String pid, List<ChaosExploreDialog.Item> picks, 
String budget, long budgetMillis) {
+        if (running) {
+            return;
+        }
+        this.pid = pid;
+        this.picks = List.copyOf(picks);
+        this.share = budgetMillis / Math.max(1, picks.size());
+        this.started = MonotonicClock.millis();
+        this.ruleStarted = started;
+        this.deadline = started + budgetMillis;
+        this.budget = budget;
+        this.current = 0;
+        this.stopped = false;
+        this.outcome = null;
+        this.refusal = null;
+        synchronized (verdicts) {
+            verdicts.clear();
+        }
+        running = true;
+        thread = new Thread(() -> run(budget), "camel-tui-chaos-explore");
+        thread.setDaemon(true);
+        thread.start();
+    }
+
+    /** Stops the exploration: it is finished now, which removes its rules. */
+    synchronized void stop() {
+        if (!running) {
+            return;
+        }
+        stopped = true;
+        if (thread != null) {
+            // the experiment that waits for its verdict is ended, and the 
exploration finished, on the way out
+            thread.interrupt();
+        }
+    }
+
+    /** Runs the exploration, on its own thread. */
+    void run(String budget) {
+        ChaosExperimentRun.Integration integration = integrations.apply(pid);
+        try {
+            integration.chaos("explore-start", root -> root.put("budget", 
budget));
+        } catch (RuntimeException e) {
+            outcome = "Not started: " + e.getMessage();
+            running = false;
+            return;
+        }
+        try {
+            for (int i = 0; i < picks.size() && !stopped; i++) {
+                current = i;
+                ruleStarted = MonotonicClock.millis();
+                // the time left, shared by the rules left: an experiment 
takes a little longer than its rules run
+                share = Math.max(1000, (deadline - ruleStarted - MARGIN) / 
(picks.size() - i));
+                ChaosExploreDialog.Item pick = picks.get(i);
+                String verdict;
+                try {
+                    // a file route that does not get messages by itself takes 
the files it took again; the rule as it
+                    // ran says until= and within= of the share, which the 
note explains
+                    String note = "Tried for its " + ChaosTab.shortTime(share) 
+ " share of the " + budget
+                                  + " budget: until= and within= are set to 
it";
+                    JsonObject ended = ChaosExperimentRun.run(integration, 
pick.route() + ": " + pick.title(),
+                            forShare(pick.rule(), share),
+                            pick.replays() ? 
ChaosExperimentRun.Feed.replay(pick.replayRoute()) : null,
+                            share + GRACE, note);
+                    verdict = ended.getString("verdict");
+                    if (ended.getLongOrDefault("timeLeft", 1) <= 0) {
+                        // the deadline of the exploration: camel-chaos has 
ended it
+                        count(verdict);
+                        break;
+                    }
+                } catch (IllegalStateException e) {
+                    // interrupted by stop
+                    break;
+                } catch (RuntimeException e) {
+                    // the integration refused it, such as a rule that is not 
valid there: the next one
+                    LOG.debug("Chaos experiment {} refused: {}", pick.rule(), 
e.getMessage());
+                    verdict = "REFUSED";
+                    if (refusal == null) {
+                        refusal = "rule " + (i + 1) + " refused: " + 
e.getMessage();
+                    }
+                }
+                count(verdict);
+            }
+        } finally {
+            // a stop interrupted the wait for the verdict: the finish is 
still sent
+            Thread.interrupted();
+            try {
+                integration.chaos("explore-finish", root -> {
+                });
+            } catch (RuntimeException e) {
+                // finished already, at its deadline
+                LOG.debug("Chaos exploration finished already: {}", 
e.getMessage());
+            }
+            int tried = tried();
+            String counts = tried + (tried == 1 ? " rule" : " rules") + " 
tried" + (tried == 0 ? "" : ", " + verdictCounts());
+            outcome = (stopped
+                    ? "Stopped after " + counts
+                    : "Done after " + 
ChaosTab.shortTime(MonotonicClock.millis() - started) + " of " + budget + ": "
+                      + counts)
+                      + (refusal != null ? " (" + refusal + ")" : "");
+            running = false;
+        }
+    }
+
+    private static int order(String verdict) {
+        int i = VERDICTS.indexOf(verdict);
+        return i >= 0 ? i : VERDICTS.size();
+    }
+
+    private void count(String verdict) {
+        synchronized (verdicts) {
+            verdicts.merge(verdict != null ? verdict : "INCONCLUSIVE", 1, 
Integer::sum);
+        }
+    }
+
+    private int tried() {
+        synchronized (verdicts) {
+            return 
verdicts.values().stream().mapToInt(Integer::intValue).sum();
+        }
+    }
+
+    private String verdictCounts() {
+        synchronized (verdicts) {
+            return verdicts.entrySet().stream().map(e -> e.getValue() + " " + 
e.getKey())
+                    .collect(Collectors.joining(", "));
+        }
+    }
+
+    /**
+     * What the exploration started here is doing, for the Explore view of the 
integration with the pid: the rule it
+     * tries, or how it ended. Null when none was started here for it.
+     */
+    String progress(String pid) {
+        if (pid == null || !pid.equals(this.pid)) {
+            return null;
+        }
+        if (!running) {
+            return outcome;
+        }
+        if (stopped) {
+            return "Stopping: the rules are removed";
+        }
+        List<ChaosExploreDialog.Item> list = picks;
+        int i = current;
+        if (i >= list.size()) {
+            return null;
+        }
+        ChaosExploreDialog.Item pick = list.get(i);
+        long left = share - (MonotonicClock.millis() - ruleStarted);
+        return "Rule " + (i + 1) + " of " + list.size() + ": " + pick.route() 
+ ", " + pick.title()
+               + (left > 0
+                       ? ", " + ChaosTab.countdown(left) + " left of " + 
ChaosTab.shortTime(share)
+                       : ", waiting for the verdict")
+               + (tried() == 0 ? "" : " · " + verdictCounts());
+    }
+
+    /**
+     * The line above the exploration: the rule being tried while it runs; 
after it, only what the exploration does not
+     * show itself (a rule the integration refused, or why it did not start). 
Null when there is nothing to say.
+     */
+    String line(String pid) {
+        String answer = progress(pid);
+        if (answer == null || running || refusal != null || 
answer.startsWith("Not started")) {
+            return answer;
+        }
+        return null;
+    }
+
+    /**
+     * The rules for their share of the budget: each ends when its share is up 
({@code until=}), and an expectation that
+     * waits longer for its messages ({@code within=}) waits that long at 
most. A fault that acts once for its duration
+     * (stall, lifecycle) takes no until: its expectation is decided when the 
share is up instead.
+     */
+    static String forShare(String rules, long share) {
+        String seconds = Math.max(1, share / 1000) + "s";
+        return Arrays.stream(rules.split(";")).map(String::strip).filter(r -> 
!r.isEmpty()).map(r -> {
+            if (ONCE.matcher(r).find()) {
+                if (WITHIN.matcher(r).find()) {
+                    return WITHIN.matcher(r).replaceAll("within=" + seconds);
+                }
+                return r.contains(" expect=") ? r + " within=" + seconds : r;
+            }
+            String rule = UNTIL.matcher(r).find() ? 
UNTIL.matcher(r).replaceAll("until=" + seconds) : r + " until=" + seconds;
+            return WITHIN.matcher(rule).replaceAll("within=" + seconds);
+        }).collect(Collectors.joining("; "));
+    }
+
+    /** The integration with the pid, as the TUI reaches it: its chaos action, 
and its status file. */
+    private static ChaosExperimentRun.Integration integration(MonitorContext 
ctx, String pid) {
+        return new ChaosExperimentRun.Integration() {
+            @Override
+            public JsonObject chaos(String command, Consumer<JsonObject> 
options) {
+                JsonObject request = new JsonObject();
+                request.put("action", "chaos");
+                request.put("command", command);
+                options.accept(request);
+                JsonObject reply = ctx.executeAction(pid, request, 10000);
+                if (reply == null) {
+                    throw new IllegalArgumentException("The integration did 
not answer");
+                }
+                if (reply.getString("error") != null) {
+                    throw new 
IllegalArgumentException(reply.getString("error"));
+                }
+                return reply;
+            }
+
+            @Override
+            public JsonObject send(ChaosExperimentRun.Feed feed) {
+                // the exploration of the TUI sends no messages: its routes 
get them by themselves, or take the files they
+                // took again (a replay, which is a chaos command)
+                throw new UnsupportedOperationException("No feed");
+            }
+
+            @Override
+            public JsonObject status() {
+                return RuntimeHelper.readStatus(Long.parseLong(pid));
+            }
+        };
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
index df2761924eb2..8c3e942a0a9e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
@@ -57,16 +57,25 @@ final class ChaosExploreView {
     /**
      * Renders the exploration.
      *
-     * @return the area of the table of experiments, for the clicks
+     * @param  progress what the exploration started on this screen is doing 
(L), or null
+     * @return          the area of the table of experiments, for the clicks
      */
-    static Rect render(Frame frame, Rect area, ChaosInfo.Exploration ex, 
TableState state, String name) {
+    static Rect render(
+            Frame frame, Rect area, ChaosInfo.Exploration ex, TableState 
state, String name, String progress) {
+        if (progress != null) {
+            List<Rect> rows = 
Layout.vertical().constraints(Constraint.length(1), 
Constraint.fill()).split(area);
+            frame.renderWidget(Paragraph.builder()
+                    .text(Text.from(Line.from(Span.styled(" 🎲 " + progress, 
Theme.warning())))).build(), rows.get(0));
+            area = rows.get(1);
+        }
         if (ex == null) {
             frame.renderWidget(Paragraph.builder()
                     .text(Text.from(Line.from(Span.raw("")),
-                            Line.from(Span.styled("  No exploration yet. Let 
there be chaos: an AI tries faults on the"
-                                                  + " integration by itself 
for a budget of time, and reports",
+                            Line.from(Span.styled("  No exploration yet. Let 
there be chaos: press L to pick the rules to"
+                                                  + " try, and they are tried 
one at a time for a budget of time.",
                                     Theme.muted())),
-                            Line.from(Span.styled("  what held and what broke: 
 camel cmd chaos-explore "
+                            Line.from(Span.styled("  Or let an AI try faults 
by itself, and report what held and what"
+                                                  + " broke:  camel cmd 
chaos-explore "
                                                   + (name != null ? name : 
"<name>") + " --budget=10m",
                                     Theme.muted()))))
                     .block(block(" Explore "))
@@ -100,8 +109,10 @@ final class ChaosExploreView {
                                + (ex.ran() > 0 ? " after " + 
ChaosTab.shortTime(ex.ran()) : "")
                                + " (budget " + ChaosTab.shortTime(ex.budget()) 
+ ")"));
         }
-        first.add(Span.styled("   focus ", Theme.muted()));
-        first.add(Span.raw(ex.focus() != null ? ex.focus() : "all routes"));
+        if (ex.focus() != null) {
+            first.add(Span.styled("   focus ", Theme.muted()));
+            first.add(Span.raw(ex.focus()));
+        }
         first.add(Span.styled("   experiments ", Theme.muted()));
         first.add(Span.raw(String.valueOf(ex.experiments().size())));
         long pass = ex.count("PASS");
@@ -205,6 +216,8 @@ final class ChaosExploreView {
                     .build(), area);
             return;
         }
+        // the width of the evidence column (2 of the 5 parts the last two 
share), for a clean cut
+        int evidenceWidth = Math.max(10, (area.width() - 2 - 3 - 3 - 6 - 13 - 
4) * 2 / 5);
         List<Row> rows = new ArrayList<>();
         for (ChaosInfo.Experiment e : ex.experiments()) {
             rows.add(Row.from(
@@ -212,7 +225,7 @@ final class ChaosExploreView {
                     Cell.from(Span.styled(clock(e.at()), Theme.muted())),
                     Cell.from(Span.styled(verdict(e), verdictStyle(e).bold())),
                     Cell.from(e.hypothesis() != null ? e.hypothesis() : 
String.join("; ", e.rules())),
-                    Cell.from(Span.styled(evidence(e), Theme.muted()))));
+                    Cell.from(Span.styled(cut(evidence(e), evidenceWidth), 
Theme.muted()))));
         }
         Style bold = Style.EMPTY.bold();
         Table table = Table.builder()
@@ -255,19 +268,53 @@ final class ChaosExploreView {
             sj.add(e.lostAtDlc() + " lost at the dead letter channel");
         }
         for (String x : e.expectations()) {
-            // orders:failed<=0: unmet, 5 failed -> 5 failed in orders
+            // orders:failed<=0: unmet, 5 failed -> 5 failed in orders; the 
counts in parentheses are in the detail
             int colon = x.indexOf(": ");
             int comma = x.indexOf(", ", colon + 2);
             String route = x.contains(":") ? x.substring(0, x.indexOf(':')) : 
"";
             if (comma > 0) {
                 String value = x.substring(comma + 2);
-                sj.add(value + (route.isEmpty() || value.contains(route) ? "" 
: " in " + route));
+                int paren = value.indexOf(" (");
+                value = paren > 0 ? value.substring(0, paren) : value;
+                int arrow = route.indexOf("->");
+                if (arrow > 0) {
+                    // inbox->ship:lost<=0
+                    sj.add(value + " from " + route.substring(0, arrow) + " to 
" + route.substring(arrow + 2));
+                } else {
+                    sj.add(value + (route.isEmpty() || value.contains(route) ? 
"" : " in " + route));
+                }
             }
         }
-        e.alsoFailed().forEach((route, n) -> sj.add("also " + n + " failed in 
" + route));
+        e.alsoFailed().forEach((route, n) -> {
+            if (handedOffTo(e, route) == null) {
+                sj.add("also " + n + " failed in " + route);
+            }
+        });
         return sj.toString();
     }
 
+    /**
+     * The route that handed the messages over to the route, when an 
expectation of the experiment counts what is lost
+     * between them ({@code inbox->ship:lost<=0}): the failures of the route 
are then where the lost messages failed.
+     * Null otherwise.
+     */
+    static String handedOffTo(ChaosInfo.Experiment e, String route) {
+        for (String x : e.expectations()) {
+            int arrow = x.indexOf("->");
+            int colon = x.indexOf(':');
+            if (arrow > 0 && colon > arrow && x.substring(arrow + 2, 
colon).equals(route)
+                    && x.startsWith(":lost", colon)) {
+                return x.substring(0, arrow);
+            }
+        }
+        return null;
+    }
+
+    /** The text cut to the width, with … where it was cut. */
+    static String cut(String text, int width) {
+        return text.length() <= width ? text : text.substring(0, Math.max(0, 
width - 1)).stripTrailing() + "…";
+    }
+
     // ---- the selected experiment ----
 
     private static void renderDetail(Frame frame, Rect area, 
ChaosInfo.Experiment e) {
@@ -302,11 +349,27 @@ final class ChaosExploreView {
                                 + " at the dead letter channel, handled there 
without being delivered",
                             Theme.error())));
         }
-        if (!e.alsoFailed().isEmpty()) {
-            StringJoiner sj = new StringJoiner(", ");
-            e.alsoFailed().forEach((route, n) -> sj.add(route + " " + n));
+        StringJoiner also = new StringJoiner(", ");
+        e.alsoFailed().forEach((route, n) -> {
+            String from = handedOffTo(e, route);
+            if (from != null) {
+                // the same messages as the lost ones: route ship failed them 
after inbox had completed
+                lines.add(Line.from(Span.styled(" where     ", label),
+                        Span.styled(n + (n == 1 ? " message" : " messages") + 
" failed in " + route + ", after " + from
+                                    + " had handed them over and completed: 
they are the lost ones",
+                                Theme.error())));
+            } else {
+                also.add(route + " " + n);
+            }
+        });
+        if (also.length() > 0) {
             lines.add(Line.from(Span.styled(" also      ", label),
-                    Span.styled("failed, in no expectation: " + sj, 
Theme.warning())));
+                    Span.styled("failed, in no expectation: " + also, 
Theme.warning())));
+        }
+        if (!e.routes().isEmpty()) {
+            // the expectations count until they are decided, the routes until 
the experiment ended: a little more
+            lines.add(Line.from(Span.styled(" routes    ", label),
+                    Span.styled("over the whole experiment (an expectation 
counts until it is decided)", label)));
         }
         for (String r : e.routes()) {
             lines.add(Line.from(Span.styled(" route     ", label), 
Span.raw(r)));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
index b9733e122b76..6b557dba32e9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
@@ -67,6 +67,8 @@ class ChaosTab extends AbstractTableTab {
     // the exploration instead of the rules (l, let there be chaos), and the 
selected rule meanwhile
     private boolean explore;
     private Integer rulesSelection;
+    // the selection follows the experiment that runs, of an exploration 
started here, until another one is selected
+    private boolean followRunning;
 
     ChaosTab(MonitorContext ctx) {
         this(ctx, new ChaosControl(ctx));
@@ -125,11 +127,20 @@ class ChaosTab extends AbstractTableTab {
             toggleExplore();
             return true;
         }
-        if (explore) {
-            // the exploration is read-only: the keys of the rules do not act 
on it
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (ke.isChar('L') && control.step() == ChaosControl.Step.NONE) {
+            // let there be chaos: pick the rules to try, for a budget of time 
(CAMEL-26760)
+            control.openExplore(info);
+            return true;
+        }
+        if (explore && control.step() == ChaosControl.Step.NONE) {
+            // the exploration is read-only: the keys of the rules do not act 
on it, but one started here can be stopped
+            if (ke.isChar('x') && info != null && 
control.explorer().isRunning(info.pid)) {
+                control.explorer().stop();
+                return true;
+            }
             return false;
         }
-        IntegrationInfo info = ctx.findSelectedIntegration();
         ChaosInfo.Rule rule = info != null && info.chaos != null ? 
selectedRule(info.chaos) : null;
         if (ke.isKey(KeyCode.F8) && ke.hasShift() && control.step() == 
ChaosControl.Step.NONE) {
             // explain with AI what the selected rule did (CAMEL-26702)
@@ -144,7 +155,15 @@ class ChaosTab extends AbstractTableTab {
             ctx.askAiCallback.ask(ChaosAiPrompt.ofTest(info, rule));
             return true;
         }
-        return control.handleKey(ke, info, rule);
+        boolean used = control.handleKey(ke, info, rule);
+        if (control.takeExploreStarted()) {
+            // the exploration that was just started
+            if (!explore) {
+                toggleExplore();
+            }
+            followRunning = true;
+        }
+        return used;
     }
 
     @Override
@@ -190,6 +209,7 @@ class ChaosTab extends AbstractTableTab {
     @Override
     public void navigateUp() {
         if (!control.isOpen()) {
+            followRunning = false;
             super.navigateUp();
         }
     }
@@ -197,6 +217,7 @@ class ChaosTab extends AbstractTableTab {
     @Override
     public void navigateDown() {
         if (!control.isOpen()) {
+            followRunning = false;
             super.navigateDown();
         }
     }
@@ -227,11 +248,19 @@ class ChaosTab extends AbstractTableTab {
                     .text(Text.from(Line.from(Span.raw("")), 
Line.from(Span.styled("  " + why, Theme.muted()))))
                     .block(block(" Chaos "))
                     .build(), area);
+            // such as why L does nothing here
+            control.render(frame, area);
             return;
         }
         if (explore) {
-            lastTableArea = ChaosExploreView.render(frame, area, 
chaos.exploration, tableState, info.name);
+            int experiments = chaos.exploration != null ? 
chaos.exploration.experiments().size() : 0;
+            if (followRunning && experiments > 0 && 
control.explorer().isRunning(info.pid)) {
+                tableState.select(experiments - 1);
+            }
+            lastTableArea = ChaosExploreView.render(frame, area, 
chaos.exploration, tableState, info.name,
+                    control.explorer().line(info.pid));
             lastDetailArea = null;
+            control.render(frame, area);
             return;
         }
         int tableHeight = Math.max(5, Math.min(chaos.rules.size() + 3, 
area.height() / 3));
@@ -849,14 +878,26 @@ class ChaosTab extends AbstractTableTab {
             hint(spans, "Esc", "back");
             return;
         }
-        if (explore) {
+        if (explore && control.step() == ChaosControl.Step.NONE) {
             hint(spans, "↑↓", "experiment");
+            if (control.explorer().isRunning(info.pid)) {
+                hint(spans, "x", "stop");
+            } else {
+                hint(spans, "L", "let there be chaos");
+            }
             hint(spans, "l", "rules");
             hint(spans, "Esc", "back");
             return;
         }
+        if (explore) {
+            // the keys of the open dialog
+            control.renderFooter(spans, false);
+            return;
+        }
         control.renderFooter(spans, !info.chaos.rules.isEmpty());
-        hint(spans, "l", "explore");
+        if (control.step() == ChaosControl.Step.NONE) {
+            hint(spans, "l", "explore");
+        }
     }
 
     @Override
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
index 48e0f8ee4d6c..25aaffcfc818 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
@@ -168,15 +168,20 @@ final class DialogHelper {
         return renderConfirm(frame, area, title, message, detail, acceptKey, 
acceptLabel, extraKey, extraLabel, null);
     }
 
-    /** As above, recording where the keys of the dialog are drawn in {@code 
clicks} (when not null). */
+    /**
+     * As above, recording where the keys of the dialog are drawn in {@code 
clicks} (when not null). Without an
+     * {@code acceptKey} it only waits, such as while the integration is 
asked: Esc cancels.
+     */
     static Rect renderConfirm(
             Frame frame, Rect area, String title, String message, String 
detail, String acceptKey, String acceptLabel,
             String extraKey, String extraLabel, HintClicks clicks) {
         Style accent = Theme.warning();
         String titleText = " " + title + " ";
         String msg = message.trim();
-        Line hints = extraKey != null
-                ? TuiHelper.hintLine(acceptKey, acceptLabel, extraKey, 
extraLabel, "Esc", "cancel")
+        Line hints = acceptKey == null
+                ? TuiHelper.hintLine("Esc", "cancel")
+                : extraKey != null
+                        ? TuiHelper.hintLine(acceptKey, acceptLabel, extraKey, 
extraLabel, "Esc", "cancel")
                 : TuiHelper.hintLine(acceptKey, acceptLabel, "Esc", "cancel");
         int contentW = Math.max(CharWidth.of(msg), Math.max(detail != null ? 
CharWidth.of(detail) : 0, hints.width()));
         int popupW = clampWidth(area, CONFIRM_MIN_WIDTH, Math.max(contentW + 
6, CharWidth.of(titleText) + 4));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
index d9cfabb1b6ae..98b4085650a9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
@@ -76,6 +76,25 @@ at a dead letter channel, the routes that failed outside its 
expectations,
 and why it could not decide (such as faults that never fired). **l** or
 Esc shows the rules again.
 
+**L** starts an exploration here, by hand: pick the rules to try from the
+suggestions of the integration (the same as **w**), the most worth trying
+first, each with what should hold despite its faults. Picked are a few
+faults not tried yet, each kind once before it is tried again in another
+route, and then two faults at once (an outage while the dead letter
+channel fails); only of routes that get messages by themselves (a timer,
+or a route they are called from), or that take files and took some
+already: those are marked *replays 3 files*, and while the rule runs the
+files the route moved to `.camel` are put back into its folder as it
+takes them, the same files each time. A route whose messages come from
+outside is marked *needs messages*: send it some while its rule runs, or
+its verdict says nothing. Space picks a rule, Left and Right change the
+budget (2m to 30m), and Enter starts. The picked rules are tried one at a
+time, each for its share of the budget, and each ends with PASS, FAIL or
+INCONCLUSIVE; the line above the experiments says which rule is tried and
+how long it has left. **x** stops it: the rules are removed at once. Every
+rule is removed when the budget ends, also when the TUI is closed, and the
+routes are not changed. One exploration runs at a time.
+
 ## Keys
 
 - **a** — Add a rule: pick what should go wrong from the known rules of
@@ -96,6 +115,10 @@ Esc shows the rules again.
   was tried in the route and what is not tried yet
 - **e** — Change the selected rule: the line starts with the rule as it is
 - **l** — Explore: the exploration and its experiments, or the rules again
+- **L** — Let there be chaos: pick the rules to try and the budget, and
+  they are tried one at a time (see Explore above). In the dialog: Space
+  picks, Left and Right change the budget, Enter starts
+- **x** — On the exploration: stop the exploration started with **L**
 - **x** — Remove the selected rule
 - **c** — Clear: remove all rules (the report keeps what they did)
 - **R** — A new experiment: no rules and a new report, with the same seed
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreTest.java
new file mode 100644
index 000000000000..8fa1af8c414c
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreTest.java
@@ -0,0 +1,356 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.dsl.jbang.core.common.ChaosExperimentRun;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Let there be chaos from the Chaos screen ({@code L}): the rules to pick 
from the suggestions of the integration, the
+ * budget, and the exploration that tries the picked rules one at a time.
+ */
+class ChaosExploreTest {
+
+    private static final String SUGGESTIONS = """
+            {"suggestions": [
+              {"route": "orders", "kind": "errors", "title": "Returns an error 
(HTTP status)",
+               "rule": "endpoint=http://localhost:8080/payment error 
status=503 rate=0.25 until=10m expect=orders:failed<=0",
+               "why": "an HTTP call without a circuit breaker", "feed": 
{"kind": "self", "fed": true}},
+              {"route": "orders", "kind": "lost", "title": "The route after a 
hand-off fails",
+               "rule": "route=shipping error rate=0.2 until=2m 
expect=orders->shipping:lost<=0 within=3m",
+               "why": "a hand-off over seda", "feed": {"kind": "self", "fed": 
true}},
+              {"route": "refunds", "kind": "consumer", "title": "The route 
fails",
+               "rule": "route=refunds error rate=0.05 until=10m 
expect=refunds:failed<=0",
+               "why": "an input from outside", "feed": {"kind": "send"}},
+              {"route": "payment", "kind": "lifecycle", "title": "The route 
stalls",
+               "rule": "route=payment stall duration=20s 
expect=payment:failed<=0", "tried": true,
+               "why": "stops taking messages", "feed": {"kind": "http", "fed": 
true}},
+              {"route": "orders", "kind": "dead letter channel", "title": "The 
dead letter channel is unreachable",
+               "rule": "endpoint=direct:parked error at=dlq 
expect=orders:failed<=0",
+               "why": "fails only when another fault sends it a message", 
"feed": {"kind": "self", "fed": true}},
+              {"route": "orders", "kind": "combined", "title": "An outage 
while the dead letter channel fails",
+               "rule": "endpoint=http://localhost:8080/payment error 
status=503 until=1m expect=orders:failed<=0",
+               "rules": ["endpoint=http://localhost:8080/payment error 
status=503 until=1m expect=orders:failed<=0",
+                         "endpoint=direct:parked error at=dlq until=1m"],
+               "why": "two faults at once", "feed": {"kind": "self", "fed": 
true}}
+            ]}
+            """;
+
+    private IntegrationInfo info;
+    private ChaosTab tab;
+    private FakeIntegration integration;
+    private ChaosExploreRun explorer;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        Theme.resetForTesting();
+        info = new IntegrationInfo();
+        info.pid = "1234";
+        info.name = "shop";
+        info.chaos = ChaosInfo.parse((JsonObject) Jsoner.deserialize("""
+                {"enabled": true, "rules": [], "report": {"verdict": "PASS", 
"final": true, "seed": 42}}
+                """));
+        MonitorContext ctx = new MonitorContext(new 
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+        ctx.selectedPid = "1234";
+        integration = new FakeIntegration();
+        explorer = new ChaosExploreRun(pid -> integration);
+        JsonObject suggestions = (JsonObject) Jsoner.deserialize(SUGGESTIONS);
+        tab = new ChaosTab(ctx, new ChaosControl(ctx, request -> suggestions, 
() -> null, explorer));
+        TuiTestHelper.renderToString(tab, 160, 40);
+    }
+
+    private static ChaosExploreDialog dialog() throws Exception {
+        return new ChaosExploreDialog("shop", (JsonObject) 
Jsoner.deserialize(SUGGESTIONS));
+    }
+
+    private void key(char ch) {
+        tab.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+    }
+
+    private void press(KeyCode code) {
+        tab.handleKeyEvent(KeyEvent.ofKey(code, KeyModifiers.NONE));
+    }
+
+    private String footer() {
+        List<Span> spans = new ArrayList<>();
+        tab.renderFooter(spans);
+        return spans.stream().map(Span::content).reduce("", String::concat);
+    }
+
+    @Test
+    void theFaultsNotTriedWhoseRoutesGetMessagesArePicked() throws Exception {
+        ChaosExploreDialog dialog = dialog();
+        assertThat(dialog.items()).hasSize(6);
+        // not refunds (its messages come from outside), not the stall 
(tried), not the dead letter channel on its own
+        // (it fails only when another fault sends it a message), and the pair 
after the single ones
+        assertThat(dialog.picked()).extracting(ChaosExploreDialog.Item::title)
+                .containsExactly("Returns an error (HTTP status)", "The route 
after a hand-off fails",
+                        "An outage while the dead letter channel fails");
+        
assertThat(dialog.items().get(0).expect()).isEqualTo("orders:failed<=0");
+        assertThat(dialog.items().get(5).rule()).contains("; 
endpoint=direct:parked error at=dlq");
+        // 10 minutes for three rules
+        assertThat(dialog.budget()).isEqualTo("10m");
+        assertThat(dialog.share()).isEqualTo(200_000);
+    }
+
+    @Test
+    void spacePicksAndTheArrowsChangeTheBudget() throws Exception {
+        ChaosExploreDialog dialog = dialog();
+        dialog.handleKey(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+        dialog.handleKey(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+        dialog.handleKey(KeyEvent.ofChar(' ', KeyModifiers.NONE));
+        assertThat(dialog.picked()).hasSize(4);
+        dialog.handleKey(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+        dialog.handleKey(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+        assertThat(dialog.budget()).isEqualTo("2m");
+        assertThat(dialog.share()).isEqualTo(30_000);
+        assertThat(dialog.cannotStart()).isNull();
+
+        // too many rules for the budget: each needs time for its faults and 
messages
+        dialog.handleKey(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+        dialog.handleKey(KeyEvent.ofChar(' ', KeyModifiers.NONE));
+        assertThat(dialog.picked()).hasSize(5);
+        assertThat(dialog.cannotStart()).startsWith("Too many rules for 2m");
+        assertThat(dialog.handleKey(KeyEvent.ofKey(KeyCode.ENTER, 
KeyModifiers.NONE)))
+                .isEqualTo(ChaosExploreDialog.Outcome.NONE);
+    }
+
+    @Test
+    void eachRuleRunsForItsShare() {
+        assertThat(ChaosExploreRun.forShare(
+                "route=shipping error rate=0.2 until=2m 
expect=orders->shipping:lost<=0 within=3m", 150_000))
+                .isEqualTo("route=shipping error rate=0.2 until=150s 
expect=orders->shipping:lost<=0 within=150s");
+        // a stall acts once, for its duration: no until, and its expectation 
is decided when the share is up
+        assertThat(ChaosExploreRun.forShare("route=payment stall duration=20s 
expect=orders:failed<=0", 60_000))
+                .isEqualTo("route=payment stall duration=20s 
expect=orders:failed<=0 within=60s");
+        // two faults at once: both end with the share
+        assertThat(ChaosExploreRun.forShare("endpoint=a error until=1m; 
endpoint=b error at=dlq until=1m", 30_000))
+                .isEqualTo("endpoint=a error until=30s; endpoint=b error 
at=dlq until=30s");
+    }
+
+    @Test
+    void letThereBeChaosTriesThePickedRulesOneAtATime() {
+        key('L');
+        assertThat(tab.isTextInputActive()).isTrue();
+        String screen = TuiTestHelper.renderToString(tab, 160, 40);
+        assertThat(screen).contains("Let there be chaos: shop", "Budget", 
"10m", "3 rules, 3m20s each",
+                "[x] Returns an error (HTTP status)", "[ ] The route fails", 
"needs messages");
+        assertThat(footer()).contains("Space", "pick", "budget", "Enter", 
"start");
+
+        press(KeyCode.ENTER);
+        // the exploration shows
+        assertThat(tab.isExplore()).isTrue();
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
!explorer.isRunning("1234"));
+        assertThat(integration.commands).containsExactly(
+                "explore-start", "experiment-begin", "experiment-end", 
"experiment-begin", "experiment-end",
+                "experiment-begin", "experiment-end", "explore-finish");
+        assertThat(integration.budget).isEqualTo("10m");
+        // each rule for its share of the time left (the fake ends each 
experiment at once, so the shares grow)
+        assertThat(integration.rules).hasSize(3);
+        assertThat(integration.rules.get(0)).matches(
+                "endpoint=http://localhost:8080/payment error status=503 
rate=0\\.25 until=19\\ds expect=orders:failed<=0");
+        assertThat(integration.rules.get(1)).matches(
+                "route=shipping error rate=0\\.2 until=29\\ds 
expect=orders->shipping:lost<=0 within=29\\ds");
+        // two faults at once: both end with the share
+        assertThat(integration.rules.get(2)).matches(
+                "endpoint=http://localhost:8080/payment error status=503 
until=59\\ds expect=orders:failed<=0; "
+                                                     + "endpoint=direct:parked 
error at=dlq until=59\\ds");
+        assertThat(integration.hypotheses).containsExactly(
+                "orders: Returns an error (HTTP status)", "orders: The route 
after a hand-off fails",
+                "orders: An outage while the dead letter channel fails");
+        assertThat(explorer.progress("1234")).matches("Done after \\d+ms of 
10m: 3 rules tried, 3 FAIL");
+        // each says it ran for its share, as its rule says until= and within= 
of the share
+        assertThat(integration.notes).hasSize(3)
+                .allMatch(n -> n
+                        .matches("Tried for its (\\d+m)?\\d+s share of the 10m 
budget: until= and within= are set to it"));
+        // the exploration shows how it went, so no line above it says it again
+        assertThat(explorer.line("1234")).isNull();
+        assertThat(TuiTestHelper.renderToString(tab, 160, 
40)).doesNotContain("🎲");
+    }
+
+    @Test
+    void aFileRouteTakesTheFilesItTookAgain() throws Exception {
+        ChaosExploreDialog dialog = new ChaosExploreDialog(
+                "shop", (JsonObject) Jsoner.deserialize(
+                        """
+                                {"suggestions": [
+                                  {"route": "inbox", "kind": "errors", 
"title": "Fails to write",
+                                   "rule": "endpoint=file://archive error 
exception=java.io.IOException rate=0.25 until=10m expect=inbox:failed<=0",
+                                   "why": "a write", "feed": {"kind": "send", 
"replay": "/work/inbox/.camel", "files": 3, "replayRoute": "files"}},
+                                  {"route": "uploads", "kind": "consumer", 
"title": "The route fails",
+                                   "rule": "route=uploads error rate=0.05 
until=10m expect=uploads:failed<=0",
+                                   "why": "an input from outside", "feed": 
{"kind": "file"}}
+                                ]}
+                                """));
+        // the files it took are put back, so it has messages; a route that 
took none needs them from outside
+        
assertThat(dialog.picked()).extracting(ChaosExploreDialog.Item::route).containsExactly("inbox");
+        assertThat(dialog.items().get(0).replay()).isEqualTo(3);
+        Rect area = new Rect(0, 0, 160, 30);
+        Buffer buffer = Buffer.empty(area);
+        dialog.render(Frame.forTesting(buffer), area);
+        assertThat(TuiTestHelper.bufferToString(buffer))
+                .contains("[x] Fails to write  inbox  replays 3 files", "[ ] 
The route fails  uploads  needs messages");
+
+        explorer.start("1234", dialog.picked(), "2m", 120_000);
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
!explorer.isRunning("1234"));
+        // after the rule is added, so its faults meet the files
+        assertThat(integration.commands).containsExactly(
+                "explore-start", "experiment-begin", "replay", 
"experiment-end", "explore-finish");
+        // the file route that sends to it
+        assertThat(integration.replayed).containsExactly("files");
+    }
+
+    @Test
+    void stopEndsTheExploration() {
+        integration.decided = false;
+        key('L');
+        press(KeyCode.ENTER);
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
integration.commands.contains("experiment-begin"));
+        assertThat(footer()).contains("x", "stop");
+        assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("Rule 
1 of 3: orders, Returns an error");
+
+        key('x');
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
!explorer.isRunning("1234"));
+        // the experiment is ended and the exploration finished, which removes 
the rules
+        assertThat(integration.commands).endsWith("experiment-end", 
"explore-finish");
+        assertThat(explorer.progress("1234")).startsWith("Stopped after 0 
rules tried");
+        assertThat(footer()).contains("L", "let there be chaos");
+    }
+
+    @Test
+    void aKindOfFaultOnceBeforeItIsTriedAgainInAnotherRoute() throws Exception 
{
+        JsonObject reply = (JsonObject) Jsoner.deserialize("""
+                {"suggestions": [
+                  {"route": "small", "kind": "lost", "title": "The route after 
a hand-off fails",
+                   "rule": "route=a error expect=small->a:lost<=0", "feed": 
{"kind": "self", "fed": true}},
+                  {"route": "medium", "kind": "lost", "title": "The route 
after a hand-off fails",
+                   "rule": "route=b error expect=medium->b:lost<=0", "feed": 
{"kind": "self", "fed": true}},
+                  {"route": "large", "kind": "lost", "title": "The route after 
a hand-off fails",
+                   "rule": "route=c error expect=large->c:lost<=0", "feed": 
{"kind": "self", "fed": true}},
+                  {"route": "small", "kind": "errors", "title": "Returns an 
error (HTTP status)",
+                   "rule": "endpoint=http://x error status=503 
expect=small:failed<=0", "feed": {"kind": "self", "fed": true}}
+                ]}
+                """);
+        ChaosExploreDialog dialog = new ChaosExploreDialog("shop", reply);
+        // the HTTP error before a second hand-off, then the hand-offs again 
while there is room
+        assertThat(dialog.picked()).extracting(ChaosExploreDialog.Item::route, 
ChaosExploreDialog.Item::kind)
+                .containsExactly(tuple("small", "lost"), tuple("medium", 
"lost"), tuple("small", "errors"));
+    }
+
+    @Test
+    void lSaysWhyWhenChaosIsOff() {
+        info.chaos = null;
+        key('L');
+        assertThat(TuiTestHelper.renderToString(tab, 160, 40))
+                .contains("camel-chaos is not in shop (yet): run it with camel 
run --chaos");
+    }
+
+    @Test
+    void oneExplorationAtATime() throws Exception {
+        info.chaos = ChaosInfo.parse((JsonObject) Jsoner.deserialize("""
+                {"enabled": true, "rules": [],
+                 "report": {"verdict": "PASS", "final": false, "seed": 42,
+                            "exploration": {"running": true, "budget": 600000, 
"timeLeft": 300000, "experiments": []}}}
+                """));
+        key('L');
+        assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("An 
exploration runs in shop already");
+    }
+
+    /** The integration, as the chaos action and the status answer: every 
experiment fails at once. */
+    private static final class FakeIntegration implements 
ChaosExperimentRun.Integration {
+        final List<String> commands = new CopyOnWriteArrayList<>();
+        final List<String> rules = new CopyOnWriteArrayList<>();
+        final List<String> hypotheses = new CopyOnWriteArrayList<>();
+        final List<String> replayed = new CopyOnWriteArrayList<>();
+        final List<String> notes = new CopyOnWriteArrayList<>();
+        volatile String budget;
+        volatile boolean decided = true;
+        private volatile int number;
+
+        @Override
+        public JsonObject chaos(String command, Consumer<JsonObject> options) {
+            JsonObject request = new JsonObject();
+            options.accept(request);
+            commands.add(command);
+            JsonObject answer = new JsonObject();
+            switch (command) {
+                case "explore-start" -> budget = request.getString("budget");
+                case "experiment-begin" -> {
+                    number++;
+                    rules.add(request.getString("rule"));
+                    hypotheses.add(request.getString("hypothesis"));
+                    answer.put("number", number);
+                }
+                case "replay" -> replayed.add(request.getString("route"));
+                case "experiment-end" -> {
+                    notes.add(request.getString("note"));
+                    answer.put("number", number);
+                    answer.put("verdict", "FAIL");
+                    answer.put("open", false);
+                }
+                default -> {
+                    // finished
+                }
+            }
+            return answer;
+        }
+
+        @Override
+        public JsonObject send(ChaosExperimentRun.Feed feed) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public JsonObject status() {
+            JsonObject experiment = new JsonObject();
+            experiment.put("number", number);
+            experiment.put("verdict", decided ? "FAIL" : "PENDING");
+            experiment.put("open", !decided);
+            JsonObject exploration = new JsonObject();
+            exploration.put("experiments", List.of(experiment));
+            exploration.put("timeLeft", 60_000);
+            JsonObject report = new JsonObject();
+            report.put("exploration", exploration);
+            JsonObject chaos = new JsonObject();
+            chaos.put("report", report);
+            JsonObject status = new JsonObject();
+            status.put("chaos", chaos);
+            return status;
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
index d29bd6393460..f06897fbfcc7 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
@@ -285,6 +285,43 @@ class ChaosTabRenderTest {
         assertThat(ex.elapsed()).isEqualTo(300_000);
     }
 
+    @Test
+    void theMessagesLostAtAHandOff() throws Exception {
+        JsonObject chaos = (JsonObject) Jsoner.deserialize(CHAOS);
+        ((JsonObject) chaos.get("report")).put("exploration", 
Jsoner.deserialize("""
+                {"running": false, "finishedBy": "deadline", "startedAt": 
1000000, "budget": 120000, "ran": 120000,
+                 "experiments": [
+                   {"number": 1, "hypothesis": "inbox: The route after a 
hand-off fails", "verdict": "FAIL",
+                    "open": false, "startedAt": 1001000, "elapsed": 118000,
+                    "ruleTexts": ["route=ship error rate=0.2 until=116s 
expect=inbox->ship:lost<=0 within=116s"],
+                    "faults": {"r1": 45},
+                    "expectations": [{"rule": "r1", "expect": 
"inbox->ship:lost<=0", "state": "unmet",
+                                      "value": "45 lost (231 completed in 
inbox, 186 in ship)"}],
+                    "routes": [{"route": "inbox", "completed": 234, "failed": 
0},
+                               {"route": "ship", "completed": 189, "failed": 
45}],
+                    "alsoFailed": {"ship": 45, "audit": 2},
+                    "note": "Tried for its 1m56s share of the 2m budget: 
until= and within= are set to it"}
+                 ]}
+                """));
+        info.chaos = ChaosInfo.parse(chaos);
+        ChaosInfo.Experiment e = info.chaos.exploration.experiments().get(0);
+        // short: the counts are in the detail, and the failures in ship are 
where the lost ones failed
+        assertThat(ChaosExploreView.evidence(e)).isEqualTo("45 lost from inbox 
to ship; also 2 failed in audit");
+        assertThat(ChaosExploreView.handedOffTo(e, "ship")).isEqualTo("inbox");
+        assertThat(ChaosExploreView.handedOffTo(e, "audit")).isNull();
+        assertThat(ChaosExploreView.cut("45 lost from inbox to ship", 
10)).isEqualTo("45 lost f…");
+
+        ChaosTab tab = new ChaosTab(ctx);
+        tab.toggleExplore();
+        String screen = TuiTestHelper.renderToString(tab, 160, 44);
+        assertThat(screen).contains("finished at its 
deadline").doesNotContain("focus")
+                .contains("45 messages failed in ship, after inbox had handed 
them over and completed: they are the lost"
+                          + " ones")
+                .contains("failed, in no expectation: audit 
2").doesNotContain("in no expectation: ship")
+                .contains("over the whole experiment (an expectation counts 
until it is decided)")
+                .contains("Tried for its 1m56s share of the 2m budget");
+    }
+
     @Test
     void withoutCamelChaosOrWhenItIsOff() throws Exception {
         String none = TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15);

Reply via email to