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 5b9cf8ffd139 CAMEL-24857, CAMEL-24858, CAMEL-24859: two validator rows
and the reload outcome in camel_write_file, from the stepwise benchmark
5b9cf8ffd139 is described below
commit 5b9cf8ffd1392ac7e8f5abca0b93e5f419337b91
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Sep 21 11:12:27 2026 +0200
CAMEL-24857, CAMEL-24858, CAMEL-24859: two validator rows and the reload
outcome in camel_write_file, from the stepwise benchmark
## Description
Three findings of the stepwise half of the round-2 local-model benchmark
(ten camel-jbang-examples built step by step through the camel-jbang-mcp
server, five runs each). Together they were the cause of most systematic
failures of the series: the model wrote something the validator accepted, the
runtime rejected it on the reload, and the write tool had already said
"written".
**CAMEL-24857, a `${...}` placeholder where `{{...}}` is meant.** `period:
"${welcome.period}"`, `period: ${properties:welcome.period}` on an endpoint,
`start: ${order.first-number}` on a bean property: Simple syntax for a property
key. The endpoint checks and a new bean property check report it with the form
to write:
```
Line 6: timer: period=${welcome.period} is a Simple expression, which an
endpoint option is not evaluated as: a property placeholder is written {{key}},
so period: "{{welcome.period}}"
```
Only a property key in Simple syntax is reported (`${welcome.period}`,
`${properties:x}`): a value whose first segment is a Simple root (`${body.id}`,
`${header.x}`, `${date:...}`, `${exception.message}`) is a Simple expression
and is left alone, as is the uri of `toD`, `wireTap`, `enrich`, `pollEnrich`,
`recipientList`, `routingSlip` and `dynamicRouter`, which evaluate it as
Simple. Seven of eight failures of two steps in the series were this shape.
**CAMEL-24858, a required path option missing.** `uri: cron` with only a
schedule under parameters passed `camel validate`; `camel run` fails with
"Option name is required when creating endpoint uri with syntax cron:name", and
in dev mode the reload fails with a WARN. A `from:` or `to:` with a bare
scheme, no path and no `//`, whose component has a required path option that is
not among the parameters either, is reported with both places it can go. An
explicit empty authority (`infini [...]
**CAMEL-24859, the reload outcome in the write tool.** When an integration
of the project is selected and runs in dev mode, `camel_write_file` remembers
the newest reload record before the write, waits up to eight seconds for a
newer one, and answers with it: `reload.status` reloaded, `properties`,
`failed` with the cause (the first lines, no stack frames) and the validator's
report the runtime logs next to a load failure (CAMEL-24851), or `unknown` when
nothing came. The message says [...]
Two documentation examples had the placeholder slip (mongodb,
mongodb-gridfs: `database=${mongodb.database}`) and one had a nameless direct
endpoint in all three forms (spring-cloud-config, `from("direct")`): fixed,
with their catalog copies. Upgrade guide note for the tool.
## Tests
- `SourceValidatorPlaceholderTest`: the endpoint option and bean property
cases with the form to write, the dynamic EIP and `{{...}}` left alone, the
missing `name` on cron reported and not reported when given in the uri or under
parameters.
- `ReloadOutcomeTest` on synthetic log records: the baseline before the
write, a failed reload with the cause and the validator's report and no stack
frames, a later successful reload.
- All 228 tests of the ai package of camel-jbang-core green, including the
documentation examples test that runs every YAML example of the docs through
the checks (which is how the three doc slips surfaced).
---
.../camel/catalog/docs/mongodb-component.adoc | 8 +-
.../catalog/docs/mongodb-gridfs-component.adoc | 6 +-
.../camel/catalog/docs/spring-cloud-config.adoc | 12 +-
.../src/main/docs/mongodb-gridfs-component.adoc | 6 +-
.../src/main/docs/mongodb-component.adoc | 8 +-
.../src/main/docs/spring-cloud-config.adoc | 12 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 5 +
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 30 ++++-
.../dsl/jbang/core/commands/ai/BeanRefChecks.java | 55 ++++++++
.../dsl/jbang/core/commands/ai/EndpointChecks.java | 84 ++++++++++++
.../dsl/jbang/core/commands/ai/ReloadOutcome.java | 144 +++++++++++++++++++++
.../dsl/jbang/core/commands/ai/YamlLines.java | 36 ++++++
.../jbang/core/commands/ai/ReloadOutcomeTest.java | 97 ++++++++++++++
.../ai/SourceValidatorPlaceholderTest.java | 109 ++++++++++++++++
14 files changed, 584 insertions(+), 28 deletions(-)
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 c4517c7b59e8..4b54aeb69976 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::
<route>
<from uri="direct:start" />
<!-- using bean 'mongoBean' defined above -->
- <to
uri="mongodb:mongoBean?database=${mongodb.database}&collection=${mongodb.collection}&operation=getDbStats"
/>
+ <to
uri="mongodb:mongoBean?database={{mongodb.database}}&collection={{mongodb.collection}}&operation=getDbStats"
/>
<to uri="direct:result" />
</route>
----
@@ -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 9fbbf6a7e143..1e4b984069e3 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::
<route>
<from uri="direct:start" />
<!-- using bean 'mongoBean' defined above -->
- <to
uri="mongodb-gridfs:mongoBean?database=${mongodb.database}&operation=findOne"
/>
+ <to
uri="mongodb-gridfs:mongoBean?database={{mongodb.database}}&operation=findOne"
/>
<to uri="direct:result" />
</route>
----
@@ -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 0d6241f66323..4ac8352ac0c2 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]
----
<route>
- <from uri="direct"/>
+ <from uri="direct:start"/>
<setHeader name="MyHeader">
<simple>{{spring-config:custom.property}}</simple>
</setHeader>
- <to uri="log"/>
+ <to uri="log:info"/>
</route>
----
@@ -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 9fbbf6a7e143..1e4b984069e3 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::
<route>
<from uri="direct:start" />
<!-- using bean 'mongoBean' defined above -->
- <to
uri="mongodb-gridfs:mongoBean?database=${mongodb.database}&operation=findOne"
/>
+ <to
uri="mongodb-gridfs:mongoBean?database={{mongodb.database}}&operation=findOne"
/>
<to uri="direct:result" />
</route>
----
@@ -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 c4517c7b59e8..4b54aeb69976 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::
<route>
<from uri="direct:start" />
<!-- using bean 'mongoBean' defined above -->
- <to
uri="mongodb:mongoBean?database=${mongodb.database}&collection=${mongodb.collection}&operation=getDbStats"
/>
+ <to
uri="mongodb:mongoBean?database={{mongodb.database}}&collection={{mongodb.collection}}&operation=getDbStats"
/>
<to uri="direct:result" />
</route>
----
@@ -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 0d6241f66323..4ac8352ac0c2 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]
----
<route>
- <from uri="direct"/>
+ <from uri="direct:start"/>
<setHeader name="MyHeader">
<simple>{{spring-config:custom.property}}</simple>
</setHeader>
- <to uri="log"/>
+ <to uri="log:info"/>
</route>
----
@@ -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 adf72d1195cf..c1176d685d12 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
@@ -836,6 +836,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 56f5c5b87a49..684cc67872a2 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 final class AuthoringTools {
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 final class AuthoringTools {
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 final class AuthoringTools {
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 5ea3ef07ece2..a6df58c31317 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 @@ final class BeanRefChecks {
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<String> validateBeanPropertyPlaceholders(String content) {
+ List<String> 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 @@ final class BeanRefChecks {
if (content == null) {
return msgs;
}
+ msgs.addAll(validateBeanPropertyPlaceholders(content));
Set<String> 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 e80081f89f14..a16ae541175f 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 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
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 @@ final class EndpointChecks {
}
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
}
@@ -388,6 +391,87 @@ final class EndpointChecks {
+ "?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<String> 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<String> errors, String fullUri, int uriLineIdx, Map<String,
Integer> 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<String> 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<String> 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() + ": <value>");
+ }
+ }
+ } 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 000000000000..5f5ba2b52a0a
--- /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<JsonObject> 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<JsonObject> 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<JsonObject> records(long pid, String name) {
+ JsonObject log = LogFileReader.read(pid, name, 40, null, null);
+ Object lines = log.get("lines");
+ return lines instanceof List ? (List<JsonObject>) 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 9d3f43da61de..9e45dbf8539b 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;
@@ -112,6 +114,40 @@ final class YamlLines {
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<String> 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 000000000000..8fc48abb0f91
--- /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<String> 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<String> 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<String> 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<JsonObject> records(List<String>... parts) {
+ List<String> all = new java.util.ArrayList<>();
+ for (List<String> p : parts) {
+ all.addAll(p);
+ }
+ return (List<JsonObject>) (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<String> 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 000000000000..36822c18c4fd
--- /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<String> 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<String> errors = SourceValidator.validateYamlEndpoints(yaml,
catalog);
+ assertThat(errors).anyMatch(e -> e.startsWith("Line 3: cron: the
required option 'name' is missing")
+ && e.contains("uri: cron:<name>") && e.contains("name:
<value>"));
+ // 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<String> 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}}\"");
+ }
+}