This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 9f2250f498f1a87a52949b4531facca97e1e3c79 Author: Luigi De Masi <[email protected]> AuthorDate: Fri Oct 9 23:12:34 2026 +0200 CAMEL-25489: Add CLI commands to inspect and evaluate semantic definitions Expose the semantic metadata and evaluation connector actions through camel get semantic and camel cmd semantic-evaluate. Support named sample exchanges, direct expert calls, typed inputs, JSON output, and documented exit codes with isolated request/reply files. Include command tests, usage documentation, and generated CLI metadata. Co-authored-by: OpenAI Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../modules/ROOT/pages/camel-jbang-managing.adoc | 90 +++++ .../camel-jbang-cmd-semantic-evaluate.adoc | 36 ++ .../ROOT/pages/jbang-commands/camel-jbang-cmd.adoc | 1 + .../jbang-commands/camel-jbang-get-semantic.adoc | 28 ++ .../ROOT/pages/jbang-commands/camel-jbang-get.adoc | 1 + .../META-INF/camel-jbang-commands-metadata.json | 4 +- .../dsl/jbang/core/commands/CamelJBangMain.java | 2 + .../jbang/core/commands/action/CamelSemantic.java | 105 ++++++ .../commands/action/SemanticActionCommand.java | 151 ++++++++ .../core/commands/action/SemanticEvaluate.java | 168 +++++++++ .../MissingPluginParameterExceptionHandler.java | 4 + .../core/commands/action/SemanticCommandTest.java | 415 +++++++++++++++++++++ 12 files changed, 1003 insertions(+), 2 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc index 3ebac3883204..06bf53b9e529 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc @@ -279,6 +279,96 @@ camel cmd enable-processor TIP: Separate multiple IDs with commas: `--id=log1,log2,setBody2`. +== Semantic definitions and evaluation + +The CLI can inspect and try the running application's +xref:next@components:languages:semantic-language.adoc[semantic definitions and experts]. +The application must include `camel-semantic` with the `semantic-metadata` and +`semantic-evaluate` developer consoles and have the local CLI connector enabled. +These commands use the connector; enabling the HTTP console is unnecessary. + +[source,bash] +---- +camel get semantic my-app +camel get semantic my-app --json +camel get semantic my-app --expert=guard +---- + +`get semantic` lists definition names, experts, operations, state selectors, result types +and resolution errors, followed by the known experts and default expert. `--expert` +shows one expert's operations, input and result semantics, and parameter contract. +Inspection does not perform inference. + +Evaluate a named definition with a sample exchange: + +[source,bash] +---- +camel cmd semantic-evaluate my-app --evaluation=safe \ + --body='Please summarize this message' --header=subject=example \ + --variable=language=en --json +---- + +The definition's state selector reads the supplied body, headers and exchange-local +variables. Variables with repository prefixes such as `global:` are rejected. The +sample is evaluated directly; it is not sent through the application's routes. + +Alternatively, invoke an expert operation directly. Use `get semantic --expert` to +find the operation names and parameters supported by that expert: + +[source,bash] +---- +camel cmd semantic-evaluate my-app --expert=guard --operation=detect \ + --input='Please summarize this message' --json +camel cmd semantic-evaluate my-app --expert=grader --operation=score \ + --input='Sample answer' --parameter='criteria=json:["poor","good","excellent"]' --json +---- + +Choose either a named evaluation or a direct expert call. Direct calls require +`--expert`, `--operation` and `--input`. Repeat `--header`, `--variable` or `--parameter` +for multiple entries; duplicate keys are rejected. + +Values are text by default. Prefix a value with `json:` for a typed JSON value, for +example `--parameter=threshold=json:0.7`, `--variable=enabled=json:true`, or +`--input='json:["first","second"]'`. Use a JSON string to pass literal text beginning +with `json:` or `file:`. `--body=file:sample.txt` and `--input=file:sample.txt` read the +file as UTF-8 text. + +Evaluation prints the value, available probability or probabilities, confidence, +provider metadata, elapsed milliseconds and any error. It invokes the configured +provider and may incur charges. `--evaluation-timeout` bounds runtime evaluation +(default and maximum: 50000 milliseconds). `--timeout` bounds the CLI's wait for the +reply (default: 60000 milliseconds). A CLI timeout removes its request and asks the +connector to cancel unfinished work. Provider interruption is cooperative; remote or +native inference may continue. Configure provider-specific request timeouts as well. + +Both commands accept an integration name or PID. With no name they select the only +running integration; ambiguous selection fails and lists the matching PIDs. +Each request has its own reply file, so concurrent commands do not consume each +other's results. + +=== Semantic command output and exit codes + +With `--json`, stdout contains exactly one JSON document. Inspection returns the +runtime metadata object, including empty `evaluations` and `experts` arrays when +nothing is published. Successful evaluations retain the runtime result fields. +Errors use `{"status":"error","code":N,"message":"..."}` and retain any runtime +result fields, such as `error` and `elapsedMillis`. Diagnostics go to stderr. + +[cols="1,4",options="header"] +|=== +| Exit code | Meaning +| 0 | Successful inspection or evaluation, including a boolean result of `false`. +| 1 | The integration rejected the action or evaluation failed. +| 2 | Invalid command arguments or sample values. +| 3 | No matching integration, ambiguous selection, unavailable semantic tooling or expert operations. +| 4 | No reply before the CLI timeout. +| 70 | Unexpected internal error; stderr includes the stack trace. +|=== + +See xref:jbang-commands/camel-jbang-get-semantic.adoc[camel get semantic] and +xref:jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc[camel cmd semantic-evaluate] +for the complete option reference. + == Developer Console Enable the web-based developer console with `--console`: diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc new file mode 100644 index 000000000000..bdb5e5089a3c --- /dev/null +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc @@ -0,0 +1,36 @@ + +// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE += camel cmd semantic-evaluate + +Evaluate a semantic definition or expert operation + + +== Usage + +[source,bash] +---- +camel cmd semantic-evaluate [options] +---- + + + +== Options + +[cols="2,5,1,2",options="header"] +|=== +| Option | Description | Default | Type +| `--body` | Sample message body (text, file:path or json:value) | | String +| `--evaluation` | Named semantic definition to evaluate | | String +| `--evaluation-timeout` | Provider evaluation timeout in milliseconds (1-50000) | 50000 | long +| `--expert` | Expert to call directly | | String +| `--header` | Sample header (key=value; use json: for a typed value) | | List +| `--input` | Direct expert input (text, file:path or json:value) | | String +| `--json` | Output a single JSON document | | boolean +| `--operation` | Expert operation to call directly | | String +| `--parameter` | Expert parameter (key=value; use json: for a typed value) | | List +| `--timeout` | Timeout in milliseconds waiting for the integration | 60000 | long +| `--variable` | Sample exchange-local variable (key=value; use json: for a typed value) | | List +| `-h,--help` | Display the help and sub-commands | | boolean +|=== + + diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc index e6edad675139..943602405d14 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc @@ -35,6 +35,7 @@ camel cmd [options] | xref:jbang-commands/camel-jbang-cmd-route-dump.adoc[route-dump] | Dump Camel route in XML, YAML, or Java DSL format | xref:jbang-commands/camel-jbang-cmd-route-structure.adoc[route-structure] | Dump Camel route structure | xref:jbang-commands/camel-jbang-cmd-route-topology.adoc[route-topology] | Display inter-route topology connections +| xref:jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc[semantic-evaluate] | Evaluate a semantic definition or expert operation | xref:jbang-commands/camel-jbang-cmd-send.adoc[send] | Send messages to endpoints | xref:jbang-commands/camel-jbang-cmd-span.adoc[span] | Display OpenTelemetry spans from running Camel integrations | xref:jbang-commands/camel-jbang-cmd-sql.adoc[sql] | Execute SQL query on a DataSource diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-semantic.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-semantic.adoc new file mode 100644 index 000000000000..ba1b6d8db73e --- /dev/null +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-semantic.adoc @@ -0,0 +1,28 @@ + +// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE += camel get semantic + +List semantic definitions and expert contracts + + +== Usage + +[source,bash] +---- +camel get semantic [options] +---- + + + +== Options + +[cols="2,5,1,2",options="header"] +|=== +| Option | Description | Default | Type +| `--expert` | Show the operations and parameter contract of this expert | | String +| `--json` | Output a single JSON document | | boolean +| `--timeout` | Timeout in milliseconds waiting for the integration | 60000 | long +| `-h,--help` | Display the help and sub-commands | | boolean +|=== + + diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc index 1e4f3601dfc9..d15d01264c16 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc @@ -44,6 +44,7 @@ camel get [options] | xref:jbang-commands/camel-jbang-get-rest.adoc[rest] | Get REST services of Camel integrations | xref:jbang-commands/camel-jbang-get-route.adoc[route] | Get status of Camel routes | xref:jbang-commands/camel-jbang-get-route-controller.adoc[route-controller] | List status of route controller +| xref:jbang-commands/camel-jbang-get-semantic.adoc[semantic] | List semantic definitions and expert contracts | xref:jbang-commands/camel-jbang-get-service.adoc[service] | Get services of Camel integrations | xref:jbang-commands/camel-jbang-get-source.adoc[source] | Display Camel route source code | xref:jbang-commands/camel-jbang-get-sql-trace.adoc[sql-trace] | Get SQL query trace data diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json index fce709a9ad77..3b83afa8e364 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json +++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json @@ -3,7 +3,7 @@ { "name": "ask", "fullName": "ask", "description": "Ask a question about a running Camel application using AI", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": "--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType": [...] { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind source and sink Kamelets as a new Camel integration", "deprecated": true, "sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": [ { "names": "--error-handler", "description": "Add error handler (none|log|sink:<endpoint>). Sink endpoints are expected in the format [[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet name.", "javaType": "java.lang.String", "type": "stri [...] { "name": "catalog", "fullName": "catalog", "description": "List artifacts from Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "component", "fullName": "catalog component", "description": "List components from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...] - { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] + { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] { "name": "completion", "fullName": "completion", "description": "Generate completion script for bash\/zsh", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ] }, { "name": "config", "fullName": "config", "description": "Get and set user configuration values", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "config get", "description": "Display user configuration value", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...] { "name": "debug", "fullName": "debug", "description": "Debug local Camel integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", "options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": "--background", "description": "Run in the background", "defaultValue": "false", "javaType": "boolean", "type": "boolean" }, { "names": "--background-wait", "description": "To [...] @@ -15,7 +15,7 @@ { "name": "eval", "fullName": "eval", "description": "Evaluate Camel expressions and scripts", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.EvalCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "expression", "fullName": "eval expression", "description": "Evaluates Camel expression", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.EvalEx [...] { "name": "explain", "fullName": "explain", "description": "Explain what a Camel route does using AI\/LLM", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names": "--api-key", "description": "API key for authentication. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama', 'openai' (OpenAI-compatible), ' [...] { "name": "export", "fullName": "export", "description": "Export to other runtimes (Camel Main, Spring Boot, or Quarkus)", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Export", "options": [ { "names": "--build-property", "description": "Maven build properties, ex. --build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, { "names": "--camel-spring-boot-version", "description": "Camel version to use with Spring Boot", "javaType": "java.lang.String", "ty [...] - { "name": "get", "fullName": "get", "description": "Get status of Camel integrations", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { "names": "--watch", "description": "Execute periodically and showing output fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", "fullName": " [...] + { "name": "get", "fullName": "get", "description": "Get status of Camel integrations", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { "names": "--watch", "description": "Execute periodically and showing output fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", "fullName": " [...] { "name": "harden", "fullName": "harden", "description": "Suggest security hardening for Camel routes using AI\/LLM", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Harden", "options": [ { "names": "--api-key", "description": "API key for authentication. Also reads OPENAI_API_KEY or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama' or 'openai' (OpenAI-compatible)", "defaultValue": "ollama", [...] { "name": "hawtio", "fullName": "hawtio", "description": "Launch Hawtio web console", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.Hawtio", "options": [ { "names": "--host", "description": "Hostname to bind the Hawtio web console to", "defaultValue": "127.0.0.1", "javaType": "java.lang.String", "type": "string" }, { "names": "--openUrl", "description": "To automatic open Hawtio web console in the web browser", "defaultValue": "true", "javaType": "boolean", "type": [...] { "name": "infra", "fullName": "infra", "description": "List and Run external services for testing and prototyping", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.infra.InfraCommand", "options": [ { "names": "--json", "description": "Output in JSON Format", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "infra [...] diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java index ddf4bb64b6b9..6b2d2bee8eca 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java @@ -123,6 +123,7 @@ public class CamelJBangMain implements Callable<Integer> { .addSubcommand("route-dump", new CommandLine(new CamelRouteDumpAction(this))) .addSubcommand("route-structure", new CommandLine(new CamelRouteStructureAction(this))) .addSubcommand("route-topology", new CommandLine(new CamelRouteTopologyAction(this))) + .addSubcommand("semantic-evaluate", new CommandLine(new SemanticEvaluate(this))) .addSubcommand("send", new CommandLine(new CamelSendAction(this))) .addSubcommand("sql", new CommandLine(new CamelSqlQueryAction(this))) .addSubcommand("span", new CommandLine(new CamelSpanAction(this))) @@ -183,6 +184,7 @@ public class CamelJBangMain implements Callable<Integer> { .addSubcommand("rest", new CommandLine(new ListRest(this))) .addSubcommand("route", new CommandLine(new CamelRouteStatus(this))) .addSubcommand("route-controller", new CommandLine(new RouteControllerAction(this))) + .addSubcommand("semantic", new CommandLine(new CamelSemantic(this))) .addSubcommand("service", new CommandLine(new ListService(this))) .addSubcommand("source", new CommandLine(new CamelSourceAction(this))) .addSubcommand("sql-trace", new CommandLine(new ListSqlTrace(this))) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java new file mode 100644 index 000000000000..b9c167ba7ee1 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java @@ -0,0 +1,105 @@ +/* + * 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.action; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; + [email protected](name = "semantic", description = "List semantic definitions and expert contracts", + sortOptions = false, showDefaultValues = true) +public class CamelSemantic extends SemanticActionCommand { + + @CommandLine.Option(names = "--expert", description = "Show the operations and parameter contract of this expert") + String expert; + + public CamelSemantic(CamelJBangMain main) { + super(main); + } + + @Override + protected JsonObject request() { + if (expert != null && expert.isBlank()) { + throw new IllegalArgumentException("--expert must not be blank"); + } + JsonObject request = new JsonObject(); + request.put("action", "semantic-metadata"); + if (expert == null) { + request.put("overview", true); + } else { + request.put("expert", expert); + } + return request; + } + + @Override + protected int printResponse(JsonObject response) { + if (expert != null) { + JsonArray operations = response.getCollection("operations"); + if (operations == null || operations.isEmpty()) { + return error(3, "No operations available for semantic expert: " + expert, null); + } + } + return super.printResponse(response); + } + + @Override + protected void render(JsonObject response) { + if (expert != null) { + printer().println("Expert: " + expert); + renderOperations(response.getCollection("operations")); + return; + } + printer().println("Definitions:"); + printer().println("NAME\tEXPERT\tOPERATION\tSTATE\tRESULT TYPE\tERROR"); + JsonArray evaluations = response.getCollection("evaluations"); + if (evaluations != null) { + for (int i = 0; i < evaluations.size(); i++) { + JsonObject value = evaluations.getMap(i); + printer().println(String.join("\t", text(value, "name"), text(value, "expert"), text(value, "operation"), + text(value, "state"), text(value, "resultType"), text(value, "error"))); + } + } + printer().println("Experts:"); + JsonArray experts = response.getCollection("experts"); + if (experts != null) { + for (int i = 0; i < experts.size(); i++) { + JsonObject value = experts.getMap(i); + printer().println(text(value, "reference") + "\t" + text(value, "name") + "\t" + text(value, "error")); + } + } + printer().println("Default expert: " + text(response, "defaultExpert")); + if (response.get("defaultError") != null) { + printer().println("Default expert error: " + response.get("defaultError")); + } + } + + private void renderOperations(JsonArray operations) { + for (int i = 0; i < operations.size(); i++) { + JsonObject operation = operations.getMap(i); + printer().println(text(operation, "name") + " (" + text(operation, "resultType") + "): " + + text(operation, "description")); + printer().println(Jsoner.prettyPrint(Jsoner.serialize(operation.get("contract")))); + } + } + + private static String text(JsonObject value, String key) { + return value.get(key) == null ? "" : value.get(key).toString(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java new file mode 100644 index 000000000000..f142c1ec01eb --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java @@ -0,0 +1,151 @@ +/* + * 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.action; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.util.json.JsonObject; +import picocli.CommandLine; + +public abstract class SemanticActionCommand extends ActionBaseCommand { + + @CommandLine.Parameters(description = "Name or pid of a running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = "--json", description = "Output a single JSON document") + boolean json; + + @CommandLine.Option(names = "--timeout", defaultValue = "60000", + description = "Timeout in milliseconds waiting for the integration") + long timeout = 60000; + + @CommandLine.Spec + CommandLine.Model.CommandSpec commandSpec; + + protected SemanticActionCommand(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() { + try { + if (timeout <= 0) { + return error(2, "--timeout must be greater than zero", null); + } + JsonObject request; + try { + request = request(); + } catch (IllegalArgumentException e) { + return error(2, e.getMessage(), null); + } + List<Long> pids = findPids(name).stream().filter(this::isRunningIntegration).toList(); + if (pids.isEmpty()) { + return error(3, "No running Camel integration matches: " + name, null); + } + if (pids.size() != 1) { + return error(3, "Multiple Camel integrations match " + name + ": " + pids + ". Specify a pid.", null); + } + JsonObject response = execute(pids.get(0), request); + if (response == null) { + return error(4, "No reply from integration within " + timeout + " milliseconds", null); + } + if (response.isEmpty()) { + return error(3, "Semantic tooling is unavailable; the integration needs a matching camel-semantic version", + null); + } + if ("failed".equals(response.getString("status")) || "error".equals(response.getString("status"))) { + String message = response.getString("error"); + if (message == null || message.isBlank()) { + message = "Semantic action failed"; + } + return error(1, message, response); + } + return printResponse(response); + } catch (Exception e) { + e.printStackTrace(commandSpec.commandLine().getErr()); + return error(70, e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), null); + } + } + + private boolean isRunningIntegration(long pid) { + JsonObject status = loadStatus(pid); + JsonObject context = status == null ? null : status.getMap("context"); + return context != null && context.getIntegerOrDefault("phase", 0) < 9 + && ProcessHandle.of(pid).filter(ProcessHandle::isAlive).isPresent(); + } + + private JsonObject execute(long pid, JsonObject request) throws Exception { + String id = UUID.randomUUID().toString(); + Path directory = CommandLineHelper.getCamelDir(); + Path action = directory.resolve(pid + "-action-" + id + ".json"); + Path output = directory.resolve(pid + "-output-" + id + ".json"); + Path temporary = directory.resolve(pid + "-" + id + ".tmp"); + try { + Files.writeString(temporary, request.toJson()); + Files.move(temporary, action, StandardCopyOption.ATOMIC_MOVE); + return getJsonObject(output, timeout); + } finally { + // Removing an unfinished request asks the connector to cancel the evaluation. + Files.deleteIfExists(action); + Files.deleteIfExists(output); + Files.deleteIfExists(temporary); + } + } + + public int usageError(String message, String[] args) { + json = Arrays.stream(args).takeWhile(arg -> !"--".equals(arg)) + .anyMatch(arg -> "--json".equals(arg) || "--json=true".equals(arg)); + return error(2, message, null); + } + + protected int error(int code, String message, JsonObject result) { + commandSpec.commandLine().getErr().println(message); + if (json) { + JsonObject response = new JsonObject(); + if (result != null) { + response.putAll(result); + } + response.put("status", "error"); + response.put("code", code); + response.put("message", message); + printer().println(response.toJson()); + } else if (result != null) { + render(result); + } + return code; + } + + protected int printResponse(JsonObject response) { + if (json) { + printer().println(response.toJson()); + } else { + render(response); + } + return 0; + } + + protected abstract JsonObject request(); + + protected abstract void render(JsonObject response); +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java new file mode 100644 index 000000000000..b58777a3849f --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.action; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; + [email protected](name = "semantic-evaluate", description = "Evaluate a semantic definition or expert operation", + sortOptions = false, showDefaultValues = true) +public class SemanticEvaluate extends SemanticActionCommand { + + private static final ObjectReader JSON_VALUE = new ObjectMapper().readerFor(Object.class) + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + @CommandLine.Option(names = "--evaluation", description = "Named semantic definition to evaluate") + String evaluation; + + @CommandLine.Option(names = "--body", description = "Sample message body (text, file:path or json:value)") + String body; + + @CommandLine.Option(names = "--header", description = "Sample header (key=value; use json: for a typed value)") + List<String> headers; + + @CommandLine.Option(names = "--variable", + description = "Sample exchange-local variable (key=value; use json: for a typed value)") + List<String> variables; + + @CommandLine.Option(names = "--expert", description = "Expert to call directly") + String expert; + + @CommandLine.Option(names = "--operation", description = "Expert operation to call directly") + String operation; + + @CommandLine.Option(names = "--input", description = "Direct expert input (text, file:path or json:value)") + String input; + + @CommandLine.Option(names = "--parameter", description = "Expert parameter (key=value; use json: for a typed value)") + List<String> parameters; + + @CommandLine.Option(names = "--evaluation-timeout", defaultValue = "50000", + description = "Provider evaluation timeout in milliseconds (1-50000)") + long evaluationTimeout = 50000; + + public SemanticEvaluate(CamelJBangMain main) { + super(main); + } + + @Override + protected JsonObject request() { + if (evaluationTimeout < 1 || evaluationTimeout > 50000) { + throw new IllegalArgumentException("--evaluation-timeout must be between 1 and 50000 milliseconds"); + } + JsonObject request = new JsonObject(); + request.put("action", "semantic-evaluate"); + request.put("timeout", evaluationTimeout); + if (evaluation != null) { + requireText(evaluation, "--evaluation"); + if (expert != null || operation != null || input != null || parameters != null) { + throw new IllegalArgumentException("Choose --evaluation or --expert with --operation and --input"); + } + request.put("evaluation", evaluation); + if (body != null) { + request.put("body", value(body, true)); + } + if (headers != null) { + request.put("headers", values(headers, "--header")); + } + if (variables != null) { + JsonObject sampleVariables = values(variables, "--variable"); + if (sampleVariables.keySet().stream().anyMatch(key -> key.contains(":"))) { + throw new IllegalArgumentException( + "Sample variables must have exchange-local names without a repository prefix"); + } + request.put("variables", sampleVariables); + } + } else { + requireText(expert, "--expert"); + requireText(operation, "--operation"); + if (input == null) { + throw new IllegalArgumentException("--input is required for a direct expert operation"); + } + if (body != null || headers != null || variables != null) { + throw new IllegalArgumentException("--body, --header and --variable require --evaluation"); + } + request.put("expert", expert); + request.put("operation", operation); + request.put("input", value(input, true)); + if (parameters != null) { + request.put("parameters", values(parameters, "--parameter")); + } + } + return request; + } + + private static void requireText(String value, String option) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(option + " is required and must not be blank"); + } + } + + private static JsonObject values(List<String> values, String option) { + JsonObject answer = new JsonObject(); + for (String entry : values) { + int separator = entry.indexOf('='); + if (separator < 1 || entry.substring(0, separator).isBlank()) { + throw new IllegalArgumentException(option + " must use key=value"); + } + String key = entry.substring(0, separator); + if (answer.containsKey(key)) { + throw new IllegalArgumentException("Duplicate " + option + " key: " + key); + } + answer.put(key, value(entry.substring(separator + 1), false)); + } + return answer; + } + + private static Object value(String value, boolean allowFile) { + if (value.startsWith("json:")) { + try { + return JSON_VALUE.readValue(value.substring(5)); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid json: value", e); + } + } + if (allowFile && value.startsWith("file:")) { + try { + return Files.readString(Path.of(value.substring(5))); + } catch (IOException e) { + throw new IllegalArgumentException("Cannot read input file: " + value.substring(5), e); + } + } + return value; + } + + @Override + protected void render(JsonObject response) { + for (String key : List.of("evaluation", "status", "value", "probability", "probabilities", "confidence", + "metadata", "elapsedMillis", "error")) { + if (response.containsKey(key)) { + printer().println(key + ": " + Jsoner.serialize(response.get(key))); + } + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/exceptionhandler/MissingPluginParameterExceptionHandler.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/exceptionhandler/MissingPluginParameterExceptionHandler.java index 6826a8a2e52a..2d16706bcde5 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/exceptionhandler/MissingPluginParameterExceptionHandler.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/exceptionhandler/MissingPluginParameterExceptionHandler.java @@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.exceptionhandler; import java.io.PrintWriter; import java.util.Optional; +import org.apache.camel.dsl.jbang.core.commands.action.SemanticActionCommand; import org.apache.camel.dsl.jbang.core.common.PluginHelper; import org.apache.camel.dsl.jbang.core.common.PluginType; import picocli.CommandLine; @@ -33,6 +34,9 @@ public class MissingPluginParameterExceptionHandler implements IParameterExcepti public int handleParseException(ParameterException ex, String[] args) throws Exception { CommandLine cmd = ex.getCommandLine(); PrintWriter err = cmd.getErr(); + if (cmd.getCommand() instanceof SemanticActionCommand semantic) { + return semantic.usageError(ex.getMessage(), args); + } if (ex.getMessage().startsWith("Unmatched argument at index 0") && args.length > 0) { Optional<PluginType> pluginType = PluginType.findByName(args[0]); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java new file mode 100644 index 000000000000..2ee93a075b0a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java @@ -0,0 +1,415 @@ +/* + * 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.action; + +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.commands.exceptionhandler.MissingPluginParameterExceptionHandler; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.core.common.StringPrinter; +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 org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import picocli.CommandLine; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SemanticCommandTest { + + @TempDir + Path home; + private final long pid = ProcessHandle.current().pid(); + private final StringPrinter printer = new StringPrinter(); + private final StringWriter errors = new StringWriter(); + private ExecutorService executor; + private Path previousHome; + + @BeforeEach + void setup() throws Exception { + previousHome = CommandLineHelper.getHomeDir(); + CommandLineHelper.useHomeDir(home.toString()); + Files.createDirectories(CommandLineHelper.getCamelDir()); + Files.writeString(CommandLineHelper.getCamelDir().resolve(pid + "-status.json"), + "{\"context\":{\"name\":\"semantic-test\",\"phase\":4}}"); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void stop() throws Exception { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + CommandLineHelper.useHomeDir(previousHome.toString()); + } + + @Test + void listsDefinitionsAndExperts() throws Exception { + JsonObject response = json(""" + {"evaluations":[{"name":"safe","expert":"guard","operation":"detect", + "state":"${body}","resultType":"boolean","error":"definition error"}], + "experts":[{"reference":"guard","name":"Safety guard","error":"expert error"}], + "defaultError":"No default expert"} + """); + AtomicReference<JsonObject> request = new AtomicReference<>(); + assertEquals(0, respond(new CamelSemantic(main()), r -> { + request.set(r); + return response; + })); + assertEquals("semantic-metadata", request.get().get("action")); + assertEquals(true, request.get().get("overview")); + assertTrue(printer.getOutput().contains("safe\tguard\tdetect\t${body}\tboolean\tdefinition error")); + assertTrue(printer.getOutput().contains("Safety guard")); + assertTrue(printer.getOutput().contains("expert error")); + assertTrue(printer.getOutput().contains("No default expert")); + assertEquals("", errors.toString()); + } + + @Test + void emitsMetadataAsOneJsonDocumentIncludingEmptyLists() throws Exception { + JsonObject response = json("{\"evaluations\":[],\"experts\":[]}"); + assertEquals(0, respond(new CamelSemantic(main()), r -> response, "--json")); + assertEquals(response, json(printer.getOutput())); + assertEquals("", errors.toString()); + } + + @Test + void showsExpertOperationAndParameterContract() throws Exception { + JsonObject response = json(""" + {"operations":[{"name":"rank","description":"Rank text","resultType":"score", + "contract":{"inputTypes":["text"],"parameters":[{"name":"criteria","type":"List", + "itemType":"String","required":true}],"resultMeaning":"quality"}}]} + """); + assertEquals(0, respond(new CamelSemantic(main()), r -> { + assertEquals("grader", r.get("expert")); + assertFalse(r.containsKey("overview")); + return response; + }, "--expert=grader")); + assertTrue(printer.getOutput().contains("rank (score): Rank text")); + assertTrue(printer.getOutput().contains("criteria")); + assertTrue(printer.getOutput().contains("quality")); + } + + @Test + void unavailableExpertReturnsNotFound() throws Exception { + assertEquals(3, respond(new CamelSemantic(main()), r -> jsonUnchecked("{\"operations\":[]}"), + "--expert=unknown", "--json")); + assertError(3, "unknown"); + } + + @Test + void evaluatesNamedDefinitionWithSampleExchangeWithoutDoubleEscaping() throws Exception { + String body = "A \"quote\"\nwith a backslash \\ and = sign"; + assertEquals(0, respond(new SemanticEvaluate(main()), r -> { + assertEquals("semantic-evaluate", r.get("action")); + assertEquals("safe", r.get("evaluation")); + assertEquals(body, r.get("body")); + assertEquals("a=b", r.getMap("headers").get("subject")); + assertEquals(true, r.getMap("variables").get("enabled")); + assertEquals(1000L, r.getLong("timeout")); + return jsonUnchecked("{\"status\":\"success\",\"value\":false,\"elapsedMillis\":12}"); + }, "--evaluation=safe", "--body=" + body, "--header=subject=a=b", "--variable=enabled=json:true", + "--evaluation-timeout=1000", "--json")); + assertEquals(false, json(printer.getOutput()).get("value")); + assertEquals("", errors.toString()); + } + + @Test + void evaluatesDirectExpertWithTypedParameters() throws Exception { + assertEquals(0, respond(new SemanticEvaluate(main()), r -> { + assertEquals("grader", r.get("expert")); + assertEquals("rank", r.get("operation")); + assertEquals(List.of("one", "two"), r.get("input")); + JsonObject parameters = r.getMap("parameters"); + assertEquals(List.of("poor", "good"), parameters.get("criteria")); + assertEquals(0.7, parameters.getDouble("threshold")); + assertEquals(true, parameters.get("strict")); + assertEquals("plain text", parameters.get("description")); + return jsonUnchecked(""" + {"status":"success","value":0.8,"probability":0.9,"probabilities":{"good":0.9}, + "confidence":0.85,"elapsedMillis":12} + """); + }, "--expert=grader", "--operation=rank", "--input=json:[\"one\",\"two\"]", + "--parameter=criteria=json:[\"poor\",\"good\"]", "--parameter=threshold=json:0.7", + "--parameter=strict=json:true", "--parameter=description=plain text")); + for (String key : List.of("value", "probability", "probabilities", "confidence", "elapsedMillis")) { + assertTrue(printer.getOutput().contains(key + ":")); + } + } + + @Test + void readsBodyFileAsText() throws Exception { + Path input = home.resolve("sample.txt"); + Files.writeString(input, "json:literal\n"); + assertEquals(0, respond(new SemanticEvaluate(main()), r -> { + assertEquals("json:literal\n", r.get("body")); + return jsonUnchecked("{\"status\":\"success\",\"value\":true}"); + }, "--evaluation=safe", "--body=file:" + input)); + } + + @Test + void providerFailureHasNonzeroExitAndRetainsTimingInJson() throws Exception { + assertEquals(1, respond(new SemanticEvaluate(main()), r -> jsonUnchecked(""" + {"status":"failed","error":"provider unavailable","elapsedMillis":23} + """), "--evaluation=safe", "--json")); + assertError(1, "provider unavailable"); + assertEquals(23L, json(printer.getOutput()).getLong("elapsedMillis")); + } + + @Test + void providerFailureWithoutMessageStillHasDiagnostic() throws Exception { + assertEquals(1, respond(new SemanticEvaluate(main()), r -> jsonUnchecked( + "{\"status\":\"failed\",\"error\":null}"), "--evaluation=safe", "--json")); + assertError(1, "Semantic action failed"); + } + + @Test + void rejectsDuplicateParameterKeys() throws Exception { + assertEquals(2, command(new SemanticEvaluate(main())).execute(Long.toString(pid), "--expert=grader", + "--operation=rank", "--input=text", "--parameter=x=first", "--parameter=x=second", "--json")); + assertError(2, "Duplicate --parameter key: x"); + assertTrue(actionFiles().isEmpty()); + } + + @Test + void missingBodyFileIsUsageError() throws Exception { + assertEquals(2, command(new SemanticEvaluate(main())).execute(Long.toString(pid), "--evaluation=safe", + "--body=file:" + home.resolve("missing.txt"), "--json")); + assertError(2, "Cannot read input file"); + assertTrue(actionFiles().isEmpty()); + } + + @Test + void textUsageErrorsGoOnlyToStderr() throws Exception { + assertEquals(2, command(new SemanticEvaluate(main())).execute(Long.toString(pid))); + assertEquals("", printer.getOutput()); + assertTrue(errors.toString().contains("--expert is required")); + assertTrue(actionFiles().isEmpty()); + } + + @Test + void unavailableConsoleReturnsNotFound() throws Exception { + assertEquals(3, respond(new SemanticEvaluate(main()), r -> new JsonObject(), "--evaluation=safe", "--json")); + assertError(3, "camel-semantic"); + } + + @Test + void timeoutCleansRequestAndIgnoresLegacyReply() throws Exception { + Files.writeString(CommandLineHelper.getCamelDir().resolve(pid + "-output.json"), + "{\"status\":\"success\",\"value\":true}"); + assertEquals(4, command(new SemanticEvaluate(main())).execute(Long.toString(pid), "--evaluation=safe", + "--timeout=150", "--json")); + assertError(4, "No reply"); + assertTrue(actionFiles().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { + "--parameter=bad", "--parameter==value", "--parameter=x=json:[bad", + "--variable=global:x=value", "--evaluation=", "--evaluation-timeout=50001", "--timeout=0", + "--expert=other", "--unknown", "--timeout=not-a-number" }) + void rejectsBadUsageWithoutSendingAnAction(String invalid) throws Exception { + assertEquals(2, command(new SemanticEvaluate(main())).execute(Long.toString(pid), "--evaluation=safe", invalid, + "--json")); + assertError(2, ""); + assertTrue(actionFiles().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { "bad", "=value", "x=json:[bad", "x=json:1 2", "x=json:[1,]", "x=json:" }) + void rejectsMalformedDirectParameters(String parameter) throws Exception { + assertEquals(2, command(new SemanticEvaluate(main())).execute(Long.toString(pid), "--expert=grader", + "--operation=rank", "--input=text", "--parameter=" + parameter, "--json")); + assertError(2, ""); + assertTrue(actionFiles().isEmpty()); + } + + @Test + void requiresAnIntegrationStatusFileEvenForNumericPid() throws Exception { + Files.delete(CommandLineHelper.getCamelDir().resolve(pid + "-status.json")); + assertEquals(3, command(new CamelSemantic(main())).execute(Long.toString(pid), "--json")); + assertError(3, "No running Camel integration"); + assertTrue(actionFiles().isEmpty()); + } + + @Test + void rejectsAmbiguousIntegrationSelection() throws Exception { + CamelSemantic semantic = new CamelSemantic(main()) { + @Override + List<Long> findPids(String name) { + return List.of(pid, pid); + } + }; + assertEquals(3, command(semantic).execute("--json")); + assertError(3, "Multiple Camel integrations"); + assertTrue(errors.toString().contains(Long.toString(pid))); + assertTrue(actionFiles().isEmpty()); + } + + @Test + void ioFailureIsInternalErrorRatherThanTimeout() throws Exception { + CamelSemantic semantic = new CamelSemantic(main()) { + @Override + List<Long> findPids(String name) { + CommandLineHelper.useHomeDir(home.resolve("missing").toString()); + return List.of(pid); + } + + @Override + JsonObject loadStatus(long id) { + return jsonUnchecked("{\"context\":{\"phase\":4}}"); + } + }; + assertEquals(70, command(semantic).execute(Long.toString(pid), "--json")); + assertError(70, ""); + assertTrue(errors.toString().contains("NoSuchFileException")); + } + + @Test + void concurrentClientsReceiveOnlyTheirOwnResponse() throws Exception { + StringPrinter secondPrinter = new StringPrinter(); + var first = CompletableFuture.supplyAsync(() -> command(new SemanticEvaluate(main())).execute(Long.toString(pid), + "--evaluation=first", "--json"), executor); + var second = CompletableFuture.supplyAsync(() -> command(new SemanticEvaluate( + new CamelJBangMain() + .withPrinter(secondPrinter))) + .execute(Long.toString(pid), "--evaluation=second", "--json"), executor); + await().atMost(5, TimeUnit.SECONDS).until(() -> actionFiles().size() == 2); + List<Path> requests = actionFiles(); + assertNotEquals(requests.get(0), requests.get(1)); + for (Path request : requests) { + JsonObject action = json(Files.readString(request)); + JsonObject response = new JsonObject(); + response.put("status", "success"); + response.put("value", action.get("evaluation")); + reply(request, response); + } + assertEquals(0, first.get(5, TimeUnit.SECONDS)); + assertEquals(0, second.get(5, TimeUnit.SECONDS)); + assertEquals("first", json(printer.getOutput()).get("value")); + assertEquals("second", json(secondPrinter.getOutput()).get("value")); + assertTrue(actionFiles().isEmpty()); + } + + @Test + void mainRegistersBothCommandsAndFormatsParseErrorsAsJson() { + CamelJBangMain main = new CamelJBangMain() { + @Override + public void postAddCommands(CommandLine command, String[] args) { + assertNotNull(command.getSubcommands().get("get").getSubcommands().get("semantic")); + assertNotNull(command.getSubcommands().get("cmd").getSubcommands().get("semantic-evaluate")); + } + + @Override + public void preExecute(CommandLine command, String[] args) { + command.setErr(new PrintWriter(errors, true)); + } + + @Override + public void quit(int code) { + assertEquals(2, code); + } + }; + main.withPrinter(printer); + main.execute("cmd", "semantic-evaluate", "--bad-option", "--json"); + assertError(2, "Unknown option"); + } + + private CamelJBangMain main() { + return new CamelJBangMain().withPrinter(printer); + } + + private CommandLine command(SemanticActionCommand command) { + return new CommandLine(command).setErr(new PrintWriter(errors, true)) + .setParameterExceptionHandler(new MissingPluginParameterExceptionHandler()); + } + + private int respond(SemanticActionCommand command, Function<JsonObject, JsonObject> response, String... options) + throws Exception { + var responder = CompletableFuture.runAsync(() -> { + try { + await().atMost(5, TimeUnit.SECONDS).until(() -> !actionFiles().isEmpty()); + Path request = actionFiles().get(0); + await().atMost(5, TimeUnit.SECONDS).until(() -> Files.size(request) > 0); + reply(request, response.apply(json(Files.readString(request)))); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, executor); + List<String> args = new ArrayList<>(List.of(options)); + args.add(Long.toString(pid)); + int result = command(command).execute(args.toArray(String[]::new)); + responder.get(5, TimeUnit.SECONDS); + assertTrue(actionFiles().isEmpty()); + return result; + } + + private static void reply(Path request, JsonObject response) throws Exception { + Path output = request.resolveSibling(request.getFileName().toString().replace("-action-", "-output-")); + Files.writeString(output, response.toJson()); + } + + private List<Path> actionFiles() throws Exception { + try (var files = Files.list(CommandLineHelper.getCamelDir())) { + return files.filter(path -> path.getFileName().toString().startsWith(pid + "-action-")).toList(); + } + } + + private void assertError(int code, String message) { + JsonObject result = jsonUnchecked(printer.getOutput()); + assertEquals("error", result.get("status")); + assertEquals(code, result.getInteger("code")); + assertTrue(result.getString("message").contains(message)); + assertFalse(errors.toString().isBlank()); + } + + private static JsonObject json(String value) throws Exception { + JsonArray documents = Jsoner.deserializeMany(new StringReader(value)); + assertEquals(1, documents.size(), "Expected exactly one JSON document"); + return documents.getMap(0); + } + + private static JsonObject jsonUnchecked(String value) { + try { + return json(value); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } +}
