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]