mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +02:00
feat(cluster): add read-only validate and plan
This commit is contained in:
parent
ab5f3b878a
commit
043b02e617
12 changed files with 1764 additions and 33 deletions
|
|
@ -17,7 +17,7 @@ Tools that support `@`-imports (Claude Code) auto-include all three files via th
|
|||
`CLAUDE.md` is a symlink to this file — there is exactly one source of truth. Edit `AGENTS.md`.
|
||||
|
||||
**Version surveyed:** 0.6.1
|
||||
**Workspace crates:** `omnigraph-compiler`, `omnigraph` (engine), `omnigraph-policy`, `omnigraph-cli`, `omnigraph-server`
|
||||
**Workspace crates:** `omnigraph-compiler`, `omnigraph` (engine), `omnigraph-policy`, `omnigraph-cluster`, `omnigraph-cli`, `omnigraph-server`
|
||||
**Storage substrate:** Lance 6.x (columnar, versioned, branchable)
|
||||
**License:** MIT
|
||||
**Toolchain:** Rust stable, edition 2024
|
||||
|
|
|
|||
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -4550,6 +4550,7 @@ dependencies = [
|
|||
"color-eyre",
|
||||
"lance",
|
||||
"lance-index",
|
||||
"omnigraph-cluster",
|
||||
"omnigraph-compiler",
|
||||
"omnigraph-engine",
|
||||
"omnigraph-policy",
|
||||
|
|
@ -4563,6 +4564,19 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "omnigraph-cluster"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"omnigraph-compiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "omnigraph-compiler"
|
||||
version = "0.6.1"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ members = [
|
|||
"crates/omnigraph-compiler",
|
||||
"crates/omnigraph",
|
||||
"crates/omnigraph-cli",
|
||||
"crates/omnigraph-cluster",
|
||||
"crates/omnigraph-policy",
|
||||
"crates/omnigraph-server",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
omnigraph = { package = "omnigraph-engine", path = "../omnigraph", version = "0.6.1" }
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.6.1" }
|
||||
omnigraph-cluster = { path = "../omnigraph-cluster", version = "0.6.1" }
|
||||
omnigraph-policy = { path = "../omnigraph-policy", version = "0.6.1" }
|
||||
omnigraph-server = { path = "../omnigraph-server", version = "0.6.1" }
|
||||
clap = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ use color_eyre::eyre::{Result, bail};
|
|||
use omnigraph::db::{Omnigraph, ReadTarget, SnapshotId};
|
||||
use omnigraph::loader::LoadMode;
|
||||
use omnigraph::storage::normalize_root_uri;
|
||||
use omnigraph_cluster::{
|
||||
DiagnosticSeverity, PlanOutput, ValidateOutput, plan_config_dir, validate_config_dir,
|
||||
};
|
||||
use omnigraph_compiler::query::parser::parse_query;
|
||||
use omnigraph_compiler::schema::parser::parse_schema;
|
||||
use omnigraph_compiler::{
|
||||
|
|
@ -305,6 +308,11 @@ enum Command {
|
|||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Validate and plan read-only cluster configuration.
|
||||
Cluster {
|
||||
#[command(subcommand)]
|
||||
command: ClusterCommand,
|
||||
},
|
||||
/// Manage graphs on a multi-graph server (MR-668)
|
||||
Graphs {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -312,6 +320,28 @@ enum Command {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum ClusterCommand {
|
||||
/// Validate cluster.yaml and referenced schemas, queries, and policy files.
|
||||
Validate {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Produce a read-only plan by diffing cluster.yaml against __cluster/state.json.
|
||||
Plan {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Operations on the graph registry of a multi-graph server (MR-668).
|
||||
///
|
||||
/// All operations target a remote multi-graph server URL (http:// or
|
||||
|
|
@ -683,6 +713,77 @@ fn print_json<T: Serialize>(value: &T) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn print_cluster_validate_human(output: &ValidateOutput) {
|
||||
if output.ok {
|
||||
println!(
|
||||
"cluster config valid: {} resource(s), {} dependency edge(s)",
|
||||
output.resources.len(),
|
||||
output.dependencies.len()
|
||||
);
|
||||
} else {
|
||||
println!("cluster config invalid");
|
||||
}
|
||||
print_cluster_diagnostics(&output.diagnostics);
|
||||
}
|
||||
|
||||
fn print_cluster_plan_human(output: &PlanOutput) {
|
||||
if output.ok {
|
||||
println!(
|
||||
"cluster plan: {} change(s), {} approval gate(s)",
|
||||
output.changes.len(),
|
||||
output.approvals_required.len()
|
||||
);
|
||||
for change in &output.changes {
|
||||
println!(" {:?} {}", change.operation, change.resource);
|
||||
}
|
||||
if output.changes.is_empty() {
|
||||
println!(" no changes");
|
||||
}
|
||||
} else {
|
||||
println!("cluster plan failed");
|
||||
}
|
||||
print_cluster_diagnostics(&output.diagnostics);
|
||||
}
|
||||
|
||||
fn print_cluster_diagnostics(diagnostics: &[omnigraph_cluster::Diagnostic]) {
|
||||
for diagnostic in diagnostics {
|
||||
let label = match diagnostic.severity {
|
||||
DiagnosticSeverity::Error => "ERROR",
|
||||
DiagnosticSeverity::Warning => "WARN ",
|
||||
};
|
||||
println!(
|
||||
"{label} {} {}: {}",
|
||||
diagnostic.code, diagnostic.path, diagnostic.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_cluster_validate(output: &ValidateOutput, json: bool) -> Result<()> {
|
||||
if json {
|
||||
print_json(output)?;
|
||||
} else {
|
||||
print_cluster_validate_human(output);
|
||||
}
|
||||
if !output.ok {
|
||||
io::stdout().flush()?;
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_cluster_plan(output: &PlanOutput, json: bool) -> Result<()> {
|
||||
if json {
|
||||
print_json(output)?;
|
||||
} else {
|
||||
print_cluster_plan_human(output);
|
||||
}
|
||||
if !output.ok {
|
||||
io::stdout().flush()?;
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_remote_uri(uri: &str) -> bool {
|
||||
uri.starts_with("http://") || uri.starts_with("https://")
|
||||
}
|
||||
|
|
@ -801,13 +902,11 @@ struct ResolvedPolicyContext {
|
|||
|
||||
fn resolve_policy_context(config: &OmnigraphConfig) -> Result<ResolvedPolicyContext> {
|
||||
let selected = config.resolve_policy_tooling_graph_selection()?;
|
||||
let policy_file = config
|
||||
.resolve_policy_file_for(selected)
|
||||
.ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"policy.file or graphs.<name>.policy.file must be set in omnigraph.yaml"
|
||||
)
|
||||
})?;
|
||||
let policy_file = config.resolve_policy_file_for(selected).ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"policy.file or graphs.<name>.policy.file must be set in omnigraph.yaml"
|
||||
)
|
||||
})?;
|
||||
let graph_id = match selected {
|
||||
Some(name) => graph_resource_id_for_selection(Some(name), ""),
|
||||
None => graph_resource_id_for_selection(None, "default"),
|
||||
|
|
@ -2166,16 +2265,14 @@ fn rewrite_deprecated_argv(args: Vec<OsString>) -> Vec<OsString> {
|
|||
}
|
||||
if let Some(sub) = args.get(1).and_then(|s| s.to_str()) {
|
||||
match sub {
|
||||
"read" => eprintln!(
|
||||
"warning: `omnigraph read` is deprecated; use `omnigraph query` instead"
|
||||
),
|
||||
"read" => {
|
||||
eprintln!("warning: `omnigraph read` is deprecated; use `omnigraph query` instead")
|
||||
}
|
||||
"change" => eprintln!(
|
||||
"warning: `omnigraph change` is deprecated; use `omnigraph mutate` instead"
|
||||
),
|
||||
"check" => {
|
||||
eprintln!(
|
||||
"warning: `omnigraph check` is deprecated; use `omnigraph lint` instead"
|
||||
);
|
||||
eprintln!("warning: `omnigraph check` is deprecated; use `omnigraph lint` instead");
|
||||
// Rewrite the top-level subcommand to `lint`; pass through the rest.
|
||||
let mut out = Vec::with_capacity(args.len());
|
||||
out.push(args[0].clone());
|
||||
|
|
@ -3111,6 +3208,16 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
}
|
||||
Command::Cluster { command } => match command {
|
||||
ClusterCommand::Validate { config, json } => {
|
||||
let output = validate_config_dir(config);
|
||||
finish_cluster_validate(&output, json)?;
|
||||
}
|
||||
ClusterCommand::Plan { config, json } => {
|
||||
let output = plan_config_dir(config);
|
||||
finish_cluster_plan(&output, json)?;
|
||||
}
|
||||
},
|
||||
Command::Graphs { command } => match command {
|
||||
GraphsCommand::List {
|
||||
uri,
|
||||
|
|
@ -3157,8 +3264,8 @@ mod tests {
|
|||
use super::{
|
||||
DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file,
|
||||
legacy_change_request_body, load_cli_config, load_env_file_into_process,
|
||||
normalize_bearer_token, parse_env_assignment, resolve_policy_context,
|
||||
resolve_cli_graph, resolve_remote_bearer_token,
|
||||
normalize_bearer_token, parse_env_assignment, resolve_cli_graph, resolve_policy_context,
|
||||
resolve_remote_bearer_token,
|
||||
};
|
||||
use omnigraph_server::load_config;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
|
|
@ -3420,7 +3527,8 @@ graphs:
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn graph_identity_resolve_policy_context_named_cli_graph_uses_graph_key_not_project_name_or_uri() {
|
||||
fn graph_identity_resolve_policy_context_named_cli_graph_uses_graph_key_not_project_name_or_uri()
|
||||
{
|
||||
let temp = tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,52 @@ policy:
|
|||
(config, policy)
|
||||
}
|
||||
|
||||
fn write_cluster_config_fixture(root: &std::path::Path) {
|
||||
fs::write(
|
||||
root.join("people.pg"),
|
||||
r#"
|
||||
node Person {
|
||||
name: String @key
|
||||
age: I32?
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("people.gq"),
|
||||
r#"
|
||||
query find_person($name: String) {
|
||||
match { $p: Person { name: $name } }
|
||||
return { $p.name, $p.age }
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap();
|
||||
fs::write(
|
||||
root.join("cluster.yaml"),
|
||||
r#"
|
||||
version: 1
|
||||
metadata:
|
||||
name: company-brain
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./people.pg
|
||||
queries:
|
||||
find_person:
|
||||
file: ./people.gq
|
||||
policies:
|
||||
base:
|
||||
file: ./base.policy.yaml
|
||||
applies_to: [knowledge]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_command_prints_current_cli_version() {
|
||||
let output = output_success(cli().arg("version"));
|
||||
|
|
@ -89,6 +135,105 @@ fn version_command_prints_current_cli_version() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_validate_config_success() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(temp.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("cluster config valid"), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_validate_json_is_stable() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
|
||||
let json = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(temp.path())
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(json["ok"], true);
|
||||
assert!(json["resource_digests"]["graph.knowledge"].is_string());
|
||||
assert!(json["resource_digests"]["query.knowledge.find_person"].is_string());
|
||||
assert_eq!(json["dependencies"][0]["from"], "policy.base");
|
||||
assert_eq!(json["dependencies"][0]["to"], "graph.knowledge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_plan_json_reads_inferred_local_state() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
let state_dir = temp.path().join("__cluster");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
fs::write(
|
||||
state_dir.join("state.json"),
|
||||
r#"
|
||||
{
|
||||
"version": 1,
|
||||
"applied_revision": {
|
||||
"config_digest": "old",
|
||||
"resources": {
|
||||
"graph.knowledge": { "digest": "old-graph" },
|
||||
"policy.old": { "digest": "old-policy" }
|
||||
}
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("plan")
|
||||
.arg("--config")
|
||||
.arg(temp.path())
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(json["ok"], true);
|
||||
assert_eq!(json["state_observations"]["state_found"], true);
|
||||
assert!(
|
||||
json["changes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|change| change["resource"] == "policy.old" && change["operation"] == "delete"),
|
||||
"plan should read state and delete stale resources: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_validate_invalid_config_exits_nonzero() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("cluster.yaml"),
|
||||
"version: 1\ngraphs: {}\npipelines: {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(temp.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("future_phase_field"), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_version_flag_prints_current_cli_version() {
|
||||
let output = output_success(cli().arg("-v"));
|
||||
|
|
@ -798,8 +943,7 @@ fn deprecated_read_and_change_subcommands_emit_warnings() {
|
|||
let output = cli().arg("read").output().unwrap();
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("`omnigraph read` is deprecated")
|
||||
&& stderr.contains("`omnigraph query`"),
|
||||
stderr.contains("`omnigraph read` is deprecated") && stderr.contains("`omnigraph query`"),
|
||||
"expected `omnigraph read` deprecation warning; got: {stderr}"
|
||||
);
|
||||
|
||||
|
|
@ -2394,9 +2538,19 @@ fn queries_validate_exits_zero_on_clean_registry() {
|
|||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&queries_test_config(&graph.path().to_string_lossy(), "find_person", "find_person.gq"),
|
||||
&queries_test_config(
|
||||
&graph.path().to_string_lossy(),
|
||||
"find_person",
|
||||
"find_person.gq",
|
||||
),
|
||||
);
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let output = output_success(cli().arg("queries").arg("validate").arg("--config").arg(&config));
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("OK"), "stdout:\n{stdout}");
|
||||
}
|
||||
|
|
@ -2405,12 +2559,21 @@ fn queries_validate_exits_zero_on_clean_registry() {
|
|||
fn queries_validate_exits_nonzero_on_type_broken_query() {
|
||||
let graph = SystemGraph::loaded();
|
||||
// `Widget` is not in the fixture schema.
|
||||
graph.write_query("ghost.gq", "query ghost() { match { $w: Widget } return { $w.name } }");
|
||||
graph.write_query(
|
||||
"ghost.gq",
|
||||
"query ghost() { match { $w: Widget } return { $w.name } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&queries_test_config(&graph.path().to_string_lossy(), "ghost", "ghost.gq"),
|
||||
);
|
||||
let output = output_failure(cli().arg("queries").arg("validate").arg("--config").arg(&config));
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(
|
||||
stdout.contains("ghost"),
|
||||
|
|
@ -2444,7 +2607,13 @@ fn queries_list_prints_registered_query() {
|
|||
graph.path().to_string_lossy().replace('\'', "''")
|
||||
),
|
||||
);
|
||||
let output = output_success(cli().arg("queries").arg("list").arg("--config").arg(&config));
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("find_person"), "stdout:\n{stdout}");
|
||||
assert!(
|
||||
|
|
@ -2480,7 +2649,13 @@ fn queries_list_requires_graph_selection_for_per_graph_only_registries() {
|
|||
),
|
||||
);
|
||||
|
||||
let output = output_failure(cli().arg("queries").arg("list").arg("--config").arg(&config));
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("local") && stderr.contains("--target local"),
|
||||
|
|
@ -2505,7 +2680,13 @@ fn queries_list_without_graph_selection_lists_top_level_registry() {
|
|||
),
|
||||
);
|
||||
|
||||
let output = output_success(cli().arg("queries").arg("list").arg("--config").arg(&config));
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("top_find"), "stdout:\n{stdout}");
|
||||
}
|
||||
|
|
@ -2524,7 +2705,11 @@ fn queries_list_unknown_target_errors() {
|
|||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&queries_test_config(&graph.path().to_string_lossy(), "find_person", "find_person.gq"),
|
||||
&queries_test_config(
|
||||
&graph.path().to_string_lossy(),
|
||||
"find_person",
|
||||
"find_person.gq",
|
||||
),
|
||||
);
|
||||
let output = output_failure(
|
||||
cli()
|
||||
|
|
@ -2566,7 +2751,7 @@ fn queries_commands_reject_named_graph_with_populated_top_level_block() {
|
|||
" file: ./find_person.gq\n",
|
||||
"cli:\n",
|
||||
" graph: local\n",
|
||||
"queries:\n", // populated top-level block: the coherence violation
|
||||
"queries:\n", // populated top-level block: the coherence violation
|
||||
" legacy:\n",
|
||||
" file: ./legacy.gq\n",
|
||||
"policy: {{}}\n",
|
||||
|
|
@ -2592,8 +2777,14 @@ fn queries_validate_exits_nonzero_on_duplicate_tool_name() {
|
|||
// collision — `queries validate` must fail (offline, before the engine
|
||||
// opens) and name both queries plus the contested tool.
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query("a.gq", "query a() { match { $p: Person } return { $p.name } }");
|
||||
graph.write_query("b.gq", "query b() { match { $p: Person } return { $p.name } }");
|
||||
graph.write_query(
|
||||
"a.gq",
|
||||
"query a() { match { $p: Person } return { $p.name } }",
|
||||
);
|
||||
graph.write_query(
|
||||
"b.gq",
|
||||
"query b() { match { $p: Person } return { $p.name } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&format!(
|
||||
|
|
@ -2615,7 +2806,13 @@ fn queries_validate_exits_nonzero_on_duplicate_tool_name() {
|
|||
graph.path().to_string_lossy().replace('\'', "''")
|
||||
),
|
||||
);
|
||||
let output = output_failure(cli().arg("queries").arg("validate").arg("--config").arg(&config));
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("dup") && stderr.contains("'a'") && stderr.contains("'b'"),
|
||||
|
|
@ -2635,7 +2832,10 @@ fn queries_validate_positional_uri_ignores_default_graph() {
|
|||
);
|
||||
// `Widget` is not in the fixture schema — the default graph's per-graph
|
||||
// query would break validate if it were (wrongly) selected.
|
||||
graph.write_query("broken.gq", "query broken() { match { $w: Widget } return { $w.name } }");
|
||||
graph.write_query(
|
||||
"broken.gq",
|
||||
"query broken() { match { $w: Widget } return { $w.name } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
concat!(
|
||||
|
|
|
|||
20
crates/omnigraph-cluster/Cargo.toml
Normal file
20
crates/omnigraph-cluster/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "omnigraph-cluster"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
description = "Read-only cluster configuration validation and planning for Omnigraph."
|
||||
license = "MIT"
|
||||
repository = "https://github.com/ModernRelay/omnigraph"
|
||||
homepage = "https://github.com/ModernRelay/omnigraph"
|
||||
documentation = "https://docs.rs/omnigraph-cluster"
|
||||
|
||||
[dependencies]
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.6.1" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
1275
crates/omnigraph-cluster/src/lib.rs
Normal file
1275
crates/omnigraph-cluster/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ This file is the always-on map of the test surface. **Consult it before every ta
|
|||
|---|---|---|
|
||||
| `omnigraph` (engine) | `crates/omnigraph/tests/` | Integration tests (21 files), fixture-driven, share `tests/helpers/mod.rs` |
|
||||
| `omnigraph-cli` | `crates/omnigraph-cli/tests/` | `cli.rs` (unit-ish), `system_local.rs`, `system_remote.rs`, share `tests/support/mod.rs` |
|
||||
| `omnigraph-cluster` | mostly in-source `#[cfg(test)] mod tests` | Cluster config parser, local JSON state diff, read-only validate/plan |
|
||||
| `omnigraph-server` | `crates/omnigraph-server/tests/` | `server.rs` (HTTP-level), `openapi.rs` (OpenAPI drift / regeneration) |
|
||||
| `omnigraph-compiler` | mostly in-source `#[cfg(test)] mod tests` | Parser, type-checker, IR lowering, lint |
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
A reference for the `omnigraph` binary's command surface and `omnigraph.yaml` schema. For a quick-start guide, see [cli.md](cli.md).
|
||||
|
||||
17 top-level command families, 40+ subcommands. All commands accept either a positional `URI`, `--uri`, or a `--target <name>` resolved against `omnigraph.yaml`.
|
||||
18 top-level command families, 40+ subcommands. Graph commands accept either a positional `URI`, `--uri`, or a `--target <name>` resolved against `omnigraph.yaml`; `cluster` commands instead use `--config <dir>`.
|
||||
|
||||
## Top-level commands
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ A reference for the `omnigraph` binary's command surface and `omnigraph.yaml` sc
|
|||
| `schema plan \| apply \| show (alias: get)` | migrations |
|
||||
| `lint` (alias: `check`) | offline / graph-backed query validation. Replaces `query lint` / `query check`, which are kept as deprecated argv-level shims that print a one-line warning and rewrite to `omnigraph lint` |
|
||||
| `queries validate \| list` | operate on the server-side stored-query registry (the `queries:` block). `validate` type-checks every stored query against the live schema offline (opens the selected graph; exits non-zero on any breakage), catching schema drift without restarting the server; `list` prints the selected registry's query names, MCP exposure, and typed params. For per-graph registries, pass `--target <graph>` or set `cli.graph`; with no graph selection, `list` shows only top-level `queries:`. Distinct from `lint`, which validates a single `.gq` file |
|
||||
| `cluster validate \| plan` | read-only cluster-control preview. `validate` checks a local `cluster.yaml` folder and referenced schema/query/policy files; `plan` diffs it against local JSON state at `__cluster/state.json`. No apply, lock, graph open, server change, or state write occurs in Stage 1 |
|
||||
| `optimize` | non-destructive Lance compaction (skips tables with `Blob` columns; `--json` reports a `skipped` field) |
|
||||
| `cleanup --keep N --older-than 7d --confirm` | destructive version GC |
|
||||
| `embed` | offline JSONL embedding pipeline |
|
||||
|
|
@ -73,6 +74,20 @@ policy:
|
|||
file: ./policy.yaml
|
||||
```
|
||||
|
||||
## Cluster config preview
|
||||
|
||||
```bash
|
||||
omnigraph cluster validate --config ./company-brain
|
||||
omnigraph cluster plan --config ./company-brain --json
|
||||
```
|
||||
|
||||
`--config` is a directory containing `cluster.yaml`; it defaults to `.`.
|
||||
Stage 1 accepts graphs, schemas, stored queries, and policy bundle file
|
||||
references. `cluster plan` reads local JSON state from
|
||||
`<config-dir>/__cluster/state.json`; a missing file means empty state. External
|
||||
state backends, apply, locks, pipelines, UI specs, embeddings, aliases, and
|
||||
bindings are reserved for later stages. See [cluster-config.md](cluster-config.md).
|
||||
|
||||
## Output formats (`query` command, alias: `read`)
|
||||
|
||||
- `json` — pretty-printed object with metadata + rows
|
||||
|
|
|
|||
95
docs/user/cluster-config.md
Normal file
95
docs/user/cluster-config.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Cluster Config
|
||||
|
||||
**Status:** Stage 1 read-only preview.
|
||||
|
||||
Cluster config is the future control-plane configuration surface for a whole
|
||||
OmniGraph deployment. In this stage, OmniGraph can validate a local
|
||||
`cluster.yaml` folder and produce a deterministic read-only plan. It does not
|
||||
apply changes, acquire locks, open graph roots, start servers, or write state.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
omnigraph cluster validate --config ./company-brain
|
||||
omnigraph cluster plan --config ./company-brain --json
|
||||
```
|
||||
|
||||
`--config` points at a directory, not a file. The directory must contain
|
||||
`cluster.yaml`. When omitted, it defaults to the current directory.
|
||||
|
||||
## Supported `cluster.yaml`
|
||||
|
||||
Stage 1 accepts only the read-only resource subset:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
metadata:
|
||||
name: company-brain
|
||||
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./knowledge.pg
|
||||
queries:
|
||||
find_experts:
|
||||
file: ./knowledge.gq
|
||||
|
||||
policies:
|
||||
base:
|
||||
file: ./base.policy.yaml
|
||||
applies_to: [knowledge]
|
||||
```
|
||||
|
||||
`metadata.name` is a display label. `state.lock` is parsed for forward
|
||||
compatibility, but no lock is acquired in this read-only stage. `state.backend`
|
||||
may be omitted or set to `cluster`; external state backends are reserved for a
|
||||
later stage.
|
||||
|
||||
## Validation
|
||||
|
||||
`cluster validate` checks:
|
||||
|
||||
- `cluster.yaml` syntax and supported fields
|
||||
- duplicate YAML keys
|
||||
- schema, query, and policy file existence
|
||||
- schema parsing and catalog construction
|
||||
- stored-query parsing and query-name matching
|
||||
- stored-query type-checking against the desired schema
|
||||
- policy `applies_to` graph references
|
||||
|
||||
Fields reserved for later phases, such as `pipelines`, `embeddings`, `ui`,
|
||||
`aliases`, and `bindings`, fail with a typed diagnostic instead of being
|
||||
silently ignored.
|
||||
|
||||
## Planning
|
||||
|
||||
`cluster plan` first performs validation, then reads local JSON state from:
|
||||
|
||||
```text
|
||||
<config-dir>/__cluster/state.json
|
||||
```
|
||||
|
||||
If the file is missing, the state is treated as empty and every desired
|
||||
resource is planned as a create. If present, the file must use this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"applied_revision": {
|
||||
"config_digest": "...",
|
||||
"resources": {
|
||||
"graph.knowledge": { "digest": "..." },
|
||||
"schema.knowledge": { "digest": "..." },
|
||||
"query.knowledge.find_experts": { "digest": "..." },
|
||||
"policy.base": { "digest": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plan output compares desired resource digests against state resource digests
|
||||
and reports `create`, `update`, and `delete` changes. The command never writes
|
||||
`state.json`; apply and locking are later-stage work.
|
||||
|
|
@ -13,6 +13,7 @@ of MRs, internal recovery mechanics, or contributor-only invariants.
|
|||
| Install OmniGraph | [install.md](install.md) |
|
||||
| Run the CLI locally | [cli.md](cli.md) |
|
||||
| Look up every CLI flag and config field | [cli-reference.md](cli-reference.md) |
|
||||
| Validate and plan cluster config | [cluster-config.md](cluster-config.md) |
|
||||
| Write schemas | [schema-language.md](schema-language.md) |
|
||||
| Read schema-lint diagnostic codes | [schema-lint.md](schema-lint.md) |
|
||||
| Write queries and mutations | [query-language.md](query-language.md) |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue