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 7d31f3b0d8c7 CAMEL-25398: camel-jbang - camel ask, MCP tools and the
monitor surface CLI failures to agents (#27931)
7d31f3b0d8c7 is described below
commit 7d31f3b0d8c7d7e1aa2335b18f318c254852e0cc
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 16 06:14:03 2026 +0200
CAMEL-25398: camel-jbang - camel ask, MCP tools and the monitor surface CLI
failures to agents (#27931)
- CliRunner runs a CLI command in this JVM and returns CliResult (exit
code, stdout and stderr apart, elapsed time, timed out), under one lock
that the camel shell (each command) and the AI panel of the monitor take
too. The panel's commands get picocli's help output captured as well.
- camel ask cli_exec returns that result as JSON with a 60 second timeout
(the exit code was dropped whenever there was output), and refuses a
command that runs until it is stopped, with the form that returns
(run without --background, --watch without --duration, log following,
infra run, shell, debug, tui, ...; export --clean-dir needs --yes).
- Tool failures are errors (MCP isError): camel_run with status failed, a
failed restart, an action the integration answers with an error or does
not answer (ToolContext.executeAction), route stop/start/suspend/resume
and camel_control stop-routes/start-routes/reset-stats/reload send the
action and wait for the confirmation (an integration before 4.24 takes
it unconfirmed), camel_runtime_ai_trace no longer reads a failed query
as no AI components, and the monitor's MCP server and AI panel rethrow
the errors of shared tools; only an unknown tool is "Unknown tool".
- The monitor watches a restarted Camel Main integration like a launch
(failed starts show why, quitting stops it), camel_control restart waits
for the outcome, a launch counts as started by the status file of the
integration (or its child, for a Maven launch), and a failed
tui_run_example is a tool error.
- docker/podman info and the Ollama monitor's commands read their output
while they run (ExternalCommand), so the timeout holds; camel doctor had
none.
A command that timed out while it waited for another one does not run
afterwards, and the MCP server of the monitor answers a tool error with the
message of the tool as it is.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 14 ++
.../modules/ROOT/pages/camel-jbang-ai.adoc | 6 +
.../camel/dsl/jbang/core/commands/AskTools.java | 130 +++++++-----
.../core/commands/CamelShellCommandRegistry.java | 65 +++++-
.../camel/dsl/jbang/core/commands/CliRunner.java | 221 +++++++++++++++++++++
.../camel/dsl/jbang/core/commands/Doctor.java | 19 +-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 4 +
.../core/commands/ai/IntegrationLauncher.java | 49 +++--
.../dsl/jbang/core/commands/ai/ToolContext.java | 63 +++++-
.../dsl/jbang/core/commands/ai/ToolRegistry.java | 4 +-
.../camel/dsl/jbang/core/common/ActionClient.java | 12 +-
.../dsl/jbang/core/common/ExternalCommand.java | 97 +++++++++
.../dsl/jbang/core/commands/AskToolsTest.java | 23 +++
.../dsl/jbang/core/commands/CliRunnerTest.java | 194 ++++++++++++++++++
.../dsl/jbang/core/common/ExternalCommandTest.java | 60 ++++++
.../dsl/jbang/core/commands/mcp/AiTraceTools.java | 7 +-
.../core/commands/tui/AiCliCommandExecutor.java | 37 ++--
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 2 +-
.../dsl/jbang/core/commands/tui/DoctorPopup.java | 32 +--
.../dsl/jbang/core/commands/tui/LaunchManager.java | 43 ++--
.../dsl/jbang/core/commands/tui/McpFacade.java | 36 +++-
.../dsl/jbang/core/commands/tui/OllamaMonitor.java | 27 +--
.../jbang/core/commands/tui/ProcessControl.java | 9 +-
.../dsl/jbang/core/commands/tui/TuiFileTools.java | 26 +--
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 12 +-
.../jbang/core/commands/tui/TuiToolRegistry.java | 22 +-
.../commands/tui/LaunchManagerOutcomeTest.java | 116 +++++++++++
.../commands/tui/TuiMcpServerToolErrorTest.java | 106 ++++++++++
.../tui/TuiToolRegistryEvalExpressionTest.java | 8 +-
29 files changed, 1225 insertions(+), 219 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 f13b9c23ba87..5ee9c372ca85 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
@@ -411,6 +411,20 @@ or follows, like Enter does.
printed. Files together with `--source-dir`, and `--background` with a
runtime that does not support it, exit with
`2`.
+=== camel-jbang - the AI tools report failures as errors
+
+* `camel ask` runs a `camel` command for the model with a timeout of 60
seconds, and gives it the exit code, stdout and
+ stderr as JSON (it gave the output only, and the exit code only when there
was no output). A command that runs until
+ it is stopped is not run, and the model is told the form that returns, such
as `camel run --background`.
+* The MCP tools of `camel mcp` and `camel tui --mcp` answer a failure with
`isError`, instead of a normal answer that
+ says what went wrong: `camel_run` of an integration that stops while it
starts (`status` is `failed`), a restart that
+ fails (`camel_control`), an action the integration answers with an error or
does not answer (such as stopping a route
+ that does not exist), and `camel_runtime_ai_trace` when the integration does
not answer. The text of the error has
+ what the answer had, such as the output of the integration.
+* `camel_control restart` in `camel tui --mcp` waits until the integration has
started again, or failed to start (it
+ answered at once), and `camel_control stop-routes` and `start-routes` answer
with what was done instead of the
+ answer of the integration.
+
=== camel-jpa, camel-hazelcast - an exchange marked rollback only is not
consumed
When a route marks the exchange rollback only (for example with
`markRollbackOnly()` or `markRollbackOnlyLast()`)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
index 1f1879fbbf11..c83a6760c54b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai.adoc
@@ -72,6 +72,12 @@ Answer `y` to let the call run. A call you do not confirm
does not run, and the
model is told so. Without a terminal to ask on, as in a script, such a call
does not run; `--yes` lets all of them run without asking.
+A `camel` command the model runs gives it the exit code, the output and the
+errors apart, as a script gets them (see
xref:manual::camel-jbang-exit-codes.adoc[Exit Codes and Output]),
+and stops after 60 seconds. A command that runs until it is stopped is not run,
+and the model is told the form that returns: `camel run` without
`--background`,
+`--watch` without `--duration`, `camel log` that follows, or `camel shell`.
+
=== Local and hosted models
A local model gets a core set of tools: the project's files, the catalog, the
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
index 8c79ec009701..58333a6c21e9 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
@@ -17,8 +17,6 @@
package org.apache.camel.dsl.jbang.core.commands;
import java.io.InputStream;
-import java.io.PrintWriter;
-import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -37,7 +35,6 @@ import
org.apache.camel.dsl.jbang.core.commands.ai.ToolContext;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolDescriptor;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolGroups;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
-import org.apache.camel.dsl.jbang.core.common.Printer;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.json.JsonObject;
@@ -75,6 +72,9 @@ public class AskTools {
private static final String CLI_LIST_COMMANDS = "cli_list_commands";
private static final String CLI_COMMAND_HELP = "cli_command_help";
private static final String CLI_EXEC = "cli_exec";
+ /** How long a command of cli_exec may run. */
+ static final long CLI_EXEC_TIMEOUT = 60000;
+ private static final int CLI_EXEC_MAX_OUTPUT = 32768;
private long targetPid;
private final ToolContext ctx;
@@ -210,8 +210,10 @@ public class AskTools {
}
tools.add(new LlmClient.ToolDef(
CLI_EXEC,
- "Execute a Camel CLI command and return its output, such as
'get route' or 'ps'. A command that changes"
- + " something (run, stop, cmd, infra run, ...) asks
the user first.",
+ "Execute a Camel CLI command, such as 'get route' or 'ps', and
return its exitCode, stdout and stderr as"
+ + " JSON (exit code 0 is success, 1 failed, 2 wrong
command line, 3 not found, 124 timed out)."
+ + " A command that changes something (run, stop,
cmd, infra run, ...) asks the user first; one"
+ + " that runs until it is stopped is refused with
the form that returns.",
objectParams(Map.of(
"command", stringProp(
"The command line without camel (e.g., 'get
error --diagram', 'catalog component --filter=kafka')")))));
@@ -508,68 +510,90 @@ public class AskTools {
// the command line without camel, as the description says; a
model often writes it anyway
cmdArgs = Arrays.copyOfRange(cmdArgs, 1, cmdArgs.length);
}
+ String blocking = blockingHint(cmdArgs);
+ if (blocking != null) {
+ JsonObject refused = new JsonObject();
+ refused.put("status", "refused");
+ refused.put("message", blocking);
+ return refused.toJson();
+ }
ToolConfirms.Call call = ToolConfirms.cliCommand(cmdArgs);
if (!ctx.confirm(call)) {
return rejected(call);
}
-
- picocli.CommandLine commandLine = CamelJBangMain.getCommandLine();
- if (commandLine == null) {
+ if (CamelJBangMain.getCommandLine() == null) {
return "Error: CLI not available";
}
+ return CliRunner.run(List.of(cmdArgs),
CLI_EXEC_TIMEOUT).toJson(CLI_EXEC_MAX_OUTPUT).toJson();
+ }
- StringBuilder captured = new StringBuilder();
- Printer capturingPrinter = new Printer() {
- @Override
- public void println() {
- captured.append('\n');
+ /**
+ * Why a command cannot run from camel ask, with the form of it that can,
or <tt>null</tt> when it can. The command
+ * runs in this JVM with the terminal of camel ask, where nobody presses
Enter or Ctrl+C to stop it, and nobody
+ * answers its prompts.
+ */
+ static String blockingHint(String[] args) {
+ if (args.length == 0) {
+ return null;
+ }
+ List<String> list = List.of(args);
+ String command = args[0] + (args.length > 1 &&
!args[1].startsWith("-") ? " " + args[1] : "");
+ boolean limited = list.stream().anyMatch(a ->
a.startsWith("--duration"));
+ switch (args[0]) {
+ case "debug", "shell", "tui", "nano", "edit", "ask" -> {
+ return "camel " + args[0] + " needs a terminal of its own, so
it cannot run from camel ask";
}
-
- @Override
- public void println(String line) {
- captured.append(line).append('\n');
+ case "hawtio" -> {
+ return "camel hawtio runs the web console until it is stopped,
so it cannot run from camel ask";
}
-
- @Override
- public void print(String output) {
- captured.append(output);
+ case "run", "dev" -> {
+ if (!isOn(list, "--background") && list.stream().noneMatch(a
-> a.startsWith("--max-"))) {
+ return "camel " + args[0] + " runs until it is stopped:
add --background (then camel get, camel log"
+ + " --follow=false and camel stop work with it), or
a limit such as --max-seconds=30";
+ }
}
-
- @Override
- public void printf(String format, Object... fmtArgs) {
- captured.append(String.format(format, fmtArgs));
+ default -> {
+ // the commands below
}
- };
+ }
+ if ("infra run".equals(command) && !isOn(list, "--background")) {
+ return "camel infra run runs the service until it is stopped: add
--background (then camel infra stop)";
+ }
+ boolean follows = "log".equals(args[0]) || "infra log".equals(command)
+ || ("trace".equals(args[0]) || "cmd receive".equals(command))
&& isDump(list);
+ if (follows && isOn(list, "--follow") && !limited) {
+ return "camel " + command + " follows until it is stopped: add
--follow=false, or --duration=10s";
+ }
+ if (list.contains("--watch") && !limited) {
+ return "--watch repeats until it is stopped: leave it out, or add
--duration=10s";
+ }
+ if ("export".equals(args[0]) && list.contains("--clean-dir") &&
!list.contains("--yes") && !list.contains("-y")) {
+ return "camel export --clean-dir asks before it deletes the files
in the directory: add --yes";
+ }
+ return null;
+ }
- CamelJBangMain main = (CamelJBangMain) commandLine.getCommand();
- StringWriter sw = new StringWriter();
- PrintWriter pw = new PrintWriter(sw);
- synchronized (CamelJBangMain.class) {
- PrintWriter originalOut = commandLine.getOut();
- PrintWriter originalErr = commandLine.getErr();
- Printer originalPrinter = main.getOut();
- commandLine.setOut(pw);
- commandLine.setErr(pw);
- main.setOut(capturingPrinter);
- try {
- int exitCode = commandLine.execute(cmdArgs);
- pw.flush();
- String output = captured.toString() + sw.toString();
- if (output.isBlank() && exitCode != 0) {
- return "Command exited with code " + exitCode;
- }
- if (output.length() > 32768) {
- output = output.substring(0, 32768) + "\n... (truncated)";
- }
- return output;
- } catch (Exception e) {
- return "Error executing command: " + e.getMessage();
- } finally {
- main.setOut(originalPrinter);
- commandLine.setOut(originalOut);
- commandLine.setErr(originalErr);
+ /**
+ * Whether a boolean option is on: <tt>--follow</tt> is on unless it is
given as false.
+ */
+ private static boolean isOn(List<String> args, String option) {
+ for (int i = 0; i < args.size(); i++) {
+ String arg = args.get(i);
+ if (arg.equals(option + "=false") || arg.equals(option) && i + 1 <
args.size()
+ && "false".equals(args.get(i + 1))) {
+ return false;
+ }
+ if (arg.equals(option) || arg.equals(option + "=true")) {
+ return true;
}
}
+ // --follow is on by default, --background is not
+ return "--follow".equals(option);
+ }
+
+ private static boolean isDump(List<String> args) {
+ int pos = args.indexOf("--action");
+ return args.contains("--action=dump") || pos >= 0 && pos + 1 <
args.size() && "dump".equals(args.get(pos + 1));
}
// ---- JSON schema helpers ----
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelShellCommandRegistry.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelShellCommandRegistry.java
index e57460a1b590..25c471865985 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelShellCommandRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelShellCommandRegistry.java
@@ -20,15 +20,21 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
+import java.util.Map;
import org.jline.picocli.PicocliCommandRegistry;
+import org.jline.reader.Completer;
import org.jline.shell.Command;
+import org.jline.shell.CommandDescription;
import picocli.CommandLine;
/**
* The Camel commands of the shell ({@code camel shell}, and the shell panel
of the monitor). The prompt is
* {@code camel>}, so commands are typed without the prefix, but the docs and
the help say {@code camel ps}: a leading
* {@code camel} is accepted, and the rest of the line runs as the command it
names.
+ * <p/>
+ * A command runs while it holds {@link CliRunner#LOCK}, as camel ask and the
AI panel of the monitor run commands in
+ * this JVM too, and the commands share the printer of the CLI.
*/
public class CamelShellCommandRegistry extends PicocliCommandRegistry {
@@ -73,14 +79,67 @@ public class CamelShellCommandRegistry extends
PicocliCommandRegistry {
@Override
public Collection<Command> commands() {
- List<Command> answer = new ArrayList<>(super.commands());
- answer.add(prefix);
+ List<Command> answer = new ArrayList<>();
+ for (Command command : super.commands()) {
+ answer.add(locked(command));
+ }
+ answer.add(locked(prefix));
return answer;
}
@Override
public Command command(String name) {
- return PREFIX.equals(name) ? prefix : super.command(name);
+ Command command = PREFIX.equals(name) ? prefix : super.command(name);
+ return command != null ? locked(command) : null;
+ }
+
+ /**
+ * The command, which runs while it holds the lock of the commands in this
JVM.
+ */
+ static Command locked(Command command) {
+ return new Command() {
+ @Override
+ public String name() {
+ return command.name();
+ }
+
+ @Override
+ public List<String> aliases() {
+ return command.aliases();
+ }
+
+ @Override
+ public String description() {
+ return command.description();
+ }
+
+ @Override
+ public CommandDescription describe(List<String> args) {
+ return command.describe(args);
+ }
+
+ @Override
+ public Object execute(org.jline.shell.CommandSession session,
String[] args) throws Exception {
+ synchronized (CliRunner.LOCK) {
+ return command.execute(session, args);
+ }
+ }
+
+ @Override
+ public List<Completer> completers() {
+ return command.completers();
+ }
+
+ @Override
+ public Completer completer() {
+ return command.completer();
+ }
+
+ @Override
+ public Map<String, Command> subcommands() {
+ return command.subcommands();
+ }
+ };
}
@Override
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CliRunner.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CliRunner.java
new file mode 100644
index 000000000000..7adcaf05e52a
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CliRunner.java
@@ -0,0 +1,221 @@
+/*
+ * 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;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+import org.apache.camel.dsl.jbang.core.common.Printer;
+import org.apache.camel.dsl.jbang.core.common.StringPrinter;
+import org.apache.camel.util.json.JsonObject;
+import picocli.CommandLine;
+
+/**
+ * Runs a Camel CLI command in this JVM, as camel ask and the TUI do, and
gives what a script running camel gets: the
+ * exit code, and stdout and stderr apart. The commands share the printer of
{@link CamelJBangMain} and the picocli
+ * command tree, so one command runs at a time: the camel shell takes the same
{@link #LOCK}.
+ */
+public final class CliRunner {
+
+ /**
+ * The lock of the commands that run in this JVM.
+ */
+ public static final Object LOCK = CamelJBangMain.class;
+
+ /**
+ * How long a command that did not finish in time gets to stop after the
interrupt, before the result says it has
+ * not.
+ */
+ private static final long STOP_GRACE_MILLIS = 2000;
+
+ private CliRunner() {
+ }
+
+ /**
+ * What a command did.
+ *
+ * @param exitCode the exit code, as camel exits with it ({@link
CliExitCode#TIMEOUT} when it did not finish in
+ * time)
+ * @param stdout the output of the command
+ * @param stderr the errors, warnings and tips
+ * @param elapsedMs how long the command ran
+ * @param timedOut whether the command did not finish in time, and was
interrupted
+ */
+ public record CliResult(int exitCode, String stdout, String stderr, long
elapsedMs, boolean timedOut) {
+
+ public boolean isSuccess() {
+ return exitCode == CliExitCode.OK;
+ }
+
+ /**
+ * The result as JSON, for an AI agent: exitCode, stdout, stderr,
elapsedMs and timedOut, where stdout and
+ * stderr are cut at the given number of characters.
+ */
+ public JsonObject toJson(int maxChars) {
+ JsonObject answer = new JsonObject();
+ answer.put("exitCode", exitCode);
+ answer.put("stdout", cut(stdout, maxChars));
+ answer.put("stderr", cut(stderr, maxChars));
+ answer.put("elapsedMs", elapsedMs);
+ answer.put("timedOut", timedOut);
+ return answer;
+ }
+
+ private static String cut(String text, int maxChars) {
+ return text.length() > maxChars ? text.substring(0, maxChars) +
"\n... (truncated)" : text;
+ }
+ }
+
+ /**
+ * Runs the command, and waits until it has finished.
+ *
+ * @param args the command line without camel, such as <tt>get route
--json</tt>
+ */
+ public static CliResult run(List<String> args) {
+ return run(args, 0);
+ }
+
+ /**
+ * Runs the command, and interrupts it when it does not finish in time.
+ *
+ * @param args the command line without camel, such as <tt>get
route --json</tt>
+ * @param timeoutMillis how long the command may run, or 0 to wait until
it has finished
+ */
+ public static CliResult run(List<String> args, long timeoutMillis) {
+ CommandLine commandLine = CamelJBangMain.getCommandLine();
+ if (commandLine == null) {
+ throw new IllegalStateException("The Camel CLI is not
initialized");
+ }
+ Output output = new Output();
+ long start = System.nanoTime();
+ if (timeoutMillis <= 0) {
+ int code = execute(commandLine, args, output, new AtomicBoolean());
+ return output.result(code, elapsed(start), false);
+ }
+
+ // set when the caller gives up, so a command still waiting for
another one to end does not run afterwards
+ AtomicBoolean abandoned = new AtomicBoolean();
+ FutureTask<Integer> task = new FutureTask<>(() -> execute(commandLine,
args, output, abandoned));
+ Thread thread = new Thread(task, "camel-cli-runner");
+ thread.setDaemon(true);
+ thread.start();
+ try {
+ int code = task.get(timeoutMillis, TimeUnit.MILLISECONDS);
+ return output.result(code, elapsed(start), false);
+ } catch (TimeoutException e) {
+ abandoned.set(true);
+ task.cancel(true);
+ output.timedOut("camel " + String.join(" ", args) + " did not
finish within " + timeoutMillis + " millis"
+ + stillRunning(thread));
+ return output.result(CliExitCode.TIMEOUT, elapsed(start), true);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ abandoned.set(true);
+ task.cancel(true);
+ output.timedOut("camel " + String.join(" ", args) + " was
interrupted");
+ return output.result(CliExitCode.TIMEOUT, elapsed(start), true);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ output.timedOut("camel " + String.join(" ", args) + " failed: " +
cause.getMessage());
+ return output.result(CliExitCode.INTERNAL, elapsed(start), false);
+ }
+ }
+
+ /**
+ * What keeps a command that did not finish in time: a command that
ignores the interrupt (such as one blocked in
+ * I/O) holds the lock, so the next command waits until it has ended.
+ */
+ private static String stillRunning(Thread thread) {
+ try {
+ thread.join(STOP_GRACE_MILLIS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ if (thread.getState() == Thread.State.BLOCKED) {
+ return "; it waited for another command that is still running, and
does not run";
+ } else if (thread.isAlive()) {
+ return "; it has not stopped yet, and the next command waits until
it has";
+ }
+ return "";
+ }
+
+ private static int execute(CommandLine commandLine, List<String> args,
Output output, AtomicBoolean abandoned) {
+ CamelJBangMain main = (CamelJBangMain) commandLine.getCommand();
+ synchronized (LOCK) {
+ if (abandoned.get()) {
+ // the caller gave up while another command held the lock
+ return CliExitCode.TIMEOUT;
+ }
+ Printer printer = main.getOut();
+ PrintWriter out = commandLine.getOut();
+ PrintWriter err = commandLine.getErr();
+ main.setOut(output.printer);
+ // picocli prints the help on its out, and a usage error on its err
+ commandLine.setOut(output.helpOut);
+ commandLine.setErr(output.helpErr);
+ try {
+ return commandLine.execute(args.toArray(String[]::new));
+ } finally {
+ output.helpOut.flush();
+ output.helpErr.flush();
+ main.setOut(printer);
+ commandLine.setOut(out);
+ commandLine.setErr(err);
+ }
+ }
+ }
+
+ private static long elapsed(long start) {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+ }
+
+ /**
+ * What a command prints: through the printer of the CLI, and the help and
usage errors of picocli.
+ */
+ private static final class Output {
+ private final StringPrinter printer = new StringPrinter();
+ private final StringWriter help = new StringWriter();
+ private final StringWriter usage = new StringWriter();
+ private final PrintWriter helpOut = new PrintWriter(help);
+ private final PrintWriter helpErr = new PrintWriter(usage);
+ private volatile String extra;
+
+ void timedOut(String message) {
+ extra = "ERROR: " + message;
+ }
+
+ CliResult result(int code, long elapsedMs, boolean timedOut) {
+ String stdout = join(printer.getStdout(), help.toString().trim());
+ String stderr = join(join(printer.getStderr(),
usage.toString().trim()), extra);
+ return new CliResult(code, stdout, stderr, elapsedMs, timedOut);
+ }
+
+ private static String join(String first, String second) {
+ if (second == null || second.isEmpty()) {
+ return first;
+ }
+ return first.isEmpty() ? second : first + System.lineSeparator() +
second;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Doctor.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Doctor.java
index a6ff38fd825d..fa986d04f1c5 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Doctor.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Doctor.java
@@ -17,7 +17,6 @@
package org.apache.camel.dsl.jbang.core.commands;
import java.io.File;
-import java.io.OutputStream;
import java.net.ServerSocket;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -28,6 +27,7 @@ import java.util.Set;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+import org.apache.camel.dsl.jbang.core.common.ExternalCommand;
import org.apache.camel.dsl.jbang.core.common.InstallDetector;
import org.apache.camel.dsl.jbang.core.common.JsonOutput;
import org.apache.camel.dsl.jbang.core.common.OllamaDoctorSupport;
@@ -189,19 +189,10 @@ public class Doctor extends CamelCommand {
private void checkContainerRuntime() {
// check docker first, then podman as fallback
for (String cmd : new String[] { "docker", "podman" }) {
- try {
- Process p = new ProcessBuilder(cmd, "info")
- .redirectErrorStream(true)
- .start();
- // drain output to prevent blocking
- p.getInputStream().transferTo(OutputStream.nullOutputStream());
- int exit = p.waitFor();
- if (exit == 0) {
- report("container", "Container", OK, "%s running (OK,
optional)".formatted(StringHelper.capitalize(cmd)));
- return;
- }
- } catch (Exception e) {
- // not found, try next
+ // a docker info that hangs (such as a stopped Docker Desktop)
does not hang camel doctor
+ if (ExternalCommand.succeeds(10000, cmd, "info")) {
+ report("container", "Container", OK, "%s running (OK,
optional)".formatted(StringHelper.capitalize(cmd)));
+ return;
}
}
report("container", "Container", WARN, "Not found (optional — needed
for running external infra services)");
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 4ef9f37c90f9..7712a6adf1ce 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
@@ -290,6 +290,10 @@ public final class AuthoringTools {
}
JsonObject result = IntegrationLauncher.run(dir, files,
args.get("name"), bool(args, "dev", true),
List.of());
+ if ("failed".equals(result.getString("status"))) {
+ // a tool error (MCP isError), which still has the
exit code and the output that tell why
+ throw new ToolExecutionException(result.toJson());
+ }
if (result.get("pid") instanceof Long pid) {
ctx.selectProcess(pid);
}
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 8577ede67471..551d2637c772 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
@@ -474,28 +474,34 @@ public final class IntegrationLauncher {
case "kill" -> {
Optional<ProcessHandle> ph = ProcessHandle.of(pid);
if (ph.isEmpty()) {
- yield "No process with pid " + pid;
+ throw new ToolExecutionException("No process with pid " +
pid);
}
ph.get().destroyForcibly();
yield "Killed pid " + pid;
}
case "restart" -> restart(pid);
- case "stop-routes", "pause" -> ctx.executeAction("route", root -> {
- root.put("id", "*");
- root.put("command", "stop");
- });
- case "start-routes", "resume" -> ctx.executeAction("route", root
-> {
- root.put("id", "*");
- root.put("command", "start");
- });
+ case "stop-routes", "pause" -> {
+ ctx.sendAction("route", root -> {
+ root.put("id", "*");
+ root.put("command", "stop");
+ });
+ yield "Stopped the routes of pid " + pid;
+ }
+ case "start-routes", "resume" -> {
+ ctx.sendAction("route", root -> {
+ root.put("id", "*");
+ root.put("command", "start");
+ });
+ yield "Started the routes of pid " + pid;
+ }
case "reset-stats" -> {
- ctx.executeAction("reset-stats", null);
+ ctx.sendAction("reset-stats", null);
yield "Statistics reset for pid " + pid;
}
case "reload" -> {
// what camel cmd reload does: the routes are loaded again
from their files without a restart, so
// a changed stylesheet or a dropped data file takes effect,
and a file consumed once is read again
- ctx.executeAction("reload", null);
+ ctx.sendAction("reload", null);
yield "Reload triggered for pid " + pid + "; camel_get_log
shows the routes reloaded summary";
}
default -> throw new ToolExecutionException(
@@ -507,11 +513,13 @@ public final class IntegrationLauncher {
/**
* Stops the process and starts it again with its own command line and
working directory, so a route file edited
* without dev mode is picked up. Returns the new pid once its status file
appears.
+ *
+ * @throws ToolExecutionException when the integration cannot be
restarted, or stops right after the restart
*/
static String restart(long pid) {
Optional<ProcessHandle> handle = ProcessHandle.of(pid);
if (handle.isEmpty()) {
- return "No process with pid " + pid;
+ throw new ToolExecutionException("No process with pid " + pid);
}
ProcessHandle ph = handle.get();
ProcessHandle.Info info = ph.info();
@@ -521,7 +529,8 @@ public final class IntegrationLauncher {
Collections.addAll(cmd, info.arguments().get());
}
if (cmd.isEmpty()) {
- return "Cannot restart pid " + pid + ": its command line is not
available; stop it and run it again";
+ throw new ToolExecutionException(
+ "Cannot restart pid " + pid + ": its command line is not
available; stop it and run it again");
}
RuntimeHelper.ProcessInfo pi = findByPid(pid);
String name = pi != null ? pi.name() : null;
@@ -556,18 +565,20 @@ public final class IntegrationLauncher {
}
if (!p.isAlive()) {
boolean kept = stoppedByRestart(directory, name);
- return "Restart of " + (name != null ? name : "pid " + pid) +
" failed with exit code "
- + p.exitValue() + ", so it is stopped now: fix the
cause below, then camel_run starts it again"
- + (kept ? " (directory " + directory + ") under the
same name, " + name : "") + ".\n"
- + failureOutput(output);
+ throw new ToolExecutionException(
+ "Restart of " + (name != null ? name : "pid " + pid) +
" failed with exit code "
+ + p.exitValue()
+ + ", so it is stopped now:
fix the cause below, then camel_run starts it again"
+ + (kept ? " (directory " +
directory + ") under the same name, " + name : "")
+ + ".\n" +
failureOutput(output));
}
return "Restarting " + (name != null ? name : "pid " + pid) + " as
pid " + p.pid()
+ " (still starting)";
} catch (IOException e) {
- return "Restart failed: " + e.getMessage();
+ throw new ToolExecutionException("Restart failed: " +
e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
- return "Restart interrupted";
+ throw new ToolExecutionException("Restart interrupted");
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
index c124a146dab0..6f3f31f52294 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -26,9 +27,11 @@ import java.util.function.Predicate;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.dsl.jbang.core.common.ActionClient;
import org.apache.camel.dsl.jbang.core.common.CatalogLoader;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
/**
* Shared execution context for AI tools. Wraps {@link RuntimeHelper} (process
IPC) and {@link CamelCatalog} (component
@@ -37,6 +40,9 @@ import org.apache.camel.util.json.JsonObject;
*/
public class ToolContext {
+ /** How long an action on the integration may take, in millis. */
+ private static final long ACTION_TIMEOUT = 10000;
+
private volatile long pid = -1;
private volatile CamelCatalog catalog;
private volatile String camelVersion;
@@ -241,9 +247,64 @@ public class ToolContext {
return RuntimeHelper.readStatus(pid);
}
+ /**
+ * Sends an action without a result, such as stopping the routes, to the
selected integration, and waits until it
+ * confirms the action (an integration before Camel 4.24 takes the action
without confirming it).
+ *
+ * @throws ToolExecutionException when the integration answers that the
action failed, or does not take it in time
+ */
+ public void sendAction(String action, Consumer<JsonObject> configure) {
+ requireProcess();
+ JsonObject request = new JsonObject();
+ request.put("action", action);
+ if (configure != null) {
+ configure.accept(request);
+ }
+ ActionClient.ActionResult result;
+ try {
+ result = ActionClient.send(pid, request, ACTION_TIMEOUT);
+ } catch (IOException e) {
+ throw new ToolExecutionException(
+ "Cannot send the action " + action + " to the integration
with pid " + pid + ": " + e.getMessage());
+ }
+ if (!result.isOk()) {
+ throw new ToolExecutionException(result.error());
+ }
+ }
+
+ /**
+ * Runs an action on the selected integration, and returns the answer as
the integration wrote it.
+ *
+ * @throws ToolExecutionException when the integration answers that the
action failed, or does not answer in time
+ */
public String executeAction(String action, Consumer<JsonObject> configure)
{
requireProcess();
- return RuntimeHelper.executeAction(pid, action, configure);
+ JsonObject request = new JsonObject();
+ request.put("action", action);
+ if (configure != null) {
+ configure.accept(request);
+ }
+ String answer;
+ try {
+ answer = ActionClient.executeText(pid, request, ACTION_TIMEOUT);
+ } catch (IOException e) {
+ throw new ToolExecutionException(
+ "Cannot send the action " + action + " to the integration
with pid " + pid + ": " + e.getMessage());
+ }
+ if (answer == null) {
+ throw new ToolExecutionException(
+ "No answer from the integration with pid " + pid + " to
the action " + action + " within "
+ + ACTION_TIMEOUT / 1000 + "
seconds");
+ }
+ // an integration of Camel 4.24 or later answers a failed action with
the error
+ JsonObject json = Jsoner.deserialize(answer, (JsonObject) null);
+ if (json != null) {
+ ActionClient.ActionResult result = ActionClient.result(json);
+ if (!result.isOk()) {
+ throw new ToolExecutionException(result.error());
+ }
+ }
+ return answer;
}
public JsonObject readErrorFile() {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
index e534e3a7a2f5..9517ddc186e0 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
@@ -582,10 +582,12 @@ public final class ToolRegistry {
if (routeId == null || routeId.isBlank()) {
throw new ToolExecutionException("routeId is
required");
}
- return ctx.executeAction("route", root -> {
+ // a route that does not exist, or fails to stop or start,
is an error of the tool
+ ctx.sendAction("route", root -> {
root.put("id", routeId);
root.put("command", command);
});
+ return "Route " + routeId + ": " + command + " done";
}));
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
index d223a23e2485..934246d6106b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
@@ -156,6 +156,16 @@ public final class ActionClient {
return run(pid, versionOf(pid), request, timeout, true);
}
+ /**
+ * Sends an action without a result, for an integration known by its pid
only, and waits until the integration
+ * confirms it.
+ *
+ * @throws IOException when the action cannot be written
+ */
+ public static ActionResult send(long pid, JsonObject request, long
timeout) throws IOException {
+ return run(pid, versionOf(pid), request, timeout, false);
+ }
+
private static ActionResult execute(
long pid, String camelVersion, JsonObject request, long timeout,
boolean hasResult) {
try {
@@ -270,7 +280,7 @@ public final class ActionClient {
* The result of an answer: an error when its status is <tt>failed</tt> or
<tt>error</tt>, the same as the connector
* does for its WebSocket transport.
*/
- static ActionResult result(JsonObject reply) {
+ public static ActionResult result(JsonObject reply) {
String status = reply.getString("status");
if ("failed".equals(status) || "error".equals(status)) {
return new ActionResult(Status.ERROR, reply, messageOf(reply),
reply.getString("reason"));
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExternalCommand.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExternalCommand.java
new file mode 100644
index 000000000000..52c347e5cd23
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExternalCommand.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.common;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Runs an external command, such as <tt>docker info</tt>, with a timeout that
holds: the output is read while the
+ * command runs (reading it to the end first waits for the command, however
long it takes), and a command that does not
+ * finish in time is destroyed.
+ */
+public final class ExternalCommand {
+
+ private ExternalCommand() {
+ }
+
+ /**
+ * Whether the command ends with exit code 0 within the timeout. Its
output is discarded.
+ */
+ public static boolean succeeds(long timeoutMillis, String... command) {
+ Process process = null;
+ try {
+ process = new ProcessBuilder(command).redirectErrorStream(true)
+ .redirectOutput(ProcessBuilder.Redirect.DISCARD).start();
+ if (!process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) {
+ process.destroyForcibly();
+ return false;
+ }
+ return process.exitValue() == 0;
+ } catch (IOException e) {
+ // no such command
+ return false;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ process.destroyForcibly();
+ return false;
+ }
+ }
+
+ /**
+ * The output of the command (stdout and stderr), when it ends with exit
code 0 within the timeout.
+ *
+ * @return the output, or <tt>null</tt> when the command does not exist,
fails or does not finish in time
+ */
+ public static String output(long timeoutMillis, List<String> command) {
+ Process process = null;
+ try {
+ process = new
ProcessBuilder(command).redirectErrorStream(true).start();
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ InputStream in = process.getInputStream();
+ Thread reader = new Thread(() -> {
+ try (in) {
+ in.transferTo(out);
+ } catch (IOException e) {
+ // the command was destroyed
+ }
+ }, "camel-external-command");
+ reader.setDaemon(true);
+ reader.start();
+ if (!process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) {
+ process.destroyForcibly();
+ return null;
+ }
+ // the rest of the output, once the command has ended
+ reader.join(TimeUnit.SECONDS.toMillis(1));
+ if (process.exitValue() != 0) {
+ return null;
+ }
+ return out.toString(StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ return null;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ process.destroyForcibly();
+ return null;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskToolsTest.java
index 4cc51a27740e..3de5c15d6177 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskToolsTest.java
@@ -138,6 +138,29 @@ class AskToolsTest {
assertThat(asked).isEmpty();
}
+ @Test
+ void aCommandThatRunsUntilItIsStoppedIsRefusedWithTheFormThatReturns() {
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("run
hello.yaml"))).contains("add --background");
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("run hello.yaml
--background"))).isNull();
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("run hello.yaml
--max-seconds=10"))).isNull();
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("log
orders"))).contains("add --follow=false");
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("log orders
--follow=false"))).isNull();
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("log orders
--duration=5s"))).isNull();
+ // trace and receive follow when they dump
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("trace
orders"))).isNull();
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("trace orders
--action=dump"))).contains("--follow=false");
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("cmd receive
--action dump"))).contains("--follow=false");
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("get route
--watch"))).contains("--duration");
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("infra run
kafka"))).contains("add --background");
+
assertThat(AskTools.blockingHint(Ask.tokenizeCommand("shell"))).contains("needs
a terminal of its own");
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("export
--clean-dir --dir=out"))).contains("add --yes");
+ assertThat(AskTools.blockingHint(Ask.tokenizeCommand("get route
--json"))).isNull();
+
+ String answer = tools(false).executeTool("cli_exec", new
JsonObject(Map.of("command", "camel log orders")));
+
assertThat(answer).contains("\"status\":\"refused\"").contains("--follow=false");
+ assertThat(asked).isEmpty();
+ }
+
@Test
void codeSqlThatChangesDataAndStoppingTheIntegrationAreAsked() {
AskTools tools = tools(false);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/CliRunnerTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/CliRunnerTest.java
new file mode 100644
index 000000000000..0cbd6ebc43fd
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/CliRunnerTest.java
@@ -0,0 +1,194 @@
+/*
+ * 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;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.StringPrinter;
+import org.apache.camel.util.json.JsonObject;
+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 picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * A command run in this JVM gives what a script running camel gets: the exit
code, stdout and stderr apart, and a
+ * timeout.
+ */
+class CliRunnerTest {
+
+ private static final long PID = ProcessHandle.current().pid();
+
+ @TempDir
+ Path home;
+
+ private Path previousHome;
+ private StringPrinter printer;
+ private TestMain main;
+
+ @BeforeEach
+ void setup() throws Exception {
+ previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ Files.createDirectories(CommandLineHelper.getCamelDir());
+ printer = new StringPrinter();
+ main = new TestMain();
+ main.withPrinter(printer);
+ main.setDiscoverPlugins(false);
+ // builds the command line of the CLI (its output goes to the printer)
+ main.execute("ps", "--json");
+ }
+
+ @AfterEach
+ void restore() {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+
+ @Test
+ void outputAndExitCode() {
+ CliRunner.CliResult result = CliRunner.run(List.of("ps", "--json"));
+
+ assertThat(result.exitCode()).isEqualTo(CliExitCode.OK);
+ assertThat(result.stdout()).isEqualTo("[]");
+ assertThat(result.stderr()).isEqualTo("No running Camel integrations");
+ assertThat(result.timedOut()).isFalse();
+ // the printer of the CLI is put back
+ assertThat(main.getOut()).isSameAs(printer);
+ }
+
+ @Test
+ void notFound() {
+ CliRunner.CliResult result = CliRunner.run(List.of("cmd", "reload",
"no-such-integration"));
+
+ assertThat(result.exitCode()).isEqualTo(CliExitCode.NOT_FOUND);
+ assertThat(result.stdout()).isEmpty();
+ assertThat(result.stderr()).isEqualTo("ERROR: No running Camel
integration matches: no-such-integration");
+ }
+
+ @Test
+ void usageErrorAndHelpOfPicocli() {
+ CliRunner.CliResult usage = CliRunner.run(List.of("ps",
"--no-such-option"));
+ assertThat(usage.exitCode()).isEqualTo(CliExitCode.USAGE);
+ assertThat(usage.stderr()).contains("Unknown option:
'--no-such-option'");
+
+ CliRunner.CliResult help = CliRunner.run(List.of("ps", "--help"));
+ assertThat(help.exitCode()).isEqualTo(CliExitCode.OK);
+ assertThat(help.stdout()).contains("Usage: camel ps");
+ }
+
+ @Test
+ void commandThatDoesNotFinishInTimeIsInterrupted() throws Exception {
+ // an integration that never answers
+ Files.writeString(CommandLineHelper.getCamelDir().resolve(PID +
"-status.json"),
+
"{\"context\":{\"name\":\"test\",\"version\":\"4.24.0\",\"phase\":5}}");
+
+ CliRunner.CliResult result = CliRunner.run(List.of("cmd", "gc",
Long.toString(PID), "--timeout=2000"), 300);
+
+ assertThat(result.timedOut()).isTrue();
+ assertThat(result.exitCode()).isEqualTo(CliExitCode.TIMEOUT);
+ // after what the interrupted command printed
+ assertThat(result.stderr())
+ .endsWith("ERROR: camel cmd gc " + PID + " --timeout=2000 did
not finish within 300 millis");
+ }
+
+ @Test
+ void commandThatIgnoresTheInterruptIsReported() {
+ CliRunner.CliResult result = CliRunner.run(List.of("busy"), 300);
+
+ assertThat(result.exitCode()).isEqualTo(CliExitCode.TIMEOUT);
+ assertThat(result.stderr()).endsWith("it has not stopped yet, and the
next command waits until it has");
+ }
+
+ @Test
+ void commandThatWaitedForAnotherOneDoesNotRunAfterItsTimeout() throws
Exception {
+ Path config =
CommandLineHelper.getHomeDir().resolve(CommandLineHelper.USER_CONFIG);
+ CliRunner.CliResult result;
+ // another command runs meanwhile
+ synchronized (CliRunner.LOCK) {
+ result = CliRunner.run(List.of("config", "set", "foo=bar"), 300);
+ }
+
+ assertThat(result.exitCode()).isEqualTo(CliExitCode.TIMEOUT);
+ assertThat(result.stderr()).endsWith("it waited for another command
that is still running, and does not run");
+ // the lock is free: the abandoned command ends without setting
anything
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
Thread.getAllStackTraces().keySet().stream()
+ .noneMatch(t -> "camel-cli-runner".equals(t.getName())));
+ assertThat(config).doesNotExist();
+ }
+
+ @Test
+ void resultAsJson() {
+ JsonObject json = new CliRunner.CliResult(3, "out", "x".repeat(10),
12, false).toJson(5);
+
+ assertThat(json.getInteger("exitCode")).isEqualTo(3);
+ assertThat(json.getString("stdout")).isEqualTo("out");
+ assertThat(json.getString("stderr")).isEqualTo("xxxxx\n...
(truncated)");
+ assertThat(json.getLong("elapsedMs")).isEqualTo(12L);
+ assertThat(json.getBoolean("timedOut")).isFalse();
+ }
+
+ private static class TestMain extends CamelJBangMain {
+ @Override
+ public void postAddCommands(CommandLine commandLine, String[] args) {
+ // the commands of the CLI, and one that ignores the interrupt
+ commandLine.addSubcommand("busy", new CommandLine(new
BusyCommand(this)));
+ }
+
+ @Override
+ public void quit(int exitCode) {
+ // in the test
+ }
+ }
+
+ /**
+ * A command that runs for 3 seconds whatever happens, as one blocked in
I/O does.
+ */
+ @CommandLine.Command(name = "busy")
+ static class BusyCommand extends CamelCommand {
+
+ BusyCommand(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ protected boolean disarrangeLogging() {
+ return false;
+ }
+
+ @Override
+ public Integer doCall() {
+ long end = System.currentTimeMillis() + 3000;
+ while (System.currentTimeMillis() < end) {
+ try {
+ TimeUnit.MILLISECONDS.sleep(end -
System.currentTimeMillis());
+ } catch (InterruptedException e) {
+ // ignores it
+ }
+ }
+ return 0;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExternalCommandTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExternalCommandTest.java
new file mode 100644
index 000000000000..6029401c3a4d
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExternalCommandTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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.common;
+
+import java.util.List;
+
+import org.apache.camel.util.StopWatch;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * An external command that hangs, such as a docker info of a stopped Docker
Desktop, is destroyed when it does not
+ * finish in time, also when it prints more than a pipe holds.
+ */
+@DisabledOnOs(OS.WINDOWS)
+class ExternalCommandTest {
+
+ @Test
+ void exitCode() {
+ assertThat(ExternalCommand.succeeds(10000, "true")).isTrue();
+ assertThat(ExternalCommand.succeeds(10000, "false")).isFalse();
+ assertThat(ExternalCommand.succeeds(10000,
"no-such-command-for-camel")).isFalse();
+ }
+
+ @Test
+ void commandThatHangsIsDestroyed() {
+ StopWatch watch = new StopWatch();
+
+ assertThat(ExternalCommand.succeeds(300, "sleep", "30")).isFalse();
+ assertThat(ExternalCommand.output(300, List.of("sleep",
"30"))).isNull();
+
+ assertThat(watch.taken()).isLessThan(10000);
+ }
+
+ @Test
+ void output() {
+ assertThat(ExternalCommand.output(10000, List.of("echo",
"hello"))).isEqualTo("hello\n");
+ assertThat(ExternalCommand.output(10000, List.of("sh", "-c", "echo
oops; exit 1"))).isNull();
+ // more than the 64 KB a pipe holds, which a command cannot write
before it is read
+ String big = ExternalCommand.output(10000, List.of("sh", "-c", "head
-c 200000 /dev/zero | tr '\\0' x"));
+ assertThat(big).hasSize(200000);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
index b6ea5ff5b53c..7edb72a3c062 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
@@ -32,7 +32,6 @@ import
org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
-import org.jboss.logging.Logger;
/**
* MCP Tool for tracing AI-specific exchange flow in running Camel
applications.
@@ -45,8 +44,6 @@ import org.jboss.logging.Logger;
@ApplicationScoped
public class AiTraceTools {
- private static final Logger LOG = Logger.getLogger(AiTraceTools.class);
-
private static final String NAME_OR_PID_DESC
= "Name or PID of the Camel process. Leave empty to auto-detect
(works when exactly one Camel process is running)";
@@ -115,8 +112,8 @@ public class AiTraceTools {
Object result = ToolRegistry.execute(toolName, ctx, args);
return RuntimeTools.toJsonObject(result);
} catch (ToolExecutionException e) {
- LOG.debugf("AI trace: %s query failed: %s", toolName,
e.getMessage());
- return new JsonObject();
+ // such as no answer from the integration: not the same as an
integration without AI components
+ throw new ToolCallException("Failed to collect AI trace data (" +
toolName + "): " + e.getMessage(), null);
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiCliCommandExecutor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiCliCommandExecutor.java
index 69a8fc884664..ec85cbdea4d5 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiCliCommandExecutor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiCliCommandExecutor.java
@@ -16,19 +16,16 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
-import java.io.PrintWriter;
-import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
-import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.commands.CliRunner;
import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper;
import org.apache.camel.dsl.jbang.core.common.Printer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import picocli.CommandLine;
final class AiCliCommandExecutor {
@@ -110,7 +107,7 @@ final class AiCliCommandExecutor {
int exitCode = 1;
boolean interrupted = false;
try {
- synchronized (CamelJBangMain.class) {
+ synchronized (CliRunner.LOCK) {
exitCode = invoker.execute(command.request.argv(), new
CapturePrinter(output));
}
interrupted = command.cancelled.get() ||
Thread.currentThread().isInterrupted();
@@ -132,33 +129,23 @@ final class AiCliCommandExecutor {
}
}
- private static int invoke(List<String> argv, Printer printer) throws
Exception {
- CommandLine commandLine = CamelJBangMain.getCommandLine();
- CamelJBangMain main = (CamelJBangMain) commandLine.getCommand();
- Printer originalPrinter = main.getOut();
- PrintWriter originalErr = commandLine.getErr();
- StringWriter errCapture = new StringWriter();
- PrintWriter errWriter = new PrintWriter(errCapture);
+ private static int invoke(List<String> argv, Printer printer) {
String originalProcess = EnvironmentHelper.getSelectedProcess();
String selectedProcess = selectedProcess(argv);
try {
- main.setOut(printer);
- // picocli's default execution strategy catches exceptions thrown
by a sub-command and
- // prints them to CommandLine.getErr() instead of rethrowing, so
without redirecting it
- // here that output would go to the real System.err and never
reach the TUI.
- commandLine.setErr(errWriter);
if (selectedProcess != null) {
EnvironmentHelper.setSelectedProcess(selectedProcess);
}
- return commandLine.execute(argv.toArray(String[]::new));
- } finally {
- main.setOut(originalPrinter);
- commandLine.setErr(originalErr);
- errWriter.flush();
- String errOutput = errCapture.toString();
- if (!errOutput.isBlank()) {
- printer.print(errOutput);
+ // the output of the command, then its errors, warnings and tips
+ CliRunner.CliResult result = CliRunner.run(argv);
+ if (!result.stdout().isEmpty()) {
+ printer.println(result.stdout());
+ }
+ if (!result.stderr().isEmpty()) {
+ printer.println(result.stderr());
}
+ return result.exitCode();
+ } finally {
if (selectedProcess != null) {
EnvironmentHelper.setSelectedProcess(originalProcess);
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index d27dce028e99..3c3400e18c5d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -2690,7 +2690,7 @@ class AiPanel {
}
try {
return toolRegistry.execute(name, args);
- } catch (IllegalArgumentException e) {
+ } catch (TuiToolRegistry.UnknownToolException e) {
return "Unknown TUI tool: " + name;
} catch (Exception e) {
return "Error executing " + name + ": " + e.getMessage();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
index 32ada8e89731..88f67c600693 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
@@ -17,12 +17,10 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.io.File;
-import java.io.OutputStream;
import java.net.ServerSocket;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
-import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import dev.tamboui.layout.Rect;
@@ -40,6 +38,7 @@ import dev.tamboui.widgets.block.Borders;
import dev.tamboui.widgets.paragraph.Paragraph;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.dsl.jbang.core.common.ExternalCommand;
import org.apache.camel.dsl.jbang.core.common.OllamaDoctorSupport;
import org.apache.camel.dsl.jbang.core.common.VersionHelper;
import org.apache.camel.tooling.maven.MavenDownloaderImpl;
@@ -257,27 +256,14 @@ class DoctorPopup {
private void checkContainerRuntime(List<Line> result) {
for (String cmd : new String[] { "docker", "podman" }) {
- try {
- Process p = new ProcessBuilder(cmd, "info")
- .redirectErrorStream(true)
- .start();
- p.getInputStream().transferTo(OutputStream.nullOutputStream());
- boolean done = p.waitFor(5, TimeUnit.SECONDS);
- if (!done) {
- p.destroyForcibly();
- continue;
- }
- if (p.exitValue() == 0) {
- String name = Character.toUpperCase(cmd.charAt(0)) +
cmd.substring(1);
- result.add(Line.from(
- Span.raw(TuiIcons.indent(TuiIcons.DOCKER)),
- Span.styled(String.format("%-14s", "Container"),
Theme.muted()),
- Span.raw(String.format("%-30s", name + "
running")),
- Span.raw(" " + TuiIcons.OK)));
- return;
- }
- } catch (Exception e) {
- // not found, try next
+ if (ExternalCommand.succeeds(5000, cmd, "info")) {
+ String name = Character.toUpperCase(cmd.charAt(0)) +
cmd.substring(1);
+ result.add(Line.from(
+ Span.raw(TuiIcons.indent(TuiIcons.DOCKER)),
+ Span.styled(String.format("%-14s", "Container"),
Theme.muted()),
+ Span.raw(String.format("%-30s", name + " running")),
+ Span.raw(" " + TuiIcons.OK)));
+ return;
}
}
result.add(Line.from(
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
index d79c01c88e39..b00c5b87ebe9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
@@ -18,7 +18,6 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.io.File;
import java.io.IOException;
-import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -35,14 +34,16 @@ import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
-import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
+import org.apache.camel.dsl.jbang.core.common.ExternalCommand;
import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.util.FileUtil;
import org.apache.camel.util.json.JsonObject;
@@ -334,21 +335,9 @@ class LaunchManager {
static boolean isContainerRuntimeAvailable() {
for (String cmd : new String[] { "docker", "podman" }) {
- try {
- Process p = new ProcessBuilder(cmd, "info")
- .redirectErrorStream(true)
- .start();
- p.getInputStream().transferTo(OutputStream.nullOutputStream());
- boolean done = p.waitFor(5, TimeUnit.SECONDS);
- if (!done) {
- p.destroyForcibly();
- continue;
- }
- if (p.exitValue() == 0) {
- return true;
- }
- } catch (Exception e) {
- // not found, try next
+ // a docker info that hangs does not hang the TUI
+ if (ExternalCommand.succeeds(5000, cmd, "info")) {
+ return true;
}
}
return false;
@@ -449,6 +438,10 @@ class LaunchManager {
pl.announced = true;
}
}
+ if (pl.infraAlias == null && !pl.started && pl.camelStarted()) {
+ // the status file of the integration says Camel has started
+ pl.started = true;
+ }
if (!pl.process.isAlive()) {
int exitCode = pl.process.exitValue();
boolean ok = pl.infraAlias != null ? pl.started : exitCode ==
0 || pl.started;
@@ -640,6 +633,22 @@ class LaunchManager {
this.startTime = startTime;
}
+ /**
+ * Whether Camel has started in the process, or in a process it
started (the app of a Maven launch): its status
+ * file (written by the CLI connector) says so. The log is read too
(see {@link #startFailed()}), for a start
+ * that fails while the JVM lives on, and for an app without the CLI
connector.
+ */
+ boolean camelStarted() {
+ return Stream.concat(Stream.of(process.toHandle()),
process.descendants())
+ .anyMatch(ph ->
isStarted(RuntimeHelper.readStatus(ph.pid())));
+ }
+
+ static boolean isStarted(JsonObject status) {
+ // phase 5 is started (6 and 7 are suspending and suspended, from
8 it is stopping)
+ return status != null && status.get("context") instanceof
JsonObject context
+ && context.getIntegerOrDefault("phase", 0) >= 5 &&
context.getIntegerOrDefault("phase", 0) < 8;
+ }
+
/** Whether the output printed since the last look says the start
failed. */
boolean startFailed() {
if (outputFile == null) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index f501a229e33a..ed0d9585915a 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -1673,6 +1673,36 @@ class McpFacade {
return controlIntegration(action, null);
}
+ /**
+ * Waits until the restarted integration has started, or failed to start:
the restart is watched as a launch.
+ *
+ * @throws ToolExecutionException when it failed to start, with the end of
its log
+ */
+ static String awaitRestart(LaunchManager lm, String name) {
+ if (lm == null) {
+ return "Restarting " + name;
+ }
+ long deadline = MonotonicClock.millis() +
TuiToolRegistry.RUN_EXAMPLE_WAIT_MS;
+ LaunchManager.LaunchOutcome outcome;
+ while ((outcome = lm.outcome(name)) == null && MonotonicClock.millis()
< deadline) {
+ try {
+ Thread.sleep(250);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return "Restarting " + name;
+ }
+ }
+ if (outcome == null) {
+ return "Restarting " + name + " (still starting)";
+ }
+ if (outcome.ok()) {
+ return "Restarted " + name;
+ }
+ throw new ToolExecutionException(
+ "Restart of " + name + " failed: it stopped while starting"
+ + (outcome.log() != null ? "\n" +
outcome.log() : ""));
+ }
+
/**
* Controls the integration with the given name or pid, which becomes the
selected one, so the screen shows what the
* agent acts on (CAMEL-25424). Without a name: the selected integration,
or the only one running.
@@ -1749,8 +1779,12 @@ class McpFacade {
if (ctx.isInfraSelected()) {
yield "Error: cannot restart infra service";
}
+ LaunchManager lm = launchManager;
+ if (lm != null) {
+ lm.clearOutcome(name);
+ }
bridge.restartProcess();
- yield "Restarting " + name;
+ yield awaitRestart(lm, name);
}
case "stop" -> {
bridge.stopProcess(false);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
index d27d5dab5daa..40609043b784 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
@@ -16,12 +16,10 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
-import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
@@ -37,11 +35,11 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.camel.component.ai.observability.GenAiAttributes;
import org.apache.camel.dsl.jbang.core.commands.LlmClient;
+import org.apache.camel.dsl.jbang.core.common.ExternalCommand;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -1213,27 +1211,8 @@ final class OllamaMonitor {
}
private static String runCommand(List<String> command) {
- Process process = null;
- try {
- process = new
ProcessBuilder(command).redirectErrorStream(true).start();
- byte[] out;
- try (InputStream in = process.getInputStream()) {
- out = in.readAllBytes();
- }
- if (!process.waitFor(2, TimeUnit.SECONDS)) {
- process.destroyForcibly();
- return null;
- }
- if (process.exitValue() != 0) {
- return null;
- }
- return new String(out, StandardCharsets.UTF_8);
- } catch (Exception e) {
- if (process != null) {
- process.destroyForcibly();
- }
- return null;
- }
+ // the output is read while the command runs, so the timeout holds
+ return ExternalCommand.output(2000, command);
}
// ---- JSON for the MCP tool ----
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControl.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControl.java
index 087c42cd2ff8..94639fd318ca 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControl.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessControl.java
@@ -19,7 +19,6 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.io.File;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
-import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
@@ -53,7 +52,7 @@ class ProcessControl {
/**
* @param tuiRunner the runner of the TUI while it runs, which a
restart tells how it went; null before and
* after
- * @param launchManager where a restarted Spring Boot or Quarkus
integration is shown while it starts
+ * @param launchManager where a restarted integration is watched while it
starts
* @param notification shows a notification: the message, and whether it
is an error
*/
ProcessControl(MonitorContext ctx, DataRefreshService dataService,
Supplier<TuiRunner> tuiRunner,
@@ -269,10 +268,12 @@ class ProcessControl {
pb.directory(new File(directory));
}
pb.redirectErrorStream(true);
- Path outputFile = Files.createTempFile("camel-restart-",
".log");
+ Path outputFile =
LaunchManager.createSecureTempFile("camel-restart-", ".log");
outputFile.toFile().deleteOnExit();
pb.redirectOutput(outputFile.toFile());
- pb.start();
+ Process process = pb.start();
+ // watched like a launch: a restart that fails to start
shows why, and quitting the TUI stops it
+ launchManager.addPendingLaunch(name, process, outputFile);
runner.runOnRenderThread(() ->
notification.accept("Restarted: " + name, false));
} catch (Exception e) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileTools.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileTools.java
index a81db479e8b7..dc1360ef2c53 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileTools.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileTools.java
@@ -24,7 +24,6 @@ import java.util.Set;
import org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools;
import org.apache.camel.dsl.jbang.core.commands.ai.IntegrationSummary;
-import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -93,21 +92,18 @@ class TuiFileTools {
if (dir == null) {
return registry.executeShared(SAVE_SUMMARY_TOOL, args);
}
- try {
- AuthoringTools.SummaryUpdate update
- = AuthoringTools.summaryUpdate(dir,
registry.sharedContext(), TuiToolRegistry.stringArgs(args));
- String name = args.get("name") instanceof String n ? n : null;
- JsonObject written = facade.writeFile(name,
IntegrationSummary.FILE_NAME, update.content(), true);
- JsonObject result = update.result();
- result.put("status", written.get("status"));
- if (written.get("message") != null) {
- result.put("message", written.get("message"));
- }
- IntegrationSummaryHints.invalidate(dir);
- return result.toJson();
- } catch (ToolExecutionException e) {
- return "Error: " + e.getMessage();
+ // a ToolExecutionException is an error of the tool
+ AuthoringTools.SummaryUpdate update
+ = AuthoringTools.summaryUpdate(dir, registry.sharedContext(),
TuiToolRegistry.stringArgs(args));
+ String name = args.get("name") instanceof String n ? n : null;
+ JsonObject written = facade.writeFile(name,
IntegrationSummary.FILE_NAME, update.content(), true);
+ JsonObject result = update.result();
+ result.put("status", written.get("status"));
+ if (written.get("message") != null) {
+ result.put("message", written.get("message"));
}
+ IntegrationSummaryHints.invalidate(dir);
+ return result.toJson();
}
private String callGetFiles(Map<String, Object> args) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 44b5ec9ec7ed..e10478e6240b 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -33,6 +33,7 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.apache.camel.dsl.jbang.core.commands.ai.RepeatedToolCalls;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolDescriptor;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -539,16 +540,21 @@ class TuiMcpServer {
text = repeat.toJson();
} else {
text = toolRegistry.execute(toolName, args);
- // executeShared catches ToolExecutionException and returns
"Error: ..." as normal text; only
- // record the answer when the call actually succeeded so
failed calls do not use the retry budget
+ // some TUI tools still answer a failure as "Error: ..." text;
only record the answer when the call
+ // succeeded, so failed calls do not use the retry budget
if (!text.startsWith("Error:")) {
repeatedCalls.recordSuccessfulAnswer(tool, args);
}
}
- } catch (IllegalArgumentException e) {
+ } catch (TuiToolRegistry.UnknownToolException e) {
text = "Unknown tool: " + toolName;
isError = true;
log(LogLevel.ERROR, "Unknown tool: " + toolName);
+ } catch (ToolExecutionException e) {
+ // the error as the tool wrote it, which can be JSON (a failed
tui_run_example has the log that tells why)
+ text = e.getMessage();
+ isError = true;
+ log(LogLevel.ERROR, "Tool error: " + toolName + " - " +
e.getMessage());
} catch (Exception e) {
text = "Error: " + e.getMessage();
isError = true;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 029a7e5bc6c4..3016d3f00ebb 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -133,15 +133,21 @@ class TuiToolRegistry {
String executeShared(String name, Map<String, Object> args) {
ToolDescriptor descriptor = ToolRegistry.findTool(name);
if (descriptor == null) {
- throw new IllegalArgumentException("Unknown tool: " + name);
+ throw new UnknownToolException(name);
}
Map<String, String> stringArgs = stringArgs(args);
ToolContext ctx = sharedContext();
- try {
- Object result = ToolRegistry.execute(name, ctx, stringArgs);
- return result != null ? result.toString() : "";
- } catch (ToolExecutionException e) {
- return "Error: " + e.getMessage();
+ // a ToolExecutionException is an error of the tool, which the MCP
server answers with isError
+ Object result = ToolRegistry.execute(name, ctx, stringArgs);
+ return result != null ? result.toString() : "";
+ }
+
+ /**
+ * A tool that does not exist, as opposed to an error of a tool that does.
+ */
+ static final class UnknownToolException extends IllegalArgumentException {
+ UnknownToolException(String name) {
+ super("Unknown tool: " + name);
}
}
@@ -287,7 +293,7 @@ class TuiToolRegistry {
if (name != null && name.startsWith(SHARED_PREFIX)) {
return executeSharedWithTuiExtras(name, args);
}
- throw new IllegalArgumentException("Unknown tool: " + name);
+ throw new UnknownToolException(name);
}
// --- Tool execution methods ---
@@ -1868,6 +1874,8 @@ class TuiToolRegistry {
if (outcome.log() != null) {
result.put("log", outcome.log());
}
+ // a tool error (MCP isError), which still has the log that tells
why
+ throw new ToolExecutionException(Jsoner.serialize(result));
}
return Jsoner.serialize(result);
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerOutcomeTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerOutcomeTest.java
new file mode 100644
index 000000000000..0aa68cc8fec5
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerOutcomeTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.tui;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * A launch has started when the status file of its integration says so (or of
a process it started, as a Maven launch
+ * runs the app in a child), and failed when it ends with an exit code other
than 0. A restart is watched the same way,
+ * and camel_control waits for how it went.
+ */
+@DisabledOnOs(OS.WINDOWS)
+class LaunchManagerOutcomeTest {
+
+ @TempDir
+ Path home;
+
+ private Path previousHome;
+ private final LaunchManager lm = new LaunchManager(List::of);
+
+ @BeforeEach
+ void setup() throws Exception {
+ previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ Files.createDirectories(CommandLineHelper.getCamelDir());
+ }
+
+ @AfterEach
+ void restore() {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+
+ @Test
+ void startedWhenTheStatusFileSaysSo() throws Exception {
+ Process process = new ProcessBuilder("sleep", "30").start();
+ try {
+ lm.addPendingLaunch("orders", process, home.resolve("orders.log"));
+ lm.tick(0);
+ assertThat(lm.outcome("orders")).isNull();
+
+ // what the CLI connector writes once Camel has started
+
Files.writeString(CommandLineHelper.getCamelDir().resolve(process.pid() +
"-status.json"),
+ "{\"context\":{\"name\":\"orders\",\"phase\":5}}");
+ lm.tick(0);
+
+ assertThat(lm.outcome("orders").ok()).isTrue();
+ assertThat(McpFacade.awaitRestart(lm,
"orders")).isEqualTo("Restarted orders");
+ } finally {
+ process.destroyForcibly();
+ }
+ }
+
+ @Test
+ void failedWhenItEndsWithAnErrorBeforeItStarted() throws Exception {
+ Path log = home.resolve("orders.log");
+ Process process = new ProcessBuilder("sh", "-c", "echo Port 8080 is
already in use; exit 3")
+
.redirectErrorStream(true).redirectOutput(log.toFile()).start();
+ assertThat(process.waitFor(30, TimeUnit.SECONDS)).isTrue();
+ lm.addPendingLaunch("orders", process, log);
+ lm.tick(0);
+
+ assertThat(lm.outcome("orders").ok()).isFalse();
+ // camel_control restart is an error of the tool, with the end of the
log
+ assertThatThrownBy(() -> McpFacade.awaitRestart(lm, "orders"))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageStartingWith("Restart of orders failed: it stopped
while starting")
+ .hasMessageContaining("Port 8080 is already in use");
+ }
+
+ @Test
+ void phasesOfAStartedCamel() {
+ assertThat(LaunchManager.PendingLaunch.isStarted(status(4))).isFalse();
+ assertThat(LaunchManager.PendingLaunch.isStarted(status(5))).isTrue();
+ assertThat(LaunchManager.PendingLaunch.isStarted(status(7))).isTrue();
+ assertThat(LaunchManager.PendingLaunch.isStarted(status(9))).isFalse();
+ assertThat(LaunchManager.PendingLaunch.isStarted(null)).isFalse();
+ }
+
+ private static JsonObject status(int phase) {
+ JsonObject context = new JsonObject();
+ context.put("phase", phase);
+ JsonObject root = new JsonObject();
+ root.put("context", context);
+ return root;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerToolErrorTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerToolErrorTest.java
new file mode 100644
index 000000000000..8e50b0ebb6ac
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerToolErrorTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.tui;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Map;
+
+import org.apache.camel.util.json.JsonArray;
+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 static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A client of {@code camel tui --mcp} learns that a tool failed from {@code
isError}, not from reading its text: the
+ * error of a shared tool is an error of the call, and only a tool that does
not exist is an unknown tool.
+ */
+class TuiMcpServerToolErrorTest {
+
+ private final HttpClient http = HttpClient.newHttpClient();
+ private TuiMcpServer server;
+ private int id;
+
+ @BeforeEach
+ void start() throws Exception {
+ server = new TuiMcpServer(0, null);
+ server.start();
+ rpc("initialize", new JsonObject(Map.of("clientInfo", new
JsonObject(Map.of("name", "test")))));
+ }
+
+ @AfterEach
+ void stop() {
+ server.stop();
+ }
+
+ @Test
+ void errorOfASharedToolIsAnError() throws Exception {
+ JsonObject result = call("camel_eval_expression", Map.of("language",
"simple"));
+
+ assertThat(result.getBoolean("isError")).isTrue();
+ // the error as the tool wrote it, so an error in JSON stays JSON
+ assertThat(text(result)).isEqualTo("expression is required");
+ }
+
+ @Test
+ void toolThatDoesNotExistIsUnknown() throws Exception {
+ JsonObject result = call("no_such_tool", Map.of());
+
+ assertThat(result.getBoolean("isError")).isTrue();
+ assertThat(text(result)).isEqualTo("Unknown tool: no_such_tool");
+ }
+
+ @Test
+ void answerIsNoError() throws Exception {
+ JsonObject result = call("camel_eval_expression", Map.of("expression",
"${body} == 'x'", "body", "x"));
+
+ assertThat(result.get("isError")).isNull();
+ assertThat(text(result)).contains("\"status\":\"ok\"");
+ }
+
+ private JsonObject call(String tool, Map<String, Object> arguments) throws
Exception {
+ JsonObject params = new JsonObject();
+ params.put("name", tool);
+ params.put("arguments", new JsonObject(arguments));
+ return rpc("tools/call", params);
+ }
+
+ private static String text(JsonObject result) {
+ JsonArray content = (JsonArray) result.get("content");
+ return ((JsonObject) content.get(0)).getString("text");
+ }
+
+ private JsonObject rpc(String method, JsonObject params) throws Exception {
+ JsonObject request = new JsonObject();
+ request.put("jsonrpc", "2.0");
+ request.put("id", ++id);
+ request.put("method", method);
+ request.put("params", params);
+ HttpRequest post =
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.getPort() +
"/mcp"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(request.toJson()))
+ .build();
+ String body = http.send(post,
HttpResponse.BodyHandlers.ofString()).body();
+ return (JsonObject) ((JsonObject)
Jsoner.deserialize(body)).get("result");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryEvalExpressionTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryEvalExpressionTest.java
index 85496eba96b7..6214c56d13e0 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryEvalExpressionTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryEvalExpressionTest.java
@@ -20,11 +20,13 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -44,8 +46,10 @@ class TuiToolRegistryEvalExpressionTest {
ctx, new AtomicReference<>(List.of()), null, null, null, null,
null, null, null, null, null, null,
null);
TuiToolRegistry registry = new TuiToolRegistry(facade);
- assertEquals("Error: expression is required",
- registry.execute("camel_eval_expression", new
JsonObject(Map.of("language", "simple"))));
+ // an error of the tool, which the MCP server answers with isError
+ ToolExecutionException error =
assertThrows(ToolExecutionException.class,
+ () -> registry.execute("camel_eval_expression", new
JsonObject(Map.of("language", "simple"))));
+ assertEquals("expression is required", error.getMessage());
// nothing selected: the expression is evaluated in a scratch context
instead
JsonObject result = (JsonObject) Jsoner.deserialize(
registry.execute("camel_eval_expression", new JsonObject(