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 54da210600f1 CAMEL-25784: camel-jbang - the AI tools wait for Camel to
start, and read the log of the previous run (#28108)
54da210600f1 is described below
commit 54da210600f1636431c9c21df5f6f9909028daa2
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Oct 19 21:57:45 2026 +0200
CAMEL-25784: camel-jbang - the AI tools wait for Camel to start, and read
the log of the previous run (#28108)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 10 ++
.../apache/camel/dsl/jbang/core/commands/Run.java | 3 +-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 115 +++++++++++++-
.../core/commands/ai/IntegrationLauncher.java | 19 ++-
.../dsl/jbang/core/commands/ai/LogFileReader.java | 13 +-
.../camel/dsl/jbang/core/common/PreviousRun.java | 9 +-
.../jbang/core/commands/ai/AuthoringToolsTest.java | 24 ++-
.../core/commands/ai/PreviousRunToolsTest.java | 168 +++++++++++++++++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 9 +-
.../core/commands/mcp/AuthoringToolsTest.java | 18 ++-
10 files changed, 364 insertions(+), 24 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 884216a6a6a1..3299d0902c77 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1866,3 +1866,13 @@ integration, with `~/.camel/<name>-previous.json`, which
says when and how the r
ends replaces both, and at most the last 10 MB of a log are kept. `camel log
<name> --previous` prints the log, like
`kubectl logs --previous` in Kubernetes. The log of an integration that was
killed is kept the same way, the next time a
previous run is looked up. `camel dirty` does not count these files.
+
+=== camel-jbang - the AI tools wait for Camel to start, and read the log of
the previous run
+
+* `camel_run` and `camel_control restart` answer that the integration started
once its CamelContext has started. They
+ answered as soon as the status file of the integration appeared, which
happens while Camel starts and before the
+ routes start. An integration whose route then failed to start was answered
as started, and stopped right after.
+ Now the answer is the failure, with its cause.
+* `camel_get_log` of an integration that is not running answers with the log
of its previous run (see
+ `camel log --previous`), and its message says how that run ended.
`previous=true` asks for that log also when the
+ integration runs again. `camel_get_errors` of an integration that is not
running says how its previous run ended.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
index e133dd3a5675..a778e6aaef1d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
@@ -3083,7 +3083,8 @@ public class Run extends CamelCommand {
exitCode = code == Integer.MIN_VALUE ? CliExitCode.OK : code;
return exitCode;
} catch (Exception | Error e) {
- // such as a route that failed to start
+ // such as a route that failed to start, or an Error such as a
NoClassDefFoundError of a missing dependency:
+ // only noted as why the run ended, and thrown again
failure = e.getMessage() != null ? e.getMessage() :
e.getClass().getName();
exitCode = new
CliExecutionExceptionHandler(getMain()).getExitCode(e);
throw e;
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 de55433771e8..b9d7f2d8302e 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
@@ -40,8 +40,10 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.dsl.jbang.core.common.PreviousRun;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -323,15 +325,31 @@ public final class AuthoringTools {
registry.accept(tool("camel_get_log",
"Recent log records of a running integration, newest first,
with optional filtering; stack traces "
- + "only with details.")
+ + "only with details. An
integration that is not running gets the log of "
+ + "its previous run, such as one
that failed to start.")
.param("name", "string", NAME_DESC, false)
.param("limit", "integer", "Maximum records to return (default
50)", false)
.param("filter", "string", "Case-insensitive substring filter
on the message", false)
.param("level", "string", "Only this log level (INFO, WARN,
ERROR, DEBUG, TRACE)", false)
.param("details", "boolean", "Include the stack traces
(default false)", false)
+ .param("previous", "boolean",
+ "The log of the previous run, the last run of the
integration that ended (default false)", false)
.core(true)
.executor((ctx, args) -> {
- RuntimeHelper.ProcessInfo p = selectProcess(ctx, args);
+ if (bool(args, "previous", false)) {
+ return previousLog(ctx, args, null).toJson();
+ }
+ RuntimeHelper.ProcessInfo p;
+ try {
+ p = selectProcess(ctx, args);
+ } catch (ToolExecutionException e) {
+ // not running: the log of its previous run tells why
(CAMEL-25784)
+ return previousLog(ctx, args, e).toJson();
+ }
+ if (p == null && hasPreviousRun(ctx.pid())) {
+ // the process selected before is gone, such as one
that died at startup
+ return previousLog(ctx, args, null).toJson();
+ }
return LogFileReader.read(ctx.pid(), p != null ? p.name()
: null, integer(args, "limit", 50),
args.get("filter"), args.get("level"), bool(args,
"details", false)).toJson();
}));
@@ -342,7 +360,16 @@ public final class AuthoringTools {
.param("name", "string", NAME_DESC, false)
.core(true)
.executor((ctx, args) -> {
- selectProcess(ctx, args);
+ RuntimeHelper.ProcessInfo p;
+ try {
+ p = selectProcess(ctx, args);
+ } catch (ToolExecutionException e) {
+ // not running: how its previous run ended tells why
(CAMEL-25784)
+ throw notRunning(ctx, args, e);
+ }
+ if (p == null && hasPreviousRun(ctx.pid())) {
+ throw notRunning(ctx, args, new
ToolExecutionException("pid " + ctx.pid() + " is not running"));
+ }
JsonObject errors = ctx.readErrorFile();
return errors != null ? unescapeBodies(errors).toJson() :
"No errors captured.";
}));
@@ -2114,6 +2141,88 @@ public final class AuthoringTools {
}
}
+ /**
+ * The log of the previous run of the integration (CAMEL-25784): the last
run of it that ended, such as one that
+ * failed to start, found by the name or pid given, else by the pid
selected before. Its message says how that run
+ * ended.
+ *
+ * @param notRunning why the integration was not found
running, or null when the previous run was asked
+ * for
+ * @throws ToolExecutionException when no previous run matches, or more
than one (notRunning, when given)
+ */
+ private static JsonObject previousLog(ToolContext ctx, Map<String, String>
args, ToolExecutionException notRunning) {
+ PreviousRun.Info info = previousRun(ctx, args, notRunning);
+ JsonObject answer = new JsonObject();
+ answer.put("message", (notRunning != null ? info.name() + " is not
running. " : "")
+ + "This is the log of its previous run" +
(info.pid() > 0 ? " (pid " + info.pid() + ")" : "")
+ + ", which " + info.howItEnded());
+ JsonObject previous = new JsonObject();
+ previous.put("name", info.name());
+ if (info.pid() > 0) {
+ previous.put("pid", info.pid());
+ }
+ previous.put("ended", info.endedAt());
+ if (info.exitCode() != null) {
+ previous.put("exitCode", info.exitCode());
+ }
+ if (info.signal() != null) {
+ previous.put("signal", info.signal());
+ }
+ if (info.failure() != null) {
+ previous.put("failure", info.failure());
+ }
+ answer.put("previous", previous);
+ answer.putAll(LogFileReader.read(info.log(), integer(args, "limit",
50), args.get("filter"), args.get("level"),
+ bool(args, "details", false)));
+ return answer;
+ }
+
+ /**
+ * The error of an integration that is not running, with how its previous
run ended when there is one.
+ */
+ private static ToolExecutionException notRunning(
+ ToolContext ctx, Map<String, String> args, ToolExecutionException
notRunning) {
+ PreviousRun.Info info;
+ try {
+ info = previousRun(ctx, args, notRunning);
+ } catch (ToolExecutionException e) {
+ return notRunning;
+ }
+ return new ToolExecutionException(
+ notRunning.getMessage() + ". Its previous run"
+ + (info.pid() > 0 ? " (pid " +
info.pid() + ")" : "") + " " + info.howItEnded()
+ + ". camel_get_log shows its log.");
+ }
+
+ // the one previous run of the name or pid given, else of the pid selected
before
+ private static PreviousRun.Info previousRun(
+ ToolContext ctx, Map<String, String> args, ToolExecutionException
notRunning) {
+ String name = args.get("name");
+ String key = name != null && !name.isBlank() ? name : (ctx.pid() >= 0
? Long.toString(ctx.pid()) : null);
+ List<PreviousRun.Info> found = key != null ? PreviousRun.find(key) :
List.of();
+ if (found.size() == 1) {
+ return found.get(0);
+ }
+ if (notRunning != null) {
+ throw notRunning;
+ }
+ if (key == null) {
+ throw new ToolExecutionException("No integration selected: pass
name (an integration name or pid)");
+ }
+ throw new ToolExecutionException(
+ found.isEmpty()
+ ? "No previous run matches '" + key + "'"
+ : "'" + key + "' matches " + found.size() + " previous
runs: "
+ +
found.stream().map(PreviousRun.Info::name).collect(Collectors.joining(", "))
+ + "; pass the name of one");
+ }
+
+ // whether a run of the pid has ended and kept its log
+ private static boolean hasPreviousRun(long pid) {
+ return pid >= 0 && ProcessHandle.of(pid).map(ph ->
!ph.isAlive()).orElse(true)
+ && !PreviousRun.find(Long.toString(pid)).isEmpty();
+ }
+
/**
* Selects the process the call names, else keeps the selected one, else
the only one running.
*
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 b666032ab2b5..20c6f0733dd9 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
@@ -168,7 +168,7 @@ public final class IntegrationLauncher {
return result;
}
RuntimeHelper.ProcessInfo info = findByPid(pid);
- if (info != null) {
+ if (info != null && started(pid)) {
// the name the other tools find the process by: the one the
caller gave, else the Camel context name
// (the source file name without its extensions); the pid is
in the result as well
String started = runName != null && !runName.isBlank()
@@ -284,6 +284,21 @@ public final class IntegrationLauncher {
return cmd;
}
+ /**
+ * Whether the CamelContext of the integration has started, as its status
file says (CAMEL-25784). The CLI connector
+ * writes the status file while Camel starts, before the routes start, so
a route that fails to start ends the
+ * process after the file has appeared: a run that was answered as started
had died at startup. A status without a
+ * state (an older Camel) counts as started.
+ */
+ static boolean started(long pid) {
+ JsonObject status = RuntimeHelper.readStatus(pid);
+ if (status == null) {
+ return false;
+ }
+ String state = status.get("context") instanceof JsonObject context ?
context.getString("state") : null;
+ return state == null || "Started".equals(state);
+ }
+
private static RuntimeHelper.ProcessInfo findByPid(long pid) {
for (RuntimeHelper.ProcessInfo p : RuntimeHelper.discoverProcesses()) {
if (p.pid() == pid) {
@@ -623,7 +638,7 @@ public final class IntegrationLauncher {
Path output = launched.log();
long deadline = System.currentTimeMillis() + STARTUP_TIMEOUT_MS;
while (System.currentTimeMillis() < deadline && p.isAlive()) {
- if (findByPid(p.pid()) != null) {
+ if (findByPid(p.pid()) != null && started(p.pid())) {
removeWhenEnded(p, output);
return "Restarted " + (name != null ? name : "pid " + pid)
+ " as pid " + p.pid();
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
index 3466f5e4ca21..f67c1dc3cd8e 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
@@ -118,7 +118,18 @@ public final class LogFileReader {
* @return lines (newest first), totalLines and returnedLines, or
an error when there is no log file
*/
public static JsonObject read(long pid, String name, int limit, String
filter, String level, boolean details) {
- Path file = logFile(pid, name);
+ return read(logFile(pid, name), limit, filter, level, details);
+ }
+
+ /**
+ * @param file the log file, such as the log of the previous run of an
integration
+ * @param limit maximum records to return
+ * @param filter case-insensitive substring the message or detail must
contain; null for all
+ * @param level only records of this level (INFO, WARN, ERROR, DEBUG,
TRACE); null for all
+ * @param details whether a stack trace stays in its record
+ * @return lines (newest first), totalLines and returnedLines, or
an error when there is no log file
+ */
+ public static JsonObject read(Path file, int limit, String filter, String
level, boolean details) {
JsonObject result = new JsonObject();
result.put("file", file.toString());
if (!Files.isRegularFile(file)) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PreviousRun.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PreviousRun.java
index c6343cb1a8c6..03e7bbac8f80 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PreviousRun.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PreviousRun.java
@@ -104,11 +104,18 @@ public final class PreviousRun {
return "The previous run of " + name + (pid > 0 ? " (pid " + pid +
")" : "") + " " + howItEnded();
}
+ /**
+ * When the run ended, in local time, such as <tt>2026-10-19
10:32:01</tt>.
+ */
+ public String endedAt() {
+ return TIME.format(Instant.ofEpochMilli(ended));
+ }
+
/**
* How the run ended: when, with which exit code, and why when it
failed.
*/
public String howItEnded() {
- StringBuilder sb = new StringBuilder("ended at
").append(TIME.format(Instant.ofEpochMilli(ended)));
+ StringBuilder sb = new StringBuilder("ended at
").append(endedAt());
long ago = System.currentTimeMillis() - ended;
if (ago >= 0) {
sb.append(" (").append(TimeUtils.printDuration(ago,
false)).append(" ago)");
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index ef477780cfcb..f684fb684dec 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -25,6 +25,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -363,14 +364,21 @@ class AuthoringToolsTest {
}
@Test
- void runtimeToolsSayWhatToDoWithoutAnIntegration() {
- ToolExecutionException e = assertThrows(ToolExecutionException.class,
- () -> ToolRegistry.execute("camel_get_errors", new
ToolContext(), Map.of("name", "no-such-app-xyz")));
- assertTrue(e.getMessage().contains("no-such-app-xyz"), e.getMessage());
- e = assertThrows(ToolExecutionException.class,
- () -> ToolRegistry.execute("camel_control", new ToolContext(),
Map.of("action", "stop", "name",
- "no-such-app-xyz")));
- assertTrue(e.getMessage().contains("no-such-app-xyz"), e.getMessage());
+ void runtimeToolsSayWhatToDoWithoutAnIntegration(@TempDir Path home) {
+ // a home of its own: the tools look for the previous run of an
integration that is not running
+ Path previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ try {
+ ToolExecutionException e =
assertThrows(ToolExecutionException.class,
+ () -> ToolRegistry.execute("camel_get_errors", new
ToolContext(), Map.of("name", "no-such-app-xyz")));
+ assertTrue(e.getMessage().contains("no-such-app-xyz"),
e.getMessage());
+ e = assertThrows(ToolExecutionException.class,
+ () -> ToolRegistry.execute("camel_control", new
ToolContext(), Map.of("action", "stop", "name",
+ "no-such-app-xyz")));
+ assertTrue(e.getMessage().contains("no-such-app-xyz"),
e.getMessage());
+ } finally {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/PreviousRunToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/PreviousRunToolsTest.java
new file mode 100644
index 000000000000..e27dbf2684d4
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/PreviousRunToolsTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.PreviousRun;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * CAMEL-25784: the AI tools read the log of the previous run of an
integration that is not running, such as one that
+ * died at startup, and camel_run and camel_control restart wait until Camel
has started.
+ */
+class PreviousRunToolsTest {
+
+ // a pid no process has (above the highest pid of Linux and macOS)
+ private static final long GONE_PID = 999_999_991L;
+
+ @TempDir
+ Path home;
+
+ private Path previousHome;
+
+ @BeforeEach
+ void useHome() throws Exception {
+ previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ Files.createDirectories(CommandLineHelper.getCamelDir());
+ }
+
+ @AfterEach
+ void restoreHome() {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+
+ @Test
+ void theLogOfAnIntegrationThatIsNotRunningIsTheLogOfItsPreviousRun()
throws Exception {
+ keepPreviousRun("orders", GONE_PID);
+
+ JsonObject log = call("camel_get_log", new ToolContext(),
Map.of("name", "orders"));
+
+ assertThat(log.getString("message"))
+ .startsWith("orders is not running. This is the log of its
previous run (pid " + GONE_PID
+ + "), which ended at ")
+ .endsWith(" with exit code 70: Failed to start route: route1
because: boom");
+ JsonObject previous = log.getMap("previous");
+ assertThat(previous.getString("name")).isEqualTo("orders");
+ assertThat(previous.getInteger("exitCode")).isEqualTo(70);
+ assertThat(previous.getString("failure")).isEqualTo("Failed to start
route: route1 because: boom");
+ assertThat(log.getString("file")).endsWith("orders-previous.log");
+ // newest first
+ JsonObject newest = (JsonObject)
log.getCollection("lines").iterator().next();
+ assertThat(newest.getString("level")).isEqualTo("ERROR");
+ assertThat(newest.getString("message")).contains("Failed to start
route route1");
+ }
+
+ @Test
+ void theLogOfThePreviousRunCanBeAskedFor() throws Exception {
+ keepPreviousRun("orders", GONE_PID);
+
+ JsonObject log = call("camel_get_log", new ToolContext(),
Map.of("name", "orders", "previous", "true"));
+
+ assertThat(log.getString("message"))
+ .startsWith("This is the log of its previous run (pid " +
GONE_PID + "), which ended at ");
+ }
+
+ @Test
+ void theIntegrationSelectedBeforeThatIsGoneHasTheLogOfItsPreviousRun()
throws Exception {
+ keepPreviousRun("orders", GONE_PID);
+ ToolContext ctx = new ToolContext();
+ // such as the pid camel_run answered with, of an integration that
then died at startup
+ ctx.selectProcess(GONE_PID);
+
+ JsonObject log = call("camel_get_log", ctx, Map.of());
+
+ assertThat(log.getString("message"))
+ .startsWith("This is the log of its previous run (pid " +
GONE_PID + "), which ended at ");
+ }
+
+ @Test
+ void anIntegrationWithoutPreviousRunIsNotRunning() throws Exception {
+ keepPreviousRun("orders", GONE_PID);
+
+ assertThatThrownBy(() -> call("camel_get_log", new ToolContext(),
Map.of("name", "payments")))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessage("No running Camel process matches 'payments';
list_processes shows what runs");
+ assertThatThrownBy(() -> call("camel_get_log", new ToolContext(),
Map.of("name", "payments", "previous", "true")))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessage("No previous run matches 'payments'");
+ }
+
+ @Test
+ void theErrorsOfAnIntegrationThatIsNotRunningSayHowItsPreviousRunEnded()
throws Exception {
+ keepPreviousRun("orders", GONE_PID);
+
+ assertThatThrownBy(() -> ToolRegistry.execute("camel_get_errors", new
ToolContext(), Map.of("name", "orders")))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageStartingWith("No running Camel process matches
'orders'; list_processes shows what runs."
+ + " Its previous run (pid " + GONE_PID
+ ") ended at ")
+ .hasMessageEndingWith(
+ " with exit code 70: Failed to start route: route1
because: boom. camel_get_log shows its log.");
+ }
+
+ @Test
+ void anIntegrationHasStartedWhenItsCamelContextHas() throws Exception {
+ // the CLI connector writes the status file while Camel starts, before
the routes start
+ status(GONE_PID, "Starting");
+ assertThat(IntegrationLauncher.started(GONE_PID)).isFalse();
+ status(GONE_PID, "Started");
+ assertThat(IntegrationLauncher.started(GONE_PID)).isTrue();
+ // an older Camel without the state
+ status(GONE_PID, null);
+ assertThat(IntegrationLauncher.started(GONE_PID)).isTrue();
+ // no status file yet
+ assertThat(IntegrationLauncher.started(GONE_PID + 1)).isFalse();
+ }
+
+ // a run of the name that failed to start
+ private static void keepPreviousRun(String name, long pid) throws
Exception {
+ String record = " " + pid + " --- [ main]
org.apache.camel.main.MainSupport : ";
+ Path log = CommandLineHelper.getCamelDir().resolve("4242.log");
+ Files.write(log, List.of("2026-10-19 12:47:11.498 INFO" + record +
"Apache Camel 4.24.0 is starting",
+ "2026-10-19 12:47:11.901 ERROR" + record + "Failed to start
route route1"));
+ PreviousRun.keep(log, name, pid, 70, "Failed to start route: route1
because: boom");
+ }
+
+ private static void status(long pid, String state) throws Exception {
+ JsonObject context = new JsonObject();
+ context.put("name", "orders");
+ if (state != null) {
+ context.put("state", state);
+ }
+ JsonObject root = new JsonObject();
+ root.put("context", context);
+ Files.writeString(CommandLineHelper.getCamelDir().resolve(pid +
"-status.json"), root.toJson());
+ }
+
+ private static JsonObject call(String tool, ToolContext ctx, Map<String,
String> args) throws Exception {
+ return (JsonObject)
Jsoner.deserialize(String.valueOf(ToolRegistry.execute(tool, ctx, new
HashMap<>(args))));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index 83a8b057721e..226bfbaf4cee 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -284,15 +284,18 @@ public class AuthoringTools {
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
description = "Recent log records of a running integration, newest
first, with optional filtering; stack "
- + "traces only with details.")
+ + "traces only with details. An integration that is
not running gets the log of its previous "
+ + "run, such as one that failed to start.")
public JsonObject camel_get_log(
@ToolArg(description = NAME_DESC, required = false) String name,
@ToolArg(description = "Maximum records to return (default 50)",
required = false) Integer limit,
@ToolArg(description = "Case-insensitive substring filter on the
message", required = false) String filter,
@ToolArg(description = "Only this log level (INFO, WARN, ERROR,
DEBUG, TRACE)", required = false) String level,
- @ToolArg(description = "Include the stack traces (default false)",
required = false) Boolean details) {
+ @ToolArg(description = "Include the stack traces (default false)",
required = false) Boolean details,
+ @ToolArg(description = "The log of the previous run, the last run
of the integration that ended (default false)",
+ required = false) Boolean previous) {
return call("camel_get_log", args("name", name, "limit", limit,
"filter", filter, "level", level,
- "details", details));
+ "details", details, "previous", previous));
}
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
index 54c245be834b..9559b8f780ed 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
@@ -34,6 +34,7 @@ import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolCallException;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolDescriptor;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.util.json.JsonObject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -128,11 +129,18 @@ class AuthoringToolsTest {
}
@Test
- void anUnknownIntegrationNameIsAnError() {
- assertThatThrownBy(() -> tools.camel_eval_expression("${body}", null,
"camel", "no-such-app-xyz-1"))
-
.isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
- assertThatThrownBy(() -> tools.camel_get_log("no-such-app-xyz-1",
null, null, null, null))
-
.isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
+ void anUnknownIntegrationNameIsAnError(@TempDir Path home) {
+ // a home of its own: the log tool looks for the previous run of an
integration that is not running
+ Path previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ try {
+ assertThatThrownBy(() -> tools.camel_eval_expression("${body}",
null, "camel", "no-such-app-xyz-1"))
+
.isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
+ assertThatThrownBy(() -> tools.camel_get_log("no-such-app-xyz-1",
null, null, null, null, null))
+
.isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
+ } finally {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
}
@Test