This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25868 in repository https://gitbox.apache.org/repos/asf/camel.git
commit bb2ff24decf71b6c22018a3314c04682eb5315ff Author: Claus Ibsen <[email protected]> AuthorDate: Tue Oct 20 09:10:16 2026 +0200 CAMEL-25868: camel-jbang - AI tools: clearer answers for cut doc pages, old errors, consumed files and wrong status header names - The repeat note no longer says a cut camel_catalog_doc page was answered in full, and names docPage=<section title>. - camel_get_errors marks the errors from before the last reload (beforeLastReload), as the errors are kept until restart. - The reload outcome of a write keeps the first three ERROR lines after the first five lines, and says how many it left out. - camel_get_files counts the files a file consumer moved into .camel folders, so a read data folder is not reported empty. - The source check reports CamelHttpStatusCode and similar in any route (REST DSL routes use direct), and a name written as an Exchange constant that does not exist, which fails the route when Camel creates it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m --- .../modules/ROOT/pages/camel-jbang-mcp.adoc | 9 +- .../dsl/jbang/core/commands/ai/AuthoringTools.java | 114 ++++++++++++++++++++- .../dsl/jbang/core/commands/ai/HeaderChecks.java | 97 +++++++++++++++++- .../dsl/jbang/core/commands/ai/ReloadOutcome.java | 23 ++++- .../jbang/core/commands/ai/RepeatedToolCalls.java | 4 +- .../jbang/core/commands/ai/AuthoringToolsTest.java | 92 +++++++++++++++++ .../jbang/core/commands/ai/ReloadOutcomeTest.java | 19 ++++ .../core/commands/ai/RepeatedToolCallsTest.java | 2 + .../commands/ai/SourceValidatorEndpointTest.java | 54 ++++++++++ 9 files changed, 400 insertions(+), 14 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc index 7c0cd31eecbc..ee0f2f80da11 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc @@ -377,7 +377,9 @@ project `directory` as an argument, the runtime tools take the integration `name files up front (`routeFiles`, `configFiles`) and, when an integration is selected, which file and line each of its routes was loaded from, mapped from the runtime's location (a jar entry of an exported project, a classpath or file resource) onto the source under `src/main`. With `file`, a path relative to the directory as the list names - it, the content of that file. + it, the content of that file. The `.camel` folders are not listed, but counted (`consumedFiles`): a file consumer + moves each file it reads into `.camel` (unless `noop=true`), so a data folder whose files were all read is not + reported as empty. | `camel_write_file` | Writes the complete content of a file in the project directory. YAML and `.properties` content is validated @@ -441,6 +443,8 @@ project `directory` as an argument, the runtime tools take the integration `name | `camel_get_errors` | The failed exchanges of a running integration: route, exchange, exception with stack trace, body and headers. + The errors are kept until the integration restarts, so those that failed before the routes were last reloaded are + marked with `beforeLastReload`: they come from an earlier version of the routes. For an integration that is not running, it says how its previous run ended: when, the exit code, and the failure. | `camel_eval_expression` @@ -490,7 +494,8 @@ stuck doing that until its tool call budget is spent. So the first two identical in full, and from the third the answer is a short note instead: that the call was already answered and how many times it was asked. The changed answer helps the agent break the loop, and the context is not filled with copies. `camel_validate_source` counts the same way when it is given `content` and no `directory`: the same source gets -the same answer, and the note also says to change the line the error names. Validating a file on disk, or +the same answer, and the note also says to change the line the error names. For `camel_catalog_doc` the note says +how to get the sections that a cut page left out: `docPage=<section title>` returns one section. Validating a file on disk, or `content` with a `directory` (the checks then read the other files of the project, which a fix can change), is always answered in full. 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 6b878fa93f25..551e175bb5c2 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 @@ -25,6 +25,9 @@ import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -36,6 +39,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.regex.Matcher; @@ -118,6 +122,8 @@ public final class AuthoringTools { .param("camelVersion", "string", VERSION_DESC, false) .core(true) .deterministic(true) + .repeatHint("When that answer was cut, it named the sections it left out: docPage=<section title> returns" + + " one of them.") .executor((ctx, args) -> { applyVersion(ctx, args); return CatalogDocs.catalogDoc(ctx.catalog(), args.get("name"), args.get("endpoint"), @@ -372,7 +378,9 @@ public final class AuthoringTools { throw notRunning(ctx, args, new ToolExecutionException("pid " + ctx.pid() + " is not running")); } JsonObject errors = ctx.readErrorFile(); - return errors != null ? unescapeBodies(errors).toJson() : "No errors captured."; + return errors != null + ? markBeforeLastReload(unescapeBodies(errors), ctx.pid(), p != null ? p.name() : null).toJson() + : "No errors captured."; })); registry.accept(tool("camel_eval_expression", @@ -1845,9 +1853,24 @@ public final class AuthoringTools { result.put("configFiles", configFiles); result.put("files", files); result.put("totalFiles", all.size()); + // a file consumer moves a file it read into .camel (without noop=true), which the listing skips + Map<String, Integer> consumed = consumedFolders(dir); + String moved = null; + if (!consumed.isEmpty()) { + JsonObject folders = new JsonObject(); + folders.putAll(consumed); + result.put("consumedFiles", folders); + moved = String.join(", ", consumed.entrySet().stream().map(e -> e.getKey() + " holds " + e.getValue() + + (e.getValue() == 1 ? " file" : " files")) + .toList()) + + " that a file consumer moved there after reading (noop=true leaves the files where they are)"; + } if (all.isEmpty()) { - result.put("message", "The directory has no files"); - } else if (all.size() > MAX_FILES) { + result.put("message", moved != null ? "The directory has no files, but " + moved : "The directory has no files"); + } else if (moved != null) { + result.put("note", moved); + } + if (all.size() > MAX_FILES) { result.put("message", "Listing the first " + MAX_FILES + " of " + all.size() + " files; routeFiles and configFiles name every route and configuration file"); } @@ -2271,6 +2294,91 @@ public final class AuthoringTools { } } + /** + * Marks the failed exchanges of an earlier version of the routes: those that failed before the newest reload of the + * routes in the log. The errors are kept until the integration restarts, and an agent that read them after a change + * took them for errors of its new version, and changed routes that already worked. + */ + static JsonObject markBeforeLastReload(JsonObject errors, long pid, String name) { + try { + JsonObject log = LogFileReader.read(pid, name, 1, ReloadOutcome.RELOADED, null); + if (log.get("lines") instanceof List<?> lines && !lines.isEmpty() && lines.get(0) instanceof JsonObject reload) { + return markBefore(errors, reload.getString("time"), LocalDateTime.now()); + } + } catch (Exception e) { + // no log to tell: the errors as they are + } + return errors; + } + + private static final long RELOAD_MARGIN_MILLIS = 1000; + + /** Marks the errors that failed before the reload at the given time of day; the log keeps the time of day only. */ + static JsonObject markBefore(JsonObject errors, String reloadTime, LocalDateTime now) { + LocalTime time; + try { + time = LocalTime.parse(reloadTime); + } catch (Exception e) { + return errors; + } + LocalDateTime reload = now.toLocalDate().atTime(time); + if (reload.isAfter(now)) { + reload = reload.minusDays(1); + } + // the summary is logged after the new routes started: a margin keeps a route that fails at once unmarked + long reloadMillis = reload.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - RELOAD_MARGIN_MILLIS; + int before = 0; + if (errors.get("errors") instanceof List<?> entries) { + for (Object o : entries) { + if (o instanceof JsonObject e && e.get("timestamp") instanceof Number ts && ts.longValue() < reloadMillis) { + e.put("beforeLastReload", true); + before++; + } + } + } + if (before > 0) { + errors.put("note", before + (before == 1 ? " exchange" : " exchanges") + " failed before the routes were last" + + " reloaded (at " + reloadTime + "), so on an earlier version of the routes" + + " (beforeLastReload); the errors are kept until the integration restarts"); + } + return errors; + } + + /** + * The .camel folders under the directory, with the number of files in each: a file consumer moves a file it read + * into .camel (its default, without noop=true), so a folder whose files were all read looked empty. + */ + static Map<String, Integer> consumedFolders(Path dir) { + Map<String, Integer> answer = new TreeMap<>(); + try { + Files.walkFileTree(dir, EnumSet.noneOf(FileVisitOption.class), MAX_DEPTH, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) throws IOException { + String name = d.getFileName() != null ? d.getFileName().toString() : ""; + if (".camel".equals(name) && !d.equals(Path.of(System.getProperty("user.home"), ".camel"))) { + try (var files = Files.list(d)) { + long count = files.filter(Files::isRegularFile).count(); + if (count > 0) { + answer.put(dir.relativize(d).toString().replace('\\', '/'), (int) count); + } + } + return FileVisitResult.SKIP_SUBTREE; + } + return !d.equals(dir) && (SKIPPED_DIRS.contains(name) || name.startsWith(".")) + ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path f, IOException e) { + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + // nothing to add + } + return answer; + } + /** * Camel JSON-escapes the body of a message dump (MessageHelper), so a JSON body would reach the AI escaped twice (a * body like {"orderId":1} showed as {\\"orderId\\":1}). The body values of the errors are unescaped once, as the diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java index 6fc97eedcc48..47b8bd33f75a 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java @@ -16,7 +16,9 @@ */ package org.apache.camel.dsl.jbang.core.commands.ai; +import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -26,9 +28,12 @@ import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.apache.camel.Exchange; +import org.apache.camel.ExchangeConstantProvider; import org.apache.camel.catalog.CamelCatalog; import static org.apache.camel.dsl.jbang.core.commands.ai.PropertiesChecks.closestName; +import static org.apache.camel.dsl.jbang.core.commands.ai.PropertiesChecks.closestNames; /** * The Camel* header check of {@link SourceValidator}: a header a component never sets, against the header metadata of @@ -42,6 +47,15 @@ final class HeaderChecks { static final Pattern CAMEL_HEADER_REF_PATTERN = Pattern.compile( "(?:\\$\\{headers?\\.|headers\\.|headers\\[['\"]|header\\(['\"]|(name):\\s*['\"]?)(Camel[A-Z][A-Za-z0-9]*(?:\\.[A-Za-z0-9_]+)*)"); + private static final String HTTP_STATUS + = "the status code of an HTTP answer is the header CamelHttpResponseCode (Exchange.HTTP_RESPONSE_CODE)"; + + /** + * A name written as a constant of Exchange, such as name: Exchange.HTTP_RESPONSE_CODE: Camel looks the constant up + * when it creates the route, for the name of a setHeader, setProperty, setVariable and of the remove EIPs. + */ + static final Pattern EXCHANGE_CONSTANT_NAME = Pattern.compile("name:\\s*['\"]?Exchange\\.([A-Z][A-Z0-9_]*)"); + static final Pattern SCHEME_IN_URI_PATTERN = Pattern.compile("uri:\\s*\"?([a-zA-Z][a-zA-Z0-9+.-]*):"); /** @@ -106,6 +120,17 @@ final class HeaderChecks { + " ${date:exchangeCreated:yyyy-MM-dd HH:mm:ss}; ${messageTimestamp} is set only by" + " components that stamp their messages.")); + /** + * Camel* names that are not headers of Camel, for a header that has another name, with what to write: the status + * code of an HTTP answer is CamelHttpResponseCode, which models write as CamelHttpStatusCode. Checked whatever the + * components of the file are: the routes of a REST DSL use direct, which has no header metadata, so the other + * checks skip them. + */ + static final Map<String, String> WRONG_NAMES = Map.of( + "CamelHttpStatusCode", HTTP_STATUS, "CamelHttpStatus", HTTP_STATUS, "CamelHttpCode", HTTP_STATUS, + "CamelHttpResponseStatus", HTTP_STATUS, "CamelHttpResponseStatusCode", HTTP_STATUS, "CamelStatusCode", + HTTP_STATUS); + static String intentHint(String name) { for (var e : INTENT_HINTS) { if (e.getKey().matcher(name).find()) { @@ -139,12 +164,64 @@ final class HeaderChecks { return msgs; } + /** + * The {@link #WRONG_NAMES} the content uses as header names, and the names written as a constant that Exchange does + * not have, which fail the route when Camel creates it; once each, the names go into reported. + */ + static List<String> wrongNames(String content, Set<String> reported) { + List<String> msgs = new ArrayList<>(); + String[] lines = content.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + Matcher c = EXCHANGE_CONSTANT_NAME.matcher(lines[i]); + if (c.find() && isSetOrRemoveName(lines, i)) { + String hint = exchangeConstantHint(c.group(1)); + if (hint != null && reported.add("Exchange." + c.group(1))) { + msgs.add("Line " + (i + 1) + ": " + hint); + } + } + Matcher m = CAMEL_HEADER_REF_PATTERN.matcher(lines[i]); + while (m.find()) { + if (m.group(1) != null && !isHeaderName(lines, i)) { + continue; + } + String name = m.group(2); + String right = WRONG_NAMES.get(name); + if (right != null && reported.add(name)) { + msgs.add("Line " + (i + 1) + ": Camel has no header " + name + ": " + right); + } + } + } + return msgs; + } + + /** + * The message for a name written as Exchange.<constant> when Exchange has no such constant, else null: Camel looks + * the constant up, so the name Exchange.HTTP_RESPONSE_CODE of a setHeader sets CamelHttpResponseCode. + */ + static String exchangeConstantHint(String constant) { + if (ExchangeConstantProvider.lookup(constant) != null) { + return null; + } + String start = "Exchange has no constant " + constant + ", so the route fails to start (Constant field with name: " + + constant + " not found on Exchange.class): "; + if (constant.contains("STATUS") || constant.contains("RESPONSE")) { + return start + HTTP_STATUS; + } + List<String> fields = Arrays.stream(Exchange.class.getFields()).map(Field::getName).toList(); + List<String> closest = closestNames(constant, fields); + return start + (closest.isEmpty() + ? "write the name of the header, such as CamelFileName" + : "did you mean " + String.join(" or ", closest.stream().map(n -> "Exchange." + n).toList()) + "?"); + } + public static List<String> validateKnownHeaders(String content, CamelCatalog catalog) { List<String> msgs = new ArrayList<>(); if (content == null) { return msgs; } msgs.addAll(timerCounterComparisons(content)); + Set<String> wrong = new HashSet<>(); + msgs.addAll(wrongNames(content, wrong)); Set<String> known = new LinkedHashSet<>(); Map<String, String> owner = new LinkedHashMap<>(); Matcher sm = SCHEME_IN_URI_PATTERN.matcher(content); @@ -189,7 +266,7 @@ final class HeaderChecks { continue; } String name = m.group(2); - if (common.contains(name) || reported.contains(name) || isKnown(name, known)) { + if (common.contains(name) || reported.contains(name) || wrong.contains(name) || isKnown(name, known)) { continue; } String propertyOwner = null; @@ -301,6 +378,20 @@ final class HeaderChecks { /** Whether the name: on this line belongs to a setHeader or removeHeader step, not a setProperty, a bean... */ static boolean isHeaderName(String[] lines, int index) { + String step = parentStep(lines, index); + return step.startsWith("setHeader:") || step.startsWith("removeHeader:"); + } + + /** + * Whether the name: of the line names a header, property or variable, which Camel parses as a constant of Exchange. + */ + static boolean isSetOrRemoveName(String[] lines, int index) { + String step = parentStep(lines, index); + return step.startsWith("set") || step.startsWith("remove"); + } + + /** The step the line belongs to, such as setHeader:, or an empty text. */ + private static String parentStep(String[] lines, int index) { int indent = YamlLines.countLeadingSpaces(lines[index]); for (int j = index - 1; j >= 0; j--) { String line = lines[j]; @@ -312,10 +403,10 @@ final class HeaderChecks { if (step.startsWith("- ")) { step = step.substring(2).trim(); } - return step.startsWith("setHeader:") || step.startsWith("removeHeader:"); + return step; } } - return false; + return ""; } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java index f816c0b87369..cb4f1e38919e 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcome.java @@ -178,13 +178,15 @@ public final class ReloadOutcome { */ private static final Pattern ROUTE_LOGGER = Pattern.compile("(^|\\.)(yaml|yml|xml|java|groovy):\\d+$"); private static final int OUTPUT_LINES = 5; + private static final int OUTPUT_ERRORS = 3; private static final int OUTPUT_CHARS = 400; /** * What the routes logged after the newest reload, waiting up to the timeout for the first of it: the records of the - * routes' log steps, and any WARN or ERROR, newest last, at most five, each cut to 400 characters. A write answered - * with the result of the change shows the agent at once what its route now does (CAMEL-25513): an agent left to - * call camel_get_log reads it before the reload ran, or not at all. + * routes' log steps, and any WARN or ERROR, newest last, each cut to 400 characters: the first five, the first + * three ERROR after them, and a line that says how many are left out. A write answered with the result of the + * change shows the agent at once what its route now does (CAMEL-25513): an agent left to call camel_get_log reads + * it before the reload ran, or not at all. */ public static JsonArray awaitOutput(long pid, String name, long timeoutMillis) { StopWatch watch = new StopWatch(); @@ -226,7 +228,20 @@ public final class ReloadOutcome { // newest first in the log: the first lines the routes logged after the reload are at the end Collections.reverse(lines); JsonArray out = new JsonArray(); - out.addAll(lines.subList(0, Math.min(OUTPUT_LINES, lines.size()))); + int first = Math.min(OUTPUT_LINES, lines.size()); + out.addAll(lines.subList(0, first)); + // an ERROR after the first lines is kept as well: the route went on, and its failure is the news + int errors = 0; + for (String line : lines.subList(first, lines.size())) { + if (line.startsWith("ERROR ") && errors < OUTPUT_ERRORS) { + out.add(line); + errors++; + } + } + int left = lines.size() - out.size(); + if (left > 0) { + out.add("... " + left + " more line" + (left == 1 ? "" : "s") + ": camel_get_log shows them"); + } return out; } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java index 1be5f79d2564..2b1350d075f2 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java @@ -77,8 +77,8 @@ public final class RepeatedToolCalls { note.put("tool", tool.name()); note.put("timesAsked", times); note.put("note", tool.name() + " was already called with these same arguments " + (times - 1) - + " times in this session and answered in full. Its answer does not change, so it is not sent" - + " again: it is in your earlier tool result." + + " times in this session. Its answer does not change, so it is not sent again: it is in your" + + " earlier tool result." + (tool.repeatHint() != null ? " " + tool.repeatHint() : "")); return note; } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java index f684fb684dec..2ca880ea05a0 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java @@ -20,6 +20,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -146,6 +148,96 @@ class AuthoringToolsTest { Map.of("directory", dir.toString(), "file", "missing.yaml"))); } + /** A file consumer moves the files it read into .camel, which the listing skips: the folder looked empty. */ + @Test + void filesAFileConsumerMovedIntoDotCamelAreCounted(@TempDir Path dir) throws IOException { + Files.createDirectories(dir.resolve("customers/.camel")); + Files.writeString(dir.resolve("customers/.camel/C-482.json"), "{}"); + Files.writeString(dir.resolve("customers/.camel/C-483.json"), "{}"); + JsonObject list = call("camel_get_files", new ToolContext(), Map.of("directory", dir.toString())); + assertEquals(0, list.getInteger("totalFiles")); + JsonObject consumed = list.getMap("consumedFiles"); + assertEquals(1, consumed.size(), consumed.toJson()); + assertEquals(2, consumed.getInteger("customers/.camel")); + assertEquals("The directory has no files, but customers/.camel holds 2 files that a file consumer moved there" + + " after reading (noop=true leaves the files where they are)", + list.getString("message")); + + Files.writeString(dir.resolve("demo.camel.yaml"), VALID_ROUTE); + list = call("camel_get_files", new ToolContext(), Map.of("directory", dir.toString())); + assertEquals(1, list.getInteger("totalFiles")); + assertTrue(list.getString("note").startsWith("customers/.camel holds 2 files"), list.toJson()); + assertFalse(list.containsKey("message"), list.toJson()); + } + + @Test + void noDotCamelFolderNoNote(@TempDir Path dir) throws IOException { + Files.writeString(dir.resolve("demo.camel.yaml"), VALID_ROUTE); + Files.createDirectories(dir.resolve("customers/.camel")); + JsonObject list = call("camel_get_files", new ToolContext(), Map.of("directory", dir.toString())); + assertFalse(list.containsKey("consumedFiles"), "an empty .camel folder is not named"); + assertFalse(list.containsKey("note"), list.toJson()); + } + + /** The errors are kept until the integration restarts: those from before the last reload are marked. */ + @Test + void theErrorsFromBeforeTheLastReloadAreMarked() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 8, 30); + JsonObject errors = errorsAt(now.withHour(8).withMinute(10), LocalDateTime.of(2026, 10, 20, 8, 18, 9, 500_000_000), + now.withHour(8).withMinute(20)); + + AuthoringTools.markBefore(errors, "08:18:09.731", now); + + List<JsonObject> entries = entries(errors); + assertEquals(Boolean.TRUE, entries.get(0).get("beforeLastReload")); + assertFalse(entries.get(1).containsKey("beforeLastReload"), "within a second of the reload: maybe the new routes"); + assertFalse(entries.get(2).containsKey("beforeLastReload"), "after the reload"); + assertEquals("1 exchange failed before the routes were last reloaded (at 08:18:09.731), so on an earlier version" + + " of the routes (beforeLastReload); the errors are kept until the integration restarts", + errors.getString("note")); + } + + @Test + void aReloadLaterInTheDayThanNowWasYesterday() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 0, 5); + JsonObject errors = errorsAt(LocalDateTime.of(2026, 10, 19, 23, 50), LocalDateTime.of(2026, 10, 20, 0, 1)); + + AuthoringTools.markBefore(errors, "23:59:00.000", now); + + List<JsonObject> entries = entries(errors); + assertEquals(Boolean.TRUE, entries.get(0).get("beforeLastReload")); + assertFalse(entries.get(1).containsKey("beforeLastReload")); + } + + @Test + void noErrorBeforeTheReloadNoNote() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 8, 30); + JsonObject errors = errorsAt(now.withMinute(20)); + AuthoringTools.markBefore(errors, "08:18:09.731", now); + assertFalse(errors.containsKey("note")); + assertFalse(entries(errors).get(0).containsKey("beforeLastReload")); + // a time the log did not have leaves the errors as they are + AuthoringTools.markBefore(errors, "", now); + assertFalse(errors.containsKey("note")); + } + + private static JsonObject errorsAt(LocalDateTime... times) { + JsonArray list = new JsonArray(); + for (LocalDateTime t : times) { + JsonObject e = new JsonObject(); + e.put("timestamp", t.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + list.add(e); + } + JsonObject errors = new JsonObject(); + errors.put("errors", list); + return errors; + } + + @SuppressWarnings("unchecked") + private static List<JsonObject> entries(JsonObject errors) { + return (List<JsonObject>) (List<?>) errors.getCollection("errors"); + } + @Test void writeValidatesFirstAndRefusesInvalidContent(@TempDir Path dir) throws IOException { Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n"); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java index 82b97f3e4904..adb97d9222fa 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ReloadOutcomeTest.java @@ -137,6 +137,25 @@ class ReloadOutcomeTest { assertTrue(output.get(1).toString().startsWith("WARN Rollback file strategy"), output.toJson()); } + /** The errors after the first lines are the news: a route that went on and failed later on. */ + @Test + void anErrorAfterTheFirstLinesIsKept() { + List<String> lines = new java.util.ArrayList<>(RELOADED); + for (int i = 1; i <= 6; i++) { + lines.add("2026-09-21 10:00:4" + (i + 1) + ".000 INFO 42 --- [ timer://tick] route1 : Order " + i); + } + lines.add("2026-09-21 10:00:48.500 ERROR 42 --- [ timer://tick] o.a.c.p.e.DefaultErrorHandler : Failed delivery for" + + " exchangeId: A. Exhausted after delivery attempt: 1 caught: java.lang.IllegalStateException: stock"); + lines.add("2026-09-21 10:00:49.000 INFO 42 --- [ timer://tick] route1 : Order 7"); + + JsonArray output = ReloadOutcome.output(records(BEFORE, lines)); + + assertEquals(List.of("Hello again", "Order 1", "Order 2", "Order 3", "Order 4"), List.copyOf(output.subList(0, 5))); + assertTrue(output.get(5).toString().startsWith("ERROR Failed delivery for exchangeId: A"), output.toJson()); + assertEquals("... 3 more lines: camel_get_log shows them", output.get(6)); + assertEquals(7, output.size(), output.toJson()); + } + @Test void whatWasLoggedBeforeTheNewestReloadIsNotOutput() { assertEquals(List.of("Hello Camel"), List.copyOf(ReloadOutcome.output(records(BEFORE)))); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java index 2a07467af1e8..c36c47269446 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java @@ -48,6 +48,8 @@ class RepeatedToolCallsTest { assertThat(note.getString("tool")).isEqualTo("camel_catalog_doc"); assertThat(note.getInteger("timesAsked")).isEqualTo(3); assertThat(note.getString("note")).contains("2 times"); + // a page that was cut was not answered in full: the note says how to get the sections it left out + assertThat(note.getString("note")).doesNotContain("in full").contains("docPage=<section title>"); assertThat(calls.repeatOf(catalogDoc, sql).getInteger("timesAsked")).isEqualTo(4); } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java index b64b7f94305e..7004d5e25002 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java @@ -516,6 +516,60 @@ class SourceValidatorEndpointTest { assertThat(msgs).isEmpty(); } + /** The routes of a REST DSL use direct, which has no header metadata: a wrong status header is still reported. */ + @Test + void aWrongHttpStatusHeaderIsReportedInADirectRoute() { + List<String> msgs = SourceValidator.validateKnownHeaders(""" + - rest: + get: + - path: /orders/{id} + to: direct:get-order + - route: + from: + uri: direct:get-order + steps: + - setHeader: + name: CamelHttpStatusCode + constant: 404 + - setHeader: + name: Exchange.HTTP_STATUS + constant: 404 + - log: "${header.CamelHttpStatusCode}" + """, catalog); + assertThat(msgs).hasSize(2); + assertThat(msgs.get(0)).startsWith("Line 10:").contains("Camel has no header CamelHttpStatusCode") + .contains("the header CamelHttpResponseCode (Exchange.HTTP_RESPONSE_CODE)"); + assertThat(msgs.get(1)).startsWith("Line 13:").contains("Exchange has no constant HTTP_STATUS") + .contains("the route fails to start").contains("the header CamelHttpResponseCode"); + } + + @Test + void aConstantOfExchangeIsTheNameCamelLooksUp() { + // Camel looks the constant up when it creates the route (DefaultCamelContextExtension.resolvePropertyPlaceholders) + List<String> msgs = SourceValidator.validateKnownHeaders(""" + - from: + uri: direct:get-order + steps: + - setHeader: + name: Exchange.HTTP_RESPONSE_CODE + constant: 404 + - setHeader: + name: CamelHttpResponseCode + constant: 404 + - setProperty: + name: Exchange.HTTP_STATUS + constant: 404 + - setHeader: + name: Exchange.FILE_NAM + constant: out.json + """, catalog); + // the name of a setProperty is looked up as well + assertThat(msgs).hasSize(2); + assertThat(msgs.get(0)).startsWith("Line 11:").contains("Exchange has no constant HTTP_STATUS"); + assertThat(msgs.get(1)).startsWith("Line 14:").contains("Exchange has no constant FILE_NAM") + .contains("did you mean Exchange.FILE_NAME"); + } + @Test void withIncludeMetadataAHeaderLookupFindsTheExchangeProperty() { // Simple's ${header.x} falls back to the exchange properties, so it reads the counter
