diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-component.adoc
index c4517c7b59e8e..4b54aeb69976e 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-component.adoc
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-component.adoc
@@ -1328,7 +1328,7 @@ Java::
[source,java]
----
from("direct:start")
- .to("mongodb:mongoBean?database=${mongodb.database}&collection=${mongodb.collection}&operation=getDbStats")
+ .to("mongodb:mongoBean?database={{mongodb.database}}&collection={{mongodb.collection}}&operation=getDbStats")
.to("direct:result");
----
@@ -1339,7 +1339,7 @@ XML::
-
+
----
@@ -1355,8 +1355,8 @@ YAML::
- to:
uri: mongodb:mongoBean
parameters:
- database: "${mongodb.database}"
- collection: "${mongodb.collection}"
+ database: "{{mongodb.database}}"
+ collection: "{{mongodb.collection}}"
operation: getDbStats
- to:
uri: direct:result
diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc
index 9fbbf6a7e143b..1e4b984069e39 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc
@@ -139,7 +139,7 @@ Java::
[source,java]
----
from("direct:start")
- .to("mongodb-gridfs:mongoBean?database=${mongodb.database}&operation=findOne")
+ .to("mongodb-gridfs:mongoBean?database={{mongodb.database}}&operation=findOne")
.to("direct:result");
----
@@ -150,7 +150,7 @@ XML::
-
+
----
@@ -166,7 +166,7 @@ YAML::
- to:
uri: mongodb-gridfs:mongoBean
parameters:
- database: "${mongodb.database}"
+ database: "{{mongodb.database}}"
operation: findOne
- to:
uri: direct:result
diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-cloud-config.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-cloud-config.adoc
index 0d6241f663232..4ac8352ac0c27 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-cloud-config.adoc
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-cloud-config.adoc
@@ -86,9 +86,9 @@ Java::
+
[source,java]
----
-from("direct")
+from("direct:start")
.setHeader("MyHeader", simple("{{spring-config:custom.property}}"))
- .to("log");
+ .to("log:info");
----
XML::
@@ -96,11 +96,11 @@ XML::
[source,xml]
----
-
+
{{spring-config:custom.property}}
-
+
----
@@ -110,13 +110,13 @@ YAML::
----
- route:
from:
- uri: direct
+ uri: direct:start
steps:
- setHeader:
name: MyHeader
simple: "{{spring-config:custom.property}}"
- to:
- uri: log
+ uri: log:info
----
====
diff --git a/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc b/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc
index 9fbbf6a7e143b..1e4b984069e39 100644
--- a/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc
+++ b/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc
@@ -139,7 +139,7 @@ Java::
[source,java]
----
from("direct:start")
- .to("mongodb-gridfs:mongoBean?database=${mongodb.database}&operation=findOne")
+ .to("mongodb-gridfs:mongoBean?database={{mongodb.database}}&operation=findOne")
.to("direct:result");
----
@@ -150,7 +150,7 @@ XML::
-
+
----
@@ -166,7 +166,7 @@ YAML::
- to:
uri: mongodb-gridfs:mongoBean
parameters:
- database: "${mongodb.database}"
+ database: "{{mongodb.database}}"
operation: findOne
- to:
uri: direct:result
diff --git a/components/camel-mongodb/src/main/docs/mongodb-component.adoc b/components/camel-mongodb/src/main/docs/mongodb-component.adoc
index c4517c7b59e8e..4b54aeb69976e 100644
--- a/components/camel-mongodb/src/main/docs/mongodb-component.adoc
+++ b/components/camel-mongodb/src/main/docs/mongodb-component.adoc
@@ -1328,7 +1328,7 @@ Java::
[source,java]
----
from("direct:start")
- .to("mongodb:mongoBean?database=${mongodb.database}&collection=${mongodb.collection}&operation=getDbStats")
+ .to("mongodb:mongoBean?database={{mongodb.database}}&collection={{mongodb.collection}}&operation=getDbStats")
.to("direct:result");
----
@@ -1339,7 +1339,7 @@ XML::
-
+
----
@@ -1355,8 +1355,8 @@ YAML::
- to:
uri: mongodb:mongoBean
parameters:
- database: "${mongodb.database}"
- collection: "${mongodb.collection}"
+ database: "{{mongodb.database}}"
+ collection: "{{mongodb.collection}}"
operation: getDbStats
- to:
uri: direct:result
diff --git a/components/camel-spring-parent/camel-spring-cloud-config/src/main/docs/spring-cloud-config.adoc b/components/camel-spring-parent/camel-spring-cloud-config/src/main/docs/spring-cloud-config.adoc
index 0d6241f663232..4ac8352ac0c27 100644
--- a/components/camel-spring-parent/camel-spring-cloud-config/src/main/docs/spring-cloud-config.adoc
+++ b/components/camel-spring-parent/camel-spring-cloud-config/src/main/docs/spring-cloud-config.adoc
@@ -86,9 +86,9 @@ Java::
+
[source,java]
----
-from("direct")
+from("direct:start")
.setHeader("MyHeader", simple("{{spring-config:custom.property}}"))
- .to("log");
+ .to("log:info");
----
XML::
@@ -96,11 +96,11 @@ XML::
[source,xml]
----
-
+
{{spring-config:custom.property}}
-
+
----
@@ -110,13 +110,13 @@ YAML::
----
- route:
from:
- uri: direct
+ uri: direct:start
steps:
- setHeader:
name: MyHeader
simple: "{{spring-config:custom.property}}"
- to:
- uri: log
+ uri: log:info
----
====
diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 4bef77ec50044..1bb07917d249a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -819,6 +819,11 @@ same `camel_` names by both MCP servers, `camel mcp` and `camel tui --mcp`: `cam
`camel_catalog_find`, `camel_validate_source`, `camel_get_files`, `camel_write_file`, `camel_run`,
`camel_control`, `camel_get_log`, `camel_get_errors`, `camel_eval_expression` and `camel_error_diagnose`.
+The `camel_write_file` tool, when an integration of the project is selected and runs in dev mode, waits up to
+eight seconds for the reload of the written file and answers with its outcome (`reload.status` reloaded, failed
+with the cause and the validator's report, properties, or unknown), so an agent does not go on with a route that
+did not load. A write with no selected integration answers as before.
+
In the TUI MCP server the tools `tui_catalog_doc`, `tui_validate_source`, `tui_write_file`, `tui_get_files`,
`tui_control`, `tui_get_log`, `tui_get_errors` and `tui_eval_expression` were renamed to the `camel_` names
above; the `tui_` prefix is now reserved for the tools that only make sense in front of the screen. Update
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 56f5c5b87a496..684cc67872a2d 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
@@ -359,6 +359,9 @@ public static JsonObject validate(ToolContext ctx, Path dir, String file, String
return result;
}
+ /** How long a write waits for the running integration's reload record before answering without it. */
+ static final long RELOAD_WAIT_MILLIS = 8000;
+
/** Writes a file after validating it, as {@code camel_write_file} does; no confirmation is asked here. */
public static JsonObject writeFile(ToolContext ctx, Path dir, String file, String content, boolean validate) {
Path path = resolveFile(dir, file);
@@ -378,6 +381,16 @@ public static JsonObject writeFile(ToolContext ctx, Path dir, String file, Strin
return result;
}
}
+ // the reload of a running integration is reported in the answer (CAMEL-24859): the reload records newer
+ // than the ones before the write
+ String processName = null;
+ String sinceKey = null;
+ boolean watch = ctx.hasProcess() && SourceValidator.isValidatableFile(file);
+ if (watch) {
+ RuntimeHelper.ProcessInfo p = RuntimeHelper.findProcess(Long.toString(ctx.pid()));
+ processName = p != null ? p.name() : null;
+ sinceKey = ReloadOutcome.latestReloadKey(ReloadOutcome.records(ctx.pid(), processName));
+ }
try {
Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
@@ -390,8 +403,21 @@ public static JsonObject writeFile(ToolContext ctx, Path dir, String file, Strin
result.put("directory", dir.toString());
result.put("lines", content.isEmpty() ? 0 : (int) content.lines().count());
result.put("bytes", content.getBytes(StandardCharsets.UTF_8).length);
- result.put("message", "An integration running the file in dev mode reloads it now; otherwise restart the"
- + " integration for the change to take effect.");
+ if (watch) {
+ JsonObject reload = ReloadOutcome.await(ctx.pid(), processName, sinceKey, RELOAD_WAIT_MILLIS);
+ result.put("reload", reload);
+ String status = reload.getString("status");
+ result.put("message", switch (status) {
+ case "reloaded" -> "The running integration reloaded the file.";
+ 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).";
+ default -> "Written; " + reload.getString("message");
+ });
+ } 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.");
+ }
return result;
}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
index 5ea3ef07ece2f..a6df58c313176 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
@@ -158,6 +158,60 @@ public static Set declaredBeans(String content) {
return names;
}
+ /** A bean property line: key: value, under properties: of a bean. */
+ private static final Pattern PROPERTY_LINE = Pattern.compile("^\\s*([A-Za-z_][\\w.-]*):\\s*(.+?)\\s*$");
+
+ /**
+ * properties: {start: ${order.first-number}} on a bean: a Simple expression, which a bean property is not; the
+ * placeholder is {{order.first-number}}. The runtime fails to bind the property ("Error binding property
+ * (start=${order.first-number})"), and camel validate said nothing (CAMEL-24857).
+ */
+ static List validateBeanPropertyPlaceholders(String content) {
+ List msgs = new ArrayList<>();
+ String[] lines = content.split("\n", -1);
+ int blockIndent = -1;
+ int propsIndent = -1;
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i];
+ if (line.isBlank() || line.trim().startsWith("#")) {
+ continue;
+ }
+ String trimmed = line.trim();
+ int indent = countLeadingSpaces(line);
+ if (blockIndent >= 0 && indent <= blockIndent) {
+ blockIndent = -1;
+ propsIndent = -1;
+ }
+ if (blockIndent < 0) {
+ if (trimmed.equals("- beans:") || trimmed.equals("beans:")) {
+ blockIndent = indent;
+ }
+ continue;
+ }
+ if (propsIndent >= 0 && indent <= propsIndent) {
+ propsIndent = -1;
+ }
+ if (trimmed.equals("properties:")) {
+ propsIndent = indent;
+ continue;
+ }
+ if (propsIndent < 0) {
+ continue;
+ }
+ Matcher m = PROPERTY_LINE.matcher(line);
+ if (m.find()) {
+ String value = unquote(m.group(2));
+ if (YamlLines.isPropertyKeyInSimpleSyntax(value)) {
+ String key = YamlLines.propertyKeyOf(value);
+ msgs.add("Line " + (i + 1) + ": " + m.group(1) + ": " + value + " is a Simple expression, which a bean"
+ + " property is not evaluated as: a property placeholder is written {{key}}, so "
+ + m.group(1) + ": \"{{" + key + "}}\"");
+ }
+ }
+ }
+ return msgs;
+ }
+
/**
* Bean references in the YAML that nothing declares, each with how to declare it. A reference that is a
* {@code #class:}, {@code #type:} or {@code #bean:} value, a property placeholder, or a class name is left alone.
@@ -169,6 +223,7 @@ public static List validateYamlBeanRefs(String content, BeanDeclarations
if (content == null) {
return msgs;
}
+ msgs.addAll(validateBeanPropertyPlaceholders(content));
Set declared = new HashSet<>(declaredBeans(content));
if (external != null) {
declared.addAll(external.names());
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
index 2c3c0977362b8..1a02f802dd6cf 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
@@ -18,6 +18,7 @@
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -234,6 +235,8 @@ && countLeadingSpaces(lines[k + 1]) > paramIndent) {
}
checkRegexOptions(errors, fullUri, i, optionLineMap);
checkDynamicDirectory(errors, fullUri, i, eipName);
+ checkSimplePlaceholders(errors, fullUri, i, optionLineMap, eipName);
+ checkRequiredPathOptions(errors, fullUri, catalog, i, eipName);
} catch (Exception e) {
// ignore validation errors
}
@@ -372,6 +375,87 @@ static void checkDynamicDirectory(List errors, String fullUri, int uriLi
+ "?fileName=${...}), or use toD: with the whole uri, which evaluates it per message");
}
+ /** The EIPs whose uri is a Simple expression evaluated per message: ${...} is right there. */
+ private static final Set DYNAMIC_URI_EIPS = Set.of("toD", "to-d", "wireTap", "wire-tap", "enrich", "pollEnrich",
+ "poll-enrich", "recipientList", "recipient-list", "routingSlip", "routing-slip", "dynamicRouter", "dynamic-router");
+
+ /**
+ * period=${welcome.period} or period=${properties:welcome.period} on a to: or from:: a Simple expression, which an
+ * endpoint option is not; the property placeholder is {{welcome.period}}. The runtime fails to bind the option at
+ * startup or on the reload, and camel validate said nothing (CAMEL-24857). toD and the other dynamic EIPs evaluate
+ * the uri as Simple first and are left alone.
+ */
+ static void checkSimplePlaceholders(
+ List errors, String fullUri, int uriLineIdx, Map optionLineMap, String eipName) {
+ if (eipName != null && DYNAMIC_URI_EIPS.contains(eipName)) {
+ return;
+ }
+ int q = fullUri.indexOf('?');
+ if (q < 0) {
+ return;
+ }
+ String scheme = fullUri.substring(0, Math.max(0, fullUri.indexOf(':')));
+ for (String pair : fullUri.substring(q + 1).split("&")) {
+ int eq = pair.indexOf('=');
+ if (eq < 0) {
+ continue;
+ }
+ String name = pair.substring(0, eq);
+ String value = pair.substring(eq + 1);
+ if (!YamlLines.isPropertyKeyInSimpleSyntax(value)) {
+ continue;
+ }
+ String key = YamlLines.propertyKeyOf(value);
+ errors.add(linePrefix(optionLineMap.getOrDefault(name, uriLineIdx)) + scheme + ": " + name + "=" + value
+ + " is a Simple expression, which an endpoint option is not evaluated as: a property placeholder"
+ + " is written {{key}}, so " + name + ": \"{{" + key + "}}\"");
+ }
+ }
+
+ /**
+ * uri: cron with only a schedule under parameters: the required path option name is neither in the uri nor among
+ * the parameters; camel run fails with "Option name is required when creating endpoint uri with syntax cron:name"
+ * (CAMEL-24858). Says both places it can go.
+ */
+ static void checkRequiredPathOptions(
+ List errors, String fullUri, CamelCatalog catalog, int uriLineIdx, String eipName) {
+ int colon = fullUri.indexOf(':');
+ if (colon < 0 || fullUri.contains("{{") || !"from".equals(eipName) && !"to".equals(eipName)) {
+ return; // only an endpoint that is created: an intercept pattern such as jms* names no destination
+ }
+ String scheme = fullUri.substring(0, colon);
+ int q = fullUri.indexOf('?');
+ String path = q >= 0 ? fullUri.substring(colon + 1, q) : fullUri.substring(colon + 1);
+ if (path.startsWith("//") || !path.isEmpty()) {
+ // a path is given: which path option it fills is the component's business; an explicit empty authority
+ // (infinispan:// with a custom listener) is a choice, a bare scheme with the options under parameters is
+ // the slip this catches
+ return;
+ }
+ Set given = new HashSet<>();
+ if (q >= 0) {
+ for (String pair : fullUri.substring(q + 1).split("&")) {
+ given.add(pair.contains("=") ? pair.substring(0, pair.indexOf('=')) : pair);
+ }
+ }
+ try {
+ var model = catalog.componentModel(scheme);
+ if (model == null) {
+ return;
+ }
+ for (var option : model.getEndpointOptions()) {
+ if (option.isRequired() && "path".equals(option.getKind()) && !given.contains(option.getName())) {
+ errors.add(linePrefix(uriLineIdx) + scheme + ": the required option '" + option.getName()
+ + "' is missing (the runtime says 'Option " + option.getName() + " is required'): write"
+ + " it in the uri, uri: " + scheme + ":<" + option.getName() + ">, or under parameters"
+ + " as " + option.getName() + ": ");
+ }
+ }
+ } catch (Exception e) {
+ // ignore: a component the catalog does not know is reported elsewhere
+ }
+ }
+
/** A wildcard such as *.txt as the regex .*\\.txt. */
static String toRegex(String wildcard) {
StringBuilder sb = new StringBuilder();
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
new file mode 100644
index 0000000000000..5f5ba2b52a0a7
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java
@@ -0,0 +1,144 @@
+/*
+ * 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.ai;
+
+import java.util.List;
+
+import org.apache.camel.util.StopWatch;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * What a dev-mode integration did with a file the agent wrote: the log records of the reload, read through
+ * {@link LogFileReader} (CAMEL-24859). A write tool answering "written" while the reload failed sends the agent on with
+ * a broken route; the outcome in the answer is what it acts on.
+ */
+public final class ReloadOutcome {
+
+ static final String RELOADED = "Routes reloaded summary";
+ static final String FAILED = "Error reloading routes from file";
+ static final String PROPERTIES = "Reloading properties";
+ static final String REPORT = "did not load";
+
+ private ReloadOutcome() {
+ }
+
+ static boolean isReload(JsonObject r) {
+ String m = r.getStringOrDefault("message", "");
+ return m.contains(RELOADED) || m.contains(FAILED) || m.contains(PROPERTIES);
+ }
+
+ static String key(JsonObject r) {
+ return r.getStringOrDefault("time", "") + "|" + r.getStringOrDefault("message", "");
+ }
+
+ /** The key of the newest reload record, or null: what a later reload is compared against. */
+ public static String latestReloadKey(List newestFirst) {
+ for (JsonObject r : newestFirst) {
+ if (isReload(r)) {
+ return key(r);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The outcome in the records newer than the given key: status reloaded, failed or properties with the record's
+ * message (and, for a failure, its detail and the validator's report when the runtime printed one), or null when no
+ * reload has happened yet.
+ */
+ public static JsonObject classify(List newestFirst, String sinceKey) {
+ for (int i = 0; i < newestFirst.size(); i++) {
+ JsonObject r = newestFirst.get(i);
+ if (!isReload(r)) {
+ continue;
+ }
+ if (sinceKey != null && sinceKey.equals(key(r))) {
+ return null; // the newest reload is the one from before the write
+ }
+ String m = r.getStringOrDefault("message", "");
+ JsonObject out = new JsonObject();
+ if (m.contains(FAILED)) {
+ out.put("status", "failed");
+ StringBuilder sb = new StringBuilder(m);
+ String detail = r.getStringOrDefault("detail", "");
+ if (!detail.isEmpty()) {
+ // the first lines of the cause, not the stack
+ for (String line : detail.split("\n")) {
+ if (line.startsWith("\tat ") || line.isBlank()) {
+ continue;
+ }
+ sb.append("\n").append(line);
+ if (sb.length() > 1500) {
+ break;
+ }
+ }
+ }
+ // the validator's report the runtime logs next to a load failure (CAMEL-24851) is older than the
+ // failure record in a newest-first list
+ for (int j = i + 1; j < Math.min(newestFirst.size(), i + 4); j++) {
+ String other = newestFirst.get(j).getStringOrDefault("message", "");
+ if (other.contains(REPORT)) {
+ sb.append("\n").append(other);
+ String d = newestFirst.get(j).getStringOrDefault("detail", "");
+ if (!d.isEmpty()) {
+ sb.append("\n").append(d);
+ }
+ break;
+ }
+ }
+ out.put("message", sb.toString());
+ } else if (m.contains(RELOADED)) {
+ out.put("status", "reloaded");
+ out.put("message", m);
+ } else {
+ out.put("status", "properties");
+ out.put("message", m);
+ }
+ return out;
+ }
+ return null;
+ }
+
+ /** Polls the integration's log for the reload of a just written file, up to the timeout. */
+ public static JsonObject await(long pid, String name, String sinceKey, long timeoutMillis) {
+ StopWatch watch = new StopWatch();
+ while (watch.taken() < timeoutMillis) {
+ JsonObject outcome = classify(records(pid, name), sinceKey);
+ if (outcome != null) {
+ return outcome;
+ }
+ try {
+ Thread.sleep(500);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ JsonObject out = new JsonObject();
+ out.put("status", "unknown");
+ out.put("message", "no reload seen in the log within " + timeoutMillis / 1000 + "s: the integration may not run"
+ + " in dev mode, or the file is not one it watches; camel_get_log shows what it did");
+ return out;
+ }
+
+ @SuppressWarnings("unchecked")
+ static List records(long pid, String name) {
+ JsonObject log = LogFileReader.read(pid, name, 40, null, null);
+ Object lines = log.get("lines");
+ return lines instanceof List ? (List) lines : List.of();
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
index 0f245b07c3d7e..e572acbe2a2db 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
@@ -16,6 +16,8 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.util.Set;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.camel.util.StringHelper;
@@ -111,6 +113,40 @@ private static String siblingValue(String line, int indent, String option) {
return unquote(trimmed.substring(trimmed.indexOf(':') + 1).trim());
}
+ /** The roots of a Simple expression: ${body...}, ${header.x}, ${date:...}: a value evaluated as Simple is fine. */
+ private static final Set SIMPLE_ROOTS = Set.of("body", "bodyAs", "mandatoryBodyAs",
+ "originalBody", "header", "headers", "headerAs", "exchangeProperty", "exchangeProperties", "exchangePropertyAs",
+ "variable", "variables", "variableAs", "exchange", "camelContext", "camelId", "routeId", "routeGroup", "stepId",
+ "id", "messageId", "exchangeId", "messageTimestamp", "messageHistory", "threadName", "threadId", "hostname",
+ "null", "date", "bean", "random", "file", "env", "sys", "sysenv", "ref", "type", "uuid", "empty", "collate",
+ "exception", "in", "out",
+ "skip", "jsonpath", "xpath", "jq", "iif", "join", "replace", "substring", "pretty", "hash", "messageAs",
+ "properties", "propertiesExist");
+
+ private static final Pattern DOTTED_KEY = Pattern.compile("^\\$\\{([A-Za-z_][\\w-]*)(?:[.-][\\w-]+)+\\}$");
+
+ /**
+ * Whether a ${...} value is a property key wearing Simple's syntax (${welcome.period}, ${properties:x}) rather than
+ * a Simple expression (${body.id}, ${date:now:yyyy}): the first segment is not a Simple root (CAMEL-24857).
+ */
+ static boolean isPropertyKeyInSimpleSyntax(String value) {
+ if (value == null) {
+ return false;
+ }
+ if (value.startsWith("${properties:") && value.endsWith("}")) {
+ return true;
+ }
+ Matcher m = DOTTED_KEY.matcher(value);
+ return m.find() && !SIMPLE_ROOTS.contains(m.group(1));
+ }
+
+ /** The key of such a value: welcome.period for ${welcome.period} or ${properties:welcome.period}. */
+ static String propertyKeyOf(String value) {
+ return value.startsWith("${properties:")
+ ? value.substring("${properties:".length(), value.length() - 1)
+ : value.substring(2, value.length() - 1);
+ }
+
static String extractEipFromLine(String trimmed) {
if (trimmed.startsWith("- ")) {
trimmed = trimmed.substring(2).trim();
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
new file mode 100644
index 0000000000000..8fc48abb0f910
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.ai;
+
+import java.util.List;
+
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** CAMEL-24859: the reload of a written file, read from the log records newer than the ones before the write. */
+class ReloadOutcomeTest {
+
+ private static final List BEFORE = List.of(
+ "2026-09-21 10:00:00.001 INFO 42 --- [ main] org.apache.camel.main.MainSupport : Apache Camel 4.23.0 is starting",
+ "2026-09-21 10:00:05.000 INFO 42 --- [rReloadStrategy] org.apache.camel.support.RouteWatcherReloadStrategy : Routes reloaded summary (total:1 started:1)",
+ "2026-09-21 10:00:06.000 INFO 42 --- [ timer://tick] route1 : Hello Camel");
+
+ private static final List FAILED = List.of(
+ "2026-09-21 10:00:20.000 ERROR 42 --- [rReloadStrategy] org.apache.camel.dsl.jbang.core.commands.ai.YamlLoadFailureReport : The route file did not load. camel validate yaml says what to write:",
+ " a.camel.yaml:",
+ " cron: the required option 'name' is missing",
+ "2026-09-21 10:00:20.001 WARN 42 --- [rReloadStrategy] org.apache.camel.support.FileWatcherResourceReloadStrategy : Error reloading routes from file: a.camel.yaml due to: Error constructing YAML node id: org.apache.camel.model.FromDefinition. This exception is ignored.",
+ "org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException: Error constructing YAML node id: org.apache.camel.model.FromDefinition",
+ "\tat org.apache.camel.dsl.yaml.YamlRoutesBuilderLoader.doConfigure(YamlRoutesBuilderLoader.java:190)",
+ "Caused by: java.lang.IllegalArgumentException: Option name is required when creating endpoint uri with syntax cron:name",
+ "\tat org.apache.camel.support.component.AbstractApiEndpoint.x(Foo.java:1)");
+
+ private static final List RELOADED = List.of(
+ "2026-09-21 10:00:40.000 INFO 42 --- [rReloadStrategy] org.apache.camel.support.RouteWatcherReloadStrategy : Routes reloaded summary (total:1 started:1)",
+ "2026-09-21 10:00:41.000 INFO 42 --- [ timer://tick] route1 : Hello again");
+
+ @SuppressWarnings("unchecked")
+ private static List records(List... parts) {
+ List all = new java.util.ArrayList<>();
+ for (List p : parts) {
+ all.addAll(p);
+ }
+ return (List) (List>) List
+ .copyOf(LogFileReader.build(all, 40, null, null, new JsonObject()).getCollection("lines"));
+ }
+
+ @Test
+ void theNewestReloadBeforeTheWriteIsTheBaseline() {
+ String since = ReloadOutcome.latestReloadKey(records(BEFORE));
+ assertEquals("10:00:05.000|Routes reloaded summary (total:1 started:1)", since);
+ assertNull(ReloadOutcome.classify(records(BEFORE), since), "no reload since the write yet");
+ }
+
+ @Test
+ void aFailedReloadCarriesTheCauseAndTheValidatorsReport() {
+ String since = ReloadOutcome.latestReloadKey(records(BEFORE));
+ JsonObject out = ReloadOutcome.classify(records(BEFORE, FAILED), since);
+ assertEquals("failed", out.getString("status"));
+ String m = out.getString("message");
+ assertTrue(m.startsWith("Error reloading routes from file: a.camel.yaml"), m);
+ assertTrue(m.contains("Caused by: java.lang.IllegalArgumentException: Option name is required"), m);
+ assertTrue(m.contains("camel validate yaml says what to write"), "the runtime's report is carried along: " + m);
+ assertTrue(m.contains("the required option 'name' is missing"), m);
+ assertTrue(!m.contains("\tat "), "no stack frames: " + m);
+ }
+
+ @Test
+ void aPropertiesReloadIsReportedAsSuch() {
+ List props = List.of(
+ "2026-09-21 10:00:30.000 INFO 42 --- [rReloadStrategy] org.apache.camel.main.DefaultConfigurationConfigurer : Reloading properties: file:application.properties");
+ String since = ReloadOutcome.latestReloadKey(records(BEFORE));
+ JsonObject out = ReloadOutcome.classify(records(BEFORE, props), since);
+ assertEquals("properties", out.getString("status"));
+ assertEquals("Reloading properties: file:application.properties", out.getString("message"));
+ }
+
+ @Test
+ void aReloadAfterTheFailureIsReloaded() {
+ String since = ReloadOutcome.latestReloadKey(records(BEFORE));
+ JsonObject out = ReloadOutcome.classify(records(BEFORE, FAILED, RELOADED), since);
+ assertEquals("reloaded", out.getString("status"));
+ assertEquals("Routes reloaded summary (total:1 started:1)", out.getString("message"));
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java
new file mode 100644
index 0000000000000..36822c18c4fdc
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.ai;
+
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24857 and CAMEL-24858: a ${...} placeholder where {{...}} is meant (an endpoint option, a bean property), and a
+ * required endpoint path option that is missing.
+ */
+class SourceValidatorPlaceholderTest {
+
+ private static final CamelCatalog catalog = new DefaultCamelCatalog();
+
+ @Test
+ void aSimplePlaceholderInAnEndpointOptionSaysToWriteAPropertyPlaceholder() {
+ String yaml = """
+ - route:
+ from:
+ uri: timer
+ parameters:
+ timerName: welcome
+ period: "${welcome.period}"
+ steps:
+ - to:
+ uri: "log:done?level=${properties:log.level}"
+ """;
+ List errors = SourceValidator.validateYamlEndpoints(yaml, catalog);
+ assertThat(errors).anyMatch(e -> e.startsWith("Line 6: timer: period=${welcome.period} is a Simple expression")
+ && e.endsWith("so period: \"{{welcome.period}}\""));
+ assertThat(errors).anyMatch(e -> e.startsWith("Line 9: log: level=${properties:log.level} is a Simple expression")
+ && e.endsWith("so level: \"{{log.level}}\""));
+ }
+
+ @Test
+ void aDynamicEipAndAPropertyPlaceholderAreFine() {
+ String yaml = """
+ - route:
+ from:
+ uri: timer
+ parameters:
+ timerName: welcome
+ period: "{{welcome.period}}"
+ steps:
+ - toD:
+ uri: "log:${header.target}?level=INFO"
+ """;
+ assertThat(SourceValidator.validateYamlEndpoints(yaml, catalog)).noneMatch(e -> e.contains("Simple expression"));
+ }
+
+ @Test
+ void aMissingRequiredPathOptionIsReportedWithBothPlaces() {
+ String yaml = """
+ - route:
+ from:
+ uri: cron
+ parameters:
+ schedule: 0/10 * * * * ?
+ steps:
+ - to:
+ uri: log:done
+ """;
+ List errors = SourceValidator.validateYamlEndpoints(yaml, catalog);
+ assertThat(errors).anyMatch(e -> e.startsWith("Line 3: cron: the required option 'name' is missing")
+ && e.contains("uri: cron:") && e.contains("name: "));
+ // given in the uri, or as a parameter: fine
+ assertThat(SourceValidator.validateYamlEndpoints(yaml.replace("uri: cron", "uri: cron:report"), catalog))
+ .noneMatch(e -> e.contains("required option"));
+ assertThat(SourceValidator.validateYamlEndpoints(yaml.replace("schedule: 0/10", "name: report\n schedule: 0/10"),
+ catalog))
+ .noneMatch(e -> e.contains("required option"));
+ }
+
+ @Test
+ void aSimplePlaceholderInABeanPropertySaysToWriteAPropertyPlaceholder() {
+ String yaml = """
+ - beans:
+ - name: orderNumber
+ type: "camel.example.OrderNumber"
+ properties:
+ start: ${order.first-number}
+ label: "{{order.label}}"
+ """;
+ List msgs = BeanRefChecks.validateBeanPropertyPlaceholders(yaml);
+ assertThat(msgs).hasSize(1);
+ assertThat(msgs.get(0)).startsWith("Line 5: start: ${order.first-number} is a Simple expression")
+ .endsWith("so start: \"{{order.first-number}}\"");
+ }
+}