This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25899 in repository https://gitbox.apache.org/repos/asf/camel.git
commit b0cd6f0a36695cb228fa85efda2a643a3b842f3b Author: Claus Ibsen <[email protected]> AuthorDate: Wed Oct 21 07:36:29 2026 +0200 CAMEL-25899: camel-jbang - AI tools: the reload output of a write keeps the hint of a failure, and a write of a script the routes load waits for the reload - The reload outcome of a write compacts a WARN or ERROR line: the message ids and attempt count of the error handler, the exchange dumps, the packages of exception classes and long bean dumps go, and such a line may be 800 characters. The hint at the end of an exception message (such as "unmarshal it first") was cut off at 400 characters. - A write of a script or template the routes load (.groovy, .js, .py, .ftl, .vm, .mustache, .jslt, .jsonata) waits for the dev-mode reload and reports what the routes then do, as a route write does; it answered at once before, and the agent read the errors before the reload. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m --- .../dsl/jbang/core/commands/ai/AuthoringTools.java | 19 +++++++- .../dsl/jbang/core/commands/ai/ReloadOutcome.java | 55 +++++++++++++++++++++- .../jbang/core/commands/ai/AuthoringToolsTest.java | 9 ++++ .../jbang/core/commands/ai/ReloadOutcomeTest.java | 47 ++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java index 551e175bb5c2..7cf13591c1a8 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java @@ -1633,7 +1633,7 @@ public final class AuthoringTools { String processName = null; String sinceKey = null; long watchPid = -1; - if (SourceValidator.isValidatableFile(file)) { + if (SourceValidator.isValidatableFile(file) || reloadsWithRoutes(file)) { if (ctx.hasProcess()) { watchPid = ctx.pid(); RuntimeHelper.ProcessInfo p = RuntimeHelper.findProcess(Long.toString(ctx.pid())); @@ -1701,8 +1701,9 @@ public final class AuthoringTools { + (output.size() > 1 ? " (and " + (output.size() - 1) + " more in reload.output)" : ""); } final String loggedAfter = logged; + String reloadedWhat = SourceValidator.isValidatableFile(file) ? "the file" : "its routes, which load the file"; result.put("message", switch (status) { - case "reloaded" -> "The running integration reloaded the file." + loggedAfter; + case "reloaded" -> "The running integration reloaded " + reloadedWhat + "." + loggedAfter; case "properties" -> "The running integration reloaded the properties."; case "failed" -> "The running integration FAILED to reload the file, the route is not running; fix the" + " content and write again (see reload.message)."; @@ -1723,6 +1724,20 @@ public final class AuthoringTools { return result; } + /** The script and template files that routes load, such as resource:file:mapping.groovy. */ + private static final List<String> ROUTE_RESOURCES + = List.of(".groovy", ".js", ".py", ".ftl", ".vm", ".mustache", ".jslt", ".jsonata"); + + /** + * A file the routes load, such as the Groovy script of a setBody (resource:file:mapping.groovy) or a template: dev + * mode reloads the routes when it changes, so its write waits for that reload and reports what the routes then do, + * as the write of a route file does. Answered at once, an agent read the errors before the reload, and saw none. + */ + static boolean reloadsWithRoutes(String fileName) { + String name = fileName == null ? "" : fileName.toLowerCase(Locale.ROOT); + return ROUTE_RESOURCES.stream().anyMatch(name::endsWith); + } + /** The content of the last refused write of each file, and how many times in a row it was refused. */ private record Refused(String content, int times) { } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java index cb4f1e38919e..aaae5143d500 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.function.Supplier; +import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.camel.util.StopWatch; @@ -180,6 +181,20 @@ public final class ReloadOutcome { private static final int OUTPUT_LINES = 5; private static final int OUTPUT_ERRORS = 3; private static final int OUTPUT_CHARS = 400; + /** A WARN or ERROR line may be longer: the hint of a failure comes late in the exception message. */ + private static final int OUTPUT_ERROR_CHARS = 800; + + // the text of the error handler around the exception, which says nothing the agent can act on + private static final Pattern FAILED_DELIVERY = Pattern.compile( + "^Failed delivery for \\(MessageId: \\S+ on ExchangeId: \\S+\\)(?: at (.+?))?\\." + + " Exhausted after delivery attempt: \\d+ caught: "); + private static final Pattern EXCEPTION_PACKAGE + = Pattern.compile("\\b(?:[a-z][a-z0-9_]*\\.)+([A-Z][\\w$]*(?:Exception|Error))\\b"); + private static final Pattern ON_THE_EXCHANGE = Pattern.compile(" on the exchange: Exchange\\[[^\\]]*]"); + // "Error processing exchange. Exchange[1028039098EFE4D-0000000000000009]. Caused by: [...]" + private static final Pattern EXCHANGE_ID = Pattern.compile("Exchange\\[[0-9A-Fa-f]+-[0-9A-Fa-f]+]\\. "); + private static final String ON_BEAN = " on bean: "; + private static final int MAX_BEAN = 80; /** * What the routes logged after the newest reload, waiting up to the timeout for the first of it: the records of the @@ -203,6 +218,39 @@ public final class ReloadOutcome { return out; } + /** + * A WARN or ERROR line, as short as it can be without losing what the agent acts on: the message ids and the + * attempt count of the error handler, the dump of the exchange, the packages of the exception classes and the long + * dump of a bean go. The hint of a failure, which Camel adds at the end of the exception message (such as + * "unmarshal it first"), was cut off: the line of a failed exchange starts with some 250 characters of such text. + */ + static String compact(String message) { + String m = message; + Matcher fd = FAILED_DELIVERY.matcher(m); + if (fd.find()) { + m = "Failed" + (fd.group(1) != null ? " at " + fd.group(1) : "") + ": " + m.substring(fd.end()); + } + m = ON_THE_EXCHANGE.matcher(m).replaceAll(""); + m = EXCHANGE_ID.matcher(m).replaceAll(""); + m = EXCEPTION_PACKAGE.matcher(m).replaceAll("$1"); + // "not found on bean: [{orderId=ORD-1001, ...}, ...] of type: java.util.ArrayList": the start of the dump + StringBuilder sb = new StringBuilder(); + int from = 0; + int at; + while ((at = m.indexOf(ON_BEAN, from)) >= 0) { + int start = at + ON_BEAN.length(); + int end = m.indexOf(" of type: ", start); + if (end < 0) { + break; + } + sb.append(m, from, start); + sb.append(end - start > MAX_BEAN ? m.substring(start, start + MAX_BEAN) + "..." : m.substring(start, end)); + from = end; + } + sb.append(m.substring(from)); + return sb.toString(); + } + static JsonArray output(List<JsonObject> newestFirst) { List<String> lines = new ArrayList<>(); for (JsonObject r : newestFirst) { @@ -222,8 +270,13 @@ public final class ReloadOutcome { // a multi-line body (an XML or JSON document) is part of what the route logged m = m + "\n" + detail; } + int max = OUTPUT_CHARS; + if (!routeLog) { + m = compact(m); + max = OUTPUT_ERROR_CHARS; + } lines.add((routeLog ? "" : level.toUpperCase(Locale.ROOT) + " ") - + (m.length() > OUTPUT_CHARS ? m.substring(0, OUTPUT_CHARS) + "..." : m)); + + (m.length() > max ? m.substring(0, max) + "..." : m)); } // newest first in the log: the first lines the routes logged after the reload are at the end Collections.reverse(lines); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java index 2ca880ea05a0..36e5816e6f68 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java @@ -238,6 +238,15 @@ class AuthoringToolsTest { return (List<JsonObject>) (List<?>) errors.getCollection("errors"); } + /** A script or template the routes load: dev mode reloads the routes when it changes, so its write waits. */ + @Test + void aWriteOfAFileTheRoutesLoadWaitsForTheReload() { + assertTrue(AuthoringTools.reloadsWithRoutes("shipment-mapping.groovy")); + assertTrue(AuthoringTools.reloadsWithRoutes("templates/letter.FTL")); + assertFalse(AuthoringTools.reloadsWithRoutes("orders/order-1001.json"), "a data file is not loaded by a route"); + assertFalse(AuthoringTools.reloadsWithRoutes("README.md")); + } + @Test void writeValidatesFirstAndRefusesInvalidContent(@TempDir Path dir) throws IOException { Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n"); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java index adb97d9222fa..befcec4ff7af 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java @@ -156,6 +156,53 @@ class ReloadOutcomeTest { assertEquals(7, output.size(), output.toJson()); } + /** + * The line of a failed exchange starts with the message ids and the attempts of the error handler, and the hint of + * the failure comes late in the exception message: cut at 400 characters, the agent never saw it (enrich step 2 of + * the r20 runs). The line is compacted, and the hint fits. + */ + @Test + void theHintOfAFailedExchangeFitsTheLine() { + List<String> failed = List.of( + "2026-10-21 00:30:00.000 INFO 42 --- [rReloadStrategy] org.apache.camel.support.RouteWatcherReloadStrategy : Routes reloaded summary (total:1 started:1)", + "2026-10-21 00:30:01.000 ERROR 42 --- [ - file://orders] or.errorhandler.DefaultErrorHandler : Failed delivery for" + + " (MessageId: 7188B2FCF80AE47-000000000000001B on ExchangeId: 7188B2FCF80AE47-000000000000001B) at" + + " incoming-orders[log14] enrich-and-dedupe.camel.yaml:42. Exhausted after delivery attempt: 1 caught:" + + " org.apache.camel.language.bean.RuntimeBeanExpressionException: Failed to invoke method: [name] on" + + " org.apache.camel.component.file.GenericFile due to: java.lang.IndexOutOfBoundsException: Key: name" + + " not found in bean: GenericFile[C-482.json] of type: org.apache.camel.component.file.GenericFile" + + " using OGNL path [[name]] (the value is the file as read (a GenericFile), not its data: unmarshal it" + + " first, for example with json, then read the key; a variable is unmarshalled in place with" + + " variableSend and variableReceive set to its name, such as unmarshal: {json: {}, variableSend:" + + " customer, variableReceive: customer})"); + + JsonArray output = ReloadOutcome.output(records(BEFORE, failed)); + + assertEquals(1, output.size(), output.toJson()); + String line = output.get(0).toString(); + assertTrue(line.startsWith("ERROR Failed at incoming-orders[log14] enrich-and-dedupe.camel.yaml:42:" + + " RuntimeBeanExpressionException: Failed to invoke method: [name]"), + line); + // the whole hint, not cut + assertTrue(line.endsWith("variableSend: customer, variableReceive: customer})"), line); + assertTrue(!line.contains("MessageId") && !line.contains("Exhausted after"), line); + } + + @Test + void theDumpOfAnExchangeAndOfALongBeanGo() { + String m = ReloadOutcome.compact("Method with name: orderId not found on bean: [" + + "{orderId=ORD-1001, customer=C-482}, ".repeat(5) + + "] of type: java.util.ArrayList on the exchange: Exchange[ABC-1]"); + assertTrue(m.startsWith("Method with name: orderId not found on bean: [{orderId=ORD-1001, customer=C-482}"), m); + assertTrue(m.endsWith("... of type: java.util.ArrayList"), m); + assertEquals("Error processing exchange. Caused by: [NullPointerException - Cannot get property 'id' on null object]", + ReloadOutcome.compact("Error processing exchange. Exchange[1028039098EFE4D-0000000000000009]. Caused by:" + + " [java.lang.NullPointerException - Cannot get property 'id' on null object]")); + // a short dump stays + assertEquals("Key: name not found in bean: GenericFile[C-482.json] of type: x", + ReloadOutcome.compact("Key: name not found in bean: GenericFile[C-482.json] of type: x")); + } + @Test void whatWasLoggedBeforeTheNewestReloadIsNotOutput() { assertEquals(List.of("Hello Camel"), List.copyOf(ReloadOutcome.output(records(BEFORE))));
