From 677320ceec391b8dc4ea49a571d8c7fc8ff820bd Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Thu, 11 Jun 2026 00:46:21 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(cluster):=20Terraform-shaped=20query?= =?UTF-8?q?=20declaration=20=E2=80=94=20discover=20from=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cluster.yaml's graphs..queries previously accepted only an explicit name->file map, forcing configs to re-enumerate every `query ` that the .gq files already declare (the SPIKE cookbook needed 66 entries for 6 files). The files ARE the declaration now: `queries: queries/` discovers every declaration in a directory's top-level *.gq (sorted), a list form takes explicit files, and the map stays for fine-grained control. Discovery is loud — unreadable/unparseable files and duplicate query names fail validation (query_parse_error, duplicate_query_name). Downstream is untouched: each discovered query is still an individually addressed resource with the containing file's digest. Co-Authored-By: Claude Fable 5 --- crates/omnigraph-cluster/src/lib.rs | 239 +++++++++++++++++++++++++++- 1 file changed, 237 insertions(+), 2 deletions(-) diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index 7703bb8..866828e 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -415,7 +415,132 @@ struct StateConfig { struct GraphConfig { schema: PathBuf, #[serde(default)] - queries: BTreeMap, + queries: QueriesDecl, +} + +/// How a graph declares its stored queries. Terraform-style: the `.gq` +/// files ARE the declaration — point at them (or a directory) and every +/// `query ` they contain is discovered. The explicit name->file map +/// remains for fine-grained control. +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum QueriesDecl { + /// `queries: ./queries/` — a directory (top-level `*.gq`, sorted) or a + /// single `.gq` file; every declaration inside is registered. + Discover(PathBuf), + /// `queries: [./queries/, ./extra.gq]` — several directories/files. + DiscoverMany(Vec), + /// `queries: { name: { file: ... } }` — explicit registry. + Explicit(BTreeMap), +} + +impl Default for QueriesDecl { + fn default() -> Self { + QueriesDecl::Explicit(BTreeMap::new()) + } +} + +/// Expand a graph's query declaration into the canonical name->file map. +/// Discovery reads and parses each `.gq`; unreadable or unparseable files +/// and duplicate query names are loud validation errors — a declaration the +/// tool cannot enumerate is broken, not partially usable. +fn resolve_query_decls( + config_dir: &Path, + graph_id: &str, + decl: &QueriesDecl, + diagnostics: &mut Vec, +) -> BTreeMap { + let paths: Vec = match decl { + QueriesDecl::Explicit(map) => { + return map + .iter() + .map(|(name, config)| (name.clone(), QueryConfig { file: config.file.clone() })) + .collect(); + } + QueriesDecl::Discover(path) => vec![path.clone()], + QueriesDecl::DiscoverMany(paths) => paths.clone(), + }; + + let mut files: Vec<(PathBuf, PathBuf)> = Vec::new(); // (declared-relative, resolved) + for declared in &paths { + let resolved = resolve_config_path(config_dir, declared); + if resolved.is_dir() { + let mut entries: Vec = match fs::read_dir(&resolved) { + Ok(read) => read + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "gq")) + .collect(), + Err(err) => { + diagnostics.push(Diagnostic::error( + "query_dir_unreadable", + format!("graphs.{graph_id}.queries"), + format!("could not list query directory '{}': {err}", resolved.display()), + )); + continue; + } + }; + entries.sort(); + if entries.is_empty() { + diagnostics.push(Diagnostic::warning( + "query_dir_empty", + format!("graphs.{graph_id}.queries"), + format!("query directory '{}' contains no .gq files", resolved.display()), + )); + } + for path in entries { + let relative = declared.join(path.file_name().expect("dir entries have names")); + files.push((relative, path)); + } + } else { + files.push((declared.clone(), resolved)); + } + } + + let mut registry: BTreeMap = BTreeMap::new(); + let mut origin: BTreeMap = BTreeMap::new(); + for (declared, resolved) in files { + let source = match fs::read_to_string(&resolved) { + Ok(source) => source, + Err(err) => { + diagnostics.push(Diagnostic::error( + "query_file_missing", + format!("graphs.{graph_id}.queries"), + format!("could not read query file '{}': {err}", resolved.display()), + )); + continue; + } + }; + let parsed = match parse_query(&source) { + Ok(parsed) => parsed, + Err(err) => { + diagnostics.push(Diagnostic::error( + "query_parse_error", + format!("graphs.{graph_id}.queries"), + format!("'{}' does not parse: {err}", resolved.display()), + )); + continue; + } + }; + for query_decl in &parsed.queries { + let name = query_decl.name.clone(); + if let Some(previous) = origin.get(&name) { + diagnostics.push(Diagnostic::error( + "duplicate_query_name", + format!("graphs.{graph_id}.queries.{name}"), + format!( + "query '{name}' is declared in both '{}' and '{}'", + previous.display(), + declared.display() + ), + )); + continue; + } + origin.insert(name.clone(), declared.clone()); + registry.insert(name, QueryConfig { file: declared.clone() }); + } + } + registry } #[derive(Debug, Serialize, Deserialize)] @@ -3600,7 +3725,8 @@ fn load_desired(config_dir: &Path) -> LoadOutcome { } }); - for (query_name, query) in &graph.queries { + let graph_queries = resolve_query_decls(&config_dir, graph_id, &graph.queries, &mut diagnostics); + for (query_name, query) in &graph_queries { validate_id( "query name", &format!("graphs.{graph_id}.queries.{query_name}"), @@ -7560,6 +7686,115 @@ policies: ); } + // ---- query discovery (Terraform-style declaration) ---- + + #[test] + fn queries_directory_discovers_every_declaration() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("people.pg"), "\nnode Person {\n name: String @key\n}\n").unwrap(); + fs::create_dir(dir.path().join("queries")).unwrap(); + fs::write( + dir.path().join("queries/people.gq"), + "\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n\nquery all_people() {\n match { $p: Person }\n return { $p.name }\n}\n", + ) + .unwrap(); + fs::write( + dir.path().join("queries/extra.gq"), + "\nquery count_people() {\n match { $p: Person }\n return { count($p) }\n}\n", + ) + .unwrap(); + fs::write(dir.path().join("queries/notes.txt"), "ignored").unwrap(); + fs::write( + dir.path().join("cluster.yaml"), + "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: ./queries/\n", + ) + .unwrap(); + + let out = validate_config_dir(dir.path()); + assert!(out.ok, "{:?}", out.diagnostics); + let names: Vec<&str> = out + .resource_digests + .keys() + .filter_map(|address| address.strip_prefix("query.knowledge.")) + .collect(); + assert_eq!(names, vec!["all_people", "count_people", "find_person"]); + } + + #[test] + fn queries_list_and_single_file_forms_discover() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("people.pg"), "\nnode Person {\n name: String @key\n}\n").unwrap(); + fs::write( + dir.path().join("a.gq"), + "\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n", + ) + .unwrap(); + fs::write( + dir.path().join("b.gq"), + "\nquery all_people() {\n match { $p: Person }\n return { $p.name }\n}\n", + ) + .unwrap(); + fs::write( + dir.path().join("cluster.yaml"), + "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: [./a.gq, ./b.gq]\n", + ) + .unwrap(); + let out = validate_config_dir(dir.path()); + assert!(out.ok, "{:?}", out.diagnostics); + assert!(out.resource_digests.contains_key("query.knowledge.find_person")); + assert!(out.resource_digests.contains_key("query.knowledge.all_people")); + + // Single-file string form + fs::write( + dir.path().join("cluster.yaml"), + "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: ./a.gq\n", + ) + .unwrap(); + let out = validate_config_dir(dir.path()); + assert!(out.ok, "{:?}", out.diagnostics); + assert!(out.resource_digests.contains_key("query.knowledge.find_person")); + assert!(!out.resource_digests.contains_key("query.knowledge.all_people")); + } + + #[test] + fn query_discovery_rejects_duplicates_and_parse_errors() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("people.pg"), "\nnode Person {\n name: String @key\n}\n").unwrap(); + let decl = "\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n"; + fs::write(dir.path().join("a.gq"), decl).unwrap(); + fs::write(dir.path().join("b.gq"), decl).unwrap(); + fs::write( + dir.path().join("cluster.yaml"), + "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: [./a.gq, ./b.gq]\n", + ) + .unwrap(); + let out = validate_config_dir(dir.path()); + assert!(!out.ok); + assert!( + out.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "duplicate_query_name"), + "{:?}", + out.diagnostics + ); + + fs::write(dir.path().join("broken.gq"), "query {{{ nope").unwrap(); + fs::write( + dir.path().join("cluster.yaml"), + "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n queries: ./broken.gq\n", + ) + .unwrap(); + let out = validate_config_dir(dir.path()); + assert!(!out.ok); + assert!( + out.diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "query_parse_error"), + "{:?}", + out.diagnostics + ); + } + #[test] fn status_warns_on_pending_recovery_sidecar() { let dir = fixture(); From 44b5866516075d79a098b1cc6fe9eb80932021c6 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Thu, 11 Jun 2026 00:46:21 +0300 Subject: [PATCH 2/3] docs: drop ./ path prefixes; document query discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paths in cluster.yaml and command examples are relative to one explicit config folder (Terraform-shaped) — the ./ prefixes were noise and are gone across the user docs (109 instances; ../ links and ./scripts executables untouched). The cluster docs now present directory discovery as the primary queries form with the list and map forms documented alongside. Co-Authored-By: Claude Fable 5 --- docs/user/cli-reference.md | 20 +++++++------- docs/user/cli.md | 54 ++++++++++++++++++------------------- docs/user/cluster-config.md | 48 ++++++++++++++++++++++----------- docs/user/cluster.md | 45 +++++++++++++++---------------- docs/user/deployment.md | 4 +-- docs/user/policy.md | 8 +++--- docs/user/transactions.md | 40 +++++++++++++-------------- 7 files changed, 118 insertions(+), 101 deletions(-) diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index ecb44b5..fb12dd8 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -54,7 +54,7 @@ cli: query: roots: [, …] # search path for .gq files auth: - env_file: ./.env.omni + env_file: .env.omni aliases: : # accepted values: `read` / `query` (read alias), `change` / `mutate` @@ -70,20 +70,20 @@ aliases: queries: # top-level registry — applies only to a bare-URI (anonymous) graph; a graph served by name uses its `graphs..queries`. Mirrors top-level `policy`. : { file: } # mcp.expose defaults to true policy: - file: ./policy.yaml + file: policy.yaml ``` ## Cluster config preview ```bash -omnigraph cluster validate --config ./company-brain -omnigraph cluster plan --config ./company-brain --json -omnigraph cluster apply --config ./company-brain --json -omnigraph cluster approve graph. --config ./company-brain --as -omnigraph cluster status --config ./company-brain --json -omnigraph cluster refresh --config ./company-brain --json -omnigraph cluster import --config ./company-brain --json -omnigraph cluster force-unlock --config ./company-brain --json +omnigraph cluster validate --config company-brain +omnigraph cluster plan --config company-brain --json +omnigraph cluster apply --config company-brain --json +omnigraph cluster approve graph. --config company-brain --as +omnigraph cluster status --config company-brain --json +omnigraph cluster refresh --config company-brain --json +omnigraph cluster import --config company-brain --json +omnigraph cluster force-unlock --config company-brain --json ``` `--config` is a directory containing `cluster.yaml`; it defaults to `.`. diff --git a/docs/user/cli.md b/docs/user/cli.md index b6f2c09..5c4297a 100644 --- a/docs/user/cli.md +++ b/docs/user/cli.md @@ -3,11 +3,11 @@ ## Core Graph Flow ```bash -omnigraph init --schema ./schema.pg ./graph.omni -omnigraph load --data ./data.jsonl --mode overwrite ./graph.omni -omnigraph snapshot ./graph.omni --branch main --json -omnigraph query --uri ./graph.omni --query ./queries.gq --name get_person --params '{"name":"Alice"}' -omnigraph mutate --uri ./graph.omni --query ./queries.gq --name insert_person --params '{"name":"Mina","age":28}' +omnigraph init --schema schema.pg graph.omni +omnigraph load --data data.jsonl --mode overwrite graph.omni +omnigraph snapshot graph.omni --branch main --json +omnigraph query --uri graph.omni --query queries.gq --name get_person --params '{"name":"Alice"}' +omnigraph mutate --uri graph.omni --query queries.gq --name insert_person --params '{"name":"Mina","age":28}' ``` `omnigraph query` is the canonical read command (pairs with `POST /query`); @@ -21,11 +21,11 @@ For ad-hoc reads and mutations (REPLs, AI agents, one-off scripts), pass the GQ source inline with `-e` / `--query-string` instead of a file path: ```bash -omnigraph query --uri ./graph.omni \ +omnigraph query --uri graph.omni \ -e 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }' \ --params '{"name":"Alice"}' -omnigraph mutate --uri ./graph.omni \ +omnigraph mutate --uri graph.omni \ -e 'query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }' \ --params '{"name":"Inline","age":42}' ``` @@ -38,14 +38,14 @@ only the source loader changes. ## Branching And Reviewable Data Flows ```bash -omnigraph branch create --uri ./graph.omni --from main feature-x -omnigraph branch list --uri ./graph.omni -omnigraph branch merge --uri ./graph.omni feature-x --into main +omnigraph branch create --uri graph.omni --from main feature-x +omnigraph branch list --uri graph.omni +omnigraph branch merge --uri graph.omni feature-x --into main -omnigraph ingest --data ./batch.jsonl --branch review/import-2026-04-09 ./graph.omni -omnigraph export ./graph.omni --branch main --type Person > people.jsonl -omnigraph commit list ./graph.omni --branch main --json -omnigraph commit show --uri ./graph.omni --json +omnigraph ingest --data batch.jsonl --branch review/import-2026-04-09 graph.omni +omnigraph export graph.omni --branch main --type Person > people.jsonl +omnigraph commit list graph.omni --branch main --json +omnigraph commit show --uri graph.omni --json ``` ## Remote Server Mode @@ -53,7 +53,7 @@ omnigraph commit show --uri ./graph.omni --json Serve a graph: ```bash -omnigraph-server ./graph.omni --bind 127.0.0.1:8080 +omnigraph-server graph.omni --bind 127.0.0.1:8080 ``` Read through the HTTP API: @@ -61,7 +61,7 @@ Read through the HTTP API: ```bash omnigraph query \ --target http://127.0.0.1:8080 \ - --query ./queries.gq \ + --query queries.gq \ --name get_person \ --params '{"name":"Alice"}' ``` @@ -87,23 +87,23 @@ Runtime add/remove is **not** in v0.6.0. To add a graph, stop the server, add a Per-graph URLs: hit a graph's cluster route from any subcommand by pointing `--uri` at it: ```bash -omnigraph read --uri http://server.example.com/graphs/beta --query ./q.gq ... +omnigraph read --uri http://server.example.com/graphs/beta --query q.gq ... ``` ## Runs, Policy, And Diagnostics ```bash -omnigraph lint --query ./queries.gq --schema ./schema.pg --json -omnigraph check --query ./queries.gq ./graph.omni --json +omnigraph lint --query queries.gq --schema schema.pg --json +omnigraph check --query queries.gq graph.omni --json -omnigraph schema plan --schema ./next.pg ./graph.omni --json -omnigraph schema apply --schema ./next.pg ./graph.omni --json -omnigraph policy validate --config ./omnigraph.yaml -omnigraph policy test --config ./omnigraph.yaml -omnigraph policy explain --config ./omnigraph.yaml --actor act-alice --action read --branch main +omnigraph schema plan --schema next.pg graph.omni --json +omnigraph schema apply --schema next.pg graph.omni --json +omnigraph policy validate --config omnigraph.yaml +omnigraph policy test --config omnigraph.yaml +omnigraph policy explain --config omnigraph.yaml --actor act-alice --action read --branch main -omnigraph commit list ./graph.omni --json -omnigraph commit show --uri ./graph.omni --json +omnigraph commit list graph.omni --json +omnigraph commit show --uri graph.omni --json ``` (The legacy `omnigraph run list/show/publish/abort` subcommands were removed in MR-771; mutations and loads publish atomically and the commit graph (`omnigraph commit list`) is the audit surface.) @@ -120,7 +120,7 @@ query roots: ```yaml graphs: local: - uri: ./demo.omni + uri: demo.omni dev: uri: http://127.0.0.1:8080 bearer_token_env: OMNIGRAPH_BEARER_TOKEN diff --git a/docs/user/cluster-config.md b/docs/user/cluster-config.md index 081bfa2..24d1833 100644 --- a/docs/user/cluster-config.md +++ b/docs/user/cluster-config.md @@ -20,14 +20,14 @@ or serve anything it applies: the server still boots from `omnigraph.yaml`. ## Commands ```bash -omnigraph cluster validate --config ./company-brain -omnigraph cluster plan --config ./company-brain --json -omnigraph cluster apply --config ./company-brain --json -omnigraph cluster approve graph. --config ./company-brain --as -omnigraph cluster status --config ./company-brain --json -omnigraph cluster refresh --config ./company-brain --json -omnigraph cluster import --config ./company-brain --json -omnigraph cluster force-unlock --config ./company-brain --json +omnigraph cluster validate --config company-brain +omnigraph cluster plan --config company-brain --json +omnigraph cluster apply --config company-brain --json +omnigraph cluster approve graph. --config company-brain --as +omnigraph cluster status --config company-brain --json +omnigraph cluster refresh --config company-brain --json +omnigraph cluster import --config company-brain --json +omnigraph cluster force-unlock --config company-brain --json ``` `--config` points at a directory, not a file. The directory must contain @@ -54,7 +54,7 @@ The exact contract: cluster state XOR `omnigraph.yaml`, never a merge. - **The other direction is ergonomics, not coupling**: a per-operator `omnigraph.yaml` may point `graphs..uri` at a cluster's derived root - (`./company-brain/graphs/knowledge.omni`) so data-plane commands can use + (`company-brain/graphs/knowledge.omni`) so data-plane commands can use `--target ` — an ordinary local path, no special handling. ## Supported `cluster.yaml` @@ -72,17 +72,35 @@ state: graphs: knowledge: - schema: ./knowledge.pg - queries: - find_experts: - file: ./knowledge.gq + schema: knowledge.pg + queries: queries/ # discover every `query ` in queries/*.gq policies: base: - file: ./base.policy.yaml + file: base.policy.yaml applies_to: [knowledge] ``` +`queries` is Terraform-shaped — the `.gq` files are the declaration. Three +forms: + +```yaml +queries: queries/ # directory: top-level *.gq, sorted; every declaration registers +queries: [people.gq, extra/a.gq] # explicit files; every declaration in each +queries: # fine-grained name -> file map + find_experts: + file: knowledge.gq +``` + +Discovery is loud: an unreadable or unparseable `.gq`, or the same query name +declared in two files, fails validation (`query_parse_error`, +`duplicate_query_name`). Each discovered query is still an individually +addressed resource (`query..`) with its own plan/apply lifecycle; +the digest is the containing file's hash, so editing a multi-query file +updates all of its queries together. Paths are relative to the config +directory — the cluster is one explicit folder, so no `./` prefixes are +needed. + `metadata.name` is a display label. `state.backend` may be omitted or set to `cluster`; external state backends are reserved for a later stage. `state.lock` defaults to `true`. When enabled, `cluster plan`, `cluster apply`, @@ -324,7 +342,7 @@ without graph movement. ## Serving from the cluster (the mode switch) ```bash -omnigraph-server --cluster ./company-brain --bind 0.0.0.0:8080 +omnigraph-server --cluster company-brain --bind 0.0.0.0:8080 ``` `--cluster ` is an **exclusive boot source** (axiom 15): it cannot diff --git a/docs/user/cluster.md b/docs/user/cluster.md index ff930da..1731f31 100644 --- a/docs/user/cluster.md +++ b/docs/user/cluster.md @@ -32,7 +32,8 @@ Lay out a config directory: company-brain/ ├── cluster.yaml ├── people.pg # schema for the "knowledge" graph -├── people.gq # a stored query +├── queries/ # stored queries — the .gq files ARE the declaration +│ └── people.gq └── base.policy.yaml # a Cedar policy bundle ``` @@ -43,27 +44,25 @@ metadata: name: company-brain graphs: knowledge: - schema: ./people.pg - queries: - find_person: - file: ./people.gq + schema: people.pg + queries: queries/ # every `query ` in queries/*.gq registers policies: base: - file: ./base.policy.yaml + file: base.policy.yaml applies_to: [knowledge] # graph-bound; use [cluster] for server-level ``` Bring it to life: ```bash -omnigraph cluster validate --config ./company-brain # parse + typecheck everything -omnigraph cluster import --config ./company-brain # create the state ledger -omnigraph cluster plan --config ./company-brain # preview: what would apply do? -omnigraph cluster apply --config ./company-brain # converge +omnigraph cluster validate --config company-brain # parse + typecheck everything +omnigraph cluster import --config company-brain # create the state ledger +omnigraph cluster plan --config company-brain # preview: what would apply do? +omnigraph cluster apply --config company-brain # converge ``` That single `apply` **creates the graph** (at the derived root -`./company-brain/graphs/knowledge.omni`), applies its schema, and publishes +`company-brain/graphs/knowledge.omni`), applies its schema, and publishes the query and policy into the content-addressed catalog (`__cluster/resources/…`). The output lists every change with its disposition; `converged: true` means there is nothing left to do — re-running @@ -73,14 +72,14 @@ Load data through the normal graph plane (the control plane manages *definitions*, not rows): ```bash -omnigraph load --data ./seed.jsonl ./company-brain/graphs/knowledge.omni +omnigraph load --data seed.jsonl company-brain/graphs/knowledge.omni ``` Serve it: ```bash OMNIGRAPH_SERVER_BEARER_TOKENS_JSON='{"act-reader":"s3cret"}' \ - omnigraph-server --cluster ./company-brain --bind 0.0.0.0:8080 + omnigraph-server --cluster company-brain --bind 0.0.0.0:8080 ``` `--cluster` is an **exclusive boot source**: it cannot be combined with a @@ -103,8 +102,8 @@ Every change follows the same loop, whatever its kind: ```bash $EDITOR company-brain/people.pg # or any .gq / policy / cluster.yaml edit -omnigraph cluster plan --config ./company-brain -omnigraph cluster apply --config ./company-brain --as andrew +omnigraph cluster plan --config company-brain +omnigraph cluster apply --config company-brain --as andrew # restart cluster-booted servers to pick it up ``` @@ -142,8 +141,8 @@ anything runs. ## 3. Inspect: status, refresh, drift ```bash -omnigraph cluster status --config ./company-brain --json # ledger only, read-only -omnigraph cluster refresh --config ./company-brain # re-observe live graphs +omnigraph cluster status --config company-brain --json # ledger only, read-only +omnigraph cluster refresh --config company-brain # re-observe live graphs ``` `status` never touches the graphs; `refresh` opens them read-only and @@ -164,13 +163,13 @@ converges the ledger. Removing a graph from `cluster.yaml` never executes silently: ```bash -omnigraph cluster apply --config ./company-brain +omnigraph cluster apply --config company-brain # Delete graph.scratch [Blocked: approval_required] -omnigraph cluster approve graph.scratch --config ./company-brain --as andrew +omnigraph cluster approve graph.scratch --config company-brain --as andrew # cluster approve: delete graph.scratch approved by andrew (approval 01KT…) -omnigraph cluster apply --config ./company-brain --as andrew +omnigraph cluster apply --config company-brain --as andrew # Delete graph.scratch [Applied] ← root removed, subtree tombstoned ``` @@ -196,8 +195,8 @@ again**. **A held lock** (a crashed process left `__cluster/lock.json`): ```bash -omnigraph cluster status --config ./company-brain # shows the lock holder + id -omnigraph cluster force-unlock --config ./company-brain +omnigraph cluster status --config company-brain # shows the lock holder + id +omnigraph cluster force-unlock --config company-brain ``` Force-unlock requires the exact lock id (from status) — there is no blind @@ -240,7 +239,7 @@ with an in-flight apply. - **`omnigraph.yaml` still has a job**: per-operator settings — your `cli.actor` default for `--as`, CLI defaults, credentials, and data-plane ergonomics (point `graphs..uri` at a derived root like - `./company-brain/graphs/knowledge.omni` to use `--target ` for + `company-brain/graphs/knowledge.omni` to use `--target ` for loads). It just no longer describes the deployment — a server boots from one source or the other, never a merge of both. diff --git a/docs/user/deployment.md b/docs/user/deployment.md index 563a501..00f8272 100644 --- a/docs/user/deployment.md +++ b/docs/user/deployment.md @@ -33,7 +33,7 @@ On Windows, the binaries are `omnigraph.exe` and `omnigraph-server.exe`. Run against a local graph: ```bash -omnigraph-server ./graph.omni --bind 0.0.0.0:8080 +omnigraph-server graph.omni --bind 0.0.0.0:8080 ``` Run against an object-store-backed graph: @@ -208,7 +208,7 @@ docker run --rm -p 8080:8080 \ -e OMNIGRAPH_CONFIG="/etc/omnigraph/omnigraph.yaml" \ -v "$PWD/config:/etc/omnigraph:ro" \ omnigraph-server:local -# /etc/omnigraph/omnigraph.yaml contains `policy: { file: ./policy.yaml }`; +# /etc/omnigraph/omnigraph.yaml contains `policy: { file: policy.yaml }`; # policy.yaml (+ optional policy.tests.yaml) sit beside it in the mount. ``` diff --git a/docs/user/policy.md b/docs/user/policy.md index ec0d214..9c484ba 100644 --- a/docs/user/policy.md +++ b/docs/user/policy.md @@ -35,13 +35,13 @@ In multi mode (`omnigraph.yaml` with a non-empty `graphs:` map), policy files at ```yaml server: policy: - file: ./server-policy.yaml # server-level: graph_list + file: server-policy.yaml # server-level: graph_list graphs: alpha: uri: s3://tenant-bucket/alpha policy: - file: ./policies/alpha.yaml # per-graph: read, change, branch_*, schema_apply + file: policies/alpha.yaml # per-graph: read, change, branch_*, schema_apply beta: uri: s3://tenant-bucket/beta # no per-graph policy → no engine-layer Cedar enforcement on beta @@ -78,8 +78,8 @@ rules: ```yaml policy: - file: ./policy.yaml # Cedar rules + groups - tests: ./policy.tests.yaml # declarative test cases + file: policy.yaml # Cedar rules + groups + tests: policy.tests.yaml # declarative test cases cli: actor: act-andrew # default actor for CLI direct-engine writes diff --git a/docs/user/transactions.md b/docs/user/transactions.md index d6c79f4..39a86c4 100644 --- a/docs/user/transactions.md +++ b/docs/user/transactions.md @@ -47,8 +47,8 @@ query register_employee_with_team($name: String, $age: I32, $team: String) { ``` ```bash -omnigraph change --query ./mutations.gq --name register_employee_with_team \ - --params '{"name":"Alice","age":30,"team":"Acme"}' ./graph.omni +omnigraph change --query mutations.gq --name register_employee_with_team \ + --params '{"name":"Alice","age":30,"team":"Acme"}' graph.omni ``` If the second statement fails (e.g. `Acme` doesn't exist), the publisher never publishes; `Alice` is not in the database. Atomic. @@ -57,10 +57,10 @@ If the second statement fails (e.g. `Acme` doesn't exist), the publisher never p ```bash # Query 1 -omnigraph change --query ./mutations.gq --name register_employee --params '{"name":"Alice","age":30}' ./graph.omni +omnigraph change --query mutations.gq --name register_employee --params '{"name":"Alice","age":30}' graph.omni # Query 2 — runs after Query 1 has already published -omnigraph change --query ./mutations.gq --name link_to_team --params '{"name":"Alice","team":"Acme"}' ./graph.omni +omnigraph change --query mutations.gq --name link_to_team --params '{"name":"Alice","team":"Acme"}' graph.omni ``` These are **two publishes** on `main`. If Query 2 fails, Query 1's effects are already visible. There is no `ROLLBACK` for Query 1. @@ -75,32 +75,32 @@ The pattern when you need to run multiple queries — possibly across multiple c ```bash # Fork a working branch from main. -omnigraph branch create --from main onboarding/2026-04-25 ./graph.omni +omnigraph branch create --from main onboarding/2026-04-25 graph.omni # Run any number of mutations on the branch — each one is its own publish on the branch. # Concurrent reads of `main` are unaffected. omnigraph change --branch onboarding/2026-04-25 \ - --query ./mutations.gq --name register_employee \ - --params '{"name":"Alice","age":30}' ./graph.omni + --query mutations.gq --name register_employee \ + --params '{"name":"Alice","age":30}' graph.omni omnigraph change --branch onboarding/2026-04-25 \ - --query ./mutations.gq --name register_employee \ - --params '{"name":"Bob","age":25}' ./graph.omni + --query mutations.gq --name register_employee \ + --params '{"name":"Bob","age":25}' graph.omni omnigraph change --branch onboarding/2026-04-25 \ - --query ./mutations.gq --name link_to_team \ - --params '{"name":"Alice","team":"Acme"}' ./graph.omni + --query mutations.gq --name link_to_team \ + --params '{"name":"Alice","team":"Acme"}' graph.omni # Inspect the branch — read queries work just like on main. omnigraph read --branch onboarding/2026-04-25 \ - --query ./queries.gq --name list_employees ./graph.omni + --query queries.gq --name list_employees graph.omni # Happy with what's on the branch? Merge it. This is one atomic publish: # `main` flips to include every commit on the branch. -omnigraph branch merge onboarding/2026-04-25 --into main ./graph.omni +omnigraph branch merge onboarding/2026-04-25 --into main graph.omni # OR: not happy? Throw it away. `main` is untouched. -# omnigraph branch delete onboarding/2026-04-25 ./graph.omni +# omnigraph branch delete onboarding/2026-04-25 graph.omni ``` Properties: @@ -115,16 +115,16 @@ Two agents writing to the same graph independently: ```bash # Agent A -omnigraph branch create --from main agent-a/work ./graph.omni -omnigraph change --branch agent-a/work … ./graph.omni +omnigraph branch create --from main agent-a/work graph.omni +omnigraph change --branch agent-a/work … graph.omni # … many mutations … -omnigraph branch merge agent-a/work --into main ./graph.omni +omnigraph branch merge agent-a/work --into main graph.omni # Agent B (running concurrently) -omnigraph branch create --from main agent-b/work ./graph.omni -omnigraph change --branch agent-b/work … ./graph.omni +omnigraph branch create --from main agent-b/work graph.omni +omnigraph change --branch agent-b/work … graph.omni # … many mutations … -omnigraph branch merge agent-b/work --into main ./graph.omni +omnigraph branch merge agent-b/work --into main graph.omni ``` Each agent sees a consistent snapshot of `main` at the time it forked. The first merge to `main` lands as a fast-forward (or a no-op if no concurrent change). The second merge runs three-way: rows touched by both branches surface as `MergeConflict`s for the caller to resolve. From 4558454bc779e33d27d0fd8be7ab2bc202237a15 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Thu, 11 Jun 2026 01:35:47 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(cluster):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20discovery=20reads=20each=20file=20exactly=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_query_decls hands its file contents to the caller; the per-query digest/typecheck pass reuses them instead of re-reading (a file with N queries was read N+1 times), which also closes the window where a file changing between enumeration and validation produced a confusing query_key_mismatch for a just-discovered name. Explicit-map declarations read as before. Co-Authored-By: Claude Fable 5 --- crates/omnigraph-cluster/src/lib.rs | 30 +++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index 866828e..bb0c66b 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -449,13 +449,17 @@ fn resolve_query_decls( graph_id: &str, decl: &QueriesDecl, diagnostics: &mut Vec, -) -> BTreeMap { +) -> (BTreeMap, BTreeMap) { let paths: Vec = match decl { QueriesDecl::Explicit(map) => { - return map - .iter() - .map(|(name, config)| (name.clone(), QueryConfig { file: config.file.clone() })) - .collect(); + return ( + map.iter() + .map(|(name, config)| { + (name.clone(), QueryConfig { file: config.file.clone() }) + }) + .collect(), + BTreeMap::new(), + ); } QueriesDecl::Discover(path) => vec![path.clone()], QueriesDecl::DiscoverMany(paths) => paths.clone(), @@ -499,6 +503,10 @@ fn resolve_query_decls( let mut registry: BTreeMap = BTreeMap::new(); let mut origin: BTreeMap = BTreeMap::new(); + // Content read once at discovery and handed to the caller — the per-query + // digest/typecheck pass reuses it instead of re-reading (no N+1 reads, no + // window for the file to change between enumeration and validation). + let mut contents: BTreeMap = BTreeMap::new(); for (declared, resolved) in files { let source = match fs::read_to_string(&resolved) { Ok(source) => source, @@ -539,8 +547,9 @@ fn resolve_query_decls( origin.insert(name.clone(), declared.clone()); registry.insert(name, QueryConfig { file: declared.clone() }); } + contents.insert(declared, source); } - registry + (registry, contents) } #[derive(Debug, Serialize, Deserialize)] @@ -3725,7 +3734,8 @@ fn load_desired(config_dir: &Path) -> LoadOutcome { } }); - let graph_queries = resolve_query_decls(&config_dir, graph_id, &graph.queries, &mut diagnostics); + let (graph_queries, query_contents) = + resolve_query_decls(&config_dir, graph_id, &graph.queries, &mut diagnostics); for (query_name, query) in &graph_queries { validate_id( "query name", @@ -3744,7 +3754,11 @@ fn load_desired(config_dir: &Path) -> LoadOutcome { }); let query_path = resolve_config_path(&config_dir, &query.file); - match fs::read_to_string(&query_path) { + let source = match query_contents.get(&query.file) { + Some(cached) => Ok(cached.clone()), + None => fs::read_to_string(&query_path), + }; + match source { Ok(source) => { let digest = sha256_hex(source.as_bytes()); graph_query_digests