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
commit 1fd5519c6ca798614742d4e2b96902fb52db89f9 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Sep 8 17:27:34 2026 +0200 CAMEL-24656: camel-jbang TUI - expose the integration log tail as an MCP resource camel://log/<pid> returns the last 200 lines of ~/.camel/<pid>.log, and camel://log/<pid>?lines=<n> a different tail length (up to 5000), read from the end of the file so a large log is never loaded whole. Listed next to the status resources and described by a URI template. The Log tab only shows a window of the file, so the template points agents at tui_get_screen when they need to know which lines the user is looking at. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 8 +- .../jbang/core/commands/tui/StatusFileReader.java | 67 ++++++++++++++++ .../dsl/jbang/core/commands/tui/TuiMcpServer.java | 90 ++++++++++++++++++++-- .../core/commands/tui/StatusFileReaderTest.java | 20 +++++ .../commands/tui/TuiMcpServerResourcesTest.java | 34 ++++++-- 5 files changed, 204 insertions(+), 15 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index 8039f5133e04..7ab129ae3ad2 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -879,13 +879,17 @@ once a second by `camel-cli-connector`. The tabs show a digest of it; the MCP se whole document as resources so an agent can fetch exactly the section it needs instead of paging through tabs: +* `camel://log/<pid>` -- the last 200 lines of the integration's log (`~/.camel/<pid>.log`); + `camel://log/<pid>?lines=<n>` for a different tail length (up to 5000). The Log tab shows only a + window of this file, so an agent that needs to know which lines the user is looking at should + use the `tui_get_screen` tool instead. * `camel://status/<pid>` -- the whole document * `camel://status/<pid>/<section>` -- one top-level section, for example `context` (name, version, state, uptime, start timestamp, statistics), `runtime`, `routes`, `endpoints`, `healthChecks`, `properties`, `main-configuration`, `routeController`, `memory`, `threads`, `gc` or `events` -`resources/list` enumerates the document and its sections for every monitored integration, and a -`resources/templates/list` template describes the URI shape. The same data is available to the +`resources/list` enumerates the log, the document and its sections for every monitored +integration, and `resources/templates/list` describes the URI shapes. The same data is available to the F8 AI panel (and as an MCP tool) through `tui_get_status`, which takes a `section` argument and returns that section only; pass `sections` to list what the document contains. Sections such as `events` can be large, so ask for the one you need. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReader.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReader.java index 906ebaafc919..53cbbf3b4e50 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReader.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReader.java @@ -16,6 +16,7 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.io.RandomAccessFile; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -33,6 +34,8 @@ import org.apache.camel.util.json.Jsoner; final class StatusFileReader { static final String SECTION_LIST = "sections"; + static final int DEFAULT_LOG_LINES = 200; + static final int MAX_LOG_LINES = 5000; private final Path camelDir; @@ -48,6 +51,70 @@ final class StatusFileReader { return camelDir.resolve(pid + "-status.json"); } + Path logFile(String pid) { + return camelDir.resolve(pid + ".log"); + } + + boolean hasLog(String pid) { + return pid != null && !pid.isBlank() && Files.isRegularFile(logFile(pid.trim())); + } + + /** + * The last {@code lines} lines of the process log ({@code ~/.camel/<pid>.log}), read from the end of the file so a + * large log is not loaded whole; {@code null} when there is no log file. {@code lines} is clamped to + * {@link #MAX_LOG_LINES}. + */ + String tailLog(String pid, int lines) { + if (!hasLog(pid)) { + return null; + } + int wanted = Math.max(1, Math.min(lines, MAX_LOG_LINES)); + Path file = logFile(pid.trim()); + try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { + long length = raf.length(); + if (length == 0) { + return ""; + } + // read backwards in chunks until the buffer holds more line breaks than lines wanted (or the whole file) + int chunk = 64 * 1024; + byte[] tail = new byte[0]; + long position = length; + while (position > 0 && countNewlines(tail) <= wanted) { + int size = (int) Math.min(chunk, position); + position -= size; + raf.seek(position); + byte[] merged = new byte[size + tail.length]; + raf.readFully(merged, 0, size); + System.arraycopy(tail, 0, merged, size, tail.length); + tail = merged; + } + // skip the final line break, then step back over 'wanted' line breaks + int cut = tail.length; + if (cut > 0 && tail[cut - 1] == '\n') { + cut--; + } + int seen = 0; + for (int i = cut - 1; i >= 0; i--) { + if (tail[i] == '\n' && ++seen == wanted) { + return new String(tail, i + 1, tail.length - i - 1, StandardCharsets.UTF_8); + } + } + return new String(tail, StandardCharsets.UTF_8); + } catch (Exception e) { + return null; + } + } + + private static int countNewlines(byte[] bytes) { + int count = 0; + for (byte b : bytes) { + if (b == '\n') { + count++; + } + } + return count; + } + /** * The parsed document, or {@code null} when there is no readable status file for the pid. */ 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 25edf336c4c5..a49bb1aff14f 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 @@ -284,7 +284,9 @@ class TuiMcpServer { // ---- Resources: the per-process status document, whole or one section at a time ---- static final String STATUS_URI_PREFIX = "camel://status/"; + static final String LOG_URI_PREFIX = "camel://log/"; private static final String JSON_MIME = "application/json"; + private static final String TEXT_MIME = "text/plain"; /** * Address of a status resource: {@code camel://status/<pid>} for the whole document, or @@ -318,6 +320,49 @@ class TuiMcpServer { } } + /** + * Address of a process log resource: {@code camel://log/<pid>} for the last + * {@link StatusFileReader#DEFAULT_LOG_LINES} lines, or {@code camel://log/<pid>?lines=<n>} for a different tail + * length. + */ + record LogUri(String pid, int lines) { + + String uri() { + return LOG_URI_PREFIX + pid + (lines != StatusFileReader.DEFAULT_LOG_LINES ? "?lines=" + lines : ""); + } + + /** + * Parses a log resource URI, or returns {@code null} when it is not one. + */ + static LogUri parse(String uri) { + if (uri == null || !uri.startsWith(LOG_URI_PREFIX)) { + return null; + } + String rest = uri.substring(LOG_URI_PREFIX.length()); + int query = rest.indexOf('?'); + String pid = query < 0 ? rest : rest.substring(0, query); + if (pid.isEmpty() || !pid.chars().allMatch(Character::isDigit)) { + return null; + } + int lines = StatusFileReader.DEFAULT_LOG_LINES; + if (query >= 0) { + String param = rest.substring(query + 1); + if (!param.startsWith("lines=")) { + return null; + } + try { + lines = Integer.parseInt(param.substring("lines=".length())); + } catch (NumberFormatException e) { + return null; + } + if (lines < 1) { + return null; + } + } + return new LogUri(pid, Math.min(lines, StatusFileReader.MAX_LOG_LINES)); + } + } + private JsonObject handleResourcesList() { JsonObject result = new JsonObject(); result.put("resources", buildResourceList(facade.liveIntegrations(), facade.statusFiles())); @@ -338,22 +383,32 @@ class TuiMcpServer { continue; } String name = info.name != null ? info.name : info.pid; + if (reader.hasLog(info.pid)) { + resources.add(resource(new LogUri(info.pid, StatusFileReader.DEFAULT_LOG_LINES).uri(), name + " log", + "Last " + StatusFileReader.DEFAULT_LOG_LINES + " log lines of " + name + " (PID " + info.pid + + "); append ?lines=<n> for a longer or shorter tail (max " + + StatusFileReader.MAX_LOG_LINES + + ")", + TEXT_MIME)); + } resources.add(resource(new StatusUri(info.pid, null).uri(), name + " status", - "Full status document of " + name + " (PID " + info.pid + "): " + String.join(", ", sections))); + "Full status document of " + name + " (PID " + info.pid + "): " + String.join(", ", sections), + JSON_MIME)); for (String section : sections) { resources.add(resource(new StatusUri(info.pid, section).uri(), name + " " + section, - "The '" + section + "' section of the status document of " + name + " (PID " + info.pid + ")")); + "The '" + section + "' section of the status document of " + name + " (PID " + info.pid + ")", + JSON_MIME)); } } return resources; } - private static JsonObject resource(String uri, String name, String description) { + private static JsonObject resource(String uri, String name, String description, String mimeType) { JsonObject resource = new JsonObject(); resource.put("uri", uri); resource.put("name", name); resource.put("description", description); - resource.put("mimeType", JSON_MIME); + resource.put("mimeType", mimeType); return resource; } @@ -366,8 +421,17 @@ class TuiMcpServer { + "Sections include context, runtime, routes, endpoints, healthChecks, properties, " + "main-configuration, memory, threads, gc and events."); template.put("mimeType", JSON_MIME); + JsonObject logTemplate = new JsonObject(); + logTemplate.put("uriTemplate", LOG_URI_PREFIX + "{pid}{?lines}"); + logTemplate.put("name", "Integration log tail"); + logTemplate.put("description", "The last lines of the log of the integration with the given PID " + + "(" + StatusFileReader.DEFAULT_LOG_LINES + " by default, lines=<n> up to " + + StatusFileReader.MAX_LOG_LINES + "). The Log tab shows only a window of this " + + "file; use the tui_get_screen tool to see which lines the user is looking at."); + logTemplate.put("mimeType", TEXT_MIME); JsonArray templates = new JsonArray(); templates.add(template); + templates.add(logTemplate); JsonObject result = new JsonObject(); result.put("resourceTemplates", templates); return result; @@ -378,7 +442,13 @@ class TuiMcpServer { * (the caller turns that into a "resource not found" error). */ private JsonObject handleResourcesRead(JsonObject request) { - StatusUri uri = StatusUri.parse(requestedUri(request)); + String requested = requestedUri(request); + LogUri logUri = LogUri.parse(requested); + if (logUri != null) { + String tail = facade.statusFiles().tailLog(logUri.pid(), logUri.lines()); + return tail == null ? null : contents(logUri.uri(), TEXT_MIME, tail); + } + StatusUri uri = StatusUri.parse(requested); if (uri == null) { return null; } @@ -388,10 +458,14 @@ class TuiMcpServer { if (payload == null) { return null; } + return contents(uri.uri(), JSON_MIME, Jsoner.serialize(payload)); + } + + private static JsonObject contents(String uri, String mimeType, String text) { JsonObject content = new JsonObject(); - content.put("uri", uri.uri()); - content.put("mimeType", JSON_MIME); - content.put("text", Jsoner.serialize(payload)); + content.put("uri", uri); + content.put("mimeType", mimeType); + content.put("text", text); JsonArray contents = new JsonArray(); contents.add(content); JsonObject result = new JsonObject(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReaderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReaderTest.java index c75cd7166739..302efd5efe26 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReaderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReaderTest.java @@ -56,6 +56,26 @@ class StatusFileReaderTest { assertNull(reader.section("4711", null)); } + @Test + void tailsTheLogFromTheEndWithoutLoadingItWhole(@TempDir Path dir) throws Exception { + StringBuilder log = new StringBuilder(); + for (int i = 1; i <= 3000; i++) { + log.append("2026-09-08 line ").append(i).append(" ").append("padding ".repeat(20)).append('\n'); + } + Files.writeString(dir.resolve("4711.log"), log.toString()); + StatusFileReader reader = new StatusFileReader(dir); + + assertTrue(reader.hasLog("4711")); + String tail = reader.tailLog("4711", 3); + assertTrue(tail.startsWith("2026-09-08 line 2998 "), tail.substring(0, 40)); + assertEquals(3, tail.strip().split("\n").length); + assertTrue(tail.strip().endsWith("line 3000 " + "padding ".repeat(20).strip())); + + // more lines than the file has yields the whole file + assertEquals(log.toString(), reader.tailLog("4711", StatusFileReader.MAX_LOG_LINES)); + assertNull(reader.tailLog("9999", 10)); + } + @Test void missingOrCorruptFilesReadAsAbsent(@TempDir Path dir) throws Exception { Files.writeString(dir.resolve("99-status.json"), "{ not json"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerResourcesTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerResourcesTest.java index 9d6bf34720e8..aab18ff1be51 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerResourcesTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerResourcesTest.java @@ -55,9 +55,29 @@ class TuiMcpServerResourcesTest { } @Test - void listsWholeDocumentThenEachSectionPerIntegration(@TempDir Path dir) throws Exception { + void parsesLogUrisWithOptionalLineCount() { + TuiMcpServer.LogUri plain = TuiMcpServer.LogUri.parse("camel://log/4711"); + assertEquals("4711", plain.pid()); + assertEquals(StatusFileReader.DEFAULT_LOG_LINES, plain.lines()); + assertEquals("camel://log/4711", plain.uri()); + + TuiMcpServer.LogUri sized = TuiMcpServer.LogUri.parse("camel://log/4711?lines=50"); + assertEquals(50, sized.lines()); + assertEquals("camel://log/4711?lines=50", sized.uri()); + assertEquals(StatusFileReader.MAX_LOG_LINES, TuiMcpServer.LogUri.parse("camel://log/4711?lines=999999").lines()); + + assertNull(TuiMcpServer.LogUri.parse("camel://log/")); + assertNull(TuiMcpServer.LogUri.parse("camel://log/abc")); + assertNull(TuiMcpServer.LogUri.parse("camel://log/4711?lines=0")); + assertNull(TuiMcpServer.LogUri.parse("camel://log/4711?tail=5")); + assertNull(TuiMcpServer.LogUri.parse("camel://status/4711")); + } + + @Test + void listsLogThenWholeDocumentThenEachSectionPerIntegration(@TempDir Path dir) throws Exception { Files.writeString(dir.resolve("4711-status.json"), "{\"runtime\":{\"pid\":4711},\"context\":{\"name\":\"timer-log\"}}"); + Files.writeString(dir.resolve("4711.log"), "hello\n"); IntegrationInfo running = new IntegrationInfo(); running.pid = "4711"; running.name = "timer-log"; @@ -67,12 +87,16 @@ class TuiMcpServerResourcesTest { JsonArray resources = TuiMcpServer.buildResourceList(List.of(running, gone), new StatusFileReader(dir)); - assertEquals(3, resources.size()); - JsonObject whole = (JsonObject) resources.get(0); + assertEquals(4, resources.size()); + JsonObject log = (JsonObject) resources.get(0); + assertEquals("camel://log/4711", log.get("uri")); + assertEquals("timer-log log", log.get("name")); + assertEquals("text/plain", log.get("mimeType")); + JsonObject whole = (JsonObject) resources.get(1); assertEquals("camel://status/4711", whole.get("uri")); assertEquals("timer-log status", whole.get("name")); assertEquals("application/json", whole.get("mimeType")); - assertEquals("camel://status/4711/runtime", ((JsonObject) resources.get(1)).get("uri")); - assertEquals("camel://status/4711/context", ((JsonObject) resources.get(2)).get("uri")); + assertEquals("camel://status/4711/runtime", ((JsonObject) resources.get(2)).get("uri")); + assertEquals("camel://status/4711/context", ((JsonObject) resources.get(3)).get("uri")); } }
