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 6e5050a04175 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 (#28209)
6e5050a04175 is described below
commit 6e5050a0417585b1d4fd94a122d5d562356ba7ba
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Oct 21 21:30:02 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
(#28209)
* 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.
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25899: camel-jbang - AI tools: the failed reload of a script the
routes load names its routes, as the reloaded one does
* CAMEL-25899: Answer a template or script write at once when the
integration does not watch it (address review)
- An integration started with its own files (not --source-dir) watches only
those: a write of a template or
script beside them no longer waits RELOAD_WAIT_MILLIS for a reload that
does not come, and says that
camel_control restart picks up the change
- The javadoc of ReloadOutcome.awaitOutput says that a WARN or ERROR line
is compacted and cut at 800 characters
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 34 +++++++++++-
.../core/commands/ai/IntegrationLauncher.java | 21 +++++++
.../dsl/jbang/core/commands/ai/ReloadOutcome.java | 64 ++++++++++++++++++++--
.../jbang/core/commands/ai/AuthoringToolsTest.java | 27 +++++++++
.../core/commands/ai/IntegrationLauncherTest.java | 17 ++++++
.../jbang/core/commands/ai/ReloadOutcomeTest.java | 47 ++++++++++++++++
6 files changed, 202 insertions(+), 8 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..31174238a18a 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()));
@@ -1648,6 +1648,14 @@ public final class AuthoringTools {
}
}
}
+ long notWatchedBy = -1;
+ if (watchPid >= 0 && !SourceValidator.isValidatableFile(file) &&
!IntegrationLauncher.watchesFile(watchPid, file)) {
+ // started with its own files, the template or script is not one
it watches: waiting the whole
+ // RELOAD_WAIT_MILLIS would only end in "no reload seen" (route
files keep the wait: application.properties
+ // is watched without being on the command line)
+ notWatchedBy = watchPid;
+ watchPid = -1;
+ }
boolean watch = watchPid >= 0;
if (watch) {
sinceKey =
ReloadOutcome.latestReloadKey(ReloadOutcome.records(watchPid, processName));
@@ -1701,13 +1709,19 @@ 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"
+ case "failed" ->
+ "The running integration FAILED to reload " + reloadedWhat
+ ", the route is not running; fix the"
+ " content and write again (see
reload.message).";
default -> "Written; " + reload.getString("message");
});
+ } else if (notWatchedBy >= 0) {
+ result.put("message", "The running integration (pid " +
notWatchedBy + ") was started with its own files"
+ + " and does not reload when this file
changes; camel_control restart picks up the"
+ + " change.");
} else {
result.put("message", "An integration running the file in dev mode
reloads it now; otherwise restart the"
+ " integration for the change to take
effect.");
@@ -1723,6 +1737,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/IntegrationLauncher.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncher.java
index 20c6f0733dd9..f38868a9e16f 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncher.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncher.java
@@ -457,6 +457,27 @@ public final class IntegrationLauncher {
: " It is not in dev mode: camel_control restart picks up a
changed or added file.");
}
+ /**
+ * Whether the integration reloads its routes when this file changes. One
started with --source-dir watches its
+ * whole directory. One started with its own files watches only those:
camel run puts a template or a script on the
+ * classpath, not in the reload pattern, so a write of it is not reloaded.
True when the command line of the process
+ * cannot be read, as before.
+ */
+ static boolean watchesFile(long pid, String file) {
+ List<String> args = ProcessHandle.of(pid).flatMap(ph ->
ph.info().arguments()).map(Arrays::asList).orElse(null);
+ return watchesFile(args, file);
+ }
+
+ static boolean watchesFile(List<String> args, String file) {
+ if (args == null || args.stream().anyMatch(a ->
a.startsWith("--source-dir"))) {
+ return true;
+ }
+ String name = Path.of(file).getFileName().toString();
+ // the file itself on the command line (a Groovy route), or a wildcard
that camel run expands
+ return args.stream().anyMatch(a -> a.contains("*") || a.equals(name)
|| a.endsWith("/" + name)
+ || a.endsWith(":" + name));
+ }
+
/**
* The integration that runs in dev mode from the directory (its working
directory, started with --dev), or null:
* the one whose reload a write to the directory is answered with when no
integration is selected (CAMEL-25513).
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..13df72e1dfcb 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,13 +181,28 @@ 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
- * routes' log steps, and any WARN or ERROR, newest last, each cut to 400
characters: the first five, the first
- * three ERROR after them, and a line that says how many are left out. A
write answered with the result of the
- * change shows the agent at once what its route now does (CAMEL-25513):
an agent left to call camel_get_log reads
- * it before the reload ran, or not at all.
+ * routes' log steps, and any WARN or ERROR, newest last. A record of a
log step is cut to 400 characters; a WARN or
+ * ERROR of Camel is compacted (no exchange dump, ids or exception
packages) and cut to 800 characters, as the hint
+ * of a failure comes late in the message. The first five, the first three
ERROR after them, and a line that says
+ * how many are left out. A write answered with the result of the change
shows the agent at once what its route now
+ * does (CAMEL-25513): an agent left to call camel_get_log reads it before
the reload ran, or not at all.
*/
public static JsonArray awaitOutput(long pid, String name, long
timeoutMillis) {
StopWatch watch = new StopWatch();
@@ -203,6 +219,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 +271,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..e4bf42b3b783 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
@@ -37,6 +37,7 @@ import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -238,6 +239,32 @@ 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"));
+ }
+
+ /**
+ * Started with its own files, an integration watches only those, not a
template or script beside them: its write is
+ * answered at once, not after waiting for a reload that does not come.
+ */
+ @Test
+ void aWriteOfATemplateTheIntegrationDoesNotWatchIsAnsweredAtOnce(@TempDir
Path dir) {
+ ToolContext ctx = new ToolContext();
+ // this JVM: its command line has no --source-dir and not the template
+ ctx.selectProcess(ProcessHandle.current().pid());
+ JsonObject written = call("camel_write_file", ctx,
+ Map.of("directory", dir.toString(), "file", "letter.ftl",
"content", "Dear ${body}"));
+ assertEquals("created", written.getString("status"));
+ assertNull(written.get("reload"), written.toJson());
+ assertTrue(written.getString("message").contains("was started with its
own files and does not reload"),
+ written.toJson());
+ }
+
@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/IntegrationLauncherTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherTest.java
index fcfbd42a33ac..023b96b7b55f 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherTest.java
@@ -231,4 +231,21 @@ class IntegrationLauncherTest {
assertThat(IntegrationLauncher.stoppedByRestart(other,
null)).isFalse();
assertThat(IntegrationLauncher.runName(other, null)).isNull();
}
+
+ /**
+ * Started with --source-dir, the integration watches its directory;
started with its own files, only those: camel
+ * run puts a template or a script on the classpath, not in the reload
pattern.
+ */
+ @Test
+ void anIntegrationStartedWithItsOwnFilesWatchesOnlyThose() {
+ List<String> sourceDir = List.of("run", "--source-dir=.", "--dev");
+ assertThat(IntegrationLauncher.watchesFile(sourceDir,
"templates/letter.ftl")).isTrue();
+ List<String> ownFiles = List.of("run", "order.camel.yaml",
"routes/stock.groovy", "--dev");
+ assertThat(IntegrationLauncher.watchesFile(ownFiles,
"letter.ftl")).isFalse();
+ assertThat(IntegrationLauncher.watchesFile(ownFiles,
"mapping.groovy")).isFalse();
+ assertThat(IntegrationLauncher.watchesFile(ownFiles,
"routes/stock.groovy")).isTrue();
+ assertThat(IntegrationLauncher.watchesFile(List.of("run", "*",
"--dev"), "letter.ftl")).isTrue();
+ // the command line cannot be read: waited for, as before
+ assertThat(IntegrationLauncher.watchesFile(null,
"letter.ftl")).isTrue();
+ }
}
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))));