Merge remote-tracking branch 'origin/main' into ragnorc/shaping-config-integration

# Conflicts:
#	crates/omnigraph-cluster/src/lib.rs
#	crates/omnigraph-cluster/src/serve.rs
#	crates/omnigraph-server/src/lib.rs
#	crates/omnigraph-server/src/settings.rs
#	docs/user/clusters/config.md
This commit is contained in:
aaltshuler 2026-06-16 04:13:00 +03:00
commit 4f8c71fa23
75 changed files with 6557 additions and 6879 deletions

View file

@ -18,10 +18,10 @@ any — run against a graph, served (--server / --profile) or embedded (--store
URI): query, mutate, load, branch, snapshot, export, commit, schema show/apply.\n \
served require a server: graphs.\n \
direct direct storage access; reject --server (init, optimize, repair, cleanup, \
schema plan, lint, queries validate).\n \
control manage a cluster via --config: cluster.\n \
local no graph; local config & tooling: policy, embed, login, logout, config, \
version, queries list.\n\
schema plan, lint).\n \
control manage or inspect a cluster (cluster via --config; policy & queries via \
--cluster).\n \
local no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\
See the 'Command capabilities' section of the CLI reference for which flags apply where.")]
pub(crate) struct Cli {
/// Actor id for direct-engine writes; overrides `cli.actor`. No effect on
@ -37,9 +37,11 @@ pub(crate) struct Cli {
#[arg(long, global = true, value_name = "NAME|URL")]
pub(crate) server: Option<String>,
/// Graph id on a multi-graph `--server` (appends `/graphs/<id>` to
/// the server url). Requires --server.
#[arg(long, global = true, value_name = "GRAPH_ID", requires = "server")]
/// Select a graph within a multi-graph scope: on a `--server` it appends
/// `/graphs/<id>` to the server url; on a `--cluster` it picks which
/// cluster graph to maintain. Rejected on a single-graph address (a
/// positional URI / `--store`).
#[arg(long, global = true, value_name = "GRAPH_ID")]
pub(crate) graph: Option<String>,
/// Select a named scope bundle (RFC-011) from `profiles:` in
@ -56,6 +58,26 @@ pub(crate) struct Cli {
#[arg(long, global = true, value_name = "URI")]
pub(crate) store: Option<String>,
/// Address a cluster-managed graph's storage for maintenance (RFC-011):
/// a cluster directory or storage-root URI — named via `clusters:` in
/// ~/.omnigraph/config.yaml, or a literal `file://`/`s3://` root. Pair
/// with `--graph <id>` to select the graph. Used by optimize / repair /
/// cleanup; exclusive with a positional URI / `--store` / `--server`.
#[arg(long, global = true, value_name = "DIR|URI")]
pub(crate) cluster: Option<String>,
/// Skip the confirmation prompt for a destructive write (`cleanup`,
/// overwrite `load`, `branch delete`) against a non-local scope (RFC-011
/// Decision 9). Without it, a non-local destructive write prompts on a TTY
/// and refuses (errors) when there is no TTY or `--json` is set.
#[arg(long, global = true)]
pub(crate) yes: bool,
/// Suppress the one-line resolved-write-target diagnostic that write
/// commands echo to stderr (RFC-011 Decision 9).
#[arg(long, global = true)]
pub(crate) quiet: bool,
#[command(subcommand)]
pub(crate) command: Command,
}
@ -70,22 +92,16 @@ pub(crate) enum Command {
/// when used. Pairs with `omnigraph mutate` on the write side.
#[command(visible_alias = "read")]
Query {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(hide = true)]
legacy_uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["query", "query_string"])]
alias: Option<String>,
#[arg(long, conflicts_with_all = ["alias", "query_string"])]
query: Option<PathBuf>,
/// Inline GQ source — alternative to `--query <path>` and `--alias <name>`.
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])]
query_string: Option<String>,
#[arg(long)]
/// Query name. With no `--query`/`-e`, the stored query to invoke from
/// the catalog (served — addressed via --server/--profile). With
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
name: Option<String>,
/// Ad-hoc query file (a `.gq` you're authoring / break-glass).
#[arg(long, conflicts_with = "query_string")]
query: Option<PathBuf>,
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
query_string: Option<String>,
#[command(flatten)]
params: ParamsArgs,
#[arg(long, conflicts_with = "snapshot")]
@ -96,8 +112,6 @@ pub(crate) enum Command {
format: Option<ReadOutputFormat>,
#[arg(long, conflicts_with = "format")]
json: bool,
#[arg()]
alias_args: Vec<String>,
},
/// Execute a graph mutation query against a branch.
///
@ -106,38 +120,48 @@ pub(crate) enum Command {
/// warning when used. Pairs with `omnigraph query` on the read side.
#[command(visible_alias = "change")]
Mutate {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(hide = true)]
legacy_uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["query", "query_string"])]
alias: Option<String>,
#[arg(long, conflicts_with_all = ["alias", "query_string"])]
query: Option<PathBuf>,
/// Inline GQ source — alternative to `--query <path>` and `--alias <name>`.
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])]
query_string: Option<String>,
#[arg(long)]
/// Query name. With no `--query`/`-e`, the stored mutation to invoke
/// from the catalog (served — addressed via --server/--profile). With
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
name: Option<String>,
/// Ad-hoc mutation file (a `.gq` you're authoring / break-glass).
#[arg(long, conflicts_with = "query_string")]
query: Option<PathBuf>,
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
query_string: Option<String>,
#[command(flatten)]
params: ParamsArgs,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
json: bool,
#[arg()]
alias_args: Vec<String>,
},
/// Invoke an operator alias (RFC-011 Decision 4).
///
/// An alias is a personal binding under `aliases:` in
/// ~/.omnigraph/config.yaml — name → (server, graph, stored-query name,
/// default params). `omnigraph alias <name> [args]` invokes the bound
/// stored query on its server. Living in its own namespace, an alias can
/// never shadow or be shadowed by a built-in verb. Replaces the removed
/// `--alias` flag on `query`/`mutate`.
Alias {
/// Alias name (a key under `aliases:` in ~/.omnigraph/config.yaml).
name: String,
/// Positional args bound to the alias's declared `args` params, in order.
args: Vec<String>,
#[command(flatten)]
params: ParamsArgs,
#[arg(long, conflicts_with = "json")]
format: Option<ReadOutputFormat>,
#[arg(long, conflicts_with = "format")]
json: bool,
},
/// Load data into a graph (local or remote)
Load {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
data: PathBuf,
/// Target branch (defaults to main). Without --from it must exist.
#[arg(long)]
@ -159,8 +183,6 @@ pub(crate) enum Command {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
data: PathBuf,
#[arg(long)]
branch: Option<String>,
@ -181,8 +203,6 @@ pub(crate) enum Command {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
json: bool,
@ -192,8 +212,6 @@ pub(crate) enum Command {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
branch: Option<String>,
#[arg(long, hide = true)]
jsonl: bool,
@ -238,30 +256,12 @@ pub(crate) enum Command {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
/// Cluster directory or storage-root URI; with --cluster-graph, resolves
/// the graph's storage URI from the served cluster state.
#[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
cluster: Option<String>,
/// Graph id within --cluster.
#[arg(long, requires = "cluster")]
cluster_graph: Option<String>,
#[arg(long)]
json: bool,
},
/// Classify and explicitly repair manifest/head drift
Repair {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
/// Cluster directory or storage-root URI; with --cluster-graph, resolves
/// the graph's storage URI from the served cluster state.
#[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
cluster: Option<String>,
/// Graph id within --cluster.
#[arg(long, requires = "cluster")]
cluster_graph: Option<String>,
/// Publish verified maintenance drift. Without this flag, repair only
/// previews what it would do.
#[arg(long)]
@ -277,15 +277,6 @@ pub(crate) enum Command {
Cleanup {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
/// Cluster directory or storage-root URI; with --cluster-graph, resolves
/// the graph's storage URI from the served cluster state.
#[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
cluster: Option<String>,
/// Graph id within --cluster.
#[arg(long, requires = "cluster")]
cluster_graph: Option<String>,
/// Number of recent versions to keep per table. Either `--keep` or
/// `--older-than` (or both) must be set.
#[arg(long)]
@ -315,8 +306,6 @@ pub(crate) enum Command {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
query: PathBuf,
#[arg(long)]
schema: Option<PathBuf>,
@ -336,8 +325,7 @@ pub(crate) enum Command {
command: ClusterCommand,
},
// ── Session / config ── no graph addressing; local tooling.
/// Policy administration and diagnostics
/// Policy administration and diagnostics against a cluster's applied bundles
Policy {
#[command(subcommand)]
command: PolicyCommand,
@ -363,16 +351,32 @@ pub(crate) enum Command {
#[arg(long)]
json: bool,
},
/// Legacy-config tooling (RFC-008): split omnigraph.yaml into its
/// two destinations.
Config {
/// Inspect the scope profiles in ~/.omnigraph/config.yaml (read-only).
Profile {
#[command(subcommand)]
command: ConfigCommand,
command: ProfileCommand,
},
/// Print the CLI version
Version,
}
#[derive(Debug, Subcommand)]
pub(crate) enum ProfileCommand {
/// List the profiles defined in ~/.omnigraph/config.yaml.
List {
#[arg(long)]
json: bool,
},
/// Show a profile's resolved scope. With no name, shows the active
/// (`$OMNIGRAPH_PROFILE`) profile, else the flat operator defaults.
Show {
/// Profile name (optional).
name: Option<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
pub(crate) enum ClusterCommand {
/// Validate cluster.yaml and referenced schemas, queries, and policy files.
@ -469,8 +473,6 @@ pub(crate) enum GraphsCommand {
#[arg(long)]
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
},
}
@ -483,8 +485,6 @@ pub(crate) enum BranchCommand {
#[arg(long)]
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
from: Option<String>,
name: String,
#[arg(long)]
@ -496,8 +496,6 @@ pub(crate) enum BranchCommand {
#[arg(long)]
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
},
/// Delete a branch
@ -505,8 +503,6 @@ pub(crate) enum BranchCommand {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
name: String,
#[arg(long)]
json: bool,
@ -516,8 +512,6 @@ pub(crate) enum BranchCommand {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
source: String,
#[arg(long)]
into: Option<String>,
@ -533,8 +527,6 @@ pub(crate) enum SchemaCommand {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
schema: PathBuf,
#[arg(long)]
json: bool,
@ -549,8 +541,6 @@ pub(crate) enum SchemaCommand {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
schema: PathBuf,
#[arg(long)]
json: bool,
@ -572,8 +562,6 @@ pub(crate) enum SchemaCommand {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
},
}
@ -586,8 +574,6 @@ pub(crate) enum CommitCommand {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
json: bool,
@ -597,8 +583,6 @@ pub(crate) enum CommitCommand {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
commit_id: String,
#[arg(long)]
json: bool,
@ -607,20 +591,24 @@ pub(crate) enum CommitCommand {
#[derive(Debug, Subcommand)]
pub(crate) enum PolicyCommand {
/// Validate policy YAML and compiled Cedar policy state
Validate {
#[arg(long)]
config: Option<PathBuf>,
},
/// Run declarative policy tests from policy.tests.yaml
/// Compile and validate the Cedar policy bundle(s) applied in a cluster.
///
/// Sources the bundle(s) from the cluster's applied policies
/// (`--cluster <dir>`); pass the global `--graph <id>` to pick one
/// graph's bundle when several apply.
Validate {},
/// Run declarative policy tests against a cluster's applied bundle.
///
/// The cluster model has no per-bundle tests file, so the cases are
/// supplied explicitly with `--tests <file>` and checked against the
/// bundle selected by `--cluster` (+ optional `--graph`).
Test {
/// Path to a policy.tests.yaml file.
#[arg(long)]
config: Option<PathBuf>,
tests: PathBuf,
},
/// Explain one policy decision locally
/// Explain one policy decision against a cluster's applied bundle.
Explain {
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
actor: String,
#[arg(long)]
@ -634,24 +622,19 @@ pub(crate) enum PolicyCommand {
#[derive(Debug, Subcommand)]
pub(crate) enum QueriesCommand {
/// Type-check the stored-query registry against the live schema.
/// Type-check a cluster's stored-query registry against its schemas.
///
/// Distinct from `omnigraph lint` (which lints one `.gq` file):
/// this validates the whole `queries:` registry — opening the graph
/// to read its schema and confirming every stored query still
/// type-checks. Exits non-zero on any breakage.
/// Distinct from `omnigraph lint` (which lints one `.gq` file): this
/// validates the whole `queries:` registry of a cluster (`--cluster
/// <dir>`, optional `--graph <id>`) by reading each graph's applied
/// schema and confirming every stored query still type-checks. Exits
/// non-zero on any breakage.
Validate {
/// Graph URI
uri: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
},
/// List the registered stored queries (name, MCP exposure, params).
/// List a cluster's registered stored queries (name, params).
List {
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
},
@ -682,7 +665,6 @@ impl From<CliLoadMode> for LoadMode {
}
}
}
impl CliLoadMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
@ -692,21 +674,3 @@ impl CliLoadMode {
}
}
}
#[derive(Debug, Subcommand)]
pub(crate) enum ConfigCommand {
/// Propose (and with --write, apply) the RFC-008 split of a legacy
/// omnigraph.yaml: team half -> a ready-to-review cluster.yaml,
/// personal half -> ~/.omnigraph/config.yaml (key-level merge,
/// existing entries always win). Touches nothing without --write.
Migrate {
/// Path to the legacy omnigraph.yaml (default: ./omnigraph.yaml)
#[arg(long)]
config: Option<PathBuf>,
/// Apply the split instead of only printing it
#[arg(long)]
write: bool,
#[arg(long)]
json: bool,
},
}

View file

@ -29,7 +29,8 @@ use omnigraph::db::{Omnigraph, ReadTarget};
use omnigraph_api_types::{
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput,
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, ReadOutput,
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest,
InvokeStoredQueryRequest, ReadOutput,
ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, commit_output,
ingest_output, read_output, schema_apply_output, snapshot_payload,
};
@ -39,22 +40,20 @@ use serde_json::Value;
use crate::cli::CliLoadMode;
use crate::helpers::{
ResolvedCliGraph, apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri,
legacy_change_request_body, open_local_db_with_policy, query_params_from_json,
apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri,
legacy_change_request_body, query_params_from_json,
remote_json, remote_url, resolve_cli_actor, resolve_cli_graph, resolve_remote_bearer_token,
select_named_query,
resolve_server_flag, select_named_query,
};
use crate::output::{LoadOutput, load_output_from_result, load_output_from_tables};
use omnigraph_server::config::OmnigraphConfig;
pub(crate) enum GraphClient {
/// Local engine at `uri`. Reads (`resolve()`) leave `graph`/`actor`
/// empty and open without policy; writes (`resolve_with_policy()`)
/// fill them, opening through `open_local_db_with_policy` and
/// attributing the resolved actor.
/// Local engine at `uri`. Reads (`resolve()`) leave `actor` empty;
/// writes (`resolve_with_policy()`) attribute the resolved actor.
/// Direct-store access carries no Cedar policy (RFC-011: policy lives
/// in the cluster/server, not in per-operator addressing).
Embedded {
uri: String,
graph: Option<ResolvedCliGraph>,
actor: Option<String>,
},
/// Remote HTTP server. The actor is resolved server-side from the
@ -66,6 +65,43 @@ pub(crate) enum GraphClient {
},
}
/// RFC-011 Decision 7: a server scope that selects no graph (no `--graph`, no
/// `default_graph`) must not silently fall through to the bare server URL when
/// the server is multi-graph. Best-effort probe `GET /graphs`: a populated list
/// forces `--graph` (listing the candidates); a single-graph/flat server (405),
/// a policy-gated `/graphs`, or an unreachable server all proceed — the bare URL
/// is then correct, or the real request surfaces the failure. Only fires on the
/// no-graph path, so a `--graph`/`default_graph` happy path does no extra I/O.
async fn require_graph_for_multi_graph_server(
scope: &crate::scope::ResolvedScope,
) -> Result<()> {
let (Some(server), None) = (scope.server.as_deref(), scope.graph.as_deref()) else {
return Ok(());
};
let Some(base) = resolve_server_flag(Some(server), None)? else {
return Ok(());
};
let token = resolve_remote_bearer_token(Some(&base))?;
let probe = GraphClient::Remote {
http: build_http_client()?,
base_url: base,
token,
};
if let Ok(resp) = probe.list_graphs().await {
if !resp.graphs.is_empty() {
let ids: Vec<&str> = resp.graphs.iter().map(|g| g.graph_id.as_str()).collect();
bail!(
"server scope '{server}' has {} {}: [{}]; pass --graph <id> to select one \
(or set `default_graph` in your operator config)",
ids.len(),
if ids.len() == 1 { "graph" } else { "graphs" },
ids.join(", ")
);
}
}
Ok(())
}
/// A remote graph must be addressed with `--server` (RFC-011): a positional or
/// `--uri` `http(s)://` URL no longer auto-dispatches to a server. A remote URL
/// produced by a server scope (`via_server`) is fine.
@ -86,8 +122,7 @@ impl GraphClient {
/// fork. Mirrors the read verbs' current preamble (`resolve_uri`
/// path, not the policy-bearing `resolve_cli_graph`). Used by reads
/// and `query` (which opens without policy, like the reads).
pub(crate) fn resolve(
config: &OmnigraphConfig,
pub(crate) async fn resolve(
server: Option<&str>,
graph: Option<&str>,
uri: Option<String>,
@ -100,8 +135,9 @@ impl GraphClient {
let scope = crate::scope::resolve_scope(
&crate::operator::load_operator_config()?,
crate::planes::Capability::Any,
crate::scope::ScopeFlags { profile, store, server, graph, uri },
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
)?;
require_graph_for_multi_graph_server(&scope).await?;
let (server, graph, uri) = (
scope.server.as_deref(),
scope.graph.as_deref(),
@ -109,8 +145,8 @@ impl GraphClient {
);
let via_server = server.is_some();
let uri = apply_server_flag(server, graph, uri)?;
let token = resolve_remote_bearer_token(config, uri.as_deref())?;
let uri = crate::helpers::resolve_uri(config, uri)?;
let token = resolve_remote_bearer_token(uri.as_deref())?;
let uri = crate::helpers::resolve_uri(uri)?;
reject_positional_remote(via_server, &uri)?;
if is_remote_uri(&uri) {
Ok(GraphClient::Remote {
@ -119,11 +155,7 @@ impl GraphClient {
token,
})
} else {
Ok(GraphClient::Embedded {
uri,
graph: None,
actor: None,
})
Ok(GraphClient::Embedded { uri, actor: None })
}
}
@ -133,8 +165,7 @@ impl GraphClient {
/// resolved up front. The embedded arm then opens WITH policy. The
/// resolution order matches the write arms exactly: server flag →
/// bearer token → graph.
pub(crate) fn resolve_with_policy(
config: &OmnigraphConfig,
pub(crate) async fn resolve_with_policy(
server: Option<&str>,
graph: Option<&str>,
uri: Option<String>,
@ -147,8 +178,9 @@ impl GraphClient {
let scope = crate::scope::resolve_scope(
&crate::operator::load_operator_config()?,
crate::planes::Capability::Any,
crate::scope::ScopeFlags { profile, store, server, graph, uri },
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
)?;
require_graph_for_multi_graph_server(&scope).await?;
let (server, graph, uri) = (
scope.server.as_deref(),
scope.graph.as_deref(),
@ -156,8 +188,8 @@ impl GraphClient {
);
let via_server = server.is_some();
let uri = apply_server_flag(server, graph, uri)?;
let token = resolve_remote_bearer_token(config, uri.as_deref())?;
let resolved = resolve_cli_graph(config, uri)?;
let token = resolve_remote_bearer_token(uri.as_deref())?;
let resolved = resolve_cli_graph(uri)?;
reject_positional_remote(via_server, &resolved.uri)?;
if resolved.is_remote {
// A served write resolves the actor server-side from the bearer
@ -175,10 +207,9 @@ impl GraphClient {
token,
})
} else {
let actor = resolve_cli_actor(cli_as, config)?;
let actor = resolve_cli_actor(cli_as)?;
Ok(GraphClient::Embedded {
uri: resolved.uri.clone(),
graph: Some(resolved),
uri: resolved.uri,
actor,
})
}
@ -192,28 +223,15 @@ impl GraphClient {
}
}
/// The selected graph name, when a policy-bearing embedded client was
/// resolved against a named graph. `None` for remote and for reads.
pub(crate) fn selected(&self) -> Option<&str> {
match self {
GraphClient::Embedded { graph, .. } => graph.as_ref().and_then(ResolvedCliGraph::selected),
GraphClient::Remote { .. } => None,
}
}
pub(crate) fn is_remote(&self) -> bool {
matches!(self, GraphClient::Remote { .. })
}
/// Open the local engine the way the resolved client demands: with
/// policy when a `graph` context is present (write path), bare
/// otherwise (read/`query` path). Captures today's two open paths in
/// one place so each verb stays a single match arm.
async fn open_embedded(uri: &str, graph: &Option<ResolvedCliGraph>) -> Result<Omnigraph> {
match graph {
Some(graph) => open_local_db_with_policy(graph).await,
None => Ok(Omnigraph::open(uri).await?),
}
/// Open the local engine. Direct-store access carries no Cedar policy
/// (RFC-011), so both read and write paths open bare; the actor is still
/// attributed on the write via the `_as` engine APIs.
async fn open_embedded(uri: &str) -> Result<Omnigraph> {
Ok(Omnigraph::open(uri).await?)
}
pub(crate) async fn branch_list(&self) -> Result<BranchListOutput> {
@ -375,8 +393,8 @@ impl GraphClient {
.await?;
Ok(load_output_from_tables(base_url, branch, mode.as_str(), &output))
}
GraphClient::Embedded { uri, graph, actor } => {
let db = Self::open_embedded(uri, graph).await?;
GraphClient::Embedded { uri, actor } => {
let db = Self::open_embedded(uri).await?;
let result = db
.load_file_as(branch, from, data, mode.into(), actor.as_deref())
.await?;
@ -418,8 +436,8 @@ impl GraphClient {
)
.await
}
GraphClient::Embedded { uri, graph, actor } => {
let db = Self::open_embedded(uri, graph).await?;
GraphClient::Embedded { uri, actor } => {
let db = Self::open_embedded(uri).await?;
let result = db
.load_file_as(branch, Some(from), data, mode.into(), actor.as_deref())
.await?;
@ -457,10 +475,10 @@ impl GraphClient {
)
.await
}
GraphClient::Embedded { uri, graph, actor } => {
GraphClient::Embedded { uri, actor } => {
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
let params = query_params_from_json(&query_params, params_json)?;
let db = Self::open_embedded(uri, graph).await?;
let db = Self::open_embedded(uri).await?;
let actor = actor.as_deref();
let result = db
.mutate_as(branch, query_source, &selected_name, &params, actor)
@ -511,10 +529,10 @@ impl GraphClient {
)
.await
}
GraphClient::Embedded { uri, graph, .. } => {
GraphClient::Embedded { uri, .. } => {
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
let params = query_params_from_json(&query_params, params_json)?;
let db = Self::open_embedded(uri, graph).await?;
let db = Self::open_embedded(uri).await?;
let result = db
.query(target.clone(), query_source, &selected_name, &params)
.await?;
@ -523,6 +541,50 @@ impl GraphClient {
}
}
/// `invoke_named` — run a stored query **by catalog name** (RFC-011 D3).
/// Served-only: the catalog is server-owned, so a `--store` (embedded)
/// scope has nothing to resolve the name against. `expect_mutation` carries
/// the verb's asserted kind; the server rejects a mismatch (400) before
/// running, so the response is exactly the expected envelope — the caller
/// deserializes it as the concrete `T` (`ReadOutput` for `query`,
/// `ChangeOutput` for `mutate`), sidestepping the untagged wire enum.
pub(crate) async fn invoke_named<T: serde::de::DeserializeOwned>(
&self,
name: &str,
expect_mutation: bool,
params_json: Option<&Value>,
branch: Option<String>,
snapshot: Option<String>,
) -> Result<T> {
match self {
GraphClient::Remote {
http,
base_url,
token,
} => {
let body = InvokeStoredQueryRequest {
params: params_json.cloned(),
branch,
snapshot,
expect_mutation: Some(expect_mutation),
};
remote_json(
http,
Method::POST,
remote_url(base_url, &["queries", name], &[])?,
Some(serde_json::to_value(body)?),
token.as_deref(),
)
.await
}
GraphClient::Embedded { .. } => bail!(
"by-name invocation needs a server (the stored-query catalog is \
server-owned); use -e '<gq>' or --query <file> for an ad-hoc query \
against --store, or address a server with --server / --profile"
),
}
}
pub(crate) async fn branch_create_from(
&self,
from: &str,
@ -546,8 +608,8 @@ impl GraphClient {
)
.await
}
GraphClient::Embedded { uri, graph, actor } => {
let db = Self::open_embedded(uri, graph).await?;
GraphClient::Embedded { uri, actor } => {
let db = Self::open_embedded(uri).await?;
let actor = actor.as_deref();
db.branch_create_from_as(ReadTarget::branch(from), name, actor)
.await?;
@ -577,8 +639,8 @@ impl GraphClient {
)
.await
}
GraphClient::Embedded { uri, graph, actor } => {
let db = Self::open_embedded(uri, graph).await?;
GraphClient::Embedded { uri, actor } => {
let db = Self::open_embedded(uri).await?;
let actor = actor.as_deref();
db.branch_delete_as(name, actor).await?;
Ok(BranchDeleteOutput {
@ -609,8 +671,8 @@ impl GraphClient {
)
.await
}
GraphClient::Embedded { uri, graph, actor } => {
let db = Self::open_embedded(uri, graph).await?;
GraphClient::Embedded { uri, actor } => {
let db = Self::open_embedded(uri).await?;
let actor = actor.as_deref();
let outcome = db.branch_merge_as(source, into, actor).await?;
Ok(BranchMergeOutput {
@ -660,8 +722,8 @@ impl GraphClient {
)
.await
}
GraphClient::Embedded { uri, graph, actor } => {
let db = Self::open_embedded(uri, graph).await?;
GraphClient::Embedded { uri, actor } => {
let db = Self::open_embedded(uri).await?;
let result = db
.apply_schema_as_with_catalog_check(
schema_source,
@ -730,9 +792,9 @@ impl GraphClient {
/// `graphs list` — enumerate the graphs a remote multi-graph server
/// serves (`GET /graphs`). Remote-only by design: there is no local
/// enumeration endpoint, so the Embedded arm fails loudly pointing the
/// operator at `omnigraph.yaml`. Routing it through the enum still buys
/// the shared `resolve()` addressing/token preamble.
/// enumeration endpoint, so the Embedded arm fails loudly. Routing it
/// through the enum still buys the shared `resolve()` addressing/token
/// preamble.
pub(crate) async fn list_graphs(&self) -> Result<GraphListResponse> {
match self {
GraphClient::Remote {
@ -750,9 +812,9 @@ impl GraphClient {
.await
}
GraphClient::Embedded { .. } => bail!(
"`omnigraph graphs list` requires a remote multi-graph server URL \
(http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \
directly."
"`omnigraph graphs list` requires a remote multi-graph server \
(--server <url>). To enumerate the graphs in a cluster, run \
`omnigraph cluster status --config <dir>`."
),
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,16 @@
//! In-source test suite for the CLI binary (moved verbatim from
//! main.rs; `use super::*` resolves through the #[path] declaration).
use std::fs;
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_cli_graph, resolve_policy_context,
resolve_remote_bearer_token,
DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, legacy_change_request_body,
normalize_bearer_token, resolve_remote_bearer_token,
};
use omnigraph_server::load_config;
use reqwest::header::AUTHORIZATION;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn legacy_change_request_body_uses_legacy_field_names() {
// `execute_change_remote` hits `POST /change`, which old
// `mutate`'s remote arm hits `POST /change`, which old
// `omnigraph-server` builds deserialize as `ChangeRequest` with
// **required** `query_source` and optional `query_name` keys.
// Newer servers accept both spellings via serde alias, but a
@ -96,120 +90,20 @@
}
#[test]
fn parse_env_assignment_supports_plain_and_exported_values() {
assert_eq!(
parse_env_assignment("DEMO_TOKEN=demo-token"),
Some(("DEMO_TOKEN".to_string(), "demo-token".to_string()))
);
assert_eq!(
parse_env_assignment("export DEMO_TOKEN=\"quoted-token\""),
Some(("DEMO_TOKEN".to_string(), "quoted-token".to_string()))
);
assert_eq!(parse_env_assignment("# comment"), None);
assert_eq!(parse_env_assignment(" "), None);
}
#[test]
fn bearer_token_from_env_file_reads_named_value() {
let temp = tempdir().unwrap();
let env_file = temp.path().join(".env.omni");
fs::write(
&env_file,
"FIRST=ignore\nexport DEMO_TOKEN=\" demo-token \"\n",
)
.unwrap();
assert_eq!(
bearer_token_from_env_file(&env_file, "DEMO_TOKEN")
.unwrap()
.as_deref(),
Some("demo-token")
);
assert_eq!(
bearer_token_from_env_file(&env_file, "MISSING").unwrap(),
None
);
}
#[test]
fn load_env_file_into_process_sets_missing_values_without_overriding_existing_ones() {
let temp = tempdir().unwrap();
let env_file = temp.path().join(".env.omni");
fs::write(
&env_file,
"AUTOLOAD_ONLY=from-file\nAUTOLOAD_PRESET=from-file\n",
)
.unwrap();
let missing_key = "AUTOLOAD_ONLY";
let preset_key = "AUTOLOAD_PRESET";
let previous_missing = std::env::var_os(missing_key);
let previous_preset = std::env::var_os(preset_key);
unsafe {
std::env::remove_var(missing_key);
std::env::set_var(preset_key, "from-env");
}
load_env_file_into_process(&env_file).unwrap();
assert_eq!(std::env::var(missing_key).unwrap(), "from-file");
assert_eq!(std::env::var(preset_key).unwrap(), "from-env");
unsafe {
if let Some(value) = previous_missing {
std::env::set_var(missing_key, value);
} else {
std::env::remove_var(missing_key);
}
if let Some(value) = previous_preset {
std::env::set_var(preset_key, value);
} else {
std::env::remove_var(preset_key);
}
}
}
#[test]
fn resolve_remote_bearer_token_uses_scoped_env_file_with_global_fallback() {
let temp = tempdir().unwrap();
fs::write(
temp.path().join("omnigraph.yaml"),
r#"
graphs:
demo:
uri: https://example.com
bearer_token_env: DEMO_TOKEN
auth:
env_file: .env.omni
cli:
graph: demo
"#,
)
.unwrap();
fs::write(
temp.path().join(".env.omni"),
"DEMO_TOKEN=scoped-token\nOMNIGRAPH_BEARER_TOKEN=global-token\n",
)
.unwrap();
fn resolve_remote_bearer_token_falls_back_to_default_env() {
// RFC-011: with no operator server matching the URL, the only chain
// left is the default `OMNIGRAPH_BEARER_TOKEN` env (no omnigraph.yaml
// scoped chain). Hermetic: no operator config is read for a literal URL
// that matches no `servers:` entry.
let previous = std::env::var_os(DEFAULT_BEARER_TOKEN_ENV);
let previous_home = std::env::var_os("OMNIGRAPH_HOME");
unsafe {
std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV);
// Hermetic: the keyed hop (RFC-007 PR 2) must not pick up a real
// ~/.omnigraph on the developer's machine — and with no operator
// servers defined, the legacy chain below must behave
// byte-identically to pre-PR-2 (tested-as-untouched).
std::env::set_var("OMNIGRAPH_HOME", temp.path().join("no-operator-config"));
std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, "global-token");
std::env::set_var("OMNIGRAPH_HOME", "/nonexistent/omnigraph-test-home");
}
let config_path = temp.path().join("omnigraph.yaml");
let config = load_config(Some(&config_path)).unwrap();
assert_eq!(
resolve_remote_bearer_token(&config, Some("https://override.example.com"))
resolve_remote_bearer_token(Some("https://override.example.com"))
.unwrap()
.as_deref(),
Some("global-token")
@ -228,196 +122,3 @@ cli:
}
}
}
#[test]
fn load_cli_config_autoloads_env_file_into_process() {
let temp = tempdir().unwrap();
fs::write(
temp.path().join("omnigraph.yaml"),
r#"
auth:
env_file: .env.omni
graphs:
demo:
uri: s3://bucket/prefix
"#,
)
.unwrap();
fs::write(
temp.path().join(".env.omni"),
"AUTOLOAD_FROM_CONFIG=loaded\n",
)
.unwrap();
let key = "AUTOLOAD_FROM_CONFIG";
let previous = std::env::var_os(key);
unsafe {
std::env::remove_var(key);
}
let config_path = temp.path().join("omnigraph.yaml");
let config = load_cli_config(Some(&config_path)).unwrap();
assert_eq!(
config.resolve_target_uri(None, Some("demo"), None).unwrap(),
"s3://bucket/prefix"
);
assert_eq!(std::env::var(key).unwrap(), "loaded");
unsafe {
if let Some(value) = previous {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
}
#[test]
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(
&config_path,
r#"
project:
name: misleading-project
graphs:
local:
uri: /tmp/local-policy-graph.omni
policy:
file: ./policy.yaml
cli:
graph: local
"#,
)
.unwrap();
let config = load_config(Some(&config_path)).unwrap();
let context = resolve_policy_context(&config).unwrap();
assert_eq!(context.graph_id, "local");
}
#[test]
fn graph_identity_resolve_policy_context_server_graph_uses_graph_key_when_cli_graph_absent() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("omnigraph.yaml");
fs::write(
&config_path,
r#"
project:
name: misleading-project
graphs:
local:
uri: /tmp/local-policy-graph.omni
policy:
file: ./server-policy.yaml
server:
graph: local
"#,
)
.unwrap();
let config = load_config(Some(&config_path)).unwrap();
let context = resolve_policy_context(&config).unwrap();
assert_eq!(context.graph_id, "local");
assert!(context.policy_file.ends_with("server-policy.yaml"));
}
#[test]
fn graph_identity_resolve_policy_context_anonymous_uses_top_level_default_identity() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("omnigraph.yaml");
fs::write(
&config_path,
r#"
project:
name: misleading-project
graphs:
local:
uri: /tmp/local-policy-graph.omni
policy:
file: ./top-policy.yaml
"#,
)
.unwrap();
let config = load_config(Some(&config_path)).unwrap();
let context = resolve_policy_context(&config).unwrap();
assert_eq!(context.graph_id, "default");
assert!(context.policy_file.ends_with("top-policy.yaml"));
}
#[test]
fn graph_identity_resolve_cli_graph_named_target_uses_graph_key_not_project_name_or_uri() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("omnigraph.yaml");
fs::write(
&config_path,
r#"
project:
name: misleading-project
graphs:
prod:
uri: s3://bucket/prod-graph/
policy:
file: ./prod-policy.yaml
cli:
graph: prod
"#,
)
.unwrap();
let config = load_config(Some(&config_path)).unwrap();
// `--target` is removed; the `cli.graph` default drives the same
// graph-key (not project name / URI) selection.
let graph = resolve_cli_graph(&config, None).unwrap();
assert_eq!(graph.selected(), Some("prod"));
assert_eq!(graph.graph_id, "prod");
assert_eq!(graph.uri, "s3://bucket/prod-graph/");
}
#[test]
fn graph_identity_resolve_cli_graph_positional_uri_uses_anonymous_normalized_uri() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("omnigraph.yaml");
fs::write(
&config_path,
r#"
project:
name: misleading-project
graphs:
local:
uri: /tmp/configured-graph.omni
policy:
file: ./policy.yaml
cli:
graph: local
"#,
)
.unwrap();
let config = load_config(Some(&config_path)).unwrap();
let local_graph_path = temp.path().join("explicit-graph.omni");
let local_graph = resolve_cli_graph(
&config,
Some(format!("file://{}", local_graph_path.display())),
)
.unwrap();
assert_eq!(local_graph.selected(), None);
assert_eq!(
local_graph.graph_id,
local_graph_path.to_string_lossy().as_ref()
);
assert_eq!(local_graph.policy_file, None);
let s3_graph = resolve_cli_graph(
&config,
Some("s3://bucket/anonymous-graph/".to_string()),
)
.unwrap();
assert_eq!(s3_graph.selected(), None);
assert_eq!(s3_graph.graph_id, "s3://bucket/anonymous-graph");
assert_eq!(s3_graph.policy_file, None);
}

View file

@ -1,408 +0,0 @@
//! `omnigraph config migrate` (RFC-008 stage 2): split a legacy
//! `omnigraph.yaml` into its two destinations — the team half as a
//! ready-to-review `cluster.yaml` proposal, the personal half merged into
//! `~/.omnigraph/config.yaml` — and name what's obsolete. The command is
//! the completeness test of RFC-008's migration map: any key it cannot
//! place is a bug in the RFC.
//!
//! Touches nothing without `--write`. Referenced `.gq`/policy files are
//! never moved; manual steps are printed instead.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use color_eyre::Result;
use color_eyre::eyre::eyre;
use omnigraph_server::OmnigraphConfig;
use serde::Serialize;
use crate::operator;
#[derive(Debug, Serialize)]
pub(crate) struct MigrateReport {
pub(crate) source: String,
/// The ready-to-review cluster.yaml text (None when the legacy file
/// declares nothing team-shaped).
pub(crate) cluster_yaml: Option<String>,
/// Operator keys to merge: dotted key -> YAML value text.
pub(crate) operator_merge: BTreeMap<String, String>,
/// Keys with no destination, and why.
pub(crate) dropped: Vec<DroppedKey>,
/// Steps the command will not do for you.
pub(crate) manual_steps: Vec<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct DroppedKey {
pub(crate) key: String,
pub(crate) reason: String,
}
/// Classify a parsed legacy config into the report. Pure — no I/O.
pub(crate) fn build_report(config: &OmnigraphConfig, source: &Path) -> MigrateReport {
let mut dropped = Vec::new();
let mut manual_steps = Vec::new();
let mut operator_merge: BTreeMap<String, String> = BTreeMap::new();
// ---- personal half ----
if let Some(actor) = &config.cli.actor {
operator_merge.insert("operator.actor".into(), actor.clone());
}
if let Some(format) = config.cli.output_format {
operator_merge.insert(
"defaults.output".into(),
serde_yaml::to_string(&format).unwrap_or_default().trim().to_string(),
);
}
if let Some(width) = config.cli.table_max_column_width {
operator_merge.insert("defaults.table_max_column_width".into(), width.to_string());
}
if let Some(layout) = config.cli.table_cell_layout {
operator_merge.insert(
"defaults.table_cell_layout".into(),
serde_yaml::to_string(&layout).unwrap_or_default().trim().to_string(),
);
}
if config.cli.graph.is_some() {
dropped.push(DroppedKey {
key: "cli.graph".into(),
reason: "address graphs explicitly via --store/--server, or set defaults.default_graph in the operator config".into(),
});
}
if config.cli.branch.is_some() {
dropped.push(DroppedKey {
key: "cli.branch".into(),
reason: "pass --branch explicitly".into(),
});
}
// Remote graphs with a token env become operator servers (the keyed
// chain replaces invented env-var names).
for (name, target) in &config.graphs {
if target.uri.starts_with("http://") || target.uri.starts_with("https://") {
operator_merge.insert(format!("servers.{name}.url"), target.uri.clone());
if target.bearer_token_env.is_some() {
manual_steps.push(format!(
"store the '{name}' token in the keyed chain: echo $TOKEN | omnigraph login {name} (replaces bearer_token_env)"
));
}
}
}
if config.auth.env_file.is_some() {
manual_steps.push(
"auth.env_file keeps working during the window; prefer `omnigraph login <server>` per server going forward".into(),
);
}
// Legacy aliases split: content -> catalog stored query, binding ->
// operator alias referencing the name.
for (name, alias) in &config.aliases {
let query_name = alias.name.clone().unwrap_or_else(|| name.clone());
operator_merge.insert(
format!("aliases.{name}"),
format!(
"{{ server: TODO-server-name, graph: {}, query: {query_name}, args: [{}] }}",
alias.graph.as_deref().unwrap_or("TODO-graph-id"),
alias.args.join(", ")
),
);
manual_steps.push(format!(
"alias '{name}': move its query content ('{}') into the cluster checkout's queries/ so '{query_name}' becomes a catalog stored query",
alias.query
));
}
// ---- team half ----
let has_team_content = !config.graphs.is_empty()
|| !config.queries.is_empty()
|| config.policy.file.is_some()
|| config.server.policy.file.is_some();
let cluster_yaml = has_team_content.then(|| {
let mut out = String::from("version: 1\n");
if let Some(name) = &config.project.name {
out.push_str(&format!("metadata:\n name: {name}\n"));
}
out.push_str("# storage: s3://bucket/prefix # or omit: this folder is the root\n");
if !config.graphs.is_empty() || !config.queries.is_empty() {
out.push_str("graphs:\n");
}
// Single-graph top-level queries belong to a graph the legacy file
// never named; propose one.
if !config.queries.is_empty() && config.graphs.is_empty() {
out.push_str(" default: # TODO: pick the graph id\n schema: # TODO: path to this graph's .pg schema\n queries: queries/\n");
}
for (name, target) in &config.graphs {
out.push_str(&format!(" {name}:\n"));
out.push_str(" schema: # TODO: path to this graph's .pg schema\n");
if !target.queries.is_empty() {
out.push_str(" queries: queries/ # move the .gq files here\n");
}
out.push_str(&format!(
" # legacy root: {} — the cluster manages graph roots under its storage; run `omnigraph cluster import` after reviewing\n",
target.uri
));
}
let mut policies: Vec<(String, String, String)> = Vec::new();
if let Some(file) = &config.policy.file {
policies.push(("default".into(), file.clone(), "graph.<id> # TODO: bind".into()));
}
if let Some(file) = &config.server.policy.file {
policies.push(("server".into(), file.clone(), "cluster".into()));
}
for (name, target) in &config.graphs {
if let Some(file) = &target.policy.file {
policies.push((name.clone(), file.clone(), format!("graph.{name}")));
}
}
if !policies.is_empty() {
out.push_str("policies:\n");
for (name, file, binding) in policies {
out.push_str(&format!(
" {name}:\n file: {file}\n applies_to: [{binding}]\n"
));
}
}
out
});
if !config.query.roots.is_empty() {
dropped.push(DroppedKey {
key: "query.roots".into(),
reason: "obsolete — cluster query discovery (queries: <dir>) replaced it".into(),
});
}
if config.server.bind.is_some() || config.server.graph.is_some() {
dropped.push(DroppedKey {
key: "server.bind / server.graph".into(),
reason: "deployment runtime — pass --bind / target flags or env".into(),
});
}
if config.project.name.is_some() && cluster_yaml.is_none() {
dropped.push(DroppedKey {
key: "project.name".into(),
reason: "the cluster's metadata.name is the deployment label".into(),
});
}
MigrateReport {
source: source.display().to_string(),
cluster_yaml,
operator_merge,
dropped,
manual_steps,
}
}
pub(crate) fn render_report(report: &MigrateReport) -> String {
let mut out = format!("migration plan for {}\n", report.source);
if let Some(cluster) = &report.cluster_yaml {
out.push_str("\n== team half -> cluster.yaml (ready to review) ==\n");
out.push_str(cluster);
}
if !report.operator_merge.is_empty() {
out.push_str("\n== personal half -> ~/.omnigraph/config.yaml ==\n");
for (key, value) in &report.operator_merge {
out.push_str(&format!(" {key}: {value}\n"));
}
}
if !report.dropped.is_empty() {
out.push_str("\n== no destination ==\n");
for dropped in &report.dropped {
out.push_str(&format!(" {}{}\n", dropped.key, dropped.reason));
}
}
if !report.manual_steps.is_empty() {
out.push_str("\n== manual steps ==\n");
for step in &report.manual_steps {
out.push_str(&format!(" - {step}\n"));
}
}
out.push_str("\n(nothing written; pass --write to apply the operator merge and emit cluster.yaml)\n");
out
}
/// `--write`: merge the personal half into the operator config (key-level,
/// existing entries always win; the prior file is backed up) and write the
/// team half to cluster.yaml in the legacy config's directory (or
/// cluster.yaml.proposed when one already exists).
pub(crate) fn apply_report(report: &MigrateReport, legacy_dir: &Path) -> Result<Vec<String>> {
let mut written = Vec::new();
if !report.operator_merge.is_empty() {
let dir = operator::operator_dir()
.ok_or_else(|| eyre!("no home directory resolvable for the operator config"))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join(operator::OPERATOR_CONFIG_FILE);
let existing_text = std::fs::read_to_string(&path).unwrap_or_default();
let mut mapping: serde_yaml::Mapping = if existing_text.trim().is_empty() {
serde_yaml::Mapping::new()
} else {
serde_yaml::from_str(&existing_text)
.map_err(|err| eyre!("operator config '{}' does not parse: {err}", path.display()))?
};
let mut merged_any = false;
for (dotted, value_text) in &report.operator_merge {
if merge_dotted_if_absent(&mut mapping, dotted, value_text)? {
merged_any = true;
}
}
if merged_any {
if !existing_text.is_empty() {
let backup = path.with_extension("yaml.bak");
std::fs::write(&backup, &existing_text)?;
written.push(format!("backed up prior operator config to {}", backup.display()));
}
let rendered = serde_yaml::to_string(&mapping)?;
let tmp = path.with_extension(format!("yaml.tmp.{}", std::process::id()));
std::fs::write(&tmp, &rendered)?;
std::fs::rename(&tmp, &path)?;
written.push(format!("merged personal keys into {}", path.display()));
} else {
written.push("operator config already carries every personal key (nothing merged)".into());
}
}
if let Some(cluster) = &report.cluster_yaml {
let target = legacy_dir.join("cluster.yaml");
let target = if target.exists() {
legacy_dir.join("cluster.yaml.proposed")
} else {
target
};
std::fs::write(&target, cluster)?;
written.push(format!("wrote team-half proposal to {}", target.display()));
}
Ok(written)
}
/// Set `a.b.c` in the mapping only when absent; returns whether it wrote.
fn merge_dotted_if_absent(
mapping: &mut serde_yaml::Mapping,
dotted: &str,
value_text: &str,
) -> Result<bool> {
let value: serde_yaml::Value =
serde_yaml::from_str(value_text).unwrap_or(serde_yaml::Value::String(value_text.into()));
let parts: Vec<&str> = dotted.split('.').collect();
let mut current = mapping;
for part in &parts[..parts.len() - 1] {
let key = serde_yaml::Value::String((*part).into());
let entry = current
.entry(key)
.or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
current = entry
.as_mapping_mut()
.ok_or_else(|| eyre!("operator config key '{dotted}' collides with a non-mapping"))?;
}
let leaf = serde_yaml::Value::String(parts[parts.len() - 1].into());
if current.contains_key(&leaf) {
return Ok(false);
}
current.insert(leaf, value);
Ok(true)
}
pub(crate) fn legacy_config_path(explicit: Option<&PathBuf>) -> PathBuf {
explicit.cloned().unwrap_or_else(|| PathBuf::from("omnigraph.yaml"))
}
#[cfg(test)]
mod tests {
use super::*;
use omnigraph_server::config::load_config;
fn full_legacy_fixture(dir: &Path) -> PathBuf {
let path = dir.join("omnigraph.yaml");
std::fs::write(
&path,
r#"
project: { name: brain }
graphs:
prod:
uri: https://graph.example.com
bearer_token_env: PROD_TOKEN
policy: { file: ./prod.policy.yaml }
queries:
find: { file: ./find.gq }
local:
uri: /tmp/local.omni
server: { bind: "0.0.0.0:9999", policy: { file: ./server.policy.yaml } }
auth: { env_file: .env.omni }
cli:
graph: prod
branch: main
actor: act-me
output_format: json
table_max_column_width: 40
query: { roots: ["."] }
aliases:
triage: { command: query, query: ./triage.gq, name: weekly_triage, args: [since], graph: prod }
policy: { file: ./top.policy.yaml }
queries:
top_q: { file: ./top.gq }
"#,
)
.unwrap();
path
}
/// The RFC-008 completeness contract: every top-level key of the
/// legacy schema must appear in the report somewhere (team half,
/// operator merge, dropped, or manual steps).
#[test]
fn every_legacy_key_is_classified() {
let dir = tempfile::tempdir().unwrap();
let path = full_legacy_fixture(dir.path());
let config = load_config(Some(&path)).unwrap();
let report = build_report(&config, &path);
let rendered = render_report(&report);
let serialized =
serde_yaml::to_value(OmnigraphConfig::default()).expect("default serializes");
for key in serialized.as_mapping().unwrap().keys() {
let key = key.as_str().unwrap();
assert!(
rendered.contains(key)
|| report.operator_merge.keys().any(|k| k.contains(key))
|| matches!(key, "graphs" | "queries" | "policy" | "project")
&& report.cluster_yaml.is_some(),
"legacy key '{key}' is unclassified — fix the RFC-008 map: {rendered}"
);
}
// spot checks on each section
assert_eq!(report.operator_merge["operator.actor"], "act-me");
assert_eq!(report.operator_merge["defaults.output"], "json");
assert_eq!(
report.operator_merge["servers.prod.url"],
"https://graph.example.com"
);
assert!(report.operator_merge["aliases.triage"].contains("query: weekly_triage"));
let cluster = report.cluster_yaml.as_deref().unwrap();
assert!(cluster.contains("version: 1"));
assert!(cluster.contains("name: brain"));
assert!(cluster.contains(" prod:"));
assert!(cluster.contains("applies_to: [cluster]"));
assert!(cluster.contains("applies_to: [graph.prod]"));
assert!(report.dropped.iter().any(|d| d.key == "query.roots"));
assert!(report.dropped.iter().any(|d| d.key.contains("server.bind")));
assert!(
report
.manual_steps
.iter()
.any(|s| s.contains("omnigraph login prod"))
);
}
#[test]
fn merge_dotted_never_clobbers_existing() {
let mut mapping: serde_yaml::Mapping =
serde_yaml::from_str("operator:\n actor: keep-me\n").unwrap();
assert!(!merge_dotted_if_absent(&mut mapping, "operator.actor", "new").unwrap());
assert!(merge_dotted_if_absent(&mut mapping, "defaults.output", "json").unwrap());
let text = serde_yaml::to_string(&mapping).unwrap();
assert!(text.contains("keep-me") && !text.contains("new"));
assert!(text.contains("output: json"));
}
}

View file

@ -18,10 +18,10 @@ use std::env;
use std::path::{Path, PathBuf};
use color_eyre::Result;
use color_eyre::eyre::eyre;
use color_eyre::eyre::{bail, eyre};
use serde::Deserialize;
use omnigraph_server::config::ReadOutputFormat;
use crate::read_format::{ReadOutputFormat, TableCellLayout};
pub(crate) const OPERATOR_HOME_ENV: &str = "OMNIGRAPH_HOME";
pub(crate) const OPERATOR_DIR: &str = ".omnigraph";
@ -91,8 +91,7 @@ pub(crate) struct OperatorServer {
#[derive(Debug, Default, Deserialize)]
pub(crate) struct OperatorIdentity {
/// Default actor for every `--as` cascade (CLI direct-engine writes and
/// cluster commands alike): `--as` > legacy config actor (RFC-008
/// window) > this > none.
/// cluster commands alike): `--as` > this > none.
pub(crate) actor: Option<String>,
#[serde(flatten)]
unknown: serde_yaml::Mapping,
@ -102,14 +101,19 @@ pub(crate) struct OperatorIdentity {
pub(crate) struct OperatorDefaults {
/// Default read output format, below every more-specific source.
pub(crate) output: Option<ReadOutputFormat>,
/// Table rendering preferences (below the legacy cli.table_* keys
/// during the RFC-008 window).
/// Table rendering preferences for `--format table`.
pub(crate) table_max_column_width: Option<usize>,
pub(crate) table_cell_layout: Option<omnigraph_server::config::TableCellLayout>,
pub(crate) table_cell_layout: Option<TableCellLayout>,
/// Default server scope (RFC-011): the everyday addressing when no
/// `--profile` / primitive / legacy address is given. Names an entry
/// under `servers:`.
/// under `servers:`. Mutually exclusive with `store` — a scope binds one
/// entity.
pub(crate) server: Option<String>,
/// Default **store** scope (RFC-011): a `file://` / `s3://` graph storage
/// URI used as the zero-flag local default for graph commands when no
/// `--profile` / primitive address is given. The local-dev counterpart of
/// `server`; mutually exclusive with it.
pub(crate) store: Option<String>,
/// Default graph selected within a server/cluster scope when no
/// `--graph` is passed (RFC-011).
pub(crate) default_graph: Option<String>,
@ -202,10 +206,36 @@ impl OperatorConfig {
self.defaults.server.as_deref()
}
/// The flat-default store scope URI, if set (RFC-011) — the zero-flag
/// local-dev default.
pub(crate) fn default_store(&self) -> Option<&str> {
self.defaults.store.as_deref()
}
/// The flat-default graph within a server/cluster scope, if set (RFC-011).
pub(crate) fn default_graph(&self) -> Option<&str> {
self.defaults.default_graph.as_deref()
}
/// A scope binds one entity (Decision 6): `defaults.server` and
/// `defaults.store` are mutually exclusive, and a `store` (already a single
/// graph) cannot carry a `default_graph`. Both are refused loudly rather
/// than silently dropped.
fn validate_defaults(&self) -> Result<()> {
if self.defaults.server.is_some() && self.defaults.store.is_some() {
bail!(
"operator config `defaults` sets both `server` and `store` — a default scope \
binds one entity; keep one (use a `profile` if you need both)"
);
}
if self.defaults.store.is_some() && self.defaults.default_graph.is_some() {
bail!(
"operator config `defaults` sets both `store` and `default_graph` — a store is \
already a single graph; drop `default_graph` (it applies only to a server/cluster scope)"
);
}
Ok(())
}
}
impl OperatorProfile {
@ -282,6 +312,7 @@ pub(crate) fn load_operator_config_at(path: &Path) -> Result<OperatorConfig> {
for warning in config.unknown_key_warnings() {
eprintln!("warning: {warning} in operator config '{}'", path.display());
}
config.validate_defaults()?;
Ok(config)
}
@ -560,6 +591,42 @@ mod tests {
assert_eq!(config.output(), Some(ReadOutputFormat::Json));
}
#[test]
fn defaults_store_parses_and_is_accessible() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, "defaults:\n store: file:///tmp/dev.omni\n").unwrap();
let config = load_operator_config_at(&path).unwrap();
assert_eq!(config.default_store(), Some("file:///tmp/dev.omni"));
assert_eq!(config.default_server(), None);
}
#[test]
fn defaults_server_and_store_together_is_a_loud_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(
&path,
"defaults:\n server: prod\n store: file:///tmp/dev.omni\n",
)
.unwrap();
let err = load_operator_config_at(&path).unwrap_err().to_string();
assert!(err.contains("binds one entity"), "{err}");
}
#[test]
fn defaults_store_with_default_graph_is_a_loud_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(
&path,
"defaults:\n store: file:///tmp/dev.omni\n default_graph: knowledge\n",
)
.unwrap();
let err = load_operator_config_at(&path).unwrap_err().to_string();
assert!(err.contains("already a single graph"), "{err}");
}
#[test]
fn unknown_keys_warn_but_load() {
// A file written for a later slice (servers/aliases) must load

View file

@ -749,15 +749,10 @@ pub(crate) fn print_snapshot_human(branch: &str, manifest_version: u64, entries:
pub(crate) fn print_read_output(
output: &ReadOutput,
format: ReadOutputFormat,
config: &OmnigraphConfig,
) -> Result<()> {
println!(
"{}",
render_read(
output,
format,
&resolve_table_render_options(config),
)?
render_read(output, format, &resolve_table_render_options())?
);
Ok(())
}
@ -907,21 +902,87 @@ pub(crate) fn finish_logout(
Ok(())
}
/// Table prefs cascade (RFC-007/008): legacy cli.table_* (window) >
/// operator defaults.table_* > built-in.
pub(crate) fn resolve_table_render_options(config: &OmnigraphConfig) -> ReadRenderOptions {
#[derive(Debug, Serialize)]
pub(crate) struct ProfileListItem {
pub(crate) name: String,
/// `server: <n>` / `cluster: <n>` / `store: <uri>` / `invalid: <reason>`.
pub(crate) binding: String,
/// `server` | `cluster` | `store` | `invalid`.
pub(crate) scope_kind: String,
/// The bound server/cluster name, or the store URI. `None` when invalid.
pub(crate) target: Option<String>,
pub(crate) valid: bool,
pub(crate) error: Option<String>,
pub(crate) default_graph: Option<String>,
pub(crate) active: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct ProfileDetail {
/// Profile name, or `(defaults)` for the no-name flat-defaults view.
pub(crate) name: String,
/// `server` | `cluster` | `store` | `none`.
pub(crate) scope_kind: String,
/// The bound server/cluster name, or the store URI.
pub(crate) target: Option<String>,
/// Resolved endpoint: a server's URL / a cluster's root / the store URI;
/// `None` if a named server/cluster isn't defined in this config.
pub(crate) endpoint: Option<String>,
pub(crate) default_graph: Option<String>,
pub(crate) output_format: Option<String>,
}
pub(crate) fn print_profile_list(items: &[ProfileListItem], json: bool) -> Result<()> {
if json {
return print_json(&items);
}
if items.is_empty() {
println!("no profiles defined in the operator config");
return Ok(());
}
for item in items {
let active = if item.active { " (active)" } else { "" };
let graph = item
.default_graph
.as_deref()
.map(|g| format!(" · graph: {g}"))
.unwrap_or_default();
println!("{}{active} {}{graph}", item.name, item.binding);
}
Ok(())
}
pub(crate) fn print_profile_detail(detail: &ProfileDetail, json: bool) -> Result<()> {
if json {
return print_json(detail);
}
println!("profile: {}", detail.name);
let target = detail
.target
.as_deref()
.map(|t| format!(" {t}"))
.unwrap_or_default();
println!(" scope: {}{target}", detail.scope_kind);
if let Some(endpoint) = &detail.endpoint {
println!(" endpoint: {endpoint}");
} else if matches!(detail.scope_kind.as_str(), "server" | "cluster") {
println!(" endpoint: (undefined — name not in this config)");
}
if let Some(graph) = &detail.default_graph {
println!(" default graph: {graph}");
}
if let Some(format) = &detail.output_format {
println!(" output: {format}");
}
Ok(())
}
/// Table prefs cascade (RFC-011): operator defaults.table_* > built-in.
pub(crate) fn resolve_table_render_options() -> ReadRenderOptions {
let operator = crate::operator::load_operator_config().unwrap_or_default();
ReadRenderOptions {
max_column_width: config
.cli
.table_max_column_width
.or(operator.defaults.table_max_column_width)
.unwrap_or(80),
cell_layout: config
.cli
.table_cell_layout
.or(operator.defaults.table_cell_layout)
.unwrap_or_default(),
max_column_width: operator.defaults.table_max_column_width.unwrap_or(80),
cell_layout: operator.defaults.table_cell_layout.unwrap_or_default(),
}
}

View file

@ -82,9 +82,7 @@ impl Capability {
/// classifier) plus the one Data→Served refinement: `graphs` is remote-only.
///
/// This reflects *current enforced behavior*, so messages stay truthful:
/// `queries list` is `Local` (reads config today) and `queries validate` is
/// `Direct` (opens a graph directly today). Both converge to the RFC end-state
/// (served / control) only when later slices re-route them.
/// `queries`/`policy` read a cluster's applied state (`Control`).
pub(crate) fn command_capability(cmd: &Command) -> Capability {
if let Command::Graphs { .. } = cmd {
return Capability::Served;
@ -100,8 +98,7 @@ pub(crate) fn command_capability(cmd: &Command) -> Capability {
/// The plane a subcommand belongs to. Exhaustive — a new `Command` variant
/// will not compile until classified. Descends into the nested enums where
/// the plane differs per subcommand (`schema plan` is storage while `schema
/// show`/`apply` are data; `queries validate` opens the graph while `queries
/// list` only reads config).
/// show`/`apply` are data; `queries`/`policy` read cluster applied state).
pub(crate) fn command_plane(cmd: &Command) -> Plane {
match cmd {
Command::Query { .. }
@ -119,23 +116,22 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane {
Command::Schema {
command: SchemaCommand::Plan { .. },
} => Plane::Storage,
Command::Queries {
command: QueriesCommand::Validate { .. },
} => Plane::Storage,
Command::Queries {
command: QueriesCommand::List { .. },
} => Plane::Session,
// `queries` and `policy` tooling now source their inputs from a
// cluster's applied state (`--cluster`), so they live on the control
// plane (RFC-011 — omnigraph.yaml excised from the CLI).
Command::Queries { .. } => Plane::Control,
Command::Policy { .. } => Plane::Control,
Command::Init { .. }
| Command::Optimize { .. }
| Command::Repair { .. }
| Command::Cleanup { .. }
| Command::Lint { .. } => Plane::Storage,
Command::Cluster { .. } => Plane::Control,
Command::Policy { .. }
Command::Alias { .. }
| Command::Embed(_)
| Command::Login { .. }
| Command::Logout { .. }
| Command::Config { .. }
| Command::Profile { .. }
| Command::Version => Plane::Session,
}
}
@ -147,7 +143,7 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str {
Command::Version => "version",
Command::Login { .. } => "login",
Command::Logout { .. } => "logout",
Command::Config { .. } => "config",
Command::Profile { .. } => "profile",
Command::Embed(_) => "embed",
Command::Init { .. } => "init",
Command::Load { .. } => "load",
@ -168,6 +164,7 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str {
Command::Commit { .. } => "commit",
Command::Query { .. } => "query",
Command::Mutate { .. } => "mutate",
Command::Alias { .. } => "alias",
Command::Policy { .. } => "policy",
Command::Optimize { .. } => "optimize",
Command::Repair { .. } => "repair",
@ -177,35 +174,128 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str {
}
}
/// Reject the data-plane addressing flags (`--server`/`--graph`) on any verb
/// that does not live on the data plane. This replaces the old silent-ignore
/// — e.g. `optimize --server prod` previously dropped `--server` and tried to
/// resolve a default target, failing (if at all) with an unrelated message.
/// Now it fails with one honest, declared error. RFC-010 Slice 1.
/// The verbs that consume a cluster scope. Maintenance/lint select a graph with
/// `--cluster <root> --graph <id>`; policy/queries inspect the cluster's
/// applied control-plane state and may optionally use `--graph` to select one
/// bundle/registry. `init` is storage-plane too but *creates* a graph (cluster
/// graphs are born from `cluster apply`, not `init`), and `schema plan` takes a
/// positional URI, so the guard rejects `--cluster`/`--graph` there rather than
/// silently dropping the flag.
pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool {
matches!(
cmd,
Command::Optimize { .. }
| Command::Repair { .. }
| Command::Cleanup { .. }
// `lint` can type-check a `.gq` against a cluster graph's schema
// (RFC-011): `--cluster <dir> --graph <id>`.
| Command::Lint { .. }
// The policy/queries tooling addresses a cluster's applied state
// (RFC-011): `--cluster <dir>` selects the cluster, `--graph <id>`
// picks a graph's bundle/registry within it.
| Command::Policy { .. }
| Command::Queries { .. }
)
}
/// Reject a scope-addressing flag (`--server`/`--cluster`/`--graph`) on a verb
/// that cannot consume it, rather than silently dropping it (the old behavior:
/// e.g. `optimize --server prod` dropped `--server` and failed later with an
/// unrelated message). `alias` gets an extra guard because its binding owns all
/// addressing and several ignored globals sit outside this three-flag guard.
/// Each flag has a distinct valid surface:
/// - `--server` → served-graph scopes (`any`/`served`);
/// - `--cluster` → cluster-scoped direct/control verbs;
/// - `--graph` → any multi-graph scope: a served scope *or* a cluster one.
/// RFC-010 Slice 1, generalized for RFC-011 cluster addressing.
pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> {
if cli.server.is_none() && cli.graph.is_none() {
if let Command::Alias { .. } = &cli.command {
let mut flags = Vec::new();
if cli.server.is_some() {
flags.push("--server");
}
if cli.graph.is_some() {
flags.push("--graph");
}
if cli.store.is_some() {
flags.push("--store");
}
if cli.cluster.is_some() {
flags.push("--cluster");
}
if cli.profile.is_some() {
flags.push("--profile");
}
if cli.as_actor.is_some() {
flags.push("--as");
}
if !flags.is_empty() {
bail!(
"`alias` uses the server, graph, and stored query declared in \
`aliases.<name>` in ~/.omnigraph/config.yaml; remove global scope \
flag(s): {}",
flags.join(", ")
);
}
}
if cli.server.is_none() && cli.cluster.is_none() && cli.graph.is_none() {
return Ok(());
}
let capability = command_capability(&cli.command);
if capability.accepts_server_addressing() {
return Ok(());
}
let label = command_label(&cli.command);
let how = match capability {
Capability::Direct => match cli.command {
Command::Init { .. } => "Pass a storage URI.",
_ => "Pass a storage URI, or --cluster <dir> --cluster-graph <id>.",
let cluster_ok = accepts_cluster_addressing(&cli.command);
if cli.server.is_some() && !capability.accepts_server_addressing() {
bail!(
"`{label}` is a {} command; --server addresses a served graph and does not apply.{}",
capability.describe(),
remediation(capability, &cli.command),
);
}
if cli.cluster.is_some() && !cluster_ok {
bail!(
"`{label}` is a {} command; --cluster addresses a cluster-scoped command \
and does not apply.{}",
capability.describe(),
remediation(capability, &cli.command),
);
}
if cli.graph.is_some() && !(capability.accepts_server_addressing() || cluster_ok) {
bail!(
"`{label}` is a {} command; --graph selects a graph within a server or cluster \
scope and does not apply.{}",
capability.describe(),
remediation(capability, &cli.command),
);
}
Ok(())
}
/// The "what to do instead" tail for a wrong-address error, by capability.
/// Includes its own leading space when non-empty so the caller appends it
/// directly — an empty tail (the served-addressing capabilities, which only
/// reach this fn for a misplaced `--cluster`/`--graph`) leaves no trailing space.
fn remediation(capability: Capability, cmd: &Command) -> &'static str {
match capability {
Capability::Direct => match cmd {
Command::Init { .. } => " Pass a storage URI.",
Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } => {
" Pass a storage URI, or --cluster <dir> --graph <id>."
}
_ => " Pass a storage URI.",
},
Capability::Control => "It operates on a cluster (pass --config <dir>).",
Capability::Local => "It does not address a graph.",
Capability::Any | Capability::Served => {
unreachable!("served-addressing capabilities returned early")
}
};
bail!(
"`{label}` is a {} command; --server/--graph address a served graph and do not apply. {how}",
capability.describe()
);
Capability::Control => match cmd {
Command::Cluster { .. } => {
" It operates on a cluster config directory (pass --config <dir>)."
}
Command::Policy { .. } | Command::Queries { .. } => {
" It operates on a cluster (pass --cluster <dir|uri>, or select a cluster profile)."
}
_ => " It operates on a cluster.",
},
Capability::Local => " It does not address a graph.",
Capability::Any | Capability::Served => "",
}
}
#[cfg(test)]
@ -235,11 +325,17 @@ mod tests {
// The one Data→Served refinement — if the `graphs` guard were deleted,
// every other assertion here would still pass.
assert_eq!(cap(&["omnigraph", "graphs", "list"]), Capability::Served);
assert_eq!(cap(&["omnigraph", "alias", "who"]), Capability::Local);
assert_eq!(cap(&["omnigraph", "optimize", "graph.omni"]), Capability::Direct);
assert_eq!(cap(&["omnigraph", "schema", "plan", "--schema", "s.pg", "graph.omni"]), Capability::Direct);
assert_eq!(cap(&["omnigraph", "cluster", "status", "--config", "."]), Capability::Control);
assert_eq!(cap(&["omnigraph", "version"]), Capability::Local);
assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Local);
// `queries`/`policy` tooling reads cluster state now (control plane).
assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Control);
assert_eq!(
cap(&["omnigraph", "policy", "validate"]),
Capability::Control
);
}
#[test]

View file

@ -1,9 +1,31 @@
use clap::ValueEnum;
use color_eyre::eyre::Result;
use omnigraph_server::ReadOutputFormat;
use omnigraph_server::api::ReadOutput;
use omnigraph_server::config::TableCellLayout;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// Output rendering format for read-shaped commands (`read`/`query`/`alias`).
/// A CLI presentation concern — lives here, not in the server.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum ReadOutputFormat {
#[default]
Table,
Kv,
Csv,
Jsonl,
Json,
}
/// How an over-wide table cell is laid out when rendering `--format table`.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum TableCellLayout {
#[default]
Truncate,
Wrap,
}
pub struct ReadRenderOptions {
pub max_column_width: usize,
pub cell_layout: TableCellLayout,

View file

@ -42,6 +42,7 @@ pub(crate) struct ScopeFlags<'a> {
pub(crate) profile: Option<&'a str>,
pub(crate) store: Option<&'a str>,
pub(crate) server: Option<&'a str>,
pub(crate) cluster: Option<&'a str>,
pub(crate) graph: Option<&'a str>,
pub(crate) uri: Option<String>,
}
@ -56,17 +57,49 @@ pub(crate) fn resolve_scope(
capability: Capability,
flags: ScopeFlags<'_>,
) -> Result<ResolvedScope> {
// `--store` is its own way to address a graph; combining it with a positional
// URI or `--server` is a contradiction, not a silent precedence.
if flags.store.is_some() && (flags.uri.is_some() || flags.server.is_some()) {
// At most one explicit scope primitive may address a command — a positional
// URI, `--store`, `--server`, or `--cluster` are mutually exclusive ways to
// name the graph. Combining them is a contradiction, not a silent precedence.
let primitives: Vec<&str> = [
flags.uri.as_deref().map(|_| "a positional URI"),
flags.store.map(|_| "--store"),
flags.server.map(|_| "--server"),
flags.cluster.map(|_| "--cluster"),
]
.into_iter()
.flatten()
.collect();
if primitives.len() > 1 {
bail!(
"--store is exclusive with a positional URI and --server — pick one way to \
address the graph"
"{} are mutually exclusive — pick one way to address the graph",
primitives.join(" and ")
);
}
// 1. Any explicit address wins; reproduce today's behavior untouched.
// `--store` is an explicit store URI — fold it into `uri`.
// 1a. `--cluster` is the cluster scope primitive (maintenance): resolve its
// root + select the graph with `--graph`.
if let Some(cluster) = flags.cluster {
return scope_from_binding(
op,
capability,
ScopeBinding::Cluster(cluster.to_string()),
flags.graph.map(str::to_string),
"--cluster",
);
}
// 1b. Any other explicit address wins; reproduce today's behavior untouched.
// `--store` is an explicit store URI — fold it into `uri`.
if flags.uri.is_some() || flags.server.is_some() || flags.store.is_some() {
// `--graph` selects within a multi-graph scope; a bare positional URI /
// `--store` is already a single graph, so a stray `--graph` is an error
// rather than a silently-dropped flag.
if flags.graph.is_some() && flags.server.is_none() {
bail!(
"--graph selects a graph within a server or cluster scope; a positional \
URI / --store is already a single graph"
);
}
return Ok(ResolvedScope {
server: flags.server.map(str::to_string),
graph: flags.graph.map(str::to_string),
@ -107,6 +140,18 @@ pub(crate) fn resolve_scope(
);
}
// 3b. Flat default store scope — the zero-flag local-dev default (RFC-011).
// Mutually exclusive with `defaults.server` (enforced at config load).
if let Some(store) = op.default_store() {
return scope_from_binding(
op,
capability,
ScopeBinding::Store(store.to_string()),
flags.graph.map(str::to_string),
"operator defaults",
);
}
// 4. Nothing resolved — leave the tuple empty; downstream falls through to
// today's behavior (legacy `cli.graph` default or a no-address error).
Ok(ResolvedScope::default())
@ -128,8 +173,8 @@ fn scope_from_binding(
if capability == Capability::Direct {
bail!(
"this command needs direct storage access, but {source} resolves a \
server scope; name storage explicitly with --store <uri> (or a \
--cluster/--cluster-graph for a managed graph)"
server scope; name storage explicitly with --store <uri> (or \
--cluster <dir> --graph <id> for a managed graph)"
);
}
Ok(ResolvedScope {
@ -141,23 +186,25 @@ fn scope_from_binding(
ScopeBinding::Cluster(cluster) => {
if capability == Capability::Any {
bail!(
"{source} resolves a cluster scope, which is maintenance-only; run \
data commands through a server, or use --store <uri> for ad-hoc \
direct access"
"{source} resolves a cluster scope, which is not valid for graph data \
commands; run data commands through a server, or use --store <uri> \
for ad-hoc direct access"
);
}
// A cluster binding is a config name (resolved against `clusters:`)
// or a literal root URI.
let root = if let Some(root) = op.cluster_root(&cluster) {
root.to_string()
} else if cluster.contains("://") {
cluster
} else {
bail!(
"unknown cluster '{cluster}' ({source}); define it under `clusters:` \
in operator config, or use a literal root URI"
);
};
// A cluster value is a config name (resolved against `clusters:`)
// or a literal root: an `s3://`/`file://` URI or a local cluster
// directory. Only a configured name is rewritten; anything else is
// passed through to the cluster-state resolver verbatim, so a bare
// directory path keeps working as it did for per-command `--cluster`.
let root = op
.cluster_root(&cluster)
.map(str::to_string)
.unwrap_or(cluster);
// A cluster holds many graphs; maintenance addresses one at a time.
// When no `--graph`/`default_graph` is given, leave `cluster_graph`
// empty and defer to the async storage-URI resolver (RFC-011 D7),
// which enumerates the catalog: auto-use a sole graph, else error
// and list the candidates.
Ok(ResolvedScope {
cluster: Some(root),
cluster_graph: graph,
@ -192,6 +239,7 @@ mod tests {
profile: None,
store: None,
server: None,
cluster: None,
graph: None,
uri: None,
}
@ -230,7 +278,7 @@ mod tests {
}
#[test]
fn store_is_exclusive_with_positional_uri_and_server() {
fn scope_primitives_are_mutually_exclusive() {
let op = OperatorConfig::default();
for flags in [
ScopeFlags {
@ -243,12 +291,128 @@ mod tests {
server: Some("prod"),
..flags()
},
ScopeFlags {
cluster: Some("./brain"),
uri: Some("file://other.omni".into()),
..flags()
},
ScopeFlags {
cluster: Some("./brain"),
server: Some("prod"),
..flags()
},
] {
let err = resolve_scope(&op, Capability::Any, flags).unwrap_err().to_string();
assert!(err.contains("--store is exclusive"), "{err}");
let err = resolve_scope(&op, Capability::Direct, flags)
.unwrap_err()
.to_string();
assert!(err.contains("mutually exclusive"), "{err}");
}
}
#[test]
fn cluster_flag_resolves_root_and_graph_for_maintenance() {
let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n");
let scope = resolve_scope(
&op,
Capability::Direct,
ScopeFlags {
cluster: Some("brain"),
graph: Some("knowledge"),
..flags()
},
)
.unwrap();
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
}
#[test]
fn cluster_flag_accepts_a_literal_root_uri() {
let op = OperatorConfig::default();
let scope = resolve_scope(
&op,
Capability::Direct,
ScopeFlags {
cluster: Some("s3://bucket/clusters/brain"),
graph: Some("knowledge"),
..flags()
},
)
.unwrap();
assert_eq!(scope.cluster.as_deref(), Some("s3://bucket/clusters/brain"));
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
}
#[test]
fn cluster_scope_without_a_graph_defers_to_catalog_enumeration() {
// RFC-011 D7: with no `--graph`/`default_graph`, resolution no longer
// bails here — it resolves the cluster root and leaves `cluster_graph`
// empty, deferring to the async storage-URI resolver (which enumerates
// the catalog: auto-use a sole graph, else error listing candidates).
let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n");
let scope = resolve_scope(
&op,
Capability::Direct,
ScopeFlags {
cluster: Some("brain"),
..flags()
},
)
.unwrap();
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
assert_eq!(scope.cluster_graph, None);
}
#[test]
fn graph_on_a_bare_store_or_uri_is_rejected() {
let op = OperatorConfig::default();
for flags in [
ScopeFlags {
uri: Some("graph.omni".into()),
graph: Some("knowledge"),
..flags()
},
ScopeFlags {
store: Some("s3://b/g.omni"),
graph: Some("knowledge"),
..flags()
},
] {
let err = resolve_scope(&op, Capability::Any, flags)
.unwrap_err()
.to_string();
assert!(err.contains("already a single graph"), "{err}");
}
}
#[test]
fn flat_default_store_drives_local_verbs() {
// RFC-011: `defaults.store` is the zero-flag local default — no flags,
// no profile → the store URI resolves as the (single-graph) store scope.
let op = cfg("defaults:\n store: file:///tmp/dev.omni\n");
let scope = resolve_scope(&op, Capability::Any, flags()).unwrap();
assert_eq!(scope.uri.as_deref(), Some("file:///tmp/dev.omni"));
assert_eq!(scope.server, None);
}
#[test]
fn flat_default_store_rejects_graph() {
// A store is already a single graph, so `--graph` against a default
// store is a loud error.
let op = cfg("defaults:\n store: file:///tmp/dev.omni\n");
let err = resolve_scope(
&op,
Capability::Any,
ScopeFlags {
graph: Some("knowledge"),
..flags()
},
)
.unwrap_err()
.to_string();
assert!(err.contains("does not apply to a store scope"), "{err}");
}
#[test]
fn flat_default_server_drives_data_verbs() {
let op = cfg("defaults:\n server: prod\n default_graph: knowledge\nservers:\n prod:\n url: https://x\n");
@ -294,6 +458,27 @@ mod tests {
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
}
#[test]
fn profile_cluster_scope_with_graph_override() {
// The deferral closed by this slice: a `--graph` flag overrides a
// profile cluster's default_graph, exactly as it does for a server scope.
let op = cfg(
"clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n default_graph: knowledge\n",
);
let scope = resolve_scope(
&op,
Capability::Direct,
ScopeFlags {
profile: Some("admin"),
graph: Some("archive"),
..flags()
},
)
.unwrap();
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
assert_eq!(scope.cluster_graph.as_deref(), Some("archive")); // flag beats profile default
}
#[test]
fn server_scope_on_maintenance_verb_errors() {
let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n");
@ -316,7 +501,7 @@ mod tests {
)
.unwrap_err()
.to_string();
assert!(err.contains("maintenance-only"), "{err}");
assert!(err.contains("not valid for graph data commands"), "{err}");
}
#[test]

View file

@ -683,51 +683,8 @@ fn cluster_apply_locked_exits_nonzero() {
assert!(!temp.path().join("__cluster/resources").exists());
}
#[test]
fn cluster_apply_uses_cli_actor_from_local_config() {
let temp = tempdir().unwrap();
write_cluster_config_fixture(temp.path());
fs::write(
temp.path().join("omnigraph.yaml"),
"cli:\n actor: act-local\n",
)
.unwrap();
// Phase 1: import once (setup, not under test).
let output = cli()
.current_dir(temp.path())
.arg("cluster")
.arg("import")
.arg("--config")
.arg(temp.path())
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
// Phase 2: apply alone, capturing the echoed actor (idempotent re-runs).
let apply = |extra: &[&str]| {
let mut command = cli();
command.current_dir(temp.path());
for arg in extra {
command.arg(arg);
}
let output = command
.arg("cluster")
.arg("apply")
.arg("--config")
.arg(temp.path())
.arg("--json")
.output()
.unwrap();
let json: serde_json::Value =
serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap();
json["actor"].clone()
};
assert_eq!(apply(&[]), "act-local", "cli.actor is the no-flag default");
assert_eq!(apply(&["--as", "andrew"]), "andrew", "--as overrides cli.actor");
}
/// RFC-007 PR 1: the operator layer joins the actor chain —
/// `--as` > legacy `cli.actor` (RFC-008 window) > `operator.actor` > none.
/// RFC-011: the actor chain is `--as` > `operator.actor` > none. The CLI no
/// longer reads omnigraph.yaml `cli.actor`.
#[test]
fn cluster_apply_uses_operator_actor_from_omnigraph_home() {
let temp = tempdir().unwrap();
@ -771,41 +728,31 @@ fn cluster_apply_uses_operator_actor_from_omnigraph_home() {
json["actor"].clone()
};
// No --as, no omnigraph.yaml: the operator identity applies.
// No --as: the operator identity applies.
assert_eq!(
apply(&[]),
"act-operator",
"operator.actor is the no-flag, no-legacy-config default"
"operator.actor is the no-flag default"
);
// --as still wins over everything.
// --as still wins over the operator layer.
assert_eq!(apply(&["--as", "andrew"]), "andrew");
// A legacy cli.actor (RFC-008 window) outranks the operator layer.
fs::write(
temp.path().join("omnigraph.yaml"),
"cli:\n actor: act-legacy\n",
)
.unwrap();
assert_eq!(
apply(&[]),
"act-legacy",
"legacy cli.actor wins over operator.actor during the deprecation window"
);
}
#[test]
fn cluster_approve_uses_cli_actor_fallback() {
fn cluster_approve_uses_operator_actor_fallback() {
let temp = tempdir().unwrap();
write_cluster_config_fixture(temp.path());
let operator_home = tempdir().unwrap();
fs::write(
temp.path().join("omnigraph.yaml"),
"cli:\n actor: act-local\n",
operator_home.path().join("config.yaml"),
"operator:\n actor: act-operator\n",
)
.unwrap();
// Converge, then remove the graph so a gated delete is pending.
for command in ["import", "apply"] {
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("cluster")
.arg(command)
.arg("--config")
@ -818,6 +765,7 @@ fn cluster_approve_uses_cli_actor_fallback() {
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("cluster")
.arg("approve")
.arg("graph.knowledge")
@ -829,14 +777,17 @@ fn cluster_approve_uses_cli_actor_fallback() {
assert!(output.status.success(), "{output:?}");
let json: serde_json::Value =
serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap();
assert_eq!(json["approved_by"], "act-local");
assert_eq!(json["approved_by"], "act-operator");
// With neither flag nor config: refused with the actionable message.
// With neither flag nor operator config: refused with the actionable
// message (an approval without an approver is meaningless).
let bare = tempdir().unwrap();
write_cluster_config_fixture(bare.path());
let bare_home = tempdir().unwrap();
let output = output_failure(
cli()
.current_dir(bare.path())
.env("OMNIGRAPH_HOME", bare_home.path())
.arg("cluster")
.arg("approve")
.arg("graph.knowledge")
@ -845,11 +796,13 @@ fn cluster_approve_uses_cli_actor_fallback() {
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("--as"), "{stderr}");
assert!(stderr.contains("cli.actor"), "{stderr}");
}
#[test]
fn cluster_commands_ignore_malformed_local_config() {
fn cluster_commands_ignore_legacy_omnigraph_yaml() {
// RFC-011: the CLI never reads omnigraph.yaml for cluster commands — a
// present (even malformed) legacy file is inert. The actor falls back to
// `operator.actor`, then to none (no loud failure on absence).
let temp = tempdir().unwrap();
write_cluster_config_fixture(temp.path());
fs::write(temp.path().join("omnigraph.yaml"), "{{{{ not yaml").unwrap();
@ -873,14 +826,11 @@ fn cluster_commands_ignore_malformed_local_config() {
"cluster {command} touched omnigraph.yaml"
);
}
// import + apply with an explicit --as: the config is never loaded.
for (command, args) in [("import", vec![]), ("apply", vec!["--as", "andrew"])] {
let mut invocation = cli();
invocation.current_dir(temp.path());
for arg in &args {
invocation.arg(arg);
}
let output = invocation
// import + apply (no --as, no operator config): the legacy file is never
// loaded and the no-actor apply succeeds (actor defaults to none).
for command in ["import", "apply"] {
let output = cli()
.current_dir(temp.path())
.arg("cluster")
.arg(command)
.arg("--config")
@ -893,20 +843,6 @@ fn cluster_commands_ignore_malformed_local_config() {
String::from_utf8_lossy(&output.stderr)
);
}
// Only the no-flag actor lookup is allowed to fail, and loudly.
let output = output_failure(
cli()
.current_dir(temp.path())
.arg("cluster")
.arg("apply")
.arg("--config")
.arg(temp.path()),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("omnigraph.yaml") && stderr.contains("--as"),
"the actor-default config read must fail loudly and actionably: {stderr}"
);
}
#[test]
@ -975,7 +911,7 @@ fn optimize_resolves_a_cluster_graph_by_id() {
.arg("optimize")
.arg("--cluster")
.arg(temp.path())
.arg("--cluster-graph")
.arg("--graph")
.arg("knowledge")
.arg("--json"),
);
@ -994,7 +930,7 @@ fn optimize_unknown_cluster_graph_id_errors() {
.arg("optimize")
.arg("--cluster")
.arg(temp.path())
.arg("--cluster-graph")
.arg("--graph")
.arg("does-not-exist")
.arg("--json"),
);
@ -1006,19 +942,80 @@ fn optimize_unknown_cluster_graph_id_errors() {
}
#[test]
fn cluster_flag_requires_cluster_graph() {
// clap enforces both-or-neither.
fn optimize_auto_uses_the_sole_cluster_graph() {
// RFC-011 D7: a cluster with exactly one applied graph needs no --graph —
// the resolver enumerates the catalog and uses the only candidate.
let temp = applied_knowledge_cluster();
let out = output_success(
cli()
.arg("optimize")
.arg("--cluster")
.arg(temp.path())
.arg("--json"),
);
assert!(
parse_stdout_json(&out)["tables"].as_array().is_some(),
"optimize should auto-resolve the sole cluster graph"
);
}
/// Stand up an applied cluster with two graphs (`knowledge`, `archive`).
fn applied_two_graph_cluster() -> tempfile::TempDir {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("people.pg"),
"node Person {\n name: String @key\n age: I32?\n}\n",
)
.unwrap();
fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap();
fs::write(
root.join("cluster.yaml"),
r#"
version: 1
metadata:
name: two-graph
state:
backend: cluster
lock: true
graphs:
knowledge:
schema: ./people.pg
archive:
schema: ./people.pg
policies:
base:
file: ./base.policy.yaml
applies_to: [knowledge, archive]
"#,
)
.unwrap();
init_named_cluster_graph(root, "knowledge", "people.pg");
init_named_cluster_graph(root, "archive", "people.pg");
assert_eq!(cluster_json(root, "import")["ok"], true);
assert_eq!(cluster_json(root, "apply")["converged"], true);
temp
}
#[test]
fn optimize_on_multi_graph_cluster_without_graph_lists_candidates() {
// RFC-011 D7: >1 graph and no --graph → error naming every candidate,
// never an auto-pick.
let temp = applied_two_graph_cluster();
let out = output_failure(
cli()
.arg("optimize")
.arg("--cluster")
.arg(".")
.arg(temp.path())
.arg("--json"),
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("cluster-graph") || stderr.contains("required"),
"expected --cluster to require --cluster-graph; got: {stderr}"
stderr.contains("2 graphs")
&& stderr.contains("archive")
&& stderr.contains("knowledge")
&& stderr.contains("--graph <id>"),
"expected a candidate-listing error; got: {stderr}"
);
}
@ -1042,6 +1039,47 @@ fn init_refuses_a_cluster_managed_path_and_signposts_cluster_apply() {
assert!(!temp.path().join("graphs").join("sneaky.omni").exists());
}
#[test]
fn schema_apply_refuses_a_cluster_managed_graph_and_signposts_cluster_apply() {
// RFC-011 Decision 10: a direct `schema apply` against a cluster-managed
// graph's storage root would bypass the ledger/recovery/approvals, so it is
// refused and points at `cluster apply` (mirrors `init`'s refusal).
let temp = applied_knowledge_cluster();
// A schema that WOULD change the graph (adds `bio`) — so the no-mutation
// assertion below is meaningful, not a no-op re-apply.
fs::write(
temp.path().join("people_v2.pg"),
"node Person {\n name: String @key\n age: I32?\n bio: String?\n}\n",
)
.unwrap();
let out = output_failure(
cli()
.arg("schema")
.arg("apply")
.arg("--schema")
.arg(temp.path().join("people_v2.pg"))
.arg("--store")
.arg(temp.path().join("graphs").join("knowledge.omni")),
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("cluster apply"),
"schema apply against a cluster-managed graph should signpost `cluster apply`; got: {stderr}"
);
// And it bailed BEFORE mutating: the live schema still lacks `bio`.
let show = output_success(
cli()
.arg("schema")
.arg("show")
.arg(temp.path().join("graphs").join("knowledge.omni")),
);
assert!(
!stdout_string(&show).contains("bio"),
"the refused apply must not have changed the live schema; got: {}",
stdout_string(&show)
);
}
#[test]
fn init_outside_a_cluster_still_works() {
// Regression guard: ordinary init (no cluster layout) is unaffected.
@ -1076,7 +1114,7 @@ fn optimize_by_cluster_works_when_catalog_payloads_are_degraded() {
.arg("optimize")
.arg("--cluster")
.arg(temp.path())
.arg("--cluster-graph")
.arg("--graph")
.arg("knowledge")
.arg("--json"),
);

View file

@ -3,6 +3,7 @@
use std::fs;
use omnigraph::db::Omnigraph;
use tempfile::tempdir;
mod support;
@ -236,27 +237,28 @@ fn cluster_e2e_out_of_band_schema_drift_then_apply_converges_it() {
let apply = cluster_json(temp.path(), "apply");
assert_eq!(apply["converged"], true, "{apply}");
// Out-of-band: the live graph evolves, cluster.yaml stays put.
fs::write(
temp.path().join("people_v2.pg"),
r#"
// Out-of-band: the live graph evolves while cluster.yaml stays put. RFC-011
// D10 makes the CLI `schema apply` refuse a cluster-managed graph, so this
// simulates a true bypass — a direct engine apply against the storage root,
// exactly the drift the control plane must still detect and converge.
let people_v2 = r#"
node Person {
name: String @key
age: I32?
bio: String?
}
"#,
)
.unwrap();
output_success(
cli()
.arg("schema")
.arg("apply")
.arg(temp.path().join("graphs/knowledge.omni"))
.arg("--schema")
.arg(temp.path().join("people_v2.pg"))
.arg("--json"),
);
"#;
tokio::runtime::Runtime::new().unwrap().block_on(async {
let db = Omnigraph::open(
temp.path()
.join("graphs/knowledge.omni")
.to_string_lossy()
.as_ref(),
)
.await
.unwrap();
db.apply_schema(people_v2).await.unwrap();
});
// Drift is visible...
let refresh = cluster_json(temp.path(), "refresh");

View file

@ -165,12 +165,87 @@ fn optimize_with_server_flag_errors_wrong_plane() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("`optimize` is a direct (storage-native) command")
&& stderr.contains("--server/--graph address a served graph and do not apply")
&& stderr.contains("Pass a storage URI, or --cluster <dir> --cluster-graph <id>."),
&& stderr.contains("--server addresses a served graph and does not apply")
&& stderr.contains("Pass a storage URI, or --cluster <dir> --graph <id>."),
"wrong-capability guard message not found; got: {stderr}"
);
}
#[test]
fn wrong_address_guard_message_has_no_trailing_space() {
// The remediation tail is empty for served-addressing capabilities, so a
// misplaced --cluster on a data verb must not leave "… does not apply. "
// with a dangling space (error text is observable contract). NO_COLOR keeps
// the assertion off ANSI styling.
let output = output_failure(
cli()
.env("NO_COLOR", "1")
.arg("query")
.arg("--cluster")
.arg("./brain")
.arg("-e")
.arg("query q { Person { id } }"),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("and does not apply."),
"expected the wrong-address message; got: {stderr}"
);
assert!(
!stderr.contains("and does not apply. "),
"trailing space after the message; got: {stderr}"
);
}
#[test]
fn graph_flag_on_a_positional_uri_errors() {
// RFC-011: `--graph` selects within a multi-graph scope (a server or
// cluster). An explicit `--store <uri>` is already a single graph, so
// pairing it with `--graph` is a loud error, not a silently-dropped flag.
// (The guard lets `--graph` reach a data verb; the scope resolver rejects
// it.)
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
let output = output_failure(
cli()
.arg("query")
.arg("--store")
.arg(&graph)
.arg("--graph")
.arg("knowledge")
.arg("-e")
.arg("query q { Person { id } }"),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("already a single graph"),
"expected --graph-on-explicit-store rejection; got: {stderr}"
);
}
#[test]
fn query_by_name_against_a_store_needs_a_server() {
// RFC-011 D3: by-name (catalog) invocation is served-only — the catalog is
// server-owned, so a bare `--store` has nothing to resolve the name
// against. The ad-hoc lane (`-e`/`--query`) is the local alternative.
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
let output = output_failure(
cli()
.arg("query")
.arg("find_people")
.arg("--store")
.arg(&graph),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("needs a server"),
"expected a served-only by-name error; got: {stderr}"
);
}
#[test]
fn optimize_with_remote_target_errors_storage_plane() {
// RFC-010 Slice 1: a maintenance verb pointed at a remote URI fails loudly
@ -454,10 +529,9 @@ query list_people() {
#[test]
fn deprecated_read_and_change_subcommands_emit_warnings() {
// Both subcommands require `--query`/`--query-string`/`--alias`, so
// invoking them with no args will exit non-zero. That's fine --
// we only care that the deprecation warning is printed before the
// argument-required error.
// Both subcommands require `--query`/`--query-string`, so invoking them
// with no args will exit non-zero. That's fine -- we only care that the
// deprecation warning is printed before the argument-required error.
let output = cli().arg("read").output().unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
@ -525,13 +599,15 @@ query list_people() {
}
#[test]
fn query_lint_can_resolve_graph_and_query_from_config() {
fn query_lint_can_resolve_graph_from_store_scope() {
// RFC-011: lint resolves its graph target through `--store` (the direct
// scope), not omnigraph.yaml's cli.graph; the .gq path is plain cwd-relative.
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config_path = temp.path().join("omnigraph.yaml");
init_graph(&graph);
let query_path = temp.path().join("queries.gq");
write_query_file(
&temp.path().join("queries.gq"),
&query_path,
r#"
query list_people() {
match { $p: Person }
@ -539,16 +615,15 @@ query list_people() {
}
"#,
);
write_config(&config_path, &local_yaml_config(&graph));
let output = output_success(
cli()
.arg("query")
.arg("lint")
.arg("--query")
.arg("queries.gq")
.arg("--config")
.arg(&config_path)
.arg(&query_path)
.arg("--store")
.arg(&graph)
.arg("--json"),
);
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
@ -616,7 +691,9 @@ query list_people() {
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("lint requires --schema <schema.pg> or a resolvable graph target")
stderr.contains("lint requires --schema <schema.pg>")
|| stderr.contains("no graph addressed"),
"expected a schema-or-graph-target requirement; got: {stderr}"
);
}
@ -785,10 +862,10 @@ fn read_json_outputs_rows_for_named_query() {
let output = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -817,7 +894,6 @@ fn read_via_store_flag_and_profile_match_positional_uri() {
let output = output_success(
cmd.arg("--query")
.arg(&queries)
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -826,8 +902,8 @@ fn read_via_store_flag_and_profile_match_positional_uri() {
serde_json::from_slice(&output.stdout).unwrap()
};
// Baseline: positional URI.
let baseline = read_rows(cli().arg("query").arg(&graph));
// Baseline: --store names the graph.
let baseline = read_rows(cli().arg("query").arg("--store").arg(&graph));
assert_eq!(baseline["rows"][0]["p.name"], "Alice");
// --store names the same graph directly.
@ -914,43 +990,38 @@ fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() {
);
}
// RFC-011: `policy validate|test|explain` source the Cedar bundle from a
// converged cluster's applied policies (`--cluster <dir>` + `--graph <id>`),
// not omnigraph.yaml's policy.file.
#[test]
fn policy_validate_accepts_valid_policy_file() {
let temp = tempdir().unwrap();
let (config, _) = write_policy_config_fixture(temp.path());
fn policy_validate_accepts_cluster_bundle() {
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
let output = output_success(
cli()
.arg("policy")
.arg("validate")
.arg("--config")
.arg(&config),
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge"),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("policy valid:"));
assert!(stdout.contains("policy.yaml"));
assert!(stdout.contains("[2 actors]"));
}
#[test]
fn policy_validate_fails_for_invalid_policy_file() {
let temp = tempdir().unwrap();
let config = temp.path().join("omnigraph.yaml");
let policy = temp.path().join("policy.yaml");
fs::write(
&config,
r#"
project:
name: policy-test-graph
policy:
file: ./policy.yaml
"#,
)
.unwrap();
fs::write(
&policy,
r#"
fn policy_validate_fails_for_invalid_cluster_bundle() {
// The cluster does not validate a policy bundle's internal rules, so an
// applied-but-malformed bundle reaches `policy validate`, which compiles it
// and surfaces the error (here: a duplicate rule id).
let cluster = converged_loaded_cluster(
"knowledge",
Some(
r#"
version: 1
groups:
team: [act-andrew]
@ -966,26 +1037,42 @@ rules:
actions: [export]
branch_scope: any
"#,
)
.unwrap();
),
);
let output = output_failure(
cli()
.arg("policy")
.arg("validate")
.arg("--config")
.arg(&config),
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("duplicate policy rule id"));
assert!(
stderr.contains("duplicate policy rule id"),
"expected a duplicate-rule error; got: {stderr}"
);
}
#[test]
fn policy_test_runs_declarative_cases() {
let temp = tempdir().unwrap();
let (config, _) = write_policy_config_fixture(temp.path());
fn policy_test_runs_declarative_cases_against_cluster_bundle() {
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
let tests = cluster.path().join("policy.tests.yaml");
fs::write(&tests, POLICY_TESTS_YAML).unwrap();
let output = output_success(cli().arg("policy").arg("test").arg("--config").arg(&config));
let output = output_success(
cli()
.arg("policy")
.arg("test")
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge")
.arg("--tests")
.arg(&tests),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("policy tests passed: 2 cases"));
@ -993,15 +1080,16 @@ fn policy_test_runs_declarative_cases() {
#[test]
fn policy_explain_reports_decision_and_matched_rule() {
let temp = tempdir().unwrap();
let (config, _) = write_policy_config_fixture(temp.path());
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
let allow = output_success(
cli()
.arg("policy")
.arg("explain")
.arg("--config")
.arg(&config)
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge")
.arg("--actor")
.arg("act-andrew")
.arg("--action")
@ -1017,8 +1105,10 @@ fn policy_explain_reports_decision_and_matched_rule() {
cli()
.arg("policy")
.arg("explain")
.arg("--config")
.arg(&config)
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge")
.arg("--actor")
.arg("act-bruno")
.arg("--action")
@ -1032,22 +1122,26 @@ fn policy_explain_reports_decision_and_matched_rule() {
}
#[test]
fn read_can_resolve_uri_from_config() {
fn read_resolves_uri_from_default_store_scope() {
// RFC-011: a zero-flag read resolves its graph from `defaults.store` in the
// operator config (the local-dev default scope) — no omnigraph.yaml.
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
init_graph(&graph);
load_fixture(&graph);
write_config(&config, &local_yaml_config(&graph));
let home = tempdir().unwrap();
std::fs::write(
home.path().join("config.yaml"),
format!("defaults:\n store: {}\n", graph.to_string_lossy()),
)
.unwrap();
let output = output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("read")
.arg("--config")
.arg(&config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -1067,10 +1161,10 @@ fn read_csv_format_outputs_header_and_row_values() {
let output = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -1104,10 +1198,10 @@ fn read_uses_operator_default_output_format() {
command
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#);
@ -1139,10 +1233,10 @@ fn read_jsonl_format_outputs_metadata_header_first() {
let output = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -1174,6 +1268,7 @@ query insert_person($name: String, $age: I32) {
let output = output_success(
cli()
.arg("change")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&mutation_file)
@ -1190,10 +1285,10 @@ query insert_person($name: String, $age: I32) {
let verify = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Eve"}"#)
@ -1205,13 +1300,13 @@ query insert_person($name: String, $age: I32) {
}
#[test]
fn change_can_resolve_uri_and_branch_from_config() {
fn change_resolves_uri_and_default_branch_from_store_scope() {
// RFC-011: a mutate resolves its graph from `--store` and defaults the
// branch to main (no omnigraph.yaml cli.graph / cli.branch).
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
init_graph(&graph);
load_fixture(&graph);
write_config(&config, &local_yaml_config(&graph));
let mutation_file = temp.path().join("config-mutations.gq");
write_query_file(
&mutation_file,
@ -1225,8 +1320,8 @@ query insert_person($name: String, $age: I32) {
let output = output_success(
cli()
.arg("change")
.arg("--config")
.arg(&config)
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&mutation_file)
.arg("--params")
@ -1248,6 +1343,7 @@ fn read_requires_name_for_multi_query_files() {
let output = output_failure(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq")),
@ -1266,6 +1362,7 @@ fn read_supports_inline_query_string() {
let output = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&repo)
.arg("-e")
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
@ -1281,11 +1378,12 @@ fn read_supports_inline_query_string() {
#[test]
fn positional_http_uri_on_a_data_verb_is_rejected() {
// RFC-011: a positional/`--uri` http(s):// URL no longer dispatches to a
// remote server — that requires `--server <url>`.
// RFC-011: a `--store` http(s):// URL no longer dispatches to a remote
// server — that requires `--server <url>`.
let output = output_failure(
cli()
.arg("query")
.arg("--store")
.arg("http://127.0.0.1:1")
.arg("-e")
.arg("query q() { match { $p: Person { } } return { $p } }"),
@ -1293,7 +1391,7 @@ fn positional_http_uri_on_a_data_verb_is_rejected() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("must be addressed with `--server <url>`"),
"expected positional-remote rejection; got: {stderr}"
"expected store-remote rejection; got: {stderr}"
);
}
@ -1331,6 +1429,7 @@ fn change_supports_inline_query_string() {
let output = output_success(
cli()
.arg("change")
.arg("--store")
.arg(&repo)
.arg("--query-string")
.arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }")
@ -1345,6 +1444,7 @@ fn change_supports_inline_query_string() {
let verify = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&repo)
.arg("-e")
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }")
@ -1366,6 +1466,7 @@ fn read_rejects_query_string_combined_with_query() {
let output = output_failure(
cli()
.arg("read")
.arg("--store")
.arg(&repo)
.arg("--query")
.arg(fixture("test.gq"))
@ -1386,7 +1487,7 @@ fn read_rejects_empty_query_string() {
init_graph(&repo);
load_fixture(&repo);
let output = output_failure(cli().arg("read").arg(&repo).arg("-e").arg(""));
let output = output_failure(cli().arg("read").arg("--store").arg(&repo).arg("-e").arg(""));
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("must not be empty"),
@ -1514,6 +1615,160 @@ fn branch_delete_rejects_main() {
assert!(stderr.contains("cannot delete branch 'main'"));
}
// ── RFC-011 Decision 9: write diagnostics + non-local destructive-confirm ──
#[test]
fn write_echoes_resolved_target_to_stderr() {
// Every write echoes its resolved target + access path to stderr; --json
// (stdout) is unaffected. A local load → "(direct, local)".
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
let data = fixture("test.jsonl");
let output = output_success(
cli()
.arg("load")
.arg("--mode")
.arg("append")
.arg("--data")
.arg(&data)
.arg(&graph)
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("omnigraph load →") && stderr.contains("(direct, local)"),
"missing write-target echo; stderr: {stderr}"
);
// stdout still parses as JSON — the echo went to stderr.
let _: Value = serde_json::from_slice(&output.stdout).unwrap();
}
#[test]
fn quiet_suppresses_the_write_target_echo() {
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
let data = fixture("test.jsonl");
let output = output_success(
cli()
.arg("--quiet")
.arg("load")
.arg("--mode")
.arg("append")
.arg("--data")
.arg(&data)
.arg(&graph),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.contains("omnigraph load →"),
"--quiet should suppress the echo; stderr: {stderr}"
);
}
#[test]
fn branch_delete_against_non_local_scope_refuses_without_yes() {
// No bucket needed: the confirm gate fires before the graph is opened.
let output = output_failure(
cli()
.arg("branch")
.arg("delete")
.arg("--store")
.arg("s3://fake-bucket/g.omni")
.arg("feature")
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("refusing destructive `branch delete`") && stderr.contains("--yes"),
"expected a non-local destructive refusal; stderr: {stderr}"
);
}
#[test]
fn branch_delete_against_non_local_scope_passes_gate_with_yes() {
// With --yes the gate is bypassed; the command then fails for an unrelated
// reason (the fake bucket can't be opened), so the refusal must be ABSENT.
let output = output_failure(
cli()
.arg("branch")
.arg("delete")
.arg("--store")
.arg("s3://fake-bucket/g.omni")
.arg("feature")
.arg("--yes")
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.contains("refusing destructive"),
"--yes should bypass the confirm gate; stderr: {stderr}"
);
}
#[test]
fn overwrite_load_against_non_local_scope_refuses_without_yes() {
let output = output_failure(
cli()
.arg("load")
.arg("--mode")
.arg("overwrite")
.arg("--data")
.arg(fixture("test.jsonl"))
.arg("--store")
.arg("s3://fake-bucket/g.omni")
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("refusing destructive `load --mode overwrite`"),
"expected a non-local overwrite refusal; stderr: {stderr}"
);
}
#[test]
fn cleanup_against_non_local_scope_refuses_without_yes() {
// Past the --confirm preview gate, a non-local cleanup still needs --yes.
let output = output_failure(
cli()
.arg("cleanup")
.arg("--store")
.arg("s3://fake-bucket/g.omni")
.arg("--keep")
.arg("5")
.arg("--confirm")
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("refusing destructive `cleanup`"),
"expected a non-local cleanup refusal; stderr: {stderr}"
);
}
#[test]
fn cleanup_against_local_scope_executes_with_confirm() {
// Local cleanup needs no --yes; --confirm alone executes (and echoes).
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
load_fixture(&graph);
let output = output_success(
cli()
.arg("cleanup")
.arg("--keep")
.arg("1")
.arg("--confirm")
.arg(&graph)
.arg("--json"),
);
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(payload["tables"].as_array().is_some(), "{payload}");
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("omnigraph cleanup →"), "stderr: {stderr}");
}
#[test]
fn branch_merge_defaults_target_to_main() {
let temp = tempdir().unwrap();
@ -1663,19 +1918,17 @@ fn snapshot_json_returns_manifest_version_and_tables() {
}
#[test]
fn snapshot_can_resolve_uri_from_config() {
fn snapshot_resolves_uri_from_store_scope() {
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
init_graph(&graph);
load_fixture(&graph);
write_config(&config, &local_yaml_config(&graph));
let output = output_success(
cli()
.arg("snapshot")
.arg("--config")
.arg(&config)
.arg("--store")
.arg(&graph)
.arg("--json"),
);
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
@ -1816,3 +2069,162 @@ fn cli_fails_for_invalid_merge_requests() {
.contains("distinct source and target")
);
}
/// RFC-011 Decision 8: `profile list` / `profile show` inspect the operator
/// config's profiles read-only. Hermetic via OMNIGRAPH_HOME.
fn profile_home() -> tempfile::TempDir {
let home = tempdir().unwrap();
std::fs::write(
home.path().join("config.yaml"),
"operator:\n actor: act-andrew\n\
defaults:\n output: json\n server: prod\n default_graph: knowledge\n\
servers:\n prod:\n url: https://graph.example.com\n\
clusters:\n brain:\n root: s3://acme/clusters/brain\n\
profiles:\n\
\x20 staging:\n server: prod\n default_graph: kb\n\
\x20 brain-admin:\n cluster: brain\n\
\x20 localdev:\n store: file:///data/dev.omni\n\
\x20 broken:\n server: a\n store: b\n",
)
.unwrap();
home
}
#[test]
fn profile_list_names_each_profile_with_its_binding_and_marks_active() {
let home = profile_home();
let out = output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.env("OMNIGRAPH_PROFILE", "staging")
.arg("profile")
.arg("list"),
);
let stdout = stdout_string(&out);
assert!(stdout.contains("staging (active)"), "{stdout}");
assert!(stdout.contains("server: prod"), "{stdout}");
assert!(stdout.contains("cluster: brain"), "{stdout}");
assert!(stdout.contains("store: file:///data/dev.omni"), "{stdout}");
// A malformed (two-scope) profile is reported, not a hard failure.
assert!(stdout.contains("broken") && stdout.contains("invalid:"), "{stdout}");
}
#[test]
fn profile_list_json_shape() {
let home = profile_home();
let out = output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("profile")
.arg("list")
.arg("--json"),
);
let items: Value = serde_json::from_slice(&out.stdout).unwrap();
let brain = items
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "brain-admin")
.unwrap();
assert_eq!(brain["binding"], "cluster: brain");
assert_eq!(brain["scope_kind"], "cluster");
assert_eq!(brain["target"], "brain");
assert_eq!(brain["valid"], true);
assert!(brain["error"].is_null());
assert_eq!(brain["active"], false);
let broken = items
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "broken")
.unwrap();
assert_eq!(broken["scope_kind"], "invalid");
assert_eq!(broken["valid"], false);
assert!(broken["target"].is_null());
assert!(
broken["error"]
.as_str()
.unwrap()
.contains("profile 'broken'")
);
}
#[test]
fn profile_show_resolves_named_scope_endpoints() {
let home = profile_home();
// A cluster profile resolves its root.
let cluster = output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("profile")
.arg("show")
.arg("brain-admin"),
);
let cs = stdout_string(&cluster);
assert!(cs.contains("scope: cluster brain"), "{cs}");
assert!(cs.contains("endpoint: s3://acme/clusters/brain"), "{cs}");
// A store profile shows its URI as the endpoint.
let store = output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("profile")
.arg("show")
.arg("localdev")
.arg("--json"),
);
let detail: Value = serde_json::from_slice(&store.stdout).unwrap();
assert_eq!(detail["scope_kind"], "store");
assert_eq!(detail["endpoint"], "file:///data/dev.omni");
}
#[test]
fn profile_show_without_name_falls_back_to_flat_defaults() {
let home = profile_home();
let out = output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("profile")
.arg("show")
.arg("--json"),
);
let detail: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(detail["name"], "(defaults)");
assert_eq!(detail["scope_kind"], "server");
assert_eq!(detail["endpoint"], "https://graph.example.com");
assert_eq!(detail["default_graph"], "knowledge");
}
#[test]
fn profile_show_without_name_uses_active_env_profile() {
let home = profile_home();
let out = output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.env("OMNIGRAPH_PROFILE", "brain-admin")
.arg("profile")
.arg("show")
.arg("--json"),
);
let detail: Value = serde_json::from_slice(&out.stdout).unwrap();
// No name arg, but $OMNIGRAPH_PROFILE selects brain-admin (not the flat defaults).
assert_eq!(detail["name"], "brain-admin");
assert_eq!(detail["scope_kind"], "cluster");
assert_eq!(detail["endpoint"], "s3://acme/clusters/brain");
// output_format renders as the canonical lowercase value name.
assert_eq!(detail["output_format"], "json");
}
#[test]
fn profile_show_unknown_name_errors() {
let home = profile_home();
let out = output_failure(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("profile")
.arg("show")
.arg("nope"),
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("unknown profile 'nope'"), "{stderr}");
}

View file

@ -2,7 +2,6 @@
//! Moved verbatim from tests/cli.rs in the modularization.
use serde_json::Value;
use tempfile::tempdir;
mod support;
@ -57,227 +56,172 @@ query list_people() {
assert_eq!(stdout_string(&lint_output), stdout_string(&check_output));
}
// Legacy `omnigraph.yaml` `aliases:` invoked via the `--alias` flag were
// removed in RFC-011 D4 — operator aliases now live under `omnigraph alias
// <name>` (the happy path is covered by system_local's operator-alias e2e).
// The legacy file-alias path has no CLI entry point.
#[test]
fn read_alias_from_yaml_config_runs_with_kv_output() {
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
let query = temp.path().join("aliases.gq");
init_graph(&graph);
load_fixture(&graph);
write_query_file(
&query,
&std::fs::read_to_string(fixture("test.gq")).unwrap(),
fn alias_flag_is_removed_from_query() {
// RFC-011 D4: `--alias` no longer exists on query/mutate; use `alias <name>`.
let output = output_failure(cli().arg("query").arg("--alias").arg("who"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unexpected argument") && stderr.contains("--alias"),
"expected clap to reject --alias on query; got: {stderr}"
);
write_config(
&config,
&format!(
"{}aliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n format: kv\n",
local_yaml_config(&graph)
),
);
let output = output_success(
cli()
.arg("read")
.arg("--config")
.arg(&config)
.arg("--alias")
.arg("owner")
.arg("Alice"),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("row 1"));
assert!(stdout.contains("p.name: Alice"));
}
#[test]
fn read_alias_uses_alias_target_without_cli_default_and_accepts_url_like_arg() {
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
let query = temp.path().join("aliases.gq");
let data = temp.path().join("url-like.jsonl");
init_graph(&graph);
write_jsonl(
&data,
r#"{"type":"Person","data":{"name":"https://example.com","age":30}}"#,
);
output_success(
fn alias_unknown_name_errors_listing_defined() {
// Hermetic: an unknown alias fails before any network, listing defined ones.
let home = tempdir().unwrap();
std::fs::write(
home.path().join("config.yaml"),
"servers:\n dev:\n url: https://x\naliases:\n who:\n server: dev\n query: find_person\n",
)
.unwrap();
let output = output_failure(
cli()
.arg("load")
.arg("--mode")
.arg("overwrite")
.arg("--data")
.arg(&data)
.arg(&graph),
.env("OMNIGRAPH_HOME", home.path())
.arg("alias")
.arg("nope"),
);
write_query_file(
&query,
&std::fs::read_to_string(fixture("test.gq")).unwrap(),
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown alias 'nope'") && stderr.contains("who"),
"expected an unknown-alias error listing defined aliases; got: {stderr}"
);
write_config(
&config,
&format!(
"graphs:\n local:\n uri: '{}'\nquery:\n roots:\n - .\npolicy: {{}}\naliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n graph: local\n format: kv\n",
graph.to_string_lossy()
),
);
let output = output_success(
cli()
.arg("read")
.arg("--config")
.arg(&config)
.arg("--alias")
.arg("owner")
.arg("https://example.com"),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("row 1"));
assert!(stdout.contains("p.name: https://example.com"));
}
#[test]
fn change_alias_from_yaml_config_persists_changes() {
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
let config = temp.path().join("omnigraph.yaml");
let query = temp.path().join("mutations.gq");
init_graph(&graph);
load_fixture(&graph);
write_query_file(
&query,
r#"
query insert_person($name: String, $age: I32) {
insert Person { name: $name, age: $age }
fn alias_rejects_global_scope_flags_that_the_binding_owns() {
for (flag, value) in [
("--server", "dev"),
("--graph", "local"),
("--store", "file:///tmp/graph.omni"),
("--cluster", "."),
("--profile", "prod"),
("--as", "act-op"),
] {
let output = output_failure(cli().arg(flag).arg(value).arg("alias").arg("who"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("`alias` uses the server, graph, and stored query")
&& stderr.contains(flag),
"expected {flag} to be rejected by the alias binding guard; got: {stderr}"
);
}
}
"#,
#[test]
fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() {
let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
"queries should point at --cluster, not --config; got: {stderr}"
);
write_config(
&config,
&format!(
"{}aliases:\n add_person:\n command: change\n query: mutations.gq\n name: insert_person\n args: [name, age]\n",
local_yaml_config(&graph)
let output = output_failure(
cli()
.arg("--server")
.arg("prod")
.arg("policy")
.arg("validate"),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
"policy should point at --cluster, not --config; got: {stderr}"
);
}
// RFC-011: `queries validate`/`list` source the registry + schemas from a
// converged cluster's applied state (`--cluster <dir>`), not omnigraph.yaml.
/// Build a converged single-graph cluster (id `knowledge`) with one stored
/// query. `query_block` is the YAML under the graph's `queries:` key.
fn converged_cluster_with_query(query_file: &str, query_src: &str, query_block: &str) -> tempfile::TempDir {
let temp = tempdir().unwrap();
let dir = temp.path();
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
write_query_file(&dir.join(query_file), query_src);
std::fs::write(
dir.join("cluster.yaml"),
format!(
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n{query_block}"
),
);
let output = output_success(
cli()
.arg("change")
.arg("--config")
.arg(&config)
.arg("--alias")
.arg("add_person")
.arg("Eve")
.arg("29")
.arg("--json"),
);
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(payload["affected_nodes"], 1);
let verify = output_success(
cli()
.arg("read")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Eve"}"#)
.arg("--json"),
);
let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap();
assert_eq!(verify_payload["row_count"], 1);
)
.unwrap();
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
temp
}
#[test]
fn queries_validate_exits_zero_on_clean_registry() {
let graph = SystemGraph::loaded();
graph.write_query(
let cluster = converged_cluster_with_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&queries_test_config(
&graph.path().to_string_lossy(),
"find_person",
"find_person.gq",
),
" find_person:\n file: ./find_person.gq\n",
);
let output = output_success(
cli()
.arg("queries")
.arg("validate")
.arg("--config")
.arg(&config),
.arg("--cluster")
.arg(cluster.path()),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("OK"), "stdout:\n{stdout}");
}
#[test]
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",
fn cluster_import_rejects_a_type_broken_query() {
// In the cluster model a stored query is type-checked at the cluster
// boundary (import/apply), so a broken query can never reach the applied
// state `queries validate` reads — the gate is upstream. `Widget` is not in
// the fixture schema, so import must reject it, naming the query.
let temp = tempdir().unwrap();
let dir = temp.path();
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
write_query_file(
&dir.join("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"),
std::fs::write(
dir.join("cluster.yaml"),
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n ghost:\n file: ./ghost.gq\n",
)
.unwrap();
let output = output_failure(cli().arg("cluster").arg("import").arg("--config").arg(dir));
let combined = format!(
"{}{}",
stdout_string(&output),
String::from_utf8_lossy(&output.stderr)
);
let output = output_failure(
cli()
.arg("queries")
.arg("validate")
.arg("--config")
.arg(&config),
);
let stdout = stdout_string(&output);
assert!(
stdout.contains("ghost"),
"validation should name the broken query; stdout:\n{stdout}"
combined.contains("ghost"),
"cluster import must reject the broken query, naming it; got:\n{combined}"
);
}
#[test]
fn queries_list_prints_registered_query() {
let graph = SystemGraph::loaded();
graph.write_query(
let cluster = converged_cluster_with_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
// Exposed with an explicit tool name so the list shows the MCP suffix.
let config = graph.write_config(
"omnigraph.yaml",
&format!(
concat!(
"graphs:\n",
" local:\n",
" uri: '{}'\n",
" queries:\n",
" find_person:\n",
" file: ./find_person.gq\n",
" mcp: {{ expose: true, tool_name: lookup_person }}\n",
"cli:\n",
" graph: local\n",
"policy: {{}}\n",
),
graph.path().to_string_lossy().replace('\'', "''")
),
" find_person:\n file: ./find_person.gq\n",
);
let output = output_success(
cli()
.arg("queries")
.arg("list")
.arg("--config")
.arg(&config),
.arg("--cluster")
.arg(cluster.path()),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("find_person"), "stdout:\n{stdout}");
@ -285,242 +229,37 @@ fn queries_list_prints_registered_query() {
stdout.contains("$name: String"),
"list should show typed params; stdout:\n{stdout}"
);
assert!(
stdout.contains("[mcp: lookup_person]"),
"list should show the MCP tool name for exposed queries; stdout:\n{stdout}"
);
}
#[test]
fn queries_list_requires_graph_selection_for_per_graph_only_registries() {
let graph = SystemGraph::loaded();
graph.write_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&format!(
concat!(
"graphs:\n",
" local:\n",
" uri: '{}'\n",
" queries:\n",
" find_person:\n",
" file: ./find_person.gq\n",
"policy: {{}}\n",
),
graph.path().to_string_lossy().replace('\'', "''")
),
);
let output = output_failure(
cli()
.arg("queries")
.arg("list")
.arg("--config")
.arg(&config),
);
fn queries_validate_requires_a_cluster() {
// RFC-011: with no --cluster (and no cluster profile), the command errors
// loudly rather than reading any omnigraph.yaml.
let output = output_failure(cli().arg("queries").arg("validate"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("local") && stderr.contains("set `cli.graph`"),
"error must name the graph and give a concrete selection hint; stderr:\n{stderr}"
stderr.contains("needs a cluster") || stderr.contains("--cluster"),
"queries validate must require a cluster; stderr:\n{stderr}"
);
}
#[test]
fn queries_list_without_graph_selection_lists_top_level_registry() {
let graph = SystemGraph::loaded();
graph.write_query(
"top_find.gq",
"query top_find($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
concat!(
"queries:\n",
" top_find:\n",
" file: ./top_find.gq\n",
"policy: {}\n",
),
);
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}");
}
#[test]
fn queries_list_unknown_cli_graph_errors() {
// `queries list` opens no graph URI, so unknown-graph validation can't ride
// along on URI resolution the way it does for every other command. An
// unknown `cli.graph` selection must still error (naming the graph) instead
// of silently falling back to the top-level registry and showing the wrong
// (or empty) catalog. (`--target` was removed; `cli.graph` drives selection.)
let graph = SystemGraph::loaded();
graph.write_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&format!(
"graphs:\n local:\n uri: '{}'\n queries:\n find_person:\n file: ./find_person.gq\ncli:\n graph: nonexistent\npolicy: {{}}\n",
graph.path().to_string_lossy().replace('\'', "''"),
),
);
let output = output_failure(cli().arg("queries").arg("list").arg("--config").arg(&config));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("nonexistent"),
"error must name the unknown graph; stderr:\n{stderr}"
);
}
#[test]
fn queries_commands_reject_named_graph_with_populated_top_level_block() {
// A named graph (here via `cli.graph`) uses its own `graphs.<name>` block,
// so a populated top-level `queries:` block would be silently ignored — a
// config the server REFUSES to boot. `queries validate`/`list` must reject
// it too (matching boot) instead of validating/listing the per-graph block
// and giving a false green.
let graph = SystemGraph::loaded();
graph.write_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&format!(
concat!(
"graphs:\n",
" local:\n",
" uri: '{}'\n",
" queries:\n",
" find_person:\n",
" file: ./find_person.gq\n",
"cli:\n",
" graph: local\n",
"queries:\n", // populated top-level block: the coherence violation
" legacy:\n",
" file: ./legacy.gq\n",
"policy: {{}}\n",
),
graph.path().to_string_lossy().replace('\'', "''")
),
);
// Both resolve `local` from cli.graph (no positional URI), so both must
// error and name the graph + the ignored block — like server boot does.
for sub in ["validate", "list"] {
let output = output_failure(cli().arg("queries").arg(sub).arg("--config").arg(&config));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("local") && stderr.contains("queries"),
"`queries {sub}` must reject a named graph with a populated top-level block; stderr:\n{stderr}"
);
}
}
#[test]
fn queries_validate_exits_nonzero_on_duplicate_tool_name() {
// Two exposed queries claiming one MCP tool name is a load-time
// 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 } }",
);
let config = graph.write_config(
"omnigraph.yaml",
&format!(
concat!(
"graphs:\n",
" local:\n",
" uri: '{}'\n",
" queries:\n",
" a:\n",
" file: ./a.gq\n",
" mcp: {{ expose: true, tool_name: dup }}\n",
" b:\n",
" file: ./b.gq\n",
" mcp: {{ expose: true, tool_name: dup }}\n",
"cli:\n",
" graph: local\n",
"policy: {{}}\n",
),
graph.path().to_string_lossy().replace('\'', "''")
),
);
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'"),
"duplicate tool name should be reported naming both queries; stderr:\n{stderr}"
);
}
#[test]
fn queries_validate_positional_uri_ignores_default_graph() {
// A positional URI is anonymous → the schema AND the registry both come
// from top-level, even when `cli.graph` names a graph whose per-graph
// queries would fail. Pins that the URI and registry can't diverge.
let graph = SystemGraph::loaded();
graph.write_query(
"clean.gq",
"query clean($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
);
// `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 } }",
);
let config = graph.write_config(
"omnigraph.yaml",
concat!(
"cli:\n graph: prod\n",
"graphs:\n",
" prod:\n",
" uri: /nonexistent-prod.omni\n",
" queries:\n",
" broken:\n",
" file: ./broken.gq\n",
"queries:\n",
" clean:\n",
" file: ./clean.gq\n",
"policy: {}\n",
),
);
// Positional URI = the real loaded graph; selection is anonymous, so the
// CLEAN top-level registry validates (not prod's broken one).
fn queries_validate_graph_filter_selects_one_graph() {
// A multi-graph cluster: validate scoped to `knowledge` type-checks only
// that graph's registry, ignoring `engineering`'s.
let temp = tempdir().unwrap();
let dir = temp.path();
write_multi_graph_cluster_fixture(dir);
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
let output = output_success(
cli()
.arg("queries")
.arg("validate")
.arg(graph.path())
.arg("--config")
.arg(&config),
);
let stdout = stdout_string(&output);
assert!(
stdout.contains("OK"),
"positional URI must validate the top-level registry, not the cli.graph default; stdout:\n{stdout}"
.arg("--cluster")
.arg(dir)
.arg("--graph")
.arg("knowledge"),
);
assert!(stdout_string(&output).contains("OK"));
}

View file

@ -121,7 +121,7 @@ fn schema_plan_with_server_flag_errors_wrong_plane() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("`schema plan` is a direct (storage-native) command")
&& stderr.contains("Pass a storage URI, or --cluster <dir> --cluster-graph <id>."),
&& stderr.contains("Pass a storage URI."),
"schema plan wrong-capability message not found; got: {stderr}"
);
}
@ -334,7 +334,13 @@ fn schema_apply_json_adds_index_for_existing_property() {
let dataset = snapshot.open("node:Person").await.unwrap();
dataset.load_indices().await.unwrap().len()
});
assert!(after_index_count > before_index_count);
// iss-848: `schema apply` records the `@index` intent but defers the physical
// index build (materialized later by ensure_indices/optimize; on this empty
// table nothing builds anyway). So the physical index count is unchanged.
assert_eq!(
after_index_count, before_index_count,
"schema apply records @index intent but defers the physical build (iss-848)"
);
}
#[test]
@ -540,163 +546,18 @@ fn graphs_subcommand_help_lists_list_only() {
#[test]
fn graphs_list_against_local_uri_errors_with_remote_only_message() {
// RFC-011: `graphs list` is served-only; a `--store` (local) address has no
// enumeration endpoint, so it fails loudly pointing at a server / cluster.
let output = output_failure(
cli()
.arg("graphs")
.arg("list")
.arg("--uri")
.arg("--store")
.arg("/tmp/local"),
);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
stderr.contains("remote multi-graph server URL"),
"expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}"
stderr.contains("remote multi-graph server"),
"expected a remote-server rejection in stderr; got:\n{stderr}"
);
}
/// RFC-008 stage 1: loading a legacy omnigraph.yaml emits the per-key
/// deprecation block (the migration map applied to THIS file), suppressible
/// via OMNIGRAPH_SUPPRESS_YAML_DEPRECATION.
#[test]
fn legacy_config_load_warns_per_key_and_suppression_silences() {
let temp = tempdir().unwrap();
fs::write(
temp.path().join("omnigraph.yaml"),
"cli:\n actor: act-x\ngraphs:\n g:\n uri: /tmp/never-opened\n",
)
.unwrap();
// `graphs list --json` loads the config and exits without touching the
// graph URI.
let output = cli()
.current_dir(temp.path())
.arg("graphs")
.arg("list")
.arg("--json")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("deprecated (RFC-008)") && stderr.contains("`cli.actor` -> `operator.actor`"),
"{stderr}"
);
assert!(stderr.contains("config migrate"), "{stderr}");
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
.arg("graphs")
.arg("list")
.arg("--json")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stderr.contains("deprecated (RFC-008)"), "{stderr}");
}
/// RFC-008 stage 2: `config migrate` proposes the split read-only, applies
/// it with --write (operator merge never clobbers; cluster.yaml emitted),
/// and a second --write is idempotent.
#[test]
fn config_migrate_splits_legacy_config() {
let temp = tempdir().unwrap();
fs::write(
temp.path().join("omnigraph.yaml"),
"graphs:\n prod:\n uri: https://graph.example.com\n bearer_token_env: PROD_TOKEN\ncli:\n actor: act-me\n output_format: json\npolicy:\n file: ./top.policy.yaml\n",
)
.unwrap();
let operator_home = tempfile::tempdir().unwrap();
fs::write(
operator_home.path().join("config.yaml"),
"operator:\n actor: act-existing\n",
)
.unwrap();
// Read-only proposal: names both halves, writes nothing.
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_HOME", operator_home.path())
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
.arg("config")
.arg("migrate")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("team half -> cluster.yaml"), "{stdout}");
assert!(stdout.contains("operator.actor: act-me"), "{stdout}");
assert!(stdout.contains("omnigraph login prod"), "{stdout}");
assert!(!temp.path().join("cluster.yaml").exists());
// --write: cluster.yaml lands; the existing operator actor is KEPT.
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_HOME", operator_home.path())
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
.arg("config")
.arg("migrate")
.arg("--write")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let cluster = fs::read_to_string(temp.path().join("cluster.yaml")).unwrap();
assert!(cluster.contains("version: 1") && cluster.contains(" prod:"), "{cluster}");
let operator_text =
fs::read_to_string(operator_home.path().join("config.yaml")).unwrap();
assert!(operator_text.contains("act-existing"), "{operator_text}");
assert!(!operator_text.contains("act-me"), "existing keys win: {operator_text}");
assert!(operator_text.contains("output: json"), "{operator_text}");
assert!(
operator_text.contains("url: https://graph.example.com"),
"{operator_text}"
);
// Second --write: cluster.yaml exists -> proposal file, no clobber.
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_HOME", operator_home.path())
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
.arg("config")
.arg("migrate")
.arg("--write")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
assert!(temp.path().join("cluster.yaml.proposed").exists());
}
/// RFC-008 stage 4: OMNIGRAPH_NO_LEGACY_CONFIG refuses a present legacy
/// file (pointing at config migrate) but changes nothing on migrated
/// setups with no file.
#[test]
fn strict_mode_refuses_legacy_file_but_not_its_absence() {
let temp = tempdir().unwrap();
fs::write(temp.path().join("omnigraph.yaml"), "cli:\n actor: a\n").unwrap();
let output = cli()
.current_dir(temp.path())
.env("OMNIGRAPH_NO_LEGACY_CONFIG", "1")
.arg("graphs")
.arg("list")
.arg("--json")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("OMNIGRAPH_NO_LEGACY_CONFIG") && stderr.contains("config migrate"),
"{stderr}"
);
// Migrated setup (no file): strict mode is a no-op — a config-loading
// command that tolerates empty defaults succeeds.
let clean = tempdir().unwrap();
let output = cli()
.current_dir(clean.path())
.env("OMNIGRAPH_NO_LEGACY_CONFIG", "1")
.arg("queries")
.arg("list")
.arg("--json")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
}

View file

@ -25,21 +25,23 @@ const KNOWN_DIVERGENCES: &[&str] = &[
// populated by the rows below as they are written
];
/// One matched setup per row: twin graphs + the SAME Cedar bundle on both
/// arms (the local arm via --config top-level policy.file; the server via
/// its config). Returns everything a row needs.
/// One matched setup per row: twin graphs + the parity Cedar bundle on the
/// served arm. The local (`--store`) arm carries no policy (RFC-011); the
/// bundle is permissive for `act-parity`, so the arms still agree.
struct Parity {
_temp: TempDir,
local: std::path::PathBuf,
local_cfg: std::path::PathBuf,
server: TestServer,
}
fn parity() -> Parity {
let (temp, local, remote) = twin_graphs();
let (local_cfg, server_cfg) = parity_configs(temp.path(), &local, &remote);
let server = spawn_server_with_config_env(
&server_cfg,
// RFC-011 cluster-only: the remote arm is served from a converged
// cluster directory (one graph, id `parity`), seeded with the same
// fixture data as the local twin.
let cluster_dir = parity_configs(temp.path(), &local, &remote);
let server = spawn_server_with_cluster_env(
&cluster_dir,
&[(
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
r#"{"act-parity":"parity-tok"}"#,
@ -48,14 +50,13 @@ fn parity() -> Parity {
Parity {
_temp: temp,
local,
local_cfg,
server,
}
}
impl Parity {
fn run(&self, args: &[&str]) -> (std::process::Output, std::process::Output) {
run_both_with_config(&self.local, Some(&self.local_cfg), &self.server.base_url, args)
run_both(&self.local, &self.server.base_url, args)
}
}
@ -83,7 +84,6 @@ fn parity_query() {
"query",
"--query",
query.to_str().unwrap(),
"--name",
"get_person",
"--params",
r#"{"name":"Alice"}"#,
@ -142,7 +142,10 @@ fn parity_branch_create_delete() {
let (l, r) = p.run(&["branch", "create", "--from", "main", "parity-branch", "--json"],
);
assert_parity("branch create", &l, &r);
let (l, r) = p.run(&["branch", "delete", "parity-branch", "--json"],
// `branch delete` is destructive: the served (remote) arm is non-local and
// requires consent (RFC-011 Decision 9), so the row passes `--yes` to test
// the operation itself, not the safety gate. The local arm ignores `--yes`.
let (l, r) = p.run(&["branch", "delete", "parity-branch", "--yes", "--json"],
);
assert_parity("branch delete", &l, &r);
}
@ -229,7 +232,6 @@ fn parity_errors_share_exit_codes() {
"query",
"--query",
query.to_str().unwrap(),
"--name",
"no_such_query",
"--json",
],
@ -249,7 +251,6 @@ fn parity_errors_share_exit_codes() {
"query",
"--query",
query.to_str().unwrap(),
"--name",
"get_person",
"--json",
],

View file

@ -339,6 +339,63 @@ impl SystemGraph {
}
}
/// A converged cluster directory the server can boot from (`--cluster`),
/// serving one graph seeded with the standard fixture. Holds the temp dir
/// alive for the test's lifetime.
pub struct ClusterFixture {
_temp: TempDir,
dir: PathBuf,
}
impl ClusterFixture {
pub fn path(&self) -> &Path {
&self.dir
}
}
/// Build a converged cluster (RFC-011 cluster-only serving) with a single
/// graph `graph_id`, seeded with the `test.jsonl` fixture so reads return
/// data. When `policy_yaml` is `Some`, the bundle is bound to the graph
/// scope. The server boots from the returned path via `--cluster`.
pub fn converged_loaded_cluster(graph_id: &str, policy_yaml: Option<&str>) -> ClusterFixture {
let temp = tempdir().unwrap();
let dir = temp.path().to_path_buf();
fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
let policy_block = match policy_yaml {
Some(source) => {
fs::write(dir.join("graph.policy.yaml"), source).unwrap();
format!(
"policies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [{graph_id}]\n"
)
}
None => String::new(),
};
fs::write(
dir.join("cluster.yaml"),
format!(
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n {graph_id}:\n schema: ./graph.pg\n{policy_block}"
),
)
.unwrap();
output_success(cli().arg("cluster").arg("import").arg("--config").arg(&dir));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(&dir));
let served_root = dir.join("graphs").join(format!("{graph_id}.omni"));
output_success(
cli()
.arg("load")
.arg("--data")
.arg(fixture("test.jsonl"))
.arg("--mode")
.arg("overwrite")
.arg(&served_root),
);
ClusterFixture { _temp: temp, dir }
}
// ---- helpers moved from the monolithic tests/cli.rs ----
#[allow(unused_imports)]
use lance::Dataset;
@ -788,29 +845,94 @@ rules:
.to_string()
}
/// Per-arm config files carrying the same policy. Both arms address the
/// graph by positional URI, so the TOP-LEVEL policy.file applies on each
/// side (single-graph semantics).
pub fn parity_configs(root: &Path, _local_graph: &Path, remote_graph: &Path) -> (PathBuf, PathBuf) {
/// The graph id the parity cluster serves the remote arm under. The
/// remote arm addresses it with `--graph PARITY_GRAPH_ID` (RFC-011: the
/// server is cluster-only, so a graph selector is required).
pub const PARITY_GRAPH_ID: &str = "parity";
/// Build the remote arm's configuration (RFC-011 cluster-only server).
///
/// The remote arm is served from a converged cluster directory whose single
/// graph (id `parity`) carries the parity Cedar bundle (bound to the graph
/// scope). The cluster's derived graph root (`<dir>/graphs/parity.omni`) is
/// seeded with the SAME fixture data as the local twin so the two arms compare
/// like-for-like. The local (`--store`) arm carries no Cedar policy (RFC-011),
/// which is fine because the parity bundle is permissive for `act-parity`.
///
/// `local_graph` is overwritten with a byte-for-byte copy of the cluster's
/// seeded served graph so identity-bearing values that are NOT scrubbed
/// (e.g. `graph_commit_id`, edge `id`s in export) match across the arms —
/// the served graph is the source of truth and the local twin mirrors it.
///
/// Returns the `cluster_dir`. The caller spawns the server with `--cluster`.
pub fn parity_configs(root: &Path, local_graph: &Path, _remote_graph: &Path) -> PathBuf {
let policy = root.join("parity.policy.yaml");
fs::write(&policy, parity_policy_yaml()).unwrap();
let local_cfg = root.join("local.omnigraph.yaml");
// Remote arm: a cluster directory the server boots from. One graph
// (`parity`), schema = the shared fixture, policy bound to the graph.
let cluster_dir = root.join("parity-cluster");
fs::create_dir_all(&cluster_dir).unwrap();
fs::copy(fixture("test.pg"), cluster_dir.join("parity.pg")).unwrap();
fs::copy(&policy, cluster_dir.join("parity.policy.yaml")).unwrap();
fs::write(
&local_cfg,
format!("policy:\n file: {}\n", policy.display()),
)
.unwrap();
let server_cfg = root.join("server.omnigraph.yaml");
fs::write(
&server_cfg,
cluster_dir.join("cluster.yaml"),
format!(
"server:\n graph: parity\ngraphs:\n parity:\n uri: {}\n policy:\n file: {}\n",
remote_graph.display(),
policy.display()
r#"version: 1
metadata:
name: parity
state:
backend: cluster
lock: true
graphs:
{PARITY_GRAPH_ID}:
schema: ./parity.pg
policies:
parity:
file: ./parity.policy.yaml
applies_to: [{PARITY_GRAPH_ID}]
"#
),
)
.unwrap();
(local_cfg, server_cfg)
// Converge the cluster (creates the empty graph at the derived root),
// then seed it with the same fixture data the local twin holds.
output_success(
cli()
.arg("cluster")
.arg("import")
.arg("--config")
.arg(&cluster_dir),
);
output_success(
cli()
.arg("cluster")
.arg("apply")
.arg("--config")
.arg(&cluster_dir),
);
let served_root = cluster_dir
.join("graphs")
.join(format!("{PARITY_GRAPH_ID}.omni"));
output_success(
cli()
.arg("load")
.arg("--data")
.arg(fixture("test.jsonl"))
.arg("--mode")
.arg("overwrite")
.arg(&served_root),
);
// Mirror the seeded served graph into the local twin so both arms hold
// identical ULIDs / commit ids (the served graph is authoritative).
if local_graph.exists() {
fs::remove_dir_all(local_graph).unwrap();
}
copy_dir(&served_root, local_graph);
cluster_dir
}
/// Run one CLI invocation per arm with identical verb args: locally against
@ -821,21 +943,14 @@ pub fn run_both(
local_graph: &Path,
server_url: &str,
args: &[&str],
) -> (std::process::Output, std::process::Output) {
run_both_with_config(local_graph, None, server_url, args)
}
pub fn run_both_with_config(
local_graph: &Path,
local_config: Option<&Path>,
server_url: &str,
args: &[&str],
) -> (std::process::Output, std::process::Output) {
// Address both arms with GLOBAL flags (`--store` / `--server`) appended after
// the verb + its args, so the address is placed correctly regardless of
// subcommand nesting (a positional graph only works for top-level verbs;
// `schema show <graph>` etc. need the global flag). Local = embedded store,
// remote = served.
// remote = served. RFC-011: a direct (`--store`) write carries no Cedar
// policy — the parity policy is permissive for `act-parity` on the served
// arm, so the two arms still agree.
let mut local = cli();
local
.args(args)
@ -843,9 +958,6 @@ pub fn run_both_with_config(
.arg(local_graph)
.arg("--as")
.arg(PARITY_ACTOR);
if let Some(config) = local_config {
local.arg("--config").arg(config);
}
let local_out = local.output().unwrap();
let mut remote = cli();
@ -853,7 +965,11 @@ pub fn run_both_with_config(
.env("OMNIGRAPH_BEARER_TOKEN", PARITY_TOKEN)
.args(args)
.arg("--server")
.arg(server_url);
.arg(server_url)
// RFC-011: the parity server is cluster-only (multi-graph), so the
// remote arm must name the graph it addresses.
.arg("--graph")
.arg(PARITY_GRAPH_ID);
let remote_out = remote.output().unwrap();
(local_out, remote_out)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff