mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
feat(cli): no-default-graph errors list candidate graphs (RFC-011 D7) (#245)
When a server/cluster scope resolves with no --graph and no default_graph, the CLI auto-uses a sole graph (cluster) or errors listing the candidate graph ids (cluster catalog; multi-graph server via best-effort GET /graphs), never a silent pick. GraphClient::resolve becomes async; flat/single-graph servers and happy paths are unaffected.
This commit is contained in:
parent
b395757e21
commit
1bc0ea6b51
10 changed files with 262 additions and 62 deletions
|
|
@ -42,7 +42,7 @@ 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,
|
||||
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;
|
||||
|
|
@ -66,6 +66,44 @@ 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(
|
||||
config: &OmnigraphConfig,
|
||||
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(config, 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,7 +124,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(
|
||||
pub(crate) async fn resolve(
|
||||
config: &OmnigraphConfig,
|
||||
server: Option<&str>,
|
||||
graph: Option<&str>,
|
||||
|
|
@ -102,6 +140,7 @@ impl GraphClient {
|
|||
crate::planes::Capability::Any,
|
||||
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
||||
)?;
|
||||
require_graph_for_multi_graph_server(config, &scope).await?;
|
||||
let (server, graph, uri) = (
|
||||
scope.server.as_deref(),
|
||||
scope.graph.as_deref(),
|
||||
|
|
@ -133,7 +172,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(
|
||||
pub(crate) async fn resolve_with_policy(
|
||||
config: &OmnigraphConfig,
|
||||
server: Option<&str>,
|
||||
graph: Option<&str>,
|
||||
|
|
@ -149,6 +188,7 @@ impl GraphClient {
|
|||
crate::planes::Capability::Any,
|
||||
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
||||
)?;
|
||||
require_graph_for_multi_graph_server(config, &scope).await?;
|
||||
let (server, graph, uri) = (
|
||||
scope.server.as_deref(),
|
||||
scope.graph.as_deref(),
|
||||
|
|
|
|||
|
|
@ -632,11 +632,12 @@ pub(crate) async fn resolve_maintenance_uri(
|
|||
}
|
||||
|
||||
/// Map a resolved direct address to a storage URI: a cluster scope
|
||||
/// (`--cluster <root> --graph <id>`, or a `--profile` cluster binding)
|
||||
/// resolves the graph's storage URI from the **served cluster state** (the
|
||||
/// truth a `--cluster` server serves); otherwise the ordinary positional-URI
|
||||
/// path. The scope resolver guarantees a cluster scope always carries a graph,
|
||||
/// so the mismatched arm is defensive.
|
||||
/// (`--cluster <root> --graph <id>`, or a `--profile` cluster binding) resolves
|
||||
/// the graph's storage URI from the **served cluster state**; otherwise the
|
||||
/// ordinary positional-URI path. When a cluster scope carries no graph
|
||||
/// selection (RFC-011 D7), enumerate the catalog: a sole graph is used
|
||||
/// automatically, otherwise error and list the candidates so the operator can
|
||||
/// pass `--graph <id>`.
|
||||
pub(crate) async fn resolve_storage_uri(
|
||||
config: &OmnigraphConfig,
|
||||
cli_uri: Option<String>,
|
||||
|
|
@ -646,8 +647,32 @@ pub(crate) async fn resolve_storage_uri(
|
|||
) -> Result<String> {
|
||||
match (cluster, cluster_graph) {
|
||||
(Some(cluster), Some(graph_id)) => resolve_cluster_graph_uri(cluster, graph_id).await,
|
||||
(Some(cluster), None) => {
|
||||
let graph_id = resolve_sole_cluster_graph(cluster).await?;
|
||||
resolve_cluster_graph_uri(cluster, &graph_id).await
|
||||
}
|
||||
(None, None) => resolve_local_uri(config, cli_uri, operation),
|
||||
_ => bail!("internal error: a cluster scope was resolved without a graph id"),
|
||||
(None, Some(_)) => {
|
||||
bail!("internal error: a graph was selected without a cluster scope")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the graph for a cluster scope that has no `--graph`/`default_graph`
|
||||
/// (RFC-011 D7): exactly one applied graph → use it; zero → error; more than
|
||||
/// one → error and list the candidates. Never auto-picks among several.
|
||||
async fn resolve_sole_cluster_graph(cluster: &str) -> Result<String> {
|
||||
let ids = omnigraph_cluster::cluster_graph_ids(cluster)
|
||||
.await
|
||||
.map_err(|diagnostic| color_eyre::eyre::eyre!("{}", diagnostic.message))?;
|
||||
match ids.as_slice() {
|
||||
[only] => Ok(only.clone()),
|
||||
[] => bail!("cluster `{cluster}` has no applied graphs; run `cluster apply` first"),
|
||||
many => bail!(
|
||||
"cluster `{cluster}` has {} graphs: [{}]; pass --graph <id> to select one",
|
||||
many.len(),
|
||||
many.join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,8 @@ async fn main() -> Result<()> {
|
|||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let branch = resolve_branch(&config, branch, None, "main");
|
||||
if matches!(mode, CliLoadMode::Overwrite) {
|
||||
confirm_destructive("load --mode overwrite", client.uri(), cli.yes, json)?;
|
||||
|
|
@ -224,7 +225,8 @@ async fn main() -> Result<()> {
|
|||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let branch = resolve_branch(&config, branch, None, "main");
|
||||
let from = resolve_branch(&config, from, None, "main");
|
||||
echo_write_target(cli.quiet, "ingest", client.uri(), client.is_remote());
|
||||
|
|
@ -254,7 +256,8 @@ async fn main() -> Result<()> {
|
|||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let from = resolve_branch(&config, from, None, "main");
|
||||
echo_write_target(cli.quiet, "branch create", client.uri(), client.is_remote());
|
||||
let payload = client.branch_create_from(&from, &name).await?;
|
||||
|
|
@ -277,7 +280,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let payload = client.branch_list().await?;
|
||||
if json {
|
||||
print_json(&payload)?;
|
||||
|
|
@ -302,7 +306,8 @@ async fn main() -> Result<()> {
|
|||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
confirm_destructive("branch delete", client.uri(), cli.yes, json)?;
|
||||
echo_write_target(cli.quiet, "branch delete", client.uri(), client.is_remote());
|
||||
let payload = client.branch_delete(&name).await?;
|
||||
|
|
@ -328,7 +333,8 @@ async fn main() -> Result<()> {
|
|||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let into = resolve_branch(&config, into, None, "main");
|
||||
echo_write_target(cli.quiet, "branch merge", client.uri(), client.is_remote());
|
||||
let payload = client.branch_merge(&source, &into).await?;
|
||||
|
|
@ -359,7 +365,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let payload = client.list_commits(branch.as_deref()).await?;
|
||||
if json {
|
||||
print_json(&payload)?;
|
||||
|
|
@ -381,7 +388,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let commit = client.get_commit(&commit_id).await?;
|
||||
if json {
|
||||
print_json(&commit)?;
|
||||
|
|
@ -436,7 +444,8 @@ async fn main() -> Result<()> {
|
|||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let schema_source = fs::read_to_string(&schema)?;
|
||||
// The stored-query registry check is an embedded-only concern
|
||||
// (the remote arm ignores the validator — the server runs its
|
||||
|
|
@ -477,7 +486,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let output = client.schema_source().await?;
|
||||
if json {
|
||||
print_json(&output)?;
|
||||
|
|
@ -528,7 +538,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let branch = resolve_branch(&config, branch, None, "main");
|
||||
let payload = client.snapshot(&branch).await?;
|
||||
if json {
|
||||
|
|
@ -553,7 +564,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let branch = resolve_branch(&config, branch, None, "main");
|
||||
if jsonl {
|
||||
eprintln!("warning: --jsonl is deprecated; `omnigraph export` always emits JSONL");
|
||||
|
|
@ -590,7 +602,8 @@ async fn main() -> Result<()> {
|
|||
uri.or(legacy_uri),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let query_source =
|
||||
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
|
||||
let params_json = load_params_json(¶ms)?;
|
||||
|
|
@ -625,7 +638,8 @@ async fn main() -> Result<()> {
|
|||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let query_source =
|
||||
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
|
||||
let params_json = load_params_json(¶ms)?;
|
||||
|
|
@ -1005,7 +1019,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let payload = client.list_graphs().await?;
|
||||
if json {
|
||||
print_json(&payload)?;
|
||||
|
|
|
|||
|
|
@ -189,15 +189,13 @@ fn scope_from_binding(
|
|||
.map(str::to_string)
|
||||
.unwrap_or(cluster);
|
||||
// A cluster holds many graphs; maintenance addresses one at a time.
|
||||
let Some(graph) = graph else {
|
||||
bail!(
|
||||
"{source} resolves a cluster scope; pass --graph <id> to select which \
|
||||
graph to maintain"
|
||||
);
|
||||
};
|
||||
// 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: Some(graph),
|
||||
cluster_graph: graph,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
|
@ -334,9 +332,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_scope_without_a_graph_is_a_loud_error() {
|
||||
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 err = resolve_scope(
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
|
|
@ -344,9 +346,9 @@ mod tests {
|
|||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("--graph <id>"), "{err}");
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1006,21 +1006,80 @@ fn optimize_unknown_cluster_graph_id_errors() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_without_graph_demands_a_graph_selector() {
|
||||
// A cluster holds many graphs; `--cluster` alone can't pick one. The scope
|
||||
// resolver demands `--graph <id>` (replacing the old `--cluster-graph`
|
||||
// requirement) before it ever touches cluster state.
|
||||
fn optimize_auto_uses_the_sole_cluster_graph() {
|
||||
// RFC-011 D7: a cluster with exactly one applied graph needs no --graph —
|
||||
// the resolver enumerates the catalog and uses the only candidate.
|
||||
let temp = applied_knowledge_cluster();
|
||||
let out = output_success(
|
||||
cli()
|
||||
.arg("optimize")
|
||||
.arg("--cluster")
|
||||
.arg(temp.path())
|
||||
.arg("--json"),
|
||||
);
|
||||
assert!(
|
||||
parse_stdout_json(&out)["tables"].as_array().is_some(),
|
||||
"optimize should auto-resolve the sole cluster graph"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stand up an applied cluster with two graphs (`knowledge`, `archive`).
|
||||
fn applied_two_graph_cluster() -> tempfile::TempDir {
|
||||
let temp = tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
fs::write(
|
||||
root.join("people.pg"),
|
||||
"node Person {\n name: String @key\n age: I32?\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap();
|
||||
fs::write(
|
||||
root.join("cluster.yaml"),
|
||||
r#"
|
||||
version: 1
|
||||
metadata:
|
||||
name: two-graph
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./people.pg
|
||||
archive:
|
||||
schema: ./people.pg
|
||||
policies:
|
||||
base:
|
||||
file: ./base.policy.yaml
|
||||
applies_to: [knowledge, archive]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
init_named_cluster_graph(root, "knowledge", "people.pg");
|
||||
init_named_cluster_graph(root, "archive", "people.pg");
|
||||
assert_eq!(cluster_json(root, "import")["ok"], true);
|
||||
assert_eq!(cluster_json(root, "apply")["converged"], true);
|
||||
temp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimize_on_multi_graph_cluster_without_graph_lists_candidates() {
|
||||
// RFC-011 D7: >1 graph and no --graph → error naming every candidate,
|
||||
// never an auto-pick.
|
||||
let temp = applied_two_graph_cluster();
|
||||
let out = output_failure(
|
||||
cli()
|
||||
.arg("optimize")
|
||||
.arg("--cluster")
|
||||
.arg(".")
|
||||
.arg(temp.path())
|
||||
.arg("--json"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("--graph <id>"),
|
||||
"expected --cluster to demand --graph; got: {stderr}"
|
||||
stderr.contains("2 graphs")
|
||||
&& stderr.contains("archive")
|
||||
&& stderr.contains("knowledge")
|
||||
&& stderr.contains("--graph <id>"),
|
||||
"expected a candidate-listing error; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1136,5 +1136,27 @@ auth:
|
|||
.collect();
|
||||
assert_eq!(ids, vec!["alpha"]);
|
||||
|
||||
// RFC-011 D7: addressing the multi-graph server via `--server <url>` with no
|
||||
// `--graph` errors and lists the candidate graphs (the resolver probes
|
||||
// GET /graphs; the default-env token authorizes it).
|
||||
let no_graph = cli()
|
||||
.env("OMNIGRAPH_BEARER_TOKEN", "admin-token")
|
||||
.arg("query")
|
||||
.arg("--server")
|
||||
.arg(&server.base_url)
|
||||
.arg("-e")
|
||||
.arg("query q { match { $p: Person { name: \"x\" } } return { $p.name } }")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!no_graph.status.success(),
|
||||
"multi-graph server with no --graph must error"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&no_graph.stderr);
|
||||
assert!(
|
||||
stderr.contains("alpha") && stderr.contains("--graph <id>"),
|
||||
"expected a candidate-listing error naming alpha; got: {stderr}"
|
||||
);
|
||||
|
||||
drop(server);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ mod store;
|
|||
use store::{ClusterStore, StateLockGuard, StateSnapshot};
|
||||
pub use types::*;
|
||||
use types::*;
|
||||
pub use serve::{ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, cluster_root_for_graph_uri, read_serving_snapshot, read_serving_snapshot_from_storage, resolve_graph_storage_uri};
|
||||
pub use serve::{ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, cluster_graph_ids, cluster_root_for_graph_uri, read_serving_snapshot, read_serving_snapshot_from_storage, resolve_graph_storage_uri};
|
||||
use config::{QueriesDecl, observe_declared_graphs, validate_cluster_header, future_field_diagnostics, initial_import_state, observe_live_graph, preview_schema_migration, state_resource_digests, graph_address, policy_address, query_address, schema_address, load_desired, normalize_policy_target, parse_cluster_config, resolve_config_path, resolve_query_decls, validate_id, validate_query_source};
|
||||
use diff::{FailedGraphOrigin, ResourceKind, append_policy_binding_changes, approved_resources, classify_changes, compute_approvals, compute_blast_radius, demote_dependents_of_failed_graphs, diff_resources, resource_kind};
|
||||
use sweep::{mark_approvals_consumed, record_approval_consumed, sweep_recovery_sidecars, tombstone_graph_subtree, warn_pending_recovery_sidecars};
|
||||
|
|
|
|||
|
|
@ -112,28 +112,13 @@ pub async fn cluster_root_for_graph_uri(graph_uri: &str) -> Option<String> {
|
|||
/// `cluster` is a config directory or a storage-root URI (`s3://…`, config-free),
|
||||
/// mirroring the server's `--cluster` dispatch.
|
||||
pub async fn resolve_graph_storage_uri(cluster: &str, graph_id: &str) -> Result<String, Diagnostic> {
|
||||
let backend = if cluster.contains("://") {
|
||||
ClusterStore::for_storage_root(cluster)?
|
||||
} else {
|
||||
ClusterStore::for_config_dir(Path::new(cluster))
|
||||
};
|
||||
let backend = open_cluster_backend(cluster)?;
|
||||
let mut observations = backend.observations();
|
||||
let snapshot = backend.read_state(&mut observations).await?;
|
||||
let state = snapshot.state.ok_or_else(|| {
|
||||
Diagnostic::error(
|
||||
"cluster_state_missing",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("cluster `{cluster}` has no applied state; run `cluster apply` first"),
|
||||
)
|
||||
})?;
|
||||
let state = snapshot.state.ok_or_else(|| missing_state_diagnostic(cluster))?;
|
||||
let address = format!("graph.{graph_id}");
|
||||
if !state.applied_revision.resources.contains_key(&address) {
|
||||
let applied: Vec<&str> = state
|
||||
.applied_revision
|
||||
.resources
|
||||
.keys()
|
||||
.filter_map(|a| a.strip_prefix("graph."))
|
||||
.collect();
|
||||
let applied = applied_graph_ids(&state);
|
||||
return Err(Diagnostic::error(
|
||||
"graph_not_applied",
|
||||
address,
|
||||
|
|
@ -147,6 +132,46 @@ pub async fn resolve_graph_storage_uri(cluster: &str, graph_id: &str) -> Result<
|
|||
Ok(backend.graph_root(graph_id))
|
||||
}
|
||||
|
||||
/// List the graph ids applied in a cluster's served state (sorted). Reads the
|
||||
/// ledger only — no catalog validation — like `resolve_graph_storage_uri`, so
|
||||
/// it works on a degraded cluster. Used to enumerate candidates when no
|
||||
/// `--graph` is selected (RFC-011 Decision 7).
|
||||
pub async fn cluster_graph_ids(cluster: &str) -> Result<Vec<String>, Diagnostic> {
|
||||
let backend = open_cluster_backend(cluster)?;
|
||||
let mut observations = backend.observations();
|
||||
let snapshot = backend.read_state(&mut observations).await?;
|
||||
let state = snapshot.state.ok_or_else(|| missing_state_diagnostic(cluster))?;
|
||||
Ok(applied_graph_ids(&state))
|
||||
}
|
||||
|
||||
fn open_cluster_backend(cluster: &str) -> Result<ClusterStore, Diagnostic> {
|
||||
if cluster.contains("://") {
|
||||
ClusterStore::for_storage_root(cluster)
|
||||
} else {
|
||||
Ok(ClusterStore::for_config_dir(Path::new(cluster)))
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_state_diagnostic(cluster: &str) -> Diagnostic {
|
||||
Diagnostic::error(
|
||||
"cluster_state_missing",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("cluster `{cluster}` has no applied state; run `cluster apply` first"),
|
||||
)
|
||||
}
|
||||
|
||||
fn applied_graph_ids(state: &crate::types::ClusterState) -> Vec<String> {
|
||||
let mut ids: Vec<String> = state
|
||||
.applied_revision
|
||||
.resources
|
||||
.keys()
|
||||
.filter_map(|a| a.strip_prefix("graph."))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Split `<root>/graphs/<id>.omni` → `<root>`, gating on the exact cluster
|
||||
/// graph-layout shape (a single `<id>` segment, no nested path). `None` for
|
||||
/// anything else — no I/O is done for non-cluster-shaped URIs.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue