This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-25513-write-reload-output in repository https://gitbox.apache.org/repos/asf/camel.git
commit b15ba9eb570873a79434c2483cfc5f5be52e514d Author: Claus Ibsen <[email protected]> AuthorDate: Sat Oct 10 07:51:50 2026 +0200 CAMEL-25513: camel-jbang-mcp - a write reports the reload and the first output of the integration running from its directory camel_write_file and camel_edit_file reported the reload of a dev-mode integration only for a selected process. Through camel-jbang-mcp the agent passes the directory and no name, so no write reported one (0 of 68 step traces in a ladder run), and the agent wrote blind: in transform-xslt the stylesheet produced empty attributes the model never saw. - with no integration selected, the write tools find the one running in dev mode from the directory (IntegrationLauncher.devModeFrom, as camel_run finds one) and await its reload - after a reload the answer carries the first lines the routes logged (and any WARN or ERROR) within 3s, in reload.output and in the message Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m --- .../modules/ROOT/pages/camel-jbang-mcp.adoc | 3 ++ .../dsl/jbang/core/commands/ai/AuthoringTools.java | 40 +++++++++++--- .../core/commands/ai/IntegrationLauncher.java | 14 +++++ .../dsl/jbang/core/commands/ai/ReloadOutcome.java | 63 ++++++++++++++++++++++ .../jbang/core/commands/ai/ReloadOutcomeTest.java | 27 ++++++++++ 5 files changed, 141 insertions(+), 6 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc index 3caabf9a0d22..e39157d531ff 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc @@ -347,6 +347,9 @@ project `directory` as an argument, the runtime tools take the integration `name plain file name in the directory is accepted. Nobody is asked before the write: the MCP client (Claude Code, Cursor and the others ask before a tool that is not read-only runs) is where the human sits, and the `read-only` access level of the security layer hides the tool altogether. + When an integration runs in dev mode from the directory (as `camel_run` starts it), the answer says how it + reloaded the file and what its routes logged right after, so the agent sees the result of the change, such as + an empty value its route now produces, without a call to `camel_get_log`. | `camel_edit_file` | Changes a file by replacing one snippet: the lines to find (they must name one place; other indentation is fine) 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 453aed7c17bc..cedd14b6a98e 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 @@ -607,6 +607,8 @@ public final class AuthoringTools { /** How long a write waits for the running integration's reload record before answering without it. */ static final long RELOAD_WAIT_MILLIS = 8000; + /** How long a write waits after the reload for the first output of the routes (CAMEL-25513). */ + static final long OUTPUT_WAIT_MILLIS = 3000; /** * Replaces one snippet of a file and writes the result through {@link #writeFile}, so a change to an existing file @@ -1093,11 +1095,25 @@ public final class AuthoringTools { // than the ones before the write String processName = null; String sinceKey = null; - boolean watch = ctx.hasProcess() && SourceValidator.isValidatableFile(file); + long watchPid = -1; + if (SourceValidator.isValidatableFile(file)) { + if (ctx.hasProcess()) { + watchPid = ctx.pid(); + RuntimeHelper.ProcessInfo p = RuntimeHelper.findProcess(Long.toString(ctx.pid())); + processName = p != null ? p.name() : null; + } else { + // no integration selected, as through camel-jbang-mcp where the agent passes the directory: the one + // running from it in dev mode reloads the file (CAMEL-25513) + RuntimeHelper.ProcessInfo p = IntegrationLauncher.devModeFrom(dir); + if (p != null) { + watchPid = p.pid(); + processName = p.name(); + } + } + } + boolean watch = watchPid >= 0; 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)); + sinceKey = ReloadOutcome.latestReloadKey(ReloadOutcome.records(watchPid, processName)); } try { Files.createDirectories(path.getParent()); @@ -1126,11 +1142,23 @@ public final class AuthoringTools { } } if (watch) { - JsonObject reload = ReloadOutcome.await(ctx.pid(), processName, sinceKey, RELOAD_WAIT_MILLIS); + JsonObject reload = ReloadOutcome.await(watchPid, processName, sinceKey, RELOAD_WAIT_MILLIS); result.put("reload", reload); String status = reload.getString("status"); + String logged = ""; + if ("reloaded".equals(status)) { + // the result of the change, so the agent sees what its route now does (CAMEL-25513) + JsonArray output = ReloadOutcome.awaitOutput(watchPid, processName, OUTPUT_WAIT_MILLIS); + reload.put("output", output); + logged = output.isEmpty() + ? " It logged nothing within " + OUTPUT_WAIT_MILLIS / 1000 + "s after the reload (camel_get_log" + + " shows what it logs later)." + : " After the reload it logged: " + output.get(0) + + (output.size() > 1 ? " (and " + (output.size() - 1) + " more in reload.output)" : ""); + } + final String loggedAfter = logged; result.put("message", switch (status) { - case "reloaded" -> "The running integration reloaded the file."; + case "reloaded" -> "The running integration reloaded the file." + loggedAfter; case "properties" -> "The running integration reloaded the properties."; case "failed" -> "The running integration FAILED to reload the file, the route is not running; fix the" + " content and write again (see reload.message)."; 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 ef81b0aff447..42d02caec526 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 @@ -353,6 +353,20 @@ public final class IntegrationLauncher { } /** The integration that runs with this directory as its working directory, or null. */ + /** + * 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). + */ + static RuntimeHelper.ProcessInfo devModeFrom(Path directory) { + RuntimeHelper.ProcessInfo running = runningFrom(directory); + if (running == null) { + return null; + } + List<String> args = ProcessHandle.of(running.pid()).flatMap(ph -> ph.info().arguments()).map(Arrays::asList) + .orElse(null); + return args != null && args.contains("--dev") ? running : null; + } + private static RuntimeHelper.ProcessInfo runningFrom(Path directory) { Path wanted = directory.toAbsolutePath().normalize(); for (RuntimeHelper.ProcessInfo p : RuntimeHelper.discoverProcesses()) { 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 5f5ba2b52a0a..d3759fcc391b 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 @@ -16,9 +16,14 @@ */ package org.apache.camel.dsl.jbang.core.commands.ai; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; import org.apache.camel.util.StopWatch; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; /** @@ -135,6 +140,64 @@ public final class ReloadOutcome { return out; } + /** + * A record a route logged: the log EIP's logger is the route's source location (xslt.camel.yaml:12), or its route + * id when source locations are off, which has no dots, unlike the class names of Camel's own loggers. + */ + private static final Pattern ROUTE_LOGGER = Pattern.compile("(^|\\.)(yaml|yml|xml|java|groovy):\\d+$"); + private static final int OUTPUT_LINES = 5; + private static final int OUTPUT_CHARS = 400; + + /** + * 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, at most five, each cut to 400 characters. 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(); + JsonArray out = output(records(pid, name)); + while (out.isEmpty() && watch.taken() < timeoutMillis) { + try { + Thread.sleep(300); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + out = output(records(pid, name)); + } + return out; + } + + static JsonArray output(List<JsonObject> newestFirst) { + List<String> lines = new ArrayList<>(); + for (JsonObject r : newestFirst) { + if (isReload(r)) { + break; // the newest reload: what came before it is not the result of this write + } + String level = r.getStringOrDefault("level", ""); + String logger = r.getStringOrDefault("logger", ""); + boolean routeLog = ROUTE_LOGGER.matcher(logger).find() + || !logger.isEmpty() && Character.isLowerCase(logger.charAt(0)); + if (!routeLog && !"WARN".equalsIgnoreCase(level) && !"ERROR".equalsIgnoreCase(level)) { + continue; + } + String m = r.getStringOrDefault("message", ""); + String detail = r.getStringOrDefault("detail", ""); + if (routeLog && !detail.isEmpty()) { + // a multi-line body (an XML or JSON document) is part of what the route logged + m = m + "\n" + detail; + } + lines.add((routeLog ? "" : level.toUpperCase(Locale.ROOT) + " ") + + (m.length() > OUTPUT_CHARS ? m.substring(0, OUTPUT_CHARS) + "..." : m)); + } + // newest first in the log: the first lines the routes logged after the reload are at the end + Collections.reverse(lines); + JsonArray out = new JsonArray(); + out.addAll(lines.subList(0, Math.min(OUTPUT_LINES, lines.size()))); + return out; + } + @SuppressWarnings("unchecked") static List<JsonObject> records(long pid, String name) { JsonObject log = LogFileReader.read(pid, name, 40, null, null); 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 8fc48abb0f91..11343b1776c1 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 @@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.ai; import java.util.List; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; import org.junit.jupiter.api.Test; @@ -94,4 +95,30 @@ class ReloadOutcomeTest { assertEquals("reloaded", out.getString("status")); assertEquals("Routes reloaded summary (total:1 started:1)", out.getString("message")); } + + /** CAMEL-25513: what the routes logged after the reload, the result of the write. */ + @Test + void theOutputIsWhatTheRoutesLoggedAfterTheReload() { + List<String> xslt = List.of( + "2026-10-10 04:44:26.089 INFO 22543 --- [rReloadStrategy] e.camel.component.file.FileEndpoint : Endpoint is configured with noop=true so forcing endpoint to be idempotent as well", + "2026-10-10 04:44:26.097 INFO 22543 --- [rReloadStrategy] org.apache.camel.support.RouteWatcherReloadStrategy : Routes reloaded summary (total:1 started:1)", + "2026-10-10 04:44:26.097 INFO 22543 --- [rReloadStrategy] org.apache.camel.support.RouteWatcherReloadStrategy : Started xslt (file://inbox) (source: xslt.camel.yaml:4)", + "2026-10-10 04:44:27.167 INFO 22543 --- [ - file://inbox] xslt.camel.yaml:12 : Packing slip: <?xml version=\"1.0\" encoding=\"UTF-8\"?><packingSlip orderId=\"\" customer=\"C-482\" country=\"\"/>", + "2026-10-10 04:44:28.001 WARN 22543 --- [ - file://inbox] org.apache.camel.component.file.GenericFileOnCompletion : Rollback file strategy: x"); + + JsonArray output = ReloadOutcome.output(records(BEFORE, xslt)); + + assertEquals(2, output.size(), output.toJson()); + assertEquals("Packing slip: <?xml version=\"1.0\" encoding=\"UTF-8\"?><packingSlip orderId=\"\" customer=\"C-482\"" + + " country=\"\"/>", + output.get(0)); + assertTrue(output.get(1).toString().startsWith("WARN Rollback file strategy"), output.toJson()); + } + + @Test + void whatWasLoggedBeforeTheNewestReloadIsNotOutput() { + assertEquals(List.of("Hello Camel"), List.copyOf(ReloadOutcome.output(records(BEFORE)))); + // after the next reload "Hello Camel" is the result of the write before + assertEquals(List.of("Hello again"), List.copyOf(ReloadOutcome.output(records(BEFORE, RELOADED)))); + } }
