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:
Andrew Altshuler 2026-06-15 15:48:29 +03:00 committed by GitHub
parent b395757e21
commit 1bc0ea6b51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 262 additions and 62 deletions

View file

@ -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};

View file

@ -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.