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 3770072255a2 chore: camel-jbang - the chaos screen shows the 
exploration (l), let there be chaos step 8 (#29551)
3770072255a2 is described below

commit 3770072255a2a35215b3c7aa04a3d781c8ed2806
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 10 09:50:19 2026 +0100

    chore: camel-jbang - the chaos screen shows the exploration (l), let there 
be chaos step 8 (#29551)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../ROOT/images/jbang/camel-tui-chaos-explore.png  | Bin 0 -> 587647 bytes
 .../ROOT/pages/camel-jbang-tui-observe.adoc        |  10 +
 .../jbang/core/commands/tui/ChaosExploreView.java  | 346 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/ChaosInfo.java     | 112 +++++++
 .../dsl/jbang/core/commands/tui/ChaosTab.java      |  73 ++++-
 .../src/main/resources/tui/help/chaos.md           |  15 +
 .../core/commands/tui/ChaosTabRenderTest.java      |  77 +++++
 7 files changed, 632 insertions(+), 1 deletion(-)

diff --git 
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-explore.png 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-explore.png
new file mode 100644
index 000000000000..dab20d6d06a2
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-explore.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 06564f0275ff..f62c46e406fb 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
@@ -382,6 +382,16 @@ matches nothing in the integration (often a typo) with a 
warning; press *e* ther
 
 image::jbang/camel-tui-chaos-add.png[Adding a chaos rule on the Chaos tab: the 
rule is shown before it is added]
 
+Let there be chaos: an AI can try faults on the integration by itself, for a 
budget of time, one experiment at a time,
+with `camel cmd chaos-explore` or an agent with the Camel MCP tools (see
+xref:camel-jbang-managing.adoc#_chaos_explorations[Chaos explorations]). Press 
*l* on the Chaos tab to watch it: the
+budget as a strip of time with the experiments on it in the colour of their 
verdict, the experiments with their
+hypothesis and the evidence in a few words, and the selected one in detail: 
its rules, its expectations, what the
+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.
+
+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
 
 The CVE Audit tab (under More > Project) scans all Maven dependencies on your 
integration's classpath
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
new file mode 100644
index 000000000000..df2761924eb2
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
@@ -0,0 +1,346 @@
+/*
+ * 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.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.StringJoiner;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+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.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+
+/**
+ * The exploration on the chaos screen (let there be chaos, CAMEL-26760): an 
AI tries chaos on the integration for a
+ * budget of time, one experiment at a time ({@code camel cmd chaos-explore}, 
or an agent with the MCP tools). Its
+ * budget as a strip of time with the experiments on it, coloured by their 
verdict, the experiments as a table, and the
+ * selected one in detail: its rules, expectations, what the routes did, and 
what the experiment could not tell.
+ */
+final class ChaosExploreView {
+
+    // every other experiment in another block, so experiments that run back 
to back are told apart
+    private static final String BAR = "█";
+    private static final String BAR_NEXT = "▓";
+
+    private ChaosExploreView() {
+    }
+
+    /**
+     * Renders the exploration.
+     *
+     * @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) {
+        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",
+                                    Theme.muted())),
+                            Line.from(Span.styled("  what held and what broke: 
 camel cmd chaos-explore "
+                                                  + (name != null ? name : 
"<name>") + " --budget=10m",
+                                    Theme.muted()))))
+                    .block(block(" Explore "))
+                    .build(), area);
+            return null;
+        }
+        int tableHeight = Math.max(4, Math.min(ex.experiments().size() + 3, 
area.height() / 2));
+        List<Rect> rows = Layout.vertical()
+                .constraints(Constraint.length(6), 
Constraint.length(tableHeight), Constraint.fill())
+                .split(area);
+        renderBudget(frame, rows.get(0), ex);
+        renderExperiments(frame, rows.get(1), ex, state);
+        Integer sel = state.selected();
+        ChaosInfo.Experiment selected
+                = sel != null && sel >= 0 && sel < ex.experiments().size() ? 
ex.experiments().get(sel) : null;
+        renderDetail(frame, rows.get(2), selected);
+        return rows.get(1);
+    }
+
+    // ---- the budget, as a strip of time ----
+
+    private static void renderBudget(Frame frame, Rect area, 
ChaosInfo.Exploration ex) {
+        List<Span> first = new ArrayList<>();
+        first.add(Span.raw(" "));
+        if (ex.running()) {
+            first.add(Span.styled("exploring", Theme.warning().bold()));
+            first.add(Span.raw(" " + ChaosTab.shortTime(ex.elapsed()) + " of " 
+ ChaosTab.shortTime(ex.budget())));
+        } else {
+            first.add(Span.styled("finished", Theme.muted().bold()));
+            first.add(Span.raw(("deadline".equals(ex.finishedBy()) ? " at its 
deadline" : "")
+                               + (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"));
+        first.add(Span.styled("   experiments ", Theme.muted()));
+        first.add(Span.raw(String.valueOf(ex.experiments().size())));
+        long pass = ex.count("PASS");
+        long fail = ex.count("FAIL");
+        long inconclusive = ex.count("INCONCLUSIVE");
+        long running = ex.experiments().size() - pass - fail - inconclusive;
+        first.add(Span.raw("  "));
+        first.add(Span.styled(pass + " pass", Theme.success()));
+        first.add(Span.raw(" · "));
+        first.add(Span.styled(fail + " fail", fail > 0 ? Theme.error() : 
Style.EMPTY));
+        if (inconclusive > 0) {
+            first.add(Span.raw(" · "));
+            first.add(Span.styled(inconclusive + " inconclusive", 
Theme.muted()));
+        }
+        if (running > 0) {
+            first.add(Span.raw(" · "));
+            first.add(Span.styled(running + " running", Theme.warning()));
+        }
+        int width = Math.max(10, area.width() - 4);
+        List<Line> lines = new ArrayList<>();
+        lines.add(Line.from(first));
+        lines.add(numbers(ex, width));
+        lines.add(strip(ex, width));
+        lines.add(scale(ex, width));
+        frame.renderWidget(Paragraph.builder()
+                .text(Text.from(lines))
+                .block(block(" Let there be chaos "))
+                .build(), area);
+    }
+
+    /** The number of each experiment above where it began, when there is room 
for it. */
+    static Line numbers(ChaosInfo.Exploration ex, int width) {
+        char[] row = new char[width];
+        Arrays.fill(row, ' ');
+        for (ChaosInfo.Experiment e : ex.experiments()) {
+            String n = String.valueOf(e.number());
+            int col = column(ex, e.at(), width);
+            boolean free = col + n.length() <= width;
+            for (int i = 0; free && i < n.length(); i++) {
+                free = row[col + i] == ' ' && (col + i == 0 || row[col + i - 
1] == ' ' || i > 0);
+            }
+            if (free) {
+                n.getChars(0, n.length(), row, col);
+            }
+        }
+        return Line.from(Span.raw(" "), Span.styled(new String(row), 
Theme.muted()));
+    }
+
+    /**
+     * The budget as a strip, a column per slice of time: an experiment as a 
bar in the colour of its verdict, the time
+     * gone by as a line, the time left as dots.
+     */
+    static Line strip(ChaosInfo.Exploration ex, int width) {
+        List<Span> spans = new ArrayList<>();
+        spans.add(Span.raw(" "));
+        long now = ex.elapsed();
+        for (int c = 0; c < width; c++) {
+            long from = ex.budget() * c / width;
+            long to = ex.budget() * (c + 1) / width;
+            ChaosInfo.Experiment hit = null;
+            for (ChaosInfo.Experiment e : ex.experiments()) {
+                if (e.at() < to && e.at() + Math.max(e.elapsed(), 1) > from) {
+                    hit = e;
+                }
+            }
+            if (hit != null) {
+                spans.add(Span.styled(hit.number() % 2 == 1 ? BAR : BAR_NEXT, 
verdictStyle(hit)));
+            } else if (from < now) {
+                spans.add(Span.styled("─", Theme.muted()));
+            } else {
+                spans.add(Span.styled("·", Theme.muted()));
+            }
+        }
+        return Line.from(spans);
+    }
+
+    private static Line scale(ChaosInfo.Exploration ex, int width) {
+        String end = ChaosTab.shortTime(ex.budget());
+        StringBuilder sb = new StringBuilder("0");
+        while (sb.length() < width - end.length()) {
+            sb.append(' ');
+        }
+        sb.append(end);
+        return Line.from(Span.raw(" "), Span.styled(sb.toString(), 
Theme.muted()));
+    }
+
+    private static int column(ChaosInfo.Exploration ex, long at, int width) {
+        if (ex.budget() <= 0) {
+            return 0;
+        }
+        return (int) Math.max(0, Math.min(width - 1, at * width / 
ex.budget()));
+    }
+
+    // ---- the experiments ----
+
+    private static void renderExperiments(Frame frame, Rect area, 
ChaosInfo.Exploration ex, TableState state) {
+        if (ex.experiments().isEmpty()) {
+            frame.renderWidget(Paragraph.builder()
+                    .text(Text.from(Line.from(Span.styled(" No experiment 
yet", Theme.muted()))))
+                    .block(block(" Experiments [0] "))
+                    .build(), area);
+            return;
+        }
+        List<Row> rows = new ArrayList<>();
+        for (ChaosInfo.Experiment e : ex.experiments()) {
+            rows.add(Row.from(
+                    Cell.from(Span.styled(String.valueOf(e.number()), 
Style.EMPTY.fg(Theme.accent()))),
+                    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()))));
+        }
+        Style bold = Style.EMPTY.bold();
+        Table table = Table.builder()
+                .rows(rows)
+                .header(Row.from(
+                        Cell.from(Span.styled("#", bold)),
+                        Cell.from(Span.styled("AT", bold)),
+                        Cell.from(Span.styled("VERDICT", bold)),
+                        Cell.from(Span.styled("HYPOTHESIS", bold)),
+                        Cell.from(Span.styled("EVIDENCE", bold))))
+                .widths(
+                        Constraint.length(3),
+                        Constraint.length(6),
+                        Constraint.length(13),
+                        Constraint.fill(3),
+                        Constraint.fill(2))
+                .highlightStyle(Theme.selectionBg())
+                .highlightSpacing(Table.HighlightSpacing.ALWAYS)
+                .block(block(" Experiments [" + ex.experiments().size() + "] 
"))
+                .build();
+        frame.renderStatefulWidget(table, area, state);
+    }
+
+    /** The verdict as the table shows it: an open experiment is running. */
+    static String verdict(ChaosInfo.Experiment e) {
+        if (e.open()) {
+            return "running";
+        }
+        return e.verdict() != null ? e.verdict() : "-";
+    }
+
+    /** The evidence in a few words: what decided it, or what it could not 
tell. */
+    static String evidence(ChaosInfo.Experiment e) {
+        StringJoiner sj = new StringJoiner("; ");
+        // what it could not tell first, as the column cuts the rest
+        if (e.reason() != null && e.reason().startsWith("its faults never 
fired")) {
+            sj.add("the faults never fired");
+        }
+        if (e.lostAtDlc() > 0) {
+            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
+            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));
+            }
+        }
+        e.alsoFailed().forEach((route, n) -> sj.add("also " + n + " failed in 
" + route));
+        return sj.toString();
+    }
+
+    // ---- the selected experiment ----
+
+    private static void renderDetail(Frame frame, Rect area, 
ChaosInfo.Experiment e) {
+        if (e == null) {
+            
frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.raw(""))))
+                    .block(block(" Experiment ")).build(), area);
+            return;
+        }
+        Style label = Theme.muted();
+        List<Line> lines = new ArrayList<>();
+        lines.add(Line.from(Span.styled(" " + verdict(e), 
verdictStyle(e).bold()),
+                Span.raw("  " + (e.hypothesis() != null ? e.hypothesis() : 
"")),
+                Span.styled("   at " + clock(e.at()) + (e.open() ? ", running 
for " : ", ran ")
+                            + ChaosTab.shortTime(e.elapsed()) + ", " + 
e.faults()
+                            + (e.faults() == 1 ? " fault" : " faults"),
+                        label)));
+        if (e.reason() != null) {
+            lines.add(Line.from(Span.styled(" " + e.reason(), 
Theme.warning())));
+        }
+        lines.add(Line.from(Span.raw("")));
+        for (String r : e.rules()) {
+            lines.add(Line.from(Span.styled(" rule      ", label), 
Span.raw(r)));
+        }
+        for (String x : e.expectations()) {
+            boolean unmet = x.contains(": unmet,");
+            lines.add(Line.from(Span.styled(" expect    ", label),
+                    Span.styled(x, unmet ? Theme.error() : x.contains(": 
met,") ? Theme.success() : Theme.warning())));
+        }
+        if (e.lostAtDlc() > 0) {
+            lines.add(Line.from(Span.styled(" lost      ", label),
+                    Span.styled(e.lostAtDlc() + (e.lostAtDlc() == 1 ? " 
message" : " messages")
+                                + " 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));
+            lines.add(Line.from(Span.styled(" also      ", label),
+                    Span.styled("failed, in no expectation: " + sj, 
Theme.warning())));
+        }
+        for (String r : e.routes()) {
+            lines.add(Line.from(Span.styled(" route     ", label), 
Span.raw(r)));
+        }
+        if (e.note() != null) {
+            lines.add(Line.from(Span.raw("")));
+            lines.add(Line.from(Span.styled(" note      ", label), 
Span.raw(e.note())));
+        }
+        frame.renderWidget(Paragraph.builder()
+                .text(Text.from(lines))
+                .block(block(" Experiment " + e.number() + " "))
+                .build(), area);
+    }
+
+    // ---- helpers ----
+
+    private static Style verdictStyle(ChaosInfo.Experiment e) {
+        if (e.open()) {
+            return Theme.warning();
+        }
+        return switch (e.verdict() != null ? e.verdict() : "") {
+            case "PASS" -> Theme.success();
+            case "FAIL" -> Theme.error();
+            default -> Theme.muted();
+        };
+    }
+
+    /** A time after the start of the exploration, as a clock: 2:05. */
+    static String clock(long ms) {
+        long s = Math.max(0, ms) / 1000;
+        return (s / 60) + ":" + String.format(Locale.ROOT, "%02d", s % 60);
+    }
+
+    private static Block block(String title) {
+        return 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
index e9e9e63cef69..d2614067a896 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
@@ -45,6 +45,8 @@ final class ChaosInfo {
     final List<RecentFault> recentFaults = new ArrayList<>();
     // what has been tried per route, by the route (CAMEL-26728)
     final Map<String, TriedRoute> tried = new LinkedHashMap<>();
+    // the exploration of the report (let there be chaos, CAMEL-26760), or 
null when there is none
+    Exploration exploration;
 
     /** A current rule, with its live state. */
     static final class Rule {
@@ -257,6 +259,46 @@ final class ChaosInfo {
     record TriedRoute(String route, List<String> tried, List<String> notTried) 
{
     }
 
+    /**
+     * An exploration: an AI (or a user) tries chaos for a budget of time, one 
experiment at a time.
+     *
+     * @param running     whether it still runs
+     * @param budget      its budget, in millis
+     * @param timeLeft    the time left of the budget while it runs, in 
millis, else -1
+     * @param ran         how long it ran once finished, in millis, else -1
+     * @param finishedBy  how it finished (finished, deadline), or null while 
it runs
+     * @param focus       what it explores, or null for all routes
+     * @param experiments its experiments, in their order
+     */
+    record Exploration(boolean running, long budget, long timeLeft, long ran, 
String finishedBy, String focus,
+            List<Experiment> experiments) {
+
+        /** How far into the budget it is: the time it ran, or the budget less 
the time left. */
+        long elapsed() {
+            return running ? Math.max(0, budget - Math.max(0, timeLeft)) : 
Math.max(0, ran);
+        }
+
+        long count(String verdict) {
+            return experiments.stream().filter(e -> 
verdict.equals(e.verdict())).count();
+        }
+    }
+
+    /**
+     * An experiment of an exploration: a hypothesis, the rules that test it, 
and what came of it.
+     *
+     * @param at           when it began, in millis after the exploration began
+     * @param elapsed      how long it ran, in millis
+     * @param expectations per rule its expectation, state and value: 
orders:failed<=0: unmet, 5 failed
+     * @param routes       what the routes did while it ran: orders 41 
completed, 0 failed
+     * @param lostAtDlc    the messages its rules lost at a dead letter 
channel (CAMEL-26789)
+     * @param alsoFailed   the routes that failed outside its expectations, 
with how many (CAMEL-26790)
+     * @param reason       why it is INCONCLUSIVE, or null
+     */
+    record Experiment(int number, String hypothesis, String verdict, boolean 
open, long at, long elapsed,
+            List<String> rules, List<String> expectations, List<String> 
routes, long faults, long lostAtDlc,
+            Map<String, Long> alsoFailed, String reason, String note) {
+    }
+
     /** A recent fault of a rule. */
     record RecentFault(long ago, String rule, String fault, String hook, 
String route, String where, String exchangeId) {
     }
@@ -338,6 +380,9 @@ final class ChaosInfo {
                     }
                 }
             }
+            if (report.get("exploration") instanceof JsonObject exploration) {
+                info.exploration = exploration(exploration);
+            }
             if (report.get("recentFaults") instanceof JsonArray faults) {
                 for (Object o : faults) {
                     if (o instanceof JsonObject f) {
@@ -352,6 +397,73 @@ final class ChaosInfo {
         return info;
     }
 
+    private static Exploration exploration(JsonObject jo) {
+        long startedAt = num(jo.get("startedAt"), 0);
+        long finishedAt = num(jo.get("finishedAt"), -1);
+        List<Experiment> experiments = new ArrayList<>();
+        if (jo.get("experiments") instanceof JsonArray list) {
+            for (Object o : list) {
+                if (o instanceof JsonObject e) {
+                    experiments.add(experiment(e, startedAt));
+                }
+            }
+        }
+        boolean running = jo.getBooleanOrDefault("running", false);
+        return new Exploration(
+                running, num(jo.get("budget"), 0), running ? 
num(jo.get("timeLeft"), -1) : -1,
+                finishedAt > 0 && startedAt > 0 ? finishedAt - startedAt : -1, 
jo.getString("finishedBy"),
+                jo.getString("focus"), experiments);
+    }
+
+    private static Experiment experiment(JsonObject e, long 
explorationStartedAt) {
+        List<String> rules = strings(e.get("ruleTexts"));
+        if (rules.isEmpty()) {
+            rules = strings(e.get("rules"));
+        }
+        List<String> expectations = new ArrayList<>();
+        if (e.get("expectations") instanceof JsonArray list) {
+            for (Object o : list) {
+                if (o instanceof JsonObject x) {
+                    expectations.add(x.getString("expect") + ": " + 
x.getString("state") + ", " + x.getString("value"));
+                }
+            }
+        }
+        List<String> routes = new ArrayList<>();
+        if (e.get("routes") instanceof JsonArray list) {
+            for (Object o : list) {
+                if (o instanceof JsonObject r) {
+                    routes.add(r.getString("route") + " " + 
num(r.get("completed"), 0) + " completed, "
+                               + num(r.get("failed"), 0) + " failed");
+                }
+            }
+        }
+        long faults = 0;
+        if (e.get("faults") instanceof Map<?, ?> perRule) {
+            for (Object v : perRule.values()) {
+                faults += num(v, 0);
+            }
+        }
+        Map<String, Long> alsoFailed = new LinkedHashMap<>();
+        if (e.get("alsoFailed") instanceof Map<?, ?> also) {
+            also.forEach((k, v) -> alsoFailed.put(String.valueOf(k), num(v, 
0)));
+        }
+        long startedAt = num(e.get("startedAt"), 0);
+        return new Experiment(
+                (int) num(e.get("number"), 0), e.getString("hypothesis"), 
e.getString("verdict"),
+                e.getBooleanOrDefault("open", false),
+                startedAt > 0 && explorationStartedAt > 0 ? startedAt - 
explorationStartedAt : 0,
+                num(e.get("elapsed"), 0), rules, expectations, routes, faults,
+                num(e.get("lostAtDeadLetterChannel"), 0), alsoFailed, 
e.getString("reason"), e.getString("note"));
+    }
+
+    private static List<String> strings(Object value) {
+        List<String> answer = new ArrayList<>();
+        if (value instanceof List<?> list) {
+            list.forEach(o -> answer.add(String.valueOf(o)));
+        }
+        return answer;
+    }
+
     private static Rule rule(JsonObject jo) {
         Rule r = new Rule();
         r.id = jo.getString("id");
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 ad9c093807e6..b9733e122b76 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
@@ -64,6 +64,9 @@ class ChaosTab extends AbstractTableTab {
     private static final int MAX_EFFECT_WIDTH = 30;
 
     private final ChaosControl control;
+    // the exploration instead of the rules (l, let there be chaos), and the 
selected rule meanwhile
+    private boolean explore;
+    private Integer rulesSelection;
 
     ChaosTab(MonitorContext ctx) {
         this(ctx, new ChaosControl(ctx));
@@ -88,8 +91,44 @@ class ChaosTab extends AbstractTableTab {
         control.openRule(ctx.findSelectedIntegration(), rule);
     }
 
+    boolean isExplore() {
+        return explore;
+    }
+
+    /** Shows the exploration instead of the rules, or the rules again, each 
with its own selection. */
+    void toggleExplore() {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (!explore) {
+            rulesSelection = tableState.selected();
+            explore = true;
+            int count = getRowCount();
+            if (count > 0) {
+                // the latest experiment
+                tableState.select(count - 1);
+            } else {
+                tableState.clearSelection();
+            }
+        } else {
+            explore = false;
+            if (rulesSelection != null && info != null && info.chaos != null
+                    && rulesSelection < info.chaos.rules.size()) {
+                tableState.select(rulesSelection);
+            } else {
+                tableState.clearSelection();
+            }
+        }
+    }
+
     @Override
     protected boolean handleTabKeyEvent(KeyEvent ke) {
+        if (ke.isChar('l') && control.step() == ChaosControl.Step.NONE) {
+            toggleExplore();
+            return true;
+        }
+        if (explore) {
+            // the exploration is read-only: the keys of the rules do not act 
on it
+            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) {
@@ -112,7 +151,7 @@ class ChaosTab extends AbstractTableTab {
     public void renderFKeyHints(List<Span> spans) {
         // with the global F-keys, as the AI key of a screen is an F-key of 
every screen that has one
         IntegrationInfo info = ctx.findSelectedIntegration();
-        if (ctx.askAiCallback != null && control.step() == 
ChaosControl.Step.NONE && info != null
+        if (!explore && ctx.askAiCallback != null && control.step() == 
ChaosControl.Step.NONE && info != null
                 && info.chaos != null && selectedRule(info.chaos) != null) {
             hint(spans, "Shift+F8", "explain with AI");
         }
@@ -120,6 +159,10 @@ class ChaosTab extends AbstractTableTab {
 
     @Override
     public boolean handleEscape() {
+        if (explore && control.step() == ChaosControl.Step.NONE) {
+            toggleExplore();
+            return true;
+        }
         return control.escape();
     }
 
@@ -166,6 +209,10 @@ class ChaosTab extends AbstractTableTab {
     @Override
     protected int getRowCount() {
         IntegrationInfo info = ctx.findSelectedIntegration();
+        if (explore) {
+            return info != null && info.chaos != null && 
info.chaos.exploration != null
+                    ? info.chaos.exploration.experiments().size() : 0;
+        }
         return info != null && info.chaos != null ? info.chaos.rules.size() : 
0;
     }
 
@@ -182,6 +229,11 @@ class ChaosTab extends AbstractTableTab {
                     .build(), area);
             return;
         }
+        if (explore) {
+            lastTableArea = ChaosExploreView.render(frame, area, 
chaos.exploration, tableState, info.name);
+            lastDetailArea = null;
+            return;
+        }
         int tableHeight = Math.max(5, Math.min(chaos.rules.size() + 3, 
area.height() / 3));
         List<Rect> rows = Layout.vertical()
                 .constraints(Constraint.length(4), 
Constraint.length(tableHeight), Constraint.fill())
@@ -797,12 +849,31 @@ class ChaosTab extends AbstractTableTab {
             hint(spans, "Esc", "back");
             return;
         }
+        if (explore) {
+            hint(spans, "↑↓", "experiment");
+            hint(spans, "l", "rules");
+            hint(spans, "Esc", "back");
+            return;
+        }
         control.renderFooter(spans, !info.chaos.rules.isEmpty());
+        hint(spans, "l", "explore");
     }
 
     @Override
     public SelectionContext getSelectionContext() {
         IntegrationInfo info = ctx.findSelectedIntegration();
+        if (explore) {
+            if (info == null || info.chaos == null || info.chaos.exploration 
== null
+                    || info.chaos.exploration.experiments().isEmpty()) {
+                return null;
+            }
+            List<String> items = info.chaos.exploration.experiments().stream()
+                    .map(e -> e.number() + " " + ChaosExploreView.verdict(e) + 
": "
+                              + (e.hypothesis() != null ? e.hypothesis() : 
String.join("; ", e.rules())))
+                    .toList();
+            Integer sel = tableState.selected();
+            return new SelectionContext("table", items, sel != null ? sel : 
-1, items.size(), "Chaos experiments");
+        }
         if (info == null || info.chaos == null || info.chaos.rules.isEmpty()) {
             return null;
         }
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 3d383ece1770..d9cfabb1b6ae 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
@@ -62,6 +62,20 @@ The counts of the report per route: completed (DONE), failed 
(FAIL),
 failed and kept by the consumer to read again (KEPT), handled by the
 error handler (HAND) and the redeliveries (REDEL).
 
+## Explore
+
+Let there be chaos: an AI tries faults on the integration by itself for a
+budget of time, one experiment at a time, and reports what held and what
+broke (`camel cmd chaos-explore <name> --budget=10m`, or an agent with the
+Camel MCP tools). **l** shows the exploration instead of the rules: its
+budget as a strip of time with the experiments on it in the colour of
+their verdict (PASS, FAIL, inconclusive or still running), the experiments with
+their hypothesis and the evidence in a few words, and the selected one in
+detail: its rules, its expectations, what the routes did, the messages lost
+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.
+
 ## Keys
 
 - **a** — Add a rule: pick what should go wrong from the known rules of
@@ -81,6 +95,7 @@ error handler (HAND) and the redeliveries (REDEL).
   tried in its route already is marked tried, and the detail says what
   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
 - **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/ChaosTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
index cfa741608e6d..d29bd6393460 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
@@ -208,6 +208,83 @@ class ChaosTabRenderTest {
         assertThat(screen.lines().filter(l -> 
l.contains("…286B")).count()).isEqualTo(1);
     }
 
+    // an exploration as camel-chaos reports it (CAMEL-26760): three 
experiments ended, the fourth running
+    private static final String EXPLORATION
+            = """
+                    {"running": true, "startedAt": 1000000, "budget": 600000, 
"timeLeft": 300000, "focus": "refunds",
+                     "experiments": [
+                       {"number": 1, "hypothesis": "When payment returns 503, 
orders should retry", "verdict": "PASS",
+                        "open": false, "startedAt": 1026000, "elapsed": 41000,
+                        "ruleTexts": ["endpoint=http://localhost:8080/payment 
error status=503 rate=0.25 expect=orders:failed<=0 within=40s"],
+                        "faults": {"r1": 11},
+                        "expectations": [{"rule": "r1", "expect": 
"orders:failed<=0", "state": "met", "value": "0 failed"}],
+                        "routes": [{"route": "orders", "completed": 41, 
"failed": 0}]},
+                       {"number": 2, "hypothesis": "When payment returns 503, 
refunds should handle errors", "verdict": "FAIL",
+                        "open": false, "startedAt": 1117000, "elapsed": 7000,
+                        "ruleTexts": ["endpoint=http://localhost:8080/payment 
error status=503 rate=0.25 expect=refunds:failed<=0 within=40s"],
+                        "faults": {"r2": 8},
+                        "expectations": [{"rule": "r2", "expect": 
"refunds:failed<=0", "state": "unmet", "value": "5 failed"}],
+                        "routes": [{"route": "refunds", "completed": 15, 
"failed": 5}],
+                        "note": "refunds has no error handling"},
+                       {"number": 3, "hypothesis": "HTTP clients see proper 
error responses", "verdict": "INCONCLUSIVE",
+                        "open": false, "startedAt": 1200000, "elapsed": 41000,
+                        "ruleTexts": ["route=payment error rate=0.05 
expect=payment:failed<=0 within=40s"], "faults": {"r3": 0},
+                        "expectations": [{"rule": "r3", "expect": 
"payment:failed<=0", "state": "met", "value": "0 failed"}],
+                        "routes": [], "alsoFailed": {"refunds": 2},
+                        "reason": "its faults never fired, so its expectations 
say nothing about them"},
+                       {"number": 4, "hypothesis": "nothing is lost when 
payment and parking fail", "verdict": "PENDING",
+                        "open": true, "startedAt": 1280000, "elapsed": 20000,
+                        "ruleTexts": ["endpoint=direct:parked error at=dlq 
expect=orders:failed<=0 within=90s"],
+                        "faults": {"r4": 30}, "lostAtDeadLetterChannel": 12,
+                        "expectations": [{"rule": "r4", "expect": 
"orders:failed<=0", "state": "pending", "value": "0 failed"}],
+                        "routes": []}
+                     ]}
+                    """;
+
+    @Test
+    void theExplorationWithItsExperiments() throws Exception {
+        JsonObject chaos = (JsonObject) Jsoner.deserialize(CHAOS);
+        ((JsonObject) chaos.get("report")).put("exploration", 
Jsoner.deserialize(EXPLORATION));
+        info.chaos = ChaosInfo.parse(chaos);
+        ChaosTab tab = new ChaosTab(ctx);
+        // the rules first; l shows the exploration (let there be chaos)
+        assertThat(TuiTestHelper.renderToString(tab, 160, 44)).contains("Rules 
[3]");
+        tab.toggleExplore();
+        String screen = TuiTestHelper.renderToString(tab, 160, 44);
+
+        assertThat(screen).contains("Let there be chaos").contains("exploring 
5m of 10m").contains("focus refunds")
+                .contains("1 pass").contains("1 fail").contains("1 
inconclusive").contains("1 running");
+        assertThat(screen).contains("Experiments 
[4]").contains("0:26").contains("PASS").contains("FAIL")
+                
.contains("INCONCLUSIVE").contains("running").contains("refunds should handle 
errors")
+                .contains("5 failed in refunds").contains("the faults never 
fired")
+                .contains("12 lost at the dead letter channel");
+        assertThat(screen).containsPattern("│ 0 {100,}10m");
+        // the latest experiment is selected, in detail
+        assertThat(screen).contains("Experiment 
4").contains("endpoint=direct:parked error at=dlq")
+                .contains("12 messages at the dead letter channel, handled 
there without being delivered");
+        // the rules again
+        tab.toggleExplore();
+        assertThat(TuiTestHelper.renderToString(tab, 160, 44)).contains("Rules 
[3]").doesNotContain("Experiments [");
+    }
+
+    @Test
+    void theExperimentThatCouldNotTell() throws Exception {
+        ChaosInfo.Exploration ex = ChaosInfo.parse((JsonObject) 
Jsoner.deserialize(
+                "{\"enabled\": true, \"report\": {\"exploration\": " + 
EXPLORATION + "}}")).exploration;
+
+        ChaosInfo.Experiment third = ex.experiments().get(2);
+        assertThat(third.alsoFailed()).containsEntry("refunds", 2L);
+        assertThat(third.at()).isEqualTo(200_000);
+        assertThat(ChaosExploreView.evidence(third)).isEqualTo(
+                "the faults never fired; 0 failed in payment; also 2 failed in 
refunds");
+        assertThat(ChaosExploreView.clock(third.at())).isEqualTo("3:20");
+        // the strip: the experiments as bars where they ran, the time gone 
by, the time left
+        String strip = ChaosExploreView.strip(ex, 60).rawContent();
+        
assertThat(strip).hasSize(61).contains("█").contains("─").contains("·");
+        assertThat(strip.indexOf('█')).isEqualTo(1 + 26_000 * 60 / 600_000);
+        assertThat(ex.elapsed()).isEqualTo(300_000);
+    }
+
     @Test
     void withoutCamelChaosOrWhenItIsOff() throws Exception {
         String none = TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15);

Reply via email to