Merge origin/main into MR-656; retrofit + fold-in run_query

Resolves the 4 hard conflicts from PR #119 (multi-graph server mode,
MR-668) landing on main:

* `crates/omnigraph-cli/src/main.rs` imports: drop unused `ChangeRequest`,
  take main's `GraphListResponse`.
* `crates/omnigraph-server/src/api.rs`: keep branch's `ChangeRequest`
  field rename (`query_source` -> `query` with serde alias, `query_name`
  -> `name`); accept main's rustfmt.
* `crates/omnigraph-server/src/lib.rs`: take both import lists (branch's
  `QueryRequest` + main's `GraphInfo`/`GraphListResponse`); rewrite the
  `server_change` signature to combine the branch's `run_mutate`
  extraction with main's `Extension<Arc<GraphHandle>>` + `ResolvedActor`
  parameter shape.
* `docs/user/server.md`: re-apply the branch's new `/query` and `/mutate`
  rows plus deprecation notes for `/read` and `/change` on top of main's
  two-column (single-mode | multi-mode) table layout.

Auto-merged but stale callsites repaired alongside the conflict
resolutions so the merge commit compiles:

* `server_query` handler now takes `Extension(handle): Extension<Arc<GraphHandle>>`
  and `Option<Extension<ResolvedActor>>`, with policy read from
  `handle.policy.as_deref()` instead of the removed `state.policy_engine()`.

Fold-in for MR-969 (next-step seam):

* Extract `run_query` mirroring `run_mutate`: both helpers now take
  `(state, handle, actor, query: &str, name: Option<&str>,
  params_json: Option<&Value>, branch, ...)` instead of the
  `QueryRequest` / `ChangeRequest` body type. The future
  `/queries/{name}` handler can call these with registry-supplied
  fields without rebuilding the request shape.
* `server_query` / `server_read` now route through `run_query`;
  `server_mutate` / `server_change` route through `run_mutate`.
* D2 mutation rejection on `/query` is preserved via the
  `reject_mutations` flag; `/read` keeps the legacy permissive
  behavior for byte-stable back-compat.

`cargo test -p omnigraph-server --test server`: 89 passed, 0 failed.
`cargo build --workspace --tests --locked`: clean.

Refs: MR-656, MR-668, MR-969.
This commit is contained in:
Ragnor Comerford 2026-05-29 11:35:06 +02:00
commit 221f427a73
No known key found for this signature in database
58 changed files with 5898 additions and 887 deletions

View file

@ -18,10 +18,11 @@ use omnigraph_compiler::{
};
use omnigraph_server::api::{
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput,
CommitOutput, ErrorOutput, ExportRequest, IngestOutput, IngestRequest, ReadOutput, ReadRequest,
SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, SnapshotTableOutput,
commit_output, ingest_output, read_output, schema_apply_output, snapshot_payload,
BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput,
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, ReadOutput,
ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput,
SnapshotTableOutput, commit_output, ingest_output, read_output, schema_apply_output,
snapshot_payload,
};
use omnigraph_server::{
AliasCommand, OmnigraphConfig, PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest,
@ -73,6 +74,13 @@ enum Command {
schema: PathBuf,
/// Graph URI (local path or s3://)
uri: String,
/// Overwrite existing schema artifacts at the URI. Without
/// this flag, init refuses to touch a URI that already holds
/// `_schema.pg`, `_schema.ir.json`, or `__schema_state.json`
/// — closes the re-init footgun (MR-668 follow-up). With the
/// flag, the operator opts in to destructive semantics.
#[arg(long)]
force: bool,
},
/// Load data into a graph
Load {
@ -285,6 +293,33 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Manage graphs on a multi-graph server (MR-668)
Graphs {
#[command(subcommand)]
command: GraphsCommand,
},
}
/// Operations on the graph registry of a multi-graph server (MR-668).
///
/// All operations target a remote multi-graph server URL (http:// or
/// https://). Local-URI invocations return a clear error. To add or
/// remove graphs, operators edit `omnigraph.yaml` directly and restart
/// the server — runtime mutation is not exposed in v0.6.0.
#[derive(Debug, Subcommand)]
enum GraphsCommand {
/// List every graph registered with the multi-graph server.
List {
/// Remote server URL (e.g. `https://server.example.com`).
#[arg(long)]
uri: Option<String>,
#[arg(long)]
target: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
@ -707,7 +742,7 @@ fn resolve_policy_engine(config: &OmnigraphConfig) -> Result<PolicyEngine> {
let policy_file = config
.resolve_policy_file()
.ok_or_else(|| color_eyre::eyre::eyre!("policy.file must be set in omnigraph.yaml"))?;
PolicyEngine::load(&policy_file, &policy_graph_id(config))
PolicyEngine::load_graph(&policy_file, &policy_graph_id(config))
}
/// Open a local-URI graph and, when `policy.file` is configured in
@ -1327,12 +1362,12 @@ fn print_commit_human(commit: &CommitOutput) {
println!("created_at: {}", commit.created_at);
}
fn print_policy_explain(decision: &PolicyDecision, request: &PolicyRequest) {
fn print_policy_explain(decision: &PolicyDecision, actor_id: &str, request: &PolicyRequest) {
println!(
"decision: {}",
if decision.allowed { "allow" } else { "deny" }
);
println!("actor: {}", request.actor_id);
println!("actor: {}", actor_id);
println!("action: {}", request.action);
if let Some(branch) = &request.branch {
println!("branch: {}", branch);
@ -1807,10 +1842,15 @@ async fn main() -> Result<()> {
print_embed_human(&output);
}
}
Command::Init { schema, uri } => {
Command::Init { schema, uri, force } => {
let schema_source = fs::read_to_string(&schema)?;
ensure_local_graph_parent(&uri)?;
Omnigraph::init(&uri, &schema_source).await?;
Omnigraph::init_with_options(
&uri,
&schema_source,
omnigraph::db::InitOptions { force },
)
.await?;
scaffold_config_if_missing(&uri)?;
println!("initialized {}", uri);
}
@ -2534,13 +2574,12 @@ async fn main() -> Result<()> {
let config = load_cli_config(config.as_ref())?;
let engine = resolve_policy_engine(&config)?;
let request = PolicyRequest {
actor_id: actor,
action,
branch,
target_branch,
};
let decision = engine.authorize(&request)?;
print_policy_explain(&decision, &request);
let decision = engine.authorize(&actor, &request)?;
print_policy_explain(&decision, &actor, &request);
}
},
Command::Optimize {
@ -2647,6 +2686,41 @@ async fn main() -> Result<()> {
);
}
}
Command::Graphs { command } => match command {
GraphsCommand::List {
uri,
target,
config,
json,
} => {
let config = load_cli_config(config.as_ref())?;
let bearer_token =
resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?;
let uri = resolve_uri(&config, uri, target.as_deref())?;
if !is_remote_uri(&uri) {
bail!(
"`omnigraph graphs list` requires a remote multi-graph server URL \
(http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \
directly."
);
}
let payload = remote_json::<GraphListResponse>(
&http_client,
Method::GET,
remote_url(&uri, "/graphs"),
None,
bearer_token.as_deref(),
)
.await?;
if json {
print_json(&payload)?;
} else {
for entry in payload.graphs {
println!("{}\t{}", entry.graph_id, entry.uri);
}
}
}
},
}
Ok(())
}

View file

@ -2261,3 +2261,47 @@ fn schema_plan_parity_cli_and_sdk() {
);
assert_eq!(cli_payload["supported"], plan.supported);
}
// ─── MR-668 PR 8 — omnigraph graphs subcommand ─────────────────────────────
/// `omnigraph graphs --help` lists only the read-only `list`
/// subcommand. Runtime add (`create`) and remove (`delete`) are
/// deferred — operators add/remove graphs by editing `omnigraph.yaml`
/// and restarting. This test pins the deferral against accidental
/// re-introduction.
#[test]
fn graphs_subcommand_help_lists_list_only() {
let output = output_success(cli().arg("graphs").arg("--help"));
let stdout = stdout_string(&output);
assert!(
stdout.contains("list"),
"expected `list` subcommand in help output:\n{stdout}"
);
let lowered = stdout.to_lowercase();
assert!(
!lowered.contains("create a new graph"),
"graph create should not be in v0.6.0 help; got:\n{stdout}"
);
assert!(
!lowered.contains("delete a graph"),
"graph delete should not be in v0.6.0 help; got:\n{stdout}"
);
}
/// `omnigraph graphs list` against a local URI errors with a clear
/// message — the CLI only operates against remote multi-graph servers.
#[test]
fn graphs_list_against_local_uri_errors_with_remote_only_message() {
let output = output_failure(
cli()
.arg("graphs")
.arg("list")
.arg("--uri")
.arg("/tmp/local"),
);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
stderr.contains("remote multi-graph server URL"),
"expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}"
);
}

View file

@ -37,6 +37,17 @@ rules:
target_branch_scope: protected
"#;
const GRAPH_LIST_SERVER_POLICY_YAML: &str = r#"
version: 1
groups:
admins: [act-admin]
rules:
- id: admins-can-list-graphs
allow:
actors: { group: admins }
actions: [graph_list]
"#;
fn yaml_string(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
@ -949,3 +960,112 @@ query insert_person($name: String, $age: I32) {
assert_eq!(verify["row_count"], 1);
assert_eq!(verify["rows"][0]["p.name"], "PolicyRemote");
}
// ─── MR-668 PR 8 — omnigraph graphs list end-to-end ────────────────────────
/// Multi-graph server + CLI `omnigraph graphs list` end-to-end.
///
/// Steps:
/// 1. Init a graph `alpha` on disk and write an `omnigraph.yaml`
/// whose `graphs:` map references it.
/// 2. Spawn the server with `--config <yaml>`.
/// 3. `omnigraph graphs list` — expect to see `alpha`.
///
/// Ignored by default — spawning servers needs loopback socket
/// permissions some sandboxes lack.
#[test]
#[ignore = "requires loopback socket permissions in sandboxed runners"]
fn graphs_list_against_multi_graph_server() {
let cfg_dir = tempfile::tempdir().unwrap();
let schema_path = fixture("test.pg");
// Init `alpha` on disk.
let alpha_uri = cfg_dir.path().join("alpha.omni");
tokio::runtime::Runtime::new().unwrap().block_on(async {
Omnigraph::init(
alpha_uri.to_str().unwrap(),
&fs::read_to_string(&schema_path).unwrap(),
)
.await
.unwrap();
});
fs::write(
cfg_dir.path().join("server-policy.yaml"),
GRAPH_LIST_SERVER_POLICY_YAML,
)
.unwrap();
// Server config with `graphs:` map and no `server.graph` selector
// — multi mode (rule 4 of the inference matrix). `GET /graphs` is a
// server-scoped action, so the success path needs an explicit server
// policy and bearer token.
let server_config_path = cfg_dir.path().join("omnigraph.yaml");
fs::write(
&server_config_path,
format!(
"\
server:
policy:
file: ./server-policy.yaml
graphs:
alpha:
uri: {}
",
yaml_string(&alpha_uri.to_string_lossy())
),
)
.unwrap();
let server = spawn_server_with_config_env(
&server_config_path,
&[(
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
r#"{"act-admin":"admin-token"}"#,
)],
);
// Client config — the CLI's `--target dev` resolves to `server.base_url`.
let client_config_path = cfg_dir.path().join("client.yaml");
fs::write(
&client_config_path,
format!(
"\
graphs:
dev:
uri: {}
bearer_token_env: GRAPH_LIST_TOKEN
cli:
graph: dev
auth:
env_file: ./.env.omni
",
yaml_string(&server.base_url)
),
)
.unwrap();
fs::write(
cfg_dir.path().join(".env.omni"),
"GRAPH_LIST_TOKEN=admin-token\n",
)
.unwrap();
// `graphs list` lists `alpha`.
let payload = parse_stdout_json(&output_success(
cli()
.arg("graphs")
.arg("list")
.arg("--config")
.arg(&client_config_path)
.arg("--json"),
));
let ids: Vec<&str> = payload["graphs"]
.as_array()
.unwrap()
.iter()
.map(|g| g["graph_id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["alpha"]);
drop(server);
}