mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-12 01:45:14 +02:00
Merge pull request #198 from ModernRelay/feat/operator-targeting
feat(cli): operator targeting — --server + aliases as pure bindings (RFC-007 PR 3)
This commit is contained in:
commit
588b0c1b6c
8 changed files with 414 additions and 11 deletions
|
|
@ -21,6 +21,17 @@ pub(crate) struct Cli {
|
|||
#[arg(long = "as", global = true, value_name = "ACTOR")]
|
||||
pub(crate) as_actor: Option<String>,
|
||||
|
||||
/// Target an operator-defined server by name (RFC-007): resolves to
|
||||
/// its `url` from `servers:` in ~/.omnigraph/config.yaml. Exclusive
|
||||
/// with a positional URI or `--target`.
|
||||
#[arg(long, global = true, value_name = "NAME")]
|
||||
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")]
|
||||
pub(crate) graph: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,6 +264,108 @@ pub(crate) fn resolve_remote_bearer_token(
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
/// `--server <name>` (RFC-007 PR 3): resolve an operator-defined server
|
||||
/// name (+ optional `--graph` for multi-graph servers) to the effective
|
||||
/// remote URI. The result feeds the ordinary `uri` slot, so graph
|
||||
/// resolution and the keyed-token URL match work unchanged — the flag is
|
||||
/// sugar for a URI the operator already owns. Unknown names fail loudly,
|
||||
/// listing what IS defined.
|
||||
pub(crate) fn resolve_server_flag(
|
||||
server: Option<&str>,
|
||||
graph: Option<&str>,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(server) = server else {
|
||||
return Ok(None);
|
||||
};
|
||||
let operator_config = operator::load_operator_config()?;
|
||||
let Some(entry) = operator_config.servers.get(server) else {
|
||||
let known = operator_config
|
||||
.servers
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
color_eyre::eyre::bail!(
|
||||
"unknown server '{server}' — servers defined in the operator config: [{known}] (add it under servers: in ~/.omnigraph/config.yaml)"
|
||||
);
|
||||
};
|
||||
let base = entry.url.trim_end_matches('/');
|
||||
Ok(Some(match graph {
|
||||
Some(graph) => format!("{base}/graphs/{graph}"),
|
||||
None => base.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Execute an OPERATOR alias (RFC-007 PR 3): a pure binding invoking a
|
||||
/// stored query by name on a named server — POST {base}/queries/{name}.
|
||||
/// Param precedence: --params > positional args > the alias's fixed
|
||||
/// params. The keyed token applies via the ordinary URL match.
|
||||
pub(crate) async fn execute_operator_alias(
|
||||
client: &reqwest::Client,
|
||||
config: &OmnigraphConfig,
|
||||
alias_name: &str,
|
||||
alias: &crate::operator::OperatorAlias,
|
||||
alias_args: &[String],
|
||||
explicit_params: Option<Value>,
|
||||
) -> Result<ReadOutput> {
|
||||
let uri = resolve_server_flag(Some(&alias.server), alias.graph.as_deref())?
|
||||
.expect("server name is present");
|
||||
let bearer_token = resolve_remote_bearer_token(config, Some(&uri), None)?;
|
||||
|
||||
let mut params = serde_json::Map::new();
|
||||
for (key, value) in &alias.params {
|
||||
let Some(key) = key.as_str() else {
|
||||
bail!("alias '{alias_name}': params keys must be strings");
|
||||
};
|
||||
params.insert(key.to_string(), serde_json::to_value(value)?);
|
||||
}
|
||||
if alias_args.len() > alias.args.len() {
|
||||
bail!(
|
||||
"alias '{alias_name}' takes {} positional arg(s) ({}), got {}",
|
||||
alias.args.len(),
|
||||
alias.args.join(", "),
|
||||
alias_args.len()
|
||||
);
|
||||
}
|
||||
for (name, value) in alias.args.iter().zip(alias_args) {
|
||||
params.insert(name.clone(), parse_alias_value(value));
|
||||
}
|
||||
if let Some(Value::Object(explicit)) = explicit_params {
|
||||
for (key, value) in explicit {
|
||||
params.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
let body = (!params.is_empty()).then(|| serde_json::json!({ "params": params }));
|
||||
remote_json(
|
||||
client,
|
||||
Method::POST,
|
||||
remote_url(&uri, &format!("/queries/{}", alias.query)),
|
||||
body,
|
||||
bearer_token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Apply `--server`/`--graph` to a command's uri/target slots: exclusive
|
||||
/// with both (loud error, not silent precedence), no-op when absent.
|
||||
pub(crate) fn apply_server_flag(
|
||||
server: Option<&str>,
|
||||
graph: Option<&str>,
|
||||
uri: Option<String>,
|
||||
target: Option<&str>,
|
||||
) -> Result<Option<String>> {
|
||||
if server.is_none() {
|
||||
return Ok(uri);
|
||||
}
|
||||
if uri.is_some() || target.is_some() {
|
||||
color_eyre::eyre::bail!(
|
||||
"--server is exclusive with a positional URI and --target — pick one way to address the graph"
|
||||
);
|
||||
}
|
||||
resolve_server_flag(server, graph)
|
||||
}
|
||||
|
||||
/// The remote base URL a token resolution is FOR — the same scoping
|
||||
/// `graph_bearer_token_env` uses: an explicit http(s) `--uri` wins, else
|
||||
/// the config-resolved target's uri (when remote). Local URIs → None.
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let graph = resolve_cli_graph(&config, uri, target.as_deref())?;
|
||||
|
|
@ -198,6 +200,8 @@ async fn main() -> Result<()> {
|
|||
use `omnigraph load --from <base> --mode <mode>` (ingest defaults: --from main --mode merge)"
|
||||
);
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let graph = resolve_cli_graph(&config, uri, target.as_deref())?;
|
||||
|
|
@ -250,6 +254,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let graph = resolve_cli_graph(&config, uri, target.as_deref())?;
|
||||
|
|
@ -293,6 +299,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let graph = resolve_cli_graph(&config, uri, target.as_deref())?;
|
||||
|
|
@ -328,6 +336,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let graph = resolve_cli_graph(&config, uri, target.as_deref())?;
|
||||
|
|
@ -367,6 +377,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let graph = resolve_cli_graph(&config, uri, target.as_deref())?;
|
||||
|
|
@ -417,6 +429,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
||||
|
|
@ -456,6 +470,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
||||
|
|
@ -519,6 +535,8 @@ async fn main() -> Result<()> {
|
|||
allow_data_loss,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let graph = resolve_cli_graph(&config, uri, target.as_deref())?;
|
||||
|
|
@ -576,6 +594,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
||||
|
|
@ -640,6 +660,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
||||
|
|
@ -675,6 +697,8 @@ async fn main() -> Result<()> {
|
|||
table_keys,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
||||
|
|
@ -722,6 +746,43 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
// Operator aliases (RFC-007 PR 3): pure bindings to stored
|
||||
// queries. A legacy file-alias with the same name wins during
|
||||
// the RFC-008 window (with a warning); an alias name found
|
||||
// only in the operator layer takes the invoke path here.
|
||||
if let Some(alias_name) = alias.as_deref() {
|
||||
let operator_config = crate::operator::load_operator_config()?;
|
||||
if let Some(operator_alias) = operator_config.aliases.get(alias_name) {
|
||||
if config.alias(alias_name).is_ok() {
|
||||
eprintln!(
|
||||
"warning: alias '{alias_name}' is defined in both omnigraph.yaml (legacy, wins during the deprecation window) and the operator config; the legacy definition applies"
|
||||
);
|
||||
} else {
|
||||
// The hidden legacy-uri positional swallows the first
|
||||
// bare arg; an operator alias always knows its target,
|
||||
// so reclaim it as the first positional param.
|
||||
let (_, alias_args) = normalize_legacy_alias_uri(
|
||||
legacy_uri.clone(),
|
||||
true,
|
||||
Some(alias_name),
|
||||
alias_args.clone(),
|
||||
);
|
||||
let output = execute_operator_alias(
|
||||
&http_client,
|
||||
&config,
|
||||
alias_name,
|
||||
operator_alias,
|
||||
&alias_args,
|
||||
load_params_json(¶ms)?,
|
||||
)
|
||||
.await?;
|
||||
let format =
|
||||
resolve_read_format(&config, format, json, operator_alias.format);
|
||||
print_read_output(&output, format, &config)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
let alias = resolve_alias(&config, alias.as_deref(), AliasCommand::Read)?;
|
||||
let alias_name = alias.as_ref().map(|(name, _)| *name);
|
||||
let alias_config = alias.as_ref().map(|(_, alias)| *alias);
|
||||
|
|
@ -736,6 +797,7 @@ async fn main() -> Result<()> {
|
|||
let target_name = target
|
||||
.as_deref()
|
||||
.or_else(|| alias_config.and_then(|alias| alias.graph.as_deref()));
|
||||
let uri = apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target_name)?;
|
||||
let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target_name)?;
|
||||
let graph = resolve_cli_graph(&config, uri, target_name)?;
|
||||
let uri = graph.uri.clone();
|
||||
|
|
@ -822,6 +884,7 @@ async fn main() -> Result<()> {
|
|||
let target_name = target
|
||||
.as_deref()
|
||||
.or_else(|| alias_config.and_then(|alias| alias.graph.as_deref()));
|
||||
let uri = apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target_name)?;
|
||||
let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target_name)?;
|
||||
let graph = resolve_cli_graph(&config, uri, target_name)?;
|
||||
let uri = graph.uri.clone();
|
||||
|
|
@ -1177,6 +1240,8 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri =
|
||||
apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?;
|
||||
let bearer_token =
|
||||
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
|
||||
let uri = resolve_uri(&config, uri, target.as_deref())?;
|
||||
|
|
|
|||
|
|
@ -38,12 +38,38 @@ pub(crate) struct OperatorConfig {
|
|||
/// can redefine an entry here. No tokens in this file, ever.
|
||||
#[serde(default)]
|
||||
pub(crate) servers: BTreeMap<String, OperatorServer>,
|
||||
/// Personal alias bindings (RFC-007 PR 3); see OperatorAlias.
|
||||
#[serde(default)]
|
||||
pub(crate) aliases: BTreeMap<String, OperatorAlias>,
|
||||
/// Everything this CLI version doesn't know. Warned once at load,
|
||||
/// otherwise ignored (forward compatibility within the operator layer).
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
/// A personal alias: a pure BINDING to a stored query on a named server —
|
||||
/// never content, never a file (RFC-007 §D2 "Aliases are bindings, not
|
||||
/// content"). The stored query is the team's contract; the alias, its
|
||||
/// defaults, and its name are the operator's.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OperatorAlias {
|
||||
/// Names an entry under `servers:`.
|
||||
pub(crate) server: String,
|
||||
/// Graph id for multi-graph servers (appends `/graphs/<id>`).
|
||||
pub(crate) graph: Option<String>,
|
||||
/// The STORED query's name on that server.
|
||||
pub(crate) query: String,
|
||||
/// Positional CLI args bind to these param names, in order.
|
||||
#[serde(default)]
|
||||
pub(crate) args: Vec<String>,
|
||||
/// Fixed default params; positionals and `--params` override per key.
|
||||
#[serde(default)]
|
||||
pub(crate) params: serde_yaml::Mapping,
|
||||
pub(crate) format: Option<ReadOutputFormat>,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OperatorServer {
|
||||
pub(crate) url: String,
|
||||
|
|
@ -163,6 +189,9 @@ impl OperatorConfig {
|
|||
for (name, server) in &self.servers {
|
||||
collect(&server.unknown, &format!("servers.{name}."));
|
||||
}
|
||||
for (name, alias) in &self.aliases {
|
||||
collect(&alias.unknown, &format!("aliases.{name}."));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
|
@ -425,10 +454,8 @@ mod tests {
|
|||
let config = load_operator_config_at(&path).unwrap();
|
||||
assert_eq!(config.actor(), Some("act-a"));
|
||||
let warnings = config.unknown_key_warnings();
|
||||
// `servers` became a known key in PR 2; `aliases` stays unknown
|
||||
// until PR 3.
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("`aliases`")));
|
||||
// `servers` (PR 2) and `aliases` (PR 3) are known keys now.
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("`operator.color`")));
|
||||
assert_eq!(config.servers["prod"].url, "https://example.com");
|
||||
}
|
||||
|
|
@ -467,6 +494,25 @@ mod tests {
|
|||
assert_eq!(config.find_server_for_url("http://other:9999"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_lookup_supports_targeting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"servers:\n intel-dev:\n url: http://127.0.0.1:8080/\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
// the --server resolution shape: bare url and graph-scoped url
|
||||
let base = config.servers["intel-dev"].url.trim_end_matches('/');
|
||||
assert_eq!(base, "http://127.0.0.1:8080");
|
||||
assert_eq!(
|
||||
format!("{base}/graphs/spike"),
|
||||
"http://127.0.0.1:8080/graphs/spike"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_env_name_uppercases_and_underscores() {
|
||||
assert_eq!(token_env_name("intel-dev"), "OMNIGRAPH_TOKEN_INTEL_DEV");
|
||||
|
|
|
|||
|
|
@ -2424,3 +2424,124 @@ fn local_cli_keyed_credentials_authenticate_url_matched_server() {
|
|||
let output = remote_read(&[]);
|
||||
assert!(!output.status.success(), "logout must revoke access");
|
||||
}
|
||||
|
||||
/// RFC-007 PR 3: --server targeting and operator aliases (pure bindings to
|
||||
/// stored queries) end to end, with the keyed credential from PR 2.
|
||||
#[test]
|
||||
fn local_cli_operator_alias_and_server_flag_invoke_stored_query() {
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
"stored-find-person.gq",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }",
|
||||
);
|
||||
// invoke_query is policy-gated (anti-probing 404 without the grant),
|
||||
// so the server gets a per-graph bundle granting it to the operator.
|
||||
graph.write_file(
|
||||
"graph.policy.yaml",
|
||||
"version: 1\ngroups:\n ops: [\"act-op\"]\nprotected_branches: [main]\nrules:\n - id: allow-invoke\n allow:\n actors: { group: ops }\n actions: [invoke_query]\n - id: allow-read\n allow:\n actors: { group: ops }\n actions: [read]\n branch_scope: any\n",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph-server.yaml",
|
||||
&format!(
|
||||
"graphs:\n local:\n uri: {}\n policy:\n file: ./graph.policy.yaml\n queries:\n find_person:\n file: ./stored-find-person.gq\n",
|
||||
yaml_string(&graph.path().to_string_lossy())
|
||||
),
|
||||
);
|
||||
let server = spawn_server_with_config_env(
|
||||
&config,
|
||||
&[(
|
||||
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
|
||||
r#"{"act-op":"srv-tok"}"#,
|
||||
)],
|
||||
);
|
||||
|
||||
let operator_home = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
operator_home.path().join("config.yaml"),
|
||||
format!(
|
||||
"servers:\n dev:\n url: {}\naliases:\n who:\n server: dev\n graph: local\n query: find_person\n args: [name]\n",
|
||||
server.base_url
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
operator_home.path().join("credentials"),
|
||||
"[dev]\ntoken = srv-tok\n",
|
||||
)
|
||||
.unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(
|
||||
operator_home.path().join("credentials"),
|
||||
fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// The operator alias: name + positional arg, nothing else — server,
|
||||
// graph, stored query, and token all resolve from the operator layer.
|
||||
let output = cli()
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("query")
|
||||
.arg("--alias")
|
||||
.arg("who")
|
||||
.arg("Alice")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"operator alias must invoke the stored query: {output:?}"
|
||||
);
|
||||
let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["rows"][0]["p.name"], "Alice", "{payload}");
|
||||
|
||||
// --server/--graph: the same stored query via explicit targeting.
|
||||
let output = cli()
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("query")
|
||||
.arg("--server")
|
||||
.arg("dev")
|
||||
.arg("--graph")
|
||||
.arg("local")
|
||||
.arg("--query-string")
|
||||
.arg("query q($name: String) { match { $p: Person { name: $name } } return { $p.name } }")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#)
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
|
||||
// Unknown --server errors listing what IS defined.
|
||||
let output = cli()
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("query")
|
||||
.arg("--server")
|
||||
.arg("nope")
|
||||
.arg("--query-string")
|
||||
.arg("query q() { match { $p: Person } return { $p.name } }")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("unknown server 'nope'") && stderr.contains("dev"), "{stderr}");
|
||||
|
||||
// --server is exclusive with a positional URI.
|
||||
let output = cli()
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("query")
|
||||
.arg(&server.base_url)
|
||||
.arg("--server")
|
||||
.arg("dev")
|
||||
.arg("--query-string")
|
||||
.arg("query q() { match { $p: Person } return { $p.name } }")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains("exclusive"),
|
||||
"{output:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,10 +137,13 @@ servers: # operator-owned endpoint definitions
|
|||
# No token here, ever. Resolution: §D4.
|
||||
|
||||
aliases: # personal shorthand over CLUSTER-owned queries
|
||||
triage: # (the query is the shared contract; the alias,
|
||||
server: intel-dev # its defaults, and its name are mine — RFC-008)
|
||||
graph: spike
|
||||
query: weekly_triage
|
||||
triage:
|
||||
server: intel-dev # required: names an operator server above
|
||||
graph: spike # optional (omit for single-mode servers)
|
||||
query: weekly_triage # STORED query name on that server — never a file
|
||||
args: [since] # positional CLI args -> params, in order
|
||||
params: { limit: 20 } # optional fixed defaults (positionals/--params win)
|
||||
format: table # optional; feeds the format cascade
|
||||
|
||||
defaults:
|
||||
output: table # read --format default
|
||||
|
|
@ -151,6 +154,38 @@ written by a newer CLI must not brick an older one; contrast with
|
|||
`cluster.yaml`, where unknown keys are deliberately fatal because they
|
||||
change what a *plan* means).
|
||||
|
||||
#### Aliases are bindings, not content
|
||||
|
||||
Three things must not be conflated:
|
||||
|
||||
- **Stored queries (the cluster catalog)** are *content plus its canonical,
|
||||
team-owned name* — reviewed, digest-pinned, invocable by name over HTTP.
|
||||
- **Legacy `omnigraph.yaml` aliases** conflate a personal name with a
|
||||
pointer to query *content in a local file* — which is why they break
|
||||
across directories and can drift from the catalog. RFC-008 retires them.
|
||||
- **Operator aliases** are pure **bindings, zero content**: a personal name
|
||||
→ (server, graph, stored-query *name*, arg mapping, defaults). An alias
|
||||
that carries content competes with the catalog; an alias that references
|
||||
a name composes with it.
|
||||
|
||||
The three senses of "global", resolved by this split:
|
||||
|
||||
1. **Across graphs/servers** — preserved and strengthened: today's aliases
|
||||
are "global" only within one per-directory config file; operator
|
||||
aliases live in one `$HOME` file, each binding self-contained, usable
|
||||
from any cwd.
|
||||
2. **Across operators (team-shared shorthand)** — deliberately *no alias
|
||||
mechanism*: the shared name IS the stored query's catalog name. A team
|
||||
that wants a shorter shared name renames the query in `cluster.yaml`
|
||||
(reviewed, one name). A parallel team-alias namespace would be two
|
||||
shared names for one thing — pure drift surface.
|
||||
3. **Across machines** — dotfile the one operator file; bindings carry no
|
||||
local-file dependencies.
|
||||
|
||||
Collision rule during the RFC-008 window: a legacy file-alias with the
|
||||
same name **wins**, with a warning naming both definitions — consistent
|
||||
with §D3's legacy-outranks-operator ordering.
|
||||
|
||||
### D3. Precedence and the merge rule
|
||||
|
||||
The end-state cascade is short, because the team surface (cluster config)
|
||||
|
|
@ -251,7 +286,7 @@ Three PRs, each independently useful, each landable without the next:
|
|||
§D4 chain (env + credentials file), the §D5 trust rules, and
|
||||
`omnigraph login <name>` (atomic write, `0600`). Legacy mechanisms
|
||||
untouched and tested-as-untouched.
|
||||
3. **PR 3 — operator targeting.** `--server <name>` on remote-capable
|
||||
3. **PR 3 — operator targeting** *(landed)*. `--server <name>` on remote-capable
|
||||
commands and `aliases:` in the operator layer (server + graph + query +
|
||||
default params), resolving through operator-defined servers. This is
|
||||
the *bridge* toward RFC-002's locator — multi-server addressing in a
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ The full `OmnigraphConfig` surface (verified against
|
|||
| `cli.actor` | identity | `operator.actor` (RFC-007 §D3) |
|
||||
| `cli.output_format`, `cli.table_*` | personal ergonomics | `defaults:` in operator config (RFC-007 §D2) |
|
||||
| `cli.graph`, `cli.branch` | personal targeting | operator config: named servers + a per-operator default target (RFC-007 PR 3) |
|
||||
| `aliases.<name>` | personal ergonomics over shared queries | operator config `aliases:` — the *queries* they invoke are cluster-owned; the *shorthand* is personal |
|
||||
| `aliases.<name>` | a personal name conflated with a content pointer | **splits in two** (RFC-007 §D2 "bindings, not content"): the referenced `.gq` file's *content* becomes a catalog stored query (team-reviewed); the *binding* becomes an operator alias referencing that name. `config migrate` proposes both halves but cannot publish catalog content itself — that is a `cluster apply` |
|
||||
| `query.roots` | discovery convenience | obsolete — cluster query discovery (#183) replaced it |
|
||||
| `project.name` | label | dropped (the cluster's `metadata.name` is the deployment label) |
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
A reference for the `omnigraph` binary's command surface and `omnigraph.yaml` schema. For a quick-start guide, see [cli.md](cli.md).
|
||||
|
||||
Top-level command families and subcommands. Graph-targeting commands accept either a positional `URI`, `--uri`, or a `--target <name>` resolved against `omnigraph.yaml`; `cluster` commands use `--config <dir>`.
|
||||
Top-level command families and subcommands. Graph-targeting commands accept a positional `URI`, `--uri`, a `--target <name>` resolved against `omnigraph.yaml`, or `--server <name>` (an operator-defined server from `~/.omnigraph/config.yaml`, optionally with `--graph <id>` for multi-graph servers; exclusive with the other forms); `cluster` commands use `--config <dir>`.
|
||||
|
||||
## Top-level commands
|
||||
|
||||
|
|
@ -67,6 +67,29 @@ refused). Token from `--token`, or — preferred, keeps it out of shell
|
|||
history — one line on stdin: `echo $TOKEN | omnigraph login prod`.
|
||||
`omnigraph logout <name>` removes it (idempotent).
|
||||
|
||||
#### Operator aliases — bindings, not content
|
||||
|
||||
An operator alias is a personal name for *invoking a stored query on a
|
||||
named server* — it carries no query content (the stored query in the
|
||||
catalog is the team's contract; the alias, its defaults, and its name are
|
||||
yours):
|
||||
|
||||
```yaml
|
||||
aliases:
|
||||
triage:
|
||||
server: intel-dev # names an entry under servers:
|
||||
graph: spike # optional (multi-graph servers)
|
||||
query: weekly_triage # the STORED query's name — never a file
|
||||
args: [since] # positional args -> params, in order
|
||||
params: { limit: 20 } # fixed defaults; positionals/--params win
|
||||
format: table
|
||||
```
|
||||
|
||||
`omnigraph query --alias triage 2026-06-01` invokes
|
||||
`POST <server>/graphs/spike/queries/weekly_triage` with the keyed
|
||||
credential. A legacy `omnigraph.yaml` alias with the same name wins during
|
||||
the deprecation window (with a warning).
|
||||
|
||||
A remote command whose URL prefix-matches an operator server's `url` (the
|
||||
`gh` host model — no flags needed) resolves its token through:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue