mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-21 02:28:07 +02:00
Merge branch 'main' into ragnorc/omnigraph-mcp-crate
Bring the MCP feature branch up to date with main (14 commits). One conflict — compiler/parser.rs: main's `NanoError` → `CompilerError` rename vs this branch's `@mcp` / per-param `@description` parser additions; resolved by keeping the new parsing under the renamed error type. The CLI `queries list` change (#280, surfacing `@description`/`@instruction`) auto-merged with this branch's `mcp_expose`/`tool_name` columns.
This commit is contained in:
commit
fbf455a250
110 changed files with 6396 additions and 2511 deletions
|
|
@ -873,6 +873,25 @@ pub(crate) async fn execute_queries_validate(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a stored-query annotation under its `queries list` entry. A
|
||||
/// `@description`/`@instruction` value may be multiline (GQ string literals
|
||||
/// admit newlines); continuation lines are indented to align under the first
|
||||
/// so the catalog stays readable instead of breaking the left margin.
|
||||
fn print_query_annotation(label: &str, value: &str) {
|
||||
let prefix = format!(" {label}: ");
|
||||
let continuation = " ".repeat(prefix.len());
|
||||
let mut lines = value.split('\n');
|
||||
match lines.next() {
|
||||
Some(first) => {
|
||||
println!("{prefix}{first}");
|
||||
for line in lines {
|
||||
println!("{continuation}{line}");
|
||||
}
|
||||
}
|
||||
None => println!("{prefix}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `queries list --cluster <dir>` (RFC-011): list the catalog's stored queries.
|
||||
/// With `--graph`, scope to one graph.
|
||||
pub(crate) async fn execute_queries_list(
|
||||
|
|
@ -891,6 +910,8 @@ pub(crate) async fn execute_queries_list(
|
|||
mcp_expose: q.is_exposed(),
|
||||
tool_name: q.decl.mcp.tool_name.clone(),
|
||||
mutation: q.is_mutation(),
|
||||
description: q.decl.description.clone(),
|
||||
instruction: q.decl.instruction.clone(),
|
||||
params: q
|
||||
.decl
|
||||
.params
|
||||
|
|
@ -931,6 +952,12 @@ pub(crate) async fn execute_queries_list(
|
|||
String::new()
|
||||
};
|
||||
println!("{kind} {}({params}){mcp}", q.name);
|
||||
if let Some(description) = &q.description {
|
||||
print_query_annotation("description", description);
|
||||
}
|
||||
if let Some(instruction) = &q.instruction {
|
||||
print_query_annotation("instruction", instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -1050,7 +1050,7 @@ async fn main() -> Result<()> {
|
|||
// The actor attributes graph-moving operations (sidecars,
|
||||
// audit entries, engine schema-apply commits). Cluster FACTS
|
||||
// stay unlayered; the operator's identity resolves --as flag
|
||||
// first, then the per-operator omnigraph.yaml `cli.actor`.
|
||||
// first, then per-operator config `operator.actor`.
|
||||
let actor = resolve_cluster_actor(cli.as_actor.as_deref())?;
|
||||
let output = apply_config_dir_with_options(config, ApplyOptions { actor }).await;
|
||||
finish_cluster_apply(&output, json)?;
|
||||
|
|
@ -1062,7 +1062,7 @@ async fn main() -> Result<()> {
|
|||
} => {
|
||||
let Some(approver) = resolve_cluster_actor(cli.as_actor.as_deref())? else {
|
||||
bail!(
|
||||
"`cluster approve` requires an approver: pass the global --as <ACTOR> flag or set `cli.actor` in your omnigraph.yaml — an approval without an approver is meaningless"
|
||||
"`cluster approve` requires an approver: pass the global --as <ACTOR> flag or set `operator.actor` in ~/.omnigraph/config.yaml — an approval without an approver is meaningless"
|
||||
);
|
||||
};
|
||||
let output = approve_config_dir(config, &resource, &approver).await;
|
||||
|
|
|
|||
|
|
@ -849,6 +849,13 @@ pub(crate) struct QueriesListItem {
|
|||
pub(crate) mcp_expose: bool,
|
||||
pub(crate) tool_name: Option<String>,
|
||||
pub(crate) mutation: bool,
|
||||
/// `@description` from the query declaration — what the query is for.
|
||||
/// Carried so the CLI catalog matches the HTTP `GET /queries` surface.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) description: Option<String>,
|
||||
/// `@instruction` from the query declaration — how/when to invoke it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) instruction: Option<String>,
|
||||
pub(crate) params: Vec<QueriesParam>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -796,6 +796,10 @@ fn cluster_approve_uses_operator_actor_fallback() {
|
|||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("--as"), "{stderr}");
|
||||
assert!(stderr.contains("operator.actor"), "{stderr}");
|
||||
assert!(stderr.contains("config.yaml"), "{stderr}");
|
||||
assert!(!stderr.contains("cli.actor"), "{stderr}");
|
||||
assert!(!stderr.contains("omnigraph.yaml"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -231,6 +231,125 @@ fn queries_list_prints_registered_query() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_surfaces_description_and_instruction() {
|
||||
// `@description`/`@instruction` are the whole point of a stored query in a
|
||||
// catalog — they tell an agent/operator what it does and how to invoke it.
|
||||
// The CLI catalog must surface them in both human and --json output, to
|
||||
// match the HTTP `GET /queries` surface.
|
||||
let cluster = converged_cluster_with_query(
|
||||
"described.gq",
|
||||
"query described($name: String) \
|
||||
@description(\"Find a person by exact name.\") \
|
||||
@instruction(\"Use for exact lookups; prefer search for fuzzy matches.\") \
|
||||
{ match { $p: Person { name: $name } } return { $p.age } }",
|
||||
" described:\n file: ./described.gq\n",
|
||||
);
|
||||
|
||||
// Human output.
|
||||
let output = output_success(
|
||||
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(
|
||||
stdout.contains("description: Find a person by exact name."),
|
||||
"human list must show @description; stdout:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("instruction: Use for exact lookups; prefer search for fuzzy matches."),
|
||||
"human list must show @instruction; stdout:\n{stdout}"
|
||||
);
|
||||
|
||||
// --json output.
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--json"),
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let entry = body["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|q| q["name"] == "described")
|
||||
.unwrap();
|
||||
assert_eq!(entry["description"], "Find a person by exact name.");
|
||||
assert_eq!(
|
||||
entry["instruction"],
|
||||
"Use for exact lookups; prefer search for fuzzy matches."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_indents_multiline_annotation_continuation() {
|
||||
// GQ string literals admit newlines, so a `@description`/`@instruction`
|
||||
// can be multiline. Human output must indent continuation lines to align
|
||||
// under the first rather than breaking back to the left margin.
|
||||
let cluster = converged_cluster_with_query(
|
||||
"multi.gq",
|
||||
"query multi($name: String) \
|
||||
@description(\"line one\\nline two\") \
|
||||
{ match { $p: Person { name: $name } } return { $p.age } }",
|
||||
" multi:\n file: ./multi.gq\n",
|
||||
);
|
||||
let output = output_success(
|
||||
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
// " description: " is 17 chars wide; the continuation aligns under it.
|
||||
assert!(
|
||||
stdout.contains(" description: line one\n line two"),
|
||||
"multiline annotation must indent the continuation; stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_omits_annotations_when_absent() {
|
||||
// The other half of the contract: a query that declares neither annotation
|
||||
// prints no extra lines and omits both JSON fields entirely. This keeps the
|
||||
// catalog clean rather than echoing empty `description:`/`instruction:`.
|
||||
let cluster = converged_cluster_with_query(
|
||||
"bare.gq",
|
||||
"query bare() { match { $p: Person } return { $p.name } }",
|
||||
" bare:\n file: ./bare.gq\n",
|
||||
);
|
||||
|
||||
// Human output: the query is listed, but no annotation lines.
|
||||
let output = output_success(
|
||||
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("bare()"), "stdout:\n{stdout}");
|
||||
assert!(
|
||||
!stdout.contains("description:") && !stdout.contains("instruction:"),
|
||||
"a query without annotations prints no annotation lines; stdout:\n{stdout}"
|
||||
);
|
||||
|
||||
// --json output: both fields omitted (not present as null).
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--json"),
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let entry = body["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|q| q["name"] == "bare")
|
||||
.unwrap();
|
||||
assert!(
|
||||
entry.get("description").is_none() && entry.get("instruction").is_none(),
|
||||
"a query without annotations omits both JSON fields: {entry}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_validate_requires_a_cluster() {
|
||||
// RFC-011: with no --cluster (and no cluster profile), the command errors
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue