This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25358 in repository https://gitbox.apache.org/repos/asf/camel.git
commit e1b0817eb1faa0fa6f1351e6efe33d0566f2c4e8 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Oct 5 20:02:56 2026 +0200 CAMEL-25358: camel-jbang - source editor: Shift+F8 (fix with AI) on a line that fails at runtime Shift+F8 in the Source editor and view now also works on a line whose processors fail at runtime (the failures in the live run data): the AI panel opens with how many exchanges failed on the line and the exception of the last one, from the integration's errors, and asks the AI to find the cause first, as it may lie elsewhere (a direct: endpoint no route consumes). A problem on the line still goes first, and a file with unsaved edits has no runtime failures (the live run data is of the saved file). Shift+F8 in the Errors tab asks the AI to fix the selected error, with the source line of the processor it happened at. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../images/jbang/camel-tui-errors-fix-with-ai.png | Bin 0 -> 345544 bytes .../jbang/camel-tui-source-fix-runtime-failure.png | Bin 0 -> 333911 bytes .../ROOT/pages/camel-jbang-tui-observe.adoc | 6 + .../ROOT/pages/camel-jbang-tui-source-editor.adoc | 12 +- .../dsl/jbang/core/commands/tui/AiFixPrompt.java | 28 ++- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 27 ++- .../dsl/jbang/core/commands/tui/ErrorsTab.java | 44 +++++ .../dsl/jbang/core/commands/tui/LiveRunLines.java | 31 +-- .../jbang/core/commands/tui/MonitorContext.java | 14 +- .../jbang/core/commands/tui/RuntimeFailures.java | 168 +++++++++++++++++ .../dsl/jbang/core/commands/tui/SourceTab.java | 7 + .../dsl/jbang/core/commands/tui/SourceViewer.java | 105 ++++++++++- .../core/commands/tui/FixRuntimeFailureTest.java | 207 +++++++++++++++++++++ 13 files changed, 620 insertions(+), 29 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-errors-fix-with-ai.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-errors-fix-with-ai.png new file mode 100644 index 000000000000..e0e7a42ef70f Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-errors-fix-with-ai.png differ diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-fix-runtime-failure.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-fix-runtime-failure.png new file mode 100644 index 000000000000..f586b719f814 Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-fix-runtime-failure.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 78c044db9398..5ef6afb403bd 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 @@ -130,6 +130,12 @@ Inspect tab): This combination of error details + visual diagram replay gives you a complete picture of what went wrong and why -- without leaving the terminal. +Press *Shift+F8* on an error to ask the AI to fix it: the AI panel opens with the source line of the processor the +error happened at, and its exception, the same question as *Shift+F8* on a failing line in the +xref:camel-jbang-tui-source-editor.adoc#_fix_with_ai[Source editor]. + +image::jbang/camel-tui-errors-fix-with-ai.png[Shift+F8 on an error writes the question for the AI] + image::jbang/camel-tui-observe-error-diagram.png[A failed order: the path from checkout to payment-provider, and the onException handler that parked it] == OpenTelemetry Spans diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc index 8fc4b368a97c..c4787850c838 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc @@ -12,7 +12,7 @@ routes. * <<_problems_as_you_type,*Problems as you type*>> -- endpoint options, Simple expressions and more are checked as soon as a file opens and while you type, in YAML, Java and XML routes; a YAML or XML file with problems is not saved. * <<_quick_fixes,*Quick fixes*>> -- *Shift+F9* applies the fix a problem names, such as an option typo. -* <<_fix_with_ai,*Fix with AI*>> -- *Shift+F8* asks the AI to fix the problem of a line; you confirm every change. +* <<_fix_with_ai,*Fix with AI*>> -- *Shift+F8* asks the AI to fix the problem of a line, or a line that fails at runtime; you confirm every change. * <<_completion,*Completion*>> -- *Tab* completes components, endpoint options and their values, Java DSL chains, XML elements and attributes, and simple expressions; light help for hand-written edits, see <<_known_limitations,Known limitations>>. @@ -100,6 +100,14 @@ first. image::jbang/camel-tui-source-fix-with-ai.png[Shift+F8 writes the question for the AI] +*Shift+F8* also works on a line that fails at runtime. While the integration runs, the live run data at the end of the +lines counts the failed exchanges (✗); on such a line, *Shift+F8* asks the AI with how many exchanges failed and the +exception of the last one, from the integration's errors. The cause is not always on that line (a `direct:` endpoint +that no route consumes, for example), so the AI is asked to find it first. This works in the view as well as in the +editor, as long as the file has no unsaved edits, and a problem on the line goes first. + +image::jbang/camel-tui-source-fix-runtime-failure.png[Shift+F8 on a line that fails at runtime writes the question for the AI] + The AI changes the file with the same tools an AI agent uses over MCP, and every change waits for you: the dialog shows what the AI wants to change, *d* shows the diff, *Enter* applies it and *Esc* rejects it. @@ -317,7 +325,7 @@ image::jbang/camel-tui-source-convert.png[An XML route converted to YAML, with t | *Ctrl+R* | Refactoring menu: replace URI, extract to property, extract a step to a new file (YAML, XML) | *F9* | Next problem | *Shift+F9* | Apply the quick fix of the problem on the line -| *Shift+F8* | Ask the AI to fix the problem on the line +| *Shift+F8* | Ask the AI to fix the problem on the line, or what fails on it at runtime | *F7* | Diff view (unsaved changes) | *Ctrl+S* | Save and continue editing | *F5* | Save and close diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiFixPrompt.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiFixPrompt.java index d8b19f75b920..0f5015772de4 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiFixPrompt.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiFixPrompt.java @@ -34,6 +34,29 @@ final class AiFixPrompt { * @param line the line of the problem, 1-based */ static String of(Path directory, Path file, int line, String problem, String lineText) { + return "Fix the problem on line " + line + " of " + nameOf(directory, file) + ": " + problem + "\n" + + "The line is: " + lineText.strip() + "\n" + + "Change only what this problem is about, with camel_edit_file, then check the file with" + + " camel_validate_source."; + } + + /** + * The question for a line that fails at runtime (Shift+F8 on a line with failures in the live run data). The cause + * may lie elsewhere than on the line (such as a direct: endpoint no route consumes), so the AI is asked to find it + * first, and dev mode reloads the fix once it is saved. + * + * @param failure how many exchanges failed on the line, and the exception of the last one when known + */ + static String ofFailure(Path directory, Path file, int line, String failure, String lineText) { + return "Exchanges fail at runtime on line " + line + " of " + nameOf(directory, file) + ": " + failure + "\n" + + "The line is: " + lineText.strip() + "\n" + + "Find the cause from the integration's errors and log, then fix it with camel_edit_file (the fix may" + + " belong on another line or in another route) and check the file with camel_validate_source." + + " Dev mode reloads the file when it is saved."; + } + + /** The name of the file relative to the project directory, when it is inside it. */ + private static String nameOf(Path directory, Path file) { String name = file.toString(); if (directory != null) { Path dir = directory.toAbsolutePath().normalize(); @@ -42,9 +65,6 @@ final class AiFixPrompt { name = dir.relativize(abs).toString(); } } - return "Fix the problem on line " + line + " of " + name + ": " + problem + "\n" - + "The line is: " + lineText.strip() + "\n" - + "Change only what this problem is about, with camel_edit_file, then check the file with" - + " camel_validate_source."; + return name; } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 315ffc1a9e96..90684e5e4937 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -37,6 +37,7 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.BiConsumer; +import java.util.function.Function; import java.util.stream.Collectors; import dev.tamboui.buffer.Buffer; @@ -500,6 +501,18 @@ public class CamelMonitor extends CamelCommand { }); } + /** + * Opens the AI panel with a question for the Source editor's fix with AI (Shift+F8), made from the directory of the + * selected integration's sources. + */ + private void openAiWithQuestion(Function<Path, String> question) { + if (shellPanel.isOpen()) { + shellPanel.close(); + } + Path dir = mcpFacade != null ? mcpFacade.getSelectedSourceDirectory() : null; + aiPanel.openWithQuestion(question.apply(dir)); + } + /** * Wires the shared MonitorContext callbacks and connects the shell and AI panels. */ @@ -507,12 +520,16 @@ public class CamelMonitor extends CamelCommand { ctx.notificationCallback = (msg, error) -> setNotification(msg, error); ctx.openMarkdownCallback = actionsPopup::openMarkdown; ctx.openMarkdownAtCallback = actionsPopup::openMarkdownAt; - ctx.askAiCallback = (file, line, problem, lineText) -> { - if (shellPanel.isOpen()) { - shellPanel.close(); + ctx.askAiCallback = new MonitorContext.AskAi() { + @Override + public void fixProblem(Path file, int line, String problem, String lineText) { + openAiWithQuestion(dir -> AiFixPrompt.of(dir, file, line, problem, lineText)); + } + + @Override + public void fixFailure(Path file, int line, String failure, String lineText) { + openAiWithQuestion(dir -> AiFixPrompt.ofFailure(dir, file, line, failure, lineText)); } - Path dir = mcpFacade != null ? mcpFacade.getSelectedSourceDirectory() : null; - aiPanel.openWithQuestion(AiFixPrompt.of(dir, file, line, problem, lineText)); }; ctx.projectOverviewCallback = () -> { if (shellPanel.isOpen()) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java index fe735789bf79..ef5f30f7d4c1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java @@ -16,6 +16,7 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -29,6 +30,7 @@ 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.tui.event.MouseEvent; import dev.tamboui.tui.event.MouseEventKind; @@ -93,6 +95,11 @@ class ErrorsTab extends AbstractTableTab { @Override protected boolean handleTabKeyEvent(KeyEvent ke) { + if (ke.isKey(KeyCode.F8) && ke.hasShift()) { + // fix with AI: the selected error, on the source line it happened at + askAiToFixSelectedError(); + return true; + } if (diagram.isShowDiagram() && diagram.isHistoryMode() && diagram.hasHistoryData()) { if (diagram.isHistoryTopologyMode()) { if (ke.isUp()) { @@ -458,6 +465,9 @@ class ErrorsTab extends AbstractTableTab { diagram.renderFooterHints(spans); return; } + if (ctx.askAiCallback != null && selectedError() != null) { + hint(spans, "Shift+F8", "fix with AI"); + } hint(spans, "Esc", "back"); hint(spans, "PgUp/Dn", "detail"); if (!wordWrap) { @@ -468,6 +478,40 @@ class ErrorsTab extends AbstractTableTab { hintShowBhpv(spans, showBody, showHeaders, showProperties, showVariables); } + /** The error selected in the table, or null. */ + private ErrorInfo selectedError() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + return null; + } + List<ErrorInfo> sorted = applyFilter(info.errors); + Integer sel = tableState.selected(); + return sel != null && sel >= 0 && sel < sorted.size() ? sorted.get(sel) : null; + } + + /** + * Asks the AI to fix the selected error (Shift+F8), as the Source editor does for a line that fails at runtime: the + * source line of the processor it happened at, and its exception. + */ + private void askAiToFixSelectedError() { + IntegrationInfo info = ctx.findSelectedIntegration(); + ErrorInfo error = selectedError(); + if (info == null || error == null || ctx.askAiCallback == null) { + return; + } + String source = RuntimeFailures.sourceOf(info.routes, error); + Path file = RuntimeFailures.fileOf(FilesBrowser.resolveSourceDirectory(info), source); + int line = file != null ? LiveRunLines.lineOf(source, file.getFileName().toString()) : -1; + if (line < 0) { + if (ctx.notificationCallback != null) { + ctx.notificationCallback.accept("The source line of this error is not known, ask the AI with F8", false); + } + return; + } + ctx.askAiCallback.fixFailure(file, line + 1, RuntimeFailures.failureOf(error), + RuntimeFailures.lineText(file, line)); + } + /** How often this kind of error happened, and how long it has been going on (CAMEL-24911). */ private static void addRepeatLine(List<Line> lines, ErrorInfo ei) { if (ei.repeatCount <= 1) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LiveRunLines.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LiveRunLines.java index 3c575c50a599..ea748e53d236 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LiveRunLines.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LiveRunLines.java @@ -55,30 +55,39 @@ final class LiveRunLines { /** Adds what a processor did to its line: totals and failures summed, the mean time weighted by the exchanges. */ private static void add(Map<Integer, long[]> sums, String name, String source, long total, long failed, long mean) { - if (source == null) { + int line = lineOf(source, name); + if (line < 0) { return; } + long[] s = sums.computeIfAbsent(line, k -> new long[3]); + s[0] += total; + s[1] += failed; + s[2] += Math.max(0, mean) * total; + } + + /** + * The 0-based line of a source location (file:line) in the file of the given name, or -1 when the location is of + * another file or has no line. + */ + static int lineOf(String source, String name) { + if (source == null || name == null) { + return -1; + } int colon = source.lastIndexOf(':'); if (colon <= 0 || !name.equals(nameOf(source.substring(0, colon)))) { - return; + return -1; } int line; try { line = Integer.parseInt(source.substring(colon + 1).trim()); } catch (NumberFormatException e) { - return; - } - if (line <= 0) { - return; + return -1; } - long[] s = sums.computeIfAbsent(line - 1, k -> new long[3]); - s[0] += total; - s[1] += failed; - s[2] += Math.max(0, mean) * total; + return line > 0 ? line - 1 : -1; } /** The file name of a location: file:/work/OrderRoute.java, classpath:OrderRoute.java and OrderRoute.java alike. */ - private static String nameOf(String location) { + static String nameOf(String location) { if (location == null || location.isBlank()) { return null; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java index ec890d4fcf62..bb31fa8400f0 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java @@ -209,9 +209,21 @@ class MonitorContext { return CommandLineHelper.getCamelDir().resolve(pid + "-trace.json"); } - /** Asks the AI to fix a problem of a source file. */ + Path getErrorFile(String pid) { + return CommandLineHelper.getCamelDir().resolve(pid + "-error.json"); + } + + /** Asks the AI to fix a problem of a source file, or a line that fails at runtime. */ @FunctionalInterface interface AskAi { void fixProblem(Path file, int line, String problem, String lineText); + + /** + * Asks the AI to fix a line whose processors fail at runtime (Shift+F8 on a line with failures in the live run + * data): the failure says how many exchanges failed and the exception of the last one. + */ + default void fixFailure(Path file, int line, String failure, String lineText) { + fixProblem(file, line, failure, lineText); + } } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeFailures.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeFailures.java new file mode 100644 index 000000000000..730e77561ef0 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeFailures.java @@ -0,0 +1,168 @@ +/* + * 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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.function.Function; + +import org.apache.camel.util.json.JsonObject; + +/** + * The runtime failures of a running integration for fix with AI (Shift+F8 in the Source editor and the Errors tab): the + * source line an error of the error registry happened on, found through the source location of its processor + * (file:line, as the live run data of the Source tab), and the exception of it as one line. + */ +final class RuntimeFailures { + + private static final int MAX_MESSAGE = 300; + + private RuntimeFailures() { + } + + /** The errors of an integration, read from its error file (written by the running integration); empty if none. */ + static List<ErrorInfo> load(String pid, Function<String, Path> errorFileResolver) { + if (pid == null) { + return List.of(); + } + try { + JsonObject root = TuiHelper.loadStatus(Long.parseLong(pid), errorFileResolver); + return root != null ? StatusParser.parseErrors(root) : List.of(); + } catch (NumberFormatException e) { + return List.of(); + } + } + + /** + * The source location (file:line) of the processor an error happened at, else of its route; null when not known. + */ + static String sourceOf(List<RouteInfo> routes, ErrorInfo error) { + if (routes == null || error == null || error.routeId == null) { + return null; + } + for (RouteInfo r : routes) { + if (!error.routeId.equals(r.routeId)) { + continue; + } + if (error.nodeId != null) { + for (ProcessorInfo p : r.processors) { + if (error.nodeId.equals(p.id) && p.source != null) { + return p.source; + } + } + } + return r.source; + } + return null; + } + + /** The exception of an error as one line: the simple class name and the first line of the message. */ + static String describe(ErrorInfo error) { + String message = error.exceptionMessage; + if (message != null) { + int nl = message.indexOf('\n'); + message = (nl >= 0 ? message.substring(0, nl) : message).strip(); + if (message.length() > MAX_MESSAGE) { + message = message.substring(0, MAX_MESSAGE) + "..."; + } + } + String type = error.exceptionType; + if (type == null) { + return message; + } + String simple = type.substring(type.lastIndexOf('.') + 1); + return message != null && !message.isEmpty() ? simple + ": " + message : simple; + } + + /** + * The exception of the last error on a 0-based line of a file, or null when no error of the error registry is in + * the file. A failure also counts on the lines of the route and the EIPs around the processor it happened at (the + * from, a choice), so when no error is on the line itself, the last error in the file is given with its line. + */ + static String lastFailure(List<RouteInfo> routes, List<ErrorInfo> errors, String filePath, int line) { + String name = LiveRunLines.nameOf(filePath); + ErrorInfo onLine = null; + ErrorInfo inFile = null; + int inFileLine = -1; + for (ErrorInfo e : errors) { + int at = LiveRunLines.lineOf(sourceOf(routes, e), name); + if (at == line && (onLine == null || e.timestamp > onLine.timestamp)) { + onLine = e; + } else if (at >= 0 && (inFile == null || e.timestamp > inFile.timestamp)) { + inFile = e; + inFileLine = at; + } + } + if (onLine != null) { + return describe(onLine); + } + return inFile != null ? describe(inFile) + " (at line " + (inFileLine + 1) + ")" : null; + } + + /** + * The failure of an error for the AI: its exception, and how many times it repeated when it did. + */ + static String failureOf(ErrorInfo error) { + String answer = describe(error); + if (answer == null) { + answer = "the exchange failed"; + } + return error.repeatCount > 1 ? answer + " (" + error.repeatCount + " times)" : answer; + } + + /** + * The file of a source location (file:line) in the source directory of the integration, or null when it is not + * there. + */ + static Path fileOf(Path sourceDir, String source) { + if (sourceDir == null || source == null) { + return null; + } + int colon = source.lastIndexOf(':'); + String name = LiveRunLines.nameOf(colon > 0 ? source.substring(0, colon) : source); + if (name == null) { + return null; + } + Path direct = sourceDir.resolve(name); + if (Files.isRegularFile(direct)) { + return direct; + } + // a route in a folder of the project (src/main/resources/camel and alike) + try (var paths = Files.walk(sourceDir, 6)) { + return paths.filter(p -> Files.isRegularFile(p) && name.equals(p.getFileName().toString())) + .filter(p -> { + String path = p.toString().replace('\\', '/'); + return !path.contains("/target/") && !path.contains("/.camel-jbang"); + }) + .findFirst().orElse(null); + } catch (IOException e) { + return null; + } + } + + /** The text of a 0-based line of a file, or empty when it cannot be read. */ + static String lineText(Path file, int line) { + try { + List<String> lines = Files.readAllLines(file); + return line >= 0 && line < lines.size() ? lines.get(line) : ""; + } catch (IOException e) { + return ""; + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java index d05c6f25b018..58783ee562a5 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java @@ -156,6 +156,13 @@ class SourceTab extends AbstractTab { IntegrationInfo info = ctx.findSelectedIntegration(); return info != null ? LiveRunLines.of(info.routes, path) : Map.of(); }); + // the exception of the last failure on a line, for fix with AI (Shift+F8) on a line that fails at runtime + sourceViewer.setLineFailures((path, line) -> { + IntegrationInfo info = ctx.findSelectedIntegration(); + return info != null + ? RuntimeFailures.lastFailure(info.routes, RuntimeFailures.load(info.pid, ctx::getErrorFile), path, line) + : null; + }); sourceViewer.setOnFileLoaded(p -> { if (hasJumpLinks(p)) { sourceViewer.setJumpLinks(computeJumpLinks(p)); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java index 62ad7cfc51ee..4f6b61f4e26b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java @@ -141,6 +141,12 @@ class SourceViewer { Map<Integer, LiveLine> lines(String filePath); } + /** The last runtime failure of a line of a file, by its path and 0-based line: the exception, or null. */ + @FunctionalInterface + interface LineFailures { + String lastFailure(String filePath, int line); + } + private boolean visible; private List<String> lines = Collections.emptyList(); private List<JsonObject> codeData = Collections.emptyList(); @@ -211,6 +217,7 @@ class SourceViewer { private EndpointValidator simpleValidator; private EndpointValidator routeValidator; private LiveRunData liveRunData; + private LineFailures lineFailures; private Map<Integer, LiveLine> liveLines = Collections.emptyMap(); private long liveLinesTime; private int liveTotalWidth; @@ -319,6 +326,37 @@ class SourceViewer { this.liveRunData = liveRunData; } + void setLineFailures(LineFailures lineFailures) { + this.lineFailures = lineFailures; + } + + /** + * The runtime failure of a 0-based line for fix with AI (Shift+F8): how many exchanges failed on the line in the + * live run data, and the exception of the last one when known; null when nothing failed on it, or when the file is + * edited and not saved (the live run data is of the saved file). + */ + String runtimeFailure(int row) { + if (!failsAtRuntime(row)) { + return null; + } + LiveLine live = liveLines().get(row); + String answer = live.failed() + (live.failed() == 1 ? " exchange" : " exchanges") + " failed on this line"; + String last = lineFailures != null && loadedFilePath != null ? lineFailures.lastFailure(loadedFilePath, row) : null; + return last != null ? answer + ", the last with " + last : answer; + } + + /** + * Whether processors on a 0-based line failed in the live run data, while the file is not edited (cheap: the hint + * of each frame asks it; the exception is only read on Shift+F8). + */ + private boolean failsAtRuntime(int row) { + if (dirty || row < 0) { + return false; + } + LiveLine live = liveLines().get(row); + return live != null && live.failed() > 0; + } + /** * The live run data as a column of its own after the line numbers, so the numbers line up whatever the length of * the code: the exchanges right-aligned, then the failures and the mean time when any line has them, blank on the @@ -742,6 +780,11 @@ class SourceViewer { goToNextProblem(viewErrors, selectedLine); return true; } + if (ke.isKey(KeyCode.F8) && ke.hasShift()) { + // fix with AI: the problem of the selected line, or what fails on it at runtime + askAiToFix(); + return true; + } if (isMarkdownFile && ke.isChar(' ')) { markdownMode = !markdownMode; return true; @@ -2057,14 +2100,24 @@ class SourceViewer { } /** - * Asks the AI to fix the problem of the cursor line (Shift+F8): the file is saved as it is in the editor, which the - * AI is about to change, edit mode left so the editor shows what the AI sees, and the AI panel opened with the - * question in its input, for the user to send with Enter or change first. + * Asks the AI to fix the problem of the cursor line (Shift+F8), or when it has none, the line whose processors fail + * at runtime: the file is saved as it is in the editor, which the AI is about to change, edit mode left so the + * editor shows what the AI sees, and the AI panel opened with the question in its input, for the user to send with + * Enter or change first. */ private void askAiToFix() { + if (askAi == null || editableFile == null) { + return; + } + if (!editMode) { + askAiToFixInView(); + return; + } int row = editState.cursorRow(); String problem = visibleInlineErrors().get(row); - if (askAi == null || editableFile == null || problem == null) { + // a runtime failure is only known for the saved file (null while dirty), so it needs no save + String failure = problem == null ? runtimeFailure(row) : null; + if (problem == null && failure == null) { return; } String lineText = editState.getLine(row); @@ -2081,7 +2134,44 @@ class SourceViewer { exitEditMode(); loadFile(file); goToLine(row); - askAi.fixProblem(file, row + 1, problem, lineText); + if (problem != null) { + askAi.fixProblem(file, row + 1, problem, lineText); + } else { + askAi.fixFailure(file, row + 1, failure, lineText); + } + } + + /** + * Shift+F8 in the view, as in the editor: the problem of the selected line, or else its runtime failure, while the + * view shows the file's own source (not the route converted to another DSL). + */ + private void askAiToFixInView() { + int row = selectedLine; + if (row < 0 || row >= lines.size() || !showsOwnSource()) { + return; + } + // the view renders the lines (line numbers, live data), so the text comes from the file, which the view shows + String lineText = RuntimeFailures.lineText(editableFile, row); + String problem = viewErrors.get(row); + if (problem != null) { + askAi.fixProblem(editableFile, row + 1, problem, lineText); + return; + } + String failure = runtimeFailure(row); + if (failure != null) { + askAi.fixFailure(editableFile, row + 1, failure, lineText); + } + } + + /** Whether the view shows the file's own source, and not its route converted to another DSL (Space). */ + private boolean showsOwnSource() { + return !markdownMode && (currentFormat == null || currentFormat.equals(originalFormat)); + } + + /** Whether Shift+F8 (fix with AI) has something to ask about on a 0-based line: a problem, or a runtime failure. */ + private boolean canAskAiToFix(int row, Map<Integer, String> problems) { + return askAi != null && editableFile != null && row >= 0 + && (problems.containsKey(row) || failsAtRuntime(row)); } /** Applies the fix of the problem on the line of the cursor (Shift+F9), and checks the content again right away. */ @@ -2984,7 +3074,7 @@ class SourceViewer { if (cursorFix() != null) { TuiHelper.hint(spans, "Shift+F9", "fix"); } - if (askAi != null && visibleInlineErrors().containsKey(editState.cursorRow())) { + if (canAskAiToFix(editState.cursorRow(), visibleInlineErrors())) { TuiHelper.hint(spans, "Shift+F8", "fix with AI"); } if (isCamelYamlFile()) { @@ -3016,6 +3106,9 @@ class SourceViewer { if (!viewErrors.isEmpty()) { TuiHelper.hint(spans, "F9", "next problem"); } + if (showsOwnSource() && canAskAiToFix(selectedLine, viewErrors)) { + TuiHelper.hint(spans, "Shift+F8", "fix with AI"); + } if (isMarkdownFile || currentRouteId != null) { TuiHelper.hint(spans, "Space", "format"); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FixRuntimeFailureTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FixRuntimeFailureTest.java new file mode 100644 index 000000000000..6e6dea026220 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FixRuntimeFailureTest.java @@ -0,0 +1,207 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Fix with AI (Shift+F8) on a line that fails at runtime: in the Source view and editor, from the live run data and the + * error registry (CAMEL-25358). + */ +class FixRuntimeFailureTest { + + private static final String ROUTE = """ + - route: + id: orders + from: + uri: timer:orders + steps: + - choice: + when: + - simple: "${header.amount} > 100" + steps: + - to: + uri: direct:big-orders + - log: + message: "${body}" + """; + + private static final int TO_LINE = 10; // 0-based line of uri: direct:big-orders + + private static final String EXCEPTION + = "DirectConsumerNotAvailableException: No consumers available on endpoint: direct://big-orders"; + + @TempDir + Path tempDir; + + private final AtomicReference<String> problem = new AtomicReference<>(); + private final AtomicReference<String> failure = new AtomicReference<>(); + + @BeforeEach + void setUp() { + Theme.resetForTesting(); + } + + private SourceViewer viewer(Path file, long failed) { + SourceViewer viewer = new SourceViewer(); + viewer.setAskAi(new MonitorContext.AskAi() { + @Override + public void fixProblem(Path f, int line, String p, String text) { + problem.set(f.getFileName() + ":" + line + ": " + p); + } + + @Override + public void fixFailure(Path f, int line, String fail, String text) { + failure.set(f.getFileName() + ":" + line + ": " + fail + " | " + text.strip()); + } + }); + viewer.setLiveRunData(path -> Map.of(TO_LINE, new SourceViewer.LiveLine(5, failed, 2))); + viewer.setLineFailures((path, line) -> line == TO_LINE ? EXCEPTION : null); + viewer.loadFile(file); + viewer.refreshLiveLinesForTesting(); + return viewer; + } + + private Path routeFile() throws Exception { + Path file = tempDir.resolve("orders.camel.yaml"); + Files.writeString(file, ROUTE, StandardCharsets.UTF_8); + return file; + } + + @Test + void theQuestionToFixARuntimeFailure() { + String q = AiFixPrompt.ofFailure(Path.of("/work/app"), Path.of("/work/app/orders.camel.yaml"), 11, + "3 exchanges failed on this line, the last with " + EXCEPTION, " uri: direct:big-orders"); + assertThat(q).startsWith("Exchanges fail at runtime on line 11 of orders.camel.yaml: 3 exchanges failed") + .contains("The line is: uri: direct:big-orders") + .contains("errors and log").contains("another route").contains("camel_edit_file"); + } + + @Test + void shiftF8InTheViewAsksTheAiToFixTheFailingLine() throws Exception { + SourceViewer viewer = viewer(routeFile(), 3); + viewer.goToLine(TO_LINE); + + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F8, KeyModifiers.SHIFT)); + + assertThat(failure.get()).isEqualTo("orders.camel.yaml:11: 3 exchanges failed on this line, the last with " + + EXCEPTION + " | uri: direct:big-orders"); + assertThat(problem.get()).isNull(); + } + + @Test + void shiftF8InTheEditorAsksTheAiToFixTheFailingLine() throws Exception { + SourceViewer viewer = viewer(routeFile(), 1); + viewer.enterEditMode(); + for (int i = 0; i < TO_LINE; i++) { + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE)); + } + + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F8, KeyModifiers.SHIFT)); + + assertThat(failure.get()).startsWith("orders.camel.yaml:11: 1 exchange failed on this line, the last with "); + assertThat(viewer.isEditMode()).isFalse(); + } + + @Test + void aLineWithoutFailuresAsksNothing() throws Exception { + SourceViewer viewer = viewer(routeFile(), 3); + viewer.goToLine(TO_LINE + 2); + + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F8, KeyModifiers.SHIFT)); + + assertThat(failure.get()).isNull(); + assertThat(problem.get()).isNull(); + } + + @Test + void anEditedFileHasNoRuntimeFailures() throws Exception { + SourceViewer viewer = viewer(routeFile(), 3); + viewer.enterEditMode(); + viewer.handleKeyEvent(KeyEvent.ofChar('x', KeyModifiers.NONE)); + for (int i = 0; i < TO_LINE; i++) { + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE)); + } + + // the live run data is of the saved file, so an unsaved edit does not use it + assertThat(viewer.runtimeFailure(TO_LINE)).isNull(); + } + + @Test + void theErrorOfALineIsFoundThroughItsProcessor() { + RouteInfo route = new RouteInfo(); + route.routeId = "orders"; + route.source = "orders.camel.yaml:2"; + ProcessorInfo to = new ProcessorInfo(); + to.id = "to1"; + to.source = "file:/work/app/orders.camel.yaml:11"; + route.processors.add(to); + ErrorInfo older = error("to1", 1000, "java.lang.IllegalStateException", "older"); + ErrorInfo last = error("to1", 2000, "org.apache.camel.component.direct.DirectConsumerNotAvailableException", + "No consumers available on endpoint: direct://big-orders\nExchange[123]"); + ErrorInfo onRoute = error("unknown", 3000, "java.lang.RuntimeException", "on the route"); + + assertThat(RuntimeFailures.sourceOf(List.of(route), last)).isEqualTo("file:/work/app/orders.camel.yaml:11"); + assertThat(RuntimeFailures.sourceOf(List.of(route), onRoute)).isEqualTo("orders.camel.yaml:2"); + assertThat(RuntimeFailures.lastFailure(List.of(route), List.of(older, last, onRoute), + "/work/app/orders.camel.yaml", TO_LINE)).isEqualTo(EXCEPTION); + assertThat(RuntimeFailures.lastFailure(List.of(route), List.of(older, last), "/work/app/other.camel.yaml", + TO_LINE)).isNull(); + // the from line of the route counts the failure too: the last error in the file, with its line + assertThat(RuntimeFailures.lastFailure(List.of(route), List.of(older, last), "/work/app/orders.camel.yaml", 3)) + .isEqualTo(EXCEPTION + " (at line 11)"); + + last.repeatCount = 4; + assertThat(RuntimeFailures.failureOf(last)).isEqualTo(EXCEPTION + " (4 times)"); + } + + @Test + void theFileOfASourceLocationIsInTheSourceDirectory() throws Exception { + Path routes = Files.createDirectories(tempDir.resolve("src/main/resources/camel")); + Files.writeString(routes.resolve("orders.camel.yaml"), ROUTE, StandardCharsets.UTF_8); + + Path file = RuntimeFailures.fileOf(tempDir, "classpath:camel/orders.camel.yaml:11"); + + assertThat(file).isEqualTo(routes.resolve("orders.camel.yaml")); + assertThat(RuntimeFailures.lineText(file, TO_LINE)).contains("uri: direct:big-orders"); + assertThat(RuntimeFailures.fileOf(tempDir, "missing.camel.yaml:3")).isNull(); + } + + private static ErrorInfo error(String nodeId, long timestamp, String type, String message) { + ErrorInfo e = new ErrorInfo(); + e.routeId = "orders"; + e.nodeId = nodeId; + e.timestamp = timestamp; + e.exceptionType = type; + e.exceptionMessage = message; + return e; + } +}
