mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
* feat(cli): --server accepts a literal URL (RFC-011 Decision 2) `resolve_server_flag` now treats a `--server` value containing `://` as a literal base URL (trailing slash trimmed; `--graph` appends `/graphs/<id>`), bypassing the operator-config `servers:` registry; a bare name still resolves through the registry. This is the replacement the upcoming `--uri http(s)://` deprecation points at, and a small ergonomic win on its own (`--server https://host` with no config entry). Token resolution for a literal-URL server falls to the legacy OMNIGRAPH_BEARER_TOKEN chain, same as a positional URL today. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cli): address the parity-matrix arms with global --store/--server flags Prep for removing the positional-http→remote dispatch. The parity harness addressed both arms with a positional graph right after the verb (`omnigraph <verb> <addr> <args…>`), which only parses for top-level verbs — for nested subcommands (`schema show`, `branch list`, …) the address landed in the subcommand slot and BOTH arms failed identically, so the test passed vacuously (matching exit codes, never comparing output). Address both arms with the global flags instead — local `--store <graph>` (embedded), remote `--server <url>` (served) — appended after the verb + args, valid regardless of nesting. The previously-vacuous nested-verb parity checks now actually compare embedded vs remote (and pass — parity holds), and the remote arm no longer relies on the positional-URL dispatch that's about to be removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: --as on a served write is a hard error (was a silent no-op) A served write resolves the actor server-side from the bearer token, so `--as` could never set identity there — it was silently ignored. It now errors (in the remote write factory, before any HTTP call), pointing the user at removing `--as` or writing directly with `--store`. Reads don't carry `--as`, so this is write-path only. BREAKING for any script that passed `--as` to a remote write (it was a no-op, so behavior is unchanged except the now-explicit error). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: a positional/--uri http(s):// URL no longer dispatches to a server Remote graphs must be addressed with `--server <url>` (or a named server / a profile binding one). A positional or `--uri` `http(s)://` URL on a data verb now errors instead of silently routing to the remote HTTP client — the scheme no longer carries transport semantics. The discriminator is `via_server`: a remote URL produced by a server scope is fine; a remote URL from a positional/`--uri` source is rejected (`reject_positional_remote` in both GraphClient factories). Storage verbs are unaffected — they already reject remote URIs through `resolve_local_graph` with the existing "direct (storage-native)" error. Migrated the gh-host keyed-credential system test to `--server <url>` (the literal URL still prefix-matches the operator server for token resolution). BREAKING: scripts addressing a server by a bare URL must switch to `--server <url>`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: remove the --target flag (use --store / --profile / --server) Removes the legacy named-graph flag and threads its parameter out of the whole resolver chain. `--target` resolved a graph name through `omnigraph.yaml`'s `graphs:` map; its replacements (`--store <uri>`, `--profile <name>`, `--server <name>`) all ship. - Drops the 22 `target` clap fields + the `--cluster` exclusion that named it. - Threads `target`/`cli_target` out of `resolve_uri`/`resolve_cli_graph`/ `resolve_local_graph`/`resolve_local_uri`/`resolve_storage_uri`/ `resolve_remote_bearer_token`/`apply_server_flag`/`execute_query_lint`/ `resolve_selected_graph`/`resolve_registry_selection_for_list`/ `execute_queries_{validate,list}`, the two `GraphClient` factories, and `ScopeFlags`/`ResolvedScope`. - Keeps the shared `OmnigraphConfig::resolve_target_uri` 3-arg (server boot uses it); the CLI passes None for the explicit-target arm. The `cli.graph` default (omnigraph.yaml bare-command fallback) is unchanged — its removal belongs to the omnigraph.yaml excision. - Operator/file aliases that bind a `graph` name still work: the name is now resolved to a URI inline (a positional URI wins). - Error messages and `--graph`/`--server`/`--store` help text no longer name `--target`; the queries-list selection hint points at `cli.graph`. BREAKING. Tests updated (named-target resolution rewritten onto `cli.graph`; positional-URI tests unchanged). Full omnigraph-cli suite green (228). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(cli): drop --target and positional-http addressing; --as-on-served is an error Update the user docs for the legacy data-plane addressing removals: - the CLI `--target` flag is gone — address graphs with a positional URI, `--store`, `--profile`, or `--server <name|url>`; - a positional `http(s)://` URI no longer dispatches to a server (use `--server`); - `--as` on a served write is now rejected (was a silent no-op). Touches cli/reference.md (addressing intro, capability table, error examples, scopes), cli/index.md (the remote-read example → --server), operations/maintenance + policy, and the cluster docs' data-plane load guidance. The server's own `--target` boot flag is unchanged (server.md untouched). Also fixes a pre-existing broken maintenance link in search/indexes.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): --store is loudly exclusive with a positional URI / --server; test graphs→Served Address two Greptile findings on the RFC-011 slices: - Slice A (P1): `--store` combined with a positional URI silently dropped the URI (`scope.rs` did `store.or(uri)`); `--store` + `--server` errored with a misleading "positional URI" message. Now both combinations fail loudly with a declared `--store is exclusive with a positional URI and --server` error. - Slice B (P2): the `command_capability` unit test never exercised the one Data→Served refinement (`graphs`); added the assertion so deleting that guard can't pass silently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
867 lines
24 KiB
Rust
867 lines
24 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use std::fs;
|
|
use std::net::TcpListener;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command as StdCommand, Output, Stdio};
|
|
use std::thread::sleep;
|
|
use std::time::Duration;
|
|
|
|
use assert_cmd::Command;
|
|
use reqwest::blocking::Client;
|
|
use serde_json::Value;
|
|
use tempfile::{TempDir, tempdir};
|
|
|
|
/// Hermetic default: point OMNIGRAPH_HOME at a path that exists on no
|
|
/// machine, so spawned binaries never read the developer's real
|
|
/// ~/.omnigraph/ (an absent operator config is an empty layer). Tests
|
|
/// exercising the operator layer override the var explicitly.
|
|
pub const HERMETIC_OPERATOR_HOME: &str = "/nonexistent/omnigraph-test-home";
|
|
|
|
pub fn cli() -> Command {
|
|
let mut command = Command::cargo_bin("omnigraph").unwrap();
|
|
command.env("OMNIGRAPH_HOME", HERMETIC_OPERATOR_HOME);
|
|
command.env_remove("OMNIGRAPH_CONFIG");
|
|
command
|
|
}
|
|
|
|
pub fn cli_process() -> StdCommand {
|
|
let mut command = StdCommand::new(assert_cmd::cargo::cargo_bin("omnigraph"));
|
|
command.env("OMNIGRAPH_HOME", HERMETIC_OPERATOR_HOME);
|
|
command.env_remove("OMNIGRAPH_CONFIG");
|
|
command
|
|
}
|
|
|
|
fn server_process() -> StdCommand {
|
|
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_omnigraph-server") {
|
|
StdCommand::new(path)
|
|
} else if let Some(path) = built_server_binary() {
|
|
StdCommand::new(path)
|
|
} else {
|
|
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
|
|
let mut cmd = StdCommand::new(cargo);
|
|
cmd.arg("run")
|
|
.arg("--quiet")
|
|
.arg("-p")
|
|
.arg("omnigraph-server")
|
|
.arg("--");
|
|
cmd
|
|
}
|
|
}
|
|
|
|
fn built_server_binary() -> Option<PathBuf> {
|
|
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
|
let candidate = workspace_root
|
|
.join("target")
|
|
.join("debug")
|
|
.join(format!("omnigraph-server{}", std::env::consts::EXE_SUFFIX));
|
|
candidate.exists().then_some(candidate)
|
|
}
|
|
|
|
pub fn fixture(name: &str) -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../omnigraph/tests/fixtures")
|
|
.join(name)
|
|
}
|
|
|
|
pub fn graph_path(root: &Path) -> PathBuf {
|
|
root.join("demo.omni")
|
|
}
|
|
|
|
pub fn output_success(cmd: &mut Command) -> Output {
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"command failed\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
output
|
|
}
|
|
|
|
pub fn output_failure(cmd: &mut Command) -> Output {
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
!output.status.success(),
|
|
"command unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
output
|
|
}
|
|
|
|
pub fn stdout_string(output: &Output) -> String {
|
|
String::from_utf8(output.stdout.clone()).unwrap()
|
|
}
|
|
|
|
pub fn parse_stdout_json(output: &Output) -> Value {
|
|
serde_json::from_slice(&output.stdout).unwrap()
|
|
}
|
|
|
|
pub fn init_graph(graph: &Path) {
|
|
let schema = fixture("test.pg");
|
|
output_success(cli().arg("init").arg("--schema").arg(&schema).arg(graph));
|
|
}
|
|
|
|
pub fn load_fixture(graph: &Path) {
|
|
let data = fixture("test.jsonl");
|
|
output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--mode")
|
|
.arg("overwrite")
|
|
.arg("--data")
|
|
.arg(&data)
|
|
.arg(graph),
|
|
);
|
|
}
|
|
|
|
pub fn write_jsonl(path: &Path, rows: &str) {
|
|
fs::write(path, rows).unwrap();
|
|
}
|
|
|
|
pub fn write_query_file(path: &Path, source: &str) {
|
|
fs::write(path, source).unwrap();
|
|
}
|
|
|
|
pub fn write_config(path: &Path, source: &str) {
|
|
fs::write(path, source).unwrap();
|
|
}
|
|
|
|
pub fn write_file(path: &Path, source: &str) {
|
|
fs::write(path, source).unwrap();
|
|
}
|
|
|
|
fn yaml_string(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
}
|
|
|
|
pub fn local_yaml_config(graph: &Path) -> String {
|
|
format!(
|
|
"\
|
|
graphs:
|
|
local:
|
|
uri: {}
|
|
cli:
|
|
graph: local
|
|
branch: main
|
|
query:
|
|
roots:
|
|
- .
|
|
policy: {{}}
|
|
",
|
|
yaml_string(&graph.to_string_lossy())
|
|
)
|
|
}
|
|
|
|
pub fn remote_yaml_config(url: &str) -> String {
|
|
format!(
|
|
"\
|
|
graphs:
|
|
dev:
|
|
uri: {}
|
|
cli:
|
|
graph: dev
|
|
branch: main
|
|
query:
|
|
roots:
|
|
- .
|
|
policy: {{}}
|
|
",
|
|
yaml_string(url)
|
|
)
|
|
}
|
|
|
|
pub struct TestServer {
|
|
child: Child,
|
|
pub base_url: String,
|
|
}
|
|
|
|
impl Drop for TestServer {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
fn free_port() -> u16 {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
port
|
|
}
|
|
|
|
fn spawn_server_process(mut command: StdCommand) -> TestServer {
|
|
let port = free_port();
|
|
let bind = format!("127.0.0.1:{}", port);
|
|
let mut child = command
|
|
.arg("--bind")
|
|
.arg(&bind)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
let base_url = format!("http://{}", bind);
|
|
let client = Client::new();
|
|
for _ in 0..300 {
|
|
if client
|
|
.get(format!("{}/healthz", base_url))
|
|
.send()
|
|
.map(|response| response.status().is_success())
|
|
.unwrap_or(false)
|
|
{
|
|
return TestServer { child, base_url };
|
|
}
|
|
if let Some(status) = child.try_wait().unwrap() {
|
|
panic!("server exited before becoming healthy: {status}");
|
|
}
|
|
sleep(Duration::from_millis(100));
|
|
}
|
|
panic!("server did not become healthy");
|
|
}
|
|
|
|
pub fn spawn_server(graph: &Path) -> TestServer {
|
|
let mut command = server_process();
|
|
command.arg(graph);
|
|
spawn_server_process(command)
|
|
}
|
|
|
|
pub fn spawn_server_with_config(config: &Path) -> TestServer {
|
|
let mut command = server_process();
|
|
command.arg("--config").arg(config);
|
|
spawn_server_process(command)
|
|
}
|
|
|
|
pub fn spawn_server_with_cluster(cluster_dir: &Path) -> TestServer {
|
|
let mut command = server_process();
|
|
command.arg("--cluster").arg(cluster_dir).arg("--unauthenticated");
|
|
spawn_server_process(command)
|
|
}
|
|
|
|
/// Cluster boot with the server process's cwd set explicitly — used to prove
|
|
/// rule 0 never touches the cwd omnigraph.yaml search.
|
|
pub fn spawn_server_with_cluster_in(cluster_dir: &Path, cwd: &Path) -> TestServer {
|
|
let mut command = server_process();
|
|
command
|
|
.arg("--cluster")
|
|
.arg(cluster_dir)
|
|
.arg("--unauthenticated")
|
|
.current_dir(cwd);
|
|
spawn_server_process(command)
|
|
}
|
|
|
|
pub fn spawn_server_with_cluster_env(cluster_dir: &Path, envs: &[(&str, &str)]) -> TestServer {
|
|
let mut command = server_process();
|
|
command.arg("--cluster").arg(cluster_dir);
|
|
for (name, value) in envs {
|
|
command.env(name, value);
|
|
}
|
|
spawn_server_process(command)
|
|
}
|
|
|
|
pub fn spawn_server_with_env(graph: &Path, envs: &[(&str, &str)]) -> TestServer {
|
|
let mut command = server_process();
|
|
command.arg(graph);
|
|
for (name, value) in envs {
|
|
command.env(name, value);
|
|
}
|
|
spawn_server_process(command)
|
|
}
|
|
|
|
pub fn spawn_server_with_config_env(config: &Path, envs: &[(&str, &str)]) -> TestServer {
|
|
let mut command = server_process();
|
|
command.arg("--config").arg(config);
|
|
for (name, value) in envs {
|
|
command.env(name, value);
|
|
}
|
|
spawn_server_process(command)
|
|
}
|
|
|
|
pub struct SystemGraph {
|
|
_temp: TempDir,
|
|
graph: PathBuf,
|
|
}
|
|
|
|
impl SystemGraph {
|
|
pub fn initialized() -> Self {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
Self { _temp: temp, graph }
|
|
}
|
|
|
|
pub fn loaded() -> Self {
|
|
let temp = tempdir().unwrap();
|
|
let graph = graph_path(temp.path());
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
Self { _temp: temp, graph }
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.graph
|
|
}
|
|
|
|
pub fn write_query(&self, name: &str, source: &str) -> PathBuf {
|
|
let path = self.graph.parent().unwrap().join(name);
|
|
write_query_file(&path, source);
|
|
path
|
|
}
|
|
|
|
pub fn write_jsonl(&self, name: &str, rows: &str) -> PathBuf {
|
|
let path = self.graph.parent().unwrap().join(name);
|
|
write_jsonl(&path, rows);
|
|
path
|
|
}
|
|
|
|
pub fn write_config(&self, name: &str, source: &str) -> PathBuf {
|
|
let path = self.graph.parent().unwrap().join(name);
|
|
write_config(&path, source);
|
|
path
|
|
}
|
|
|
|
pub fn write_file(&self, name: &str, source: &str) -> PathBuf {
|
|
let path = self.graph.parent().unwrap().join(name);
|
|
write_file(&path, source);
|
|
path
|
|
}
|
|
|
|
pub fn spawn_server(&self) -> TestServer {
|
|
spawn_server(&self.graph)
|
|
}
|
|
|
|
pub fn spawn_server_with_config(&self, config: &Path) -> TestServer {
|
|
spawn_server_with_config(config)
|
|
}
|
|
|
|
pub fn spawn_server_with_config_env(&self, config: &Path, envs: &[(&str, &str)]) -> TestServer {
|
|
spawn_server_with_config_env(config, envs)
|
|
}
|
|
}
|
|
|
|
// ---- helpers moved from the monolithic tests/cli.rs ----
|
|
#[allow(unused_imports)]
|
|
use lance::Dataset;
|
|
#[allow(unused_imports)]
|
|
use lance::index::DatasetIndexExt;
|
|
#[allow(unused_imports)]
|
|
use omnigraph::db::{Omnigraph, ReadTarget};
|
|
|
|
pub const POLICY_YAML: &str = r#"
|
|
version: 1
|
|
groups:
|
|
team: [act-andrew, act-bruno]
|
|
admins: [act-andrew]
|
|
protected_branches: [main]
|
|
rules:
|
|
- id: team-read
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [read]
|
|
branch_scope: any
|
|
- id: team-write
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [change]
|
|
branch_scope: unprotected
|
|
- id: admins-promote
|
|
allow:
|
|
actors: { group: admins }
|
|
actions: [branch_merge]
|
|
target_branch_scope: protected
|
|
"#;
|
|
|
|
pub const POLICY_TESTS_YAML: &str = r#"
|
|
version: 1
|
|
cases:
|
|
- id: allow-feature-write
|
|
actor: act-andrew
|
|
action: change
|
|
branch: feature
|
|
expect: allow
|
|
- id: deny-main-write
|
|
actor: act-bruno
|
|
action: change
|
|
branch: main
|
|
expect: deny
|
|
"#;
|
|
|
|
pub fn manifest_dataset_version(graph: &std::path::Path) -> u64 {
|
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
|
Omnigraph::open(graph.to_string_lossy().as_ref())
|
|
.await
|
|
.unwrap()
|
|
.snapshot_of(ReadTarget::branch("main"))
|
|
.await
|
|
.unwrap()
|
|
.version()
|
|
})
|
|
}
|
|
|
|
pub fn forge_person_delete_drift(graph: &std::path::Path) -> (u64, u64) {
|
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
|
let uri = graph.to_string_lossy();
|
|
let db = Omnigraph::open(uri.as_ref()).await.unwrap();
|
|
let snap = db
|
|
.snapshot_of(ReadTarget::branch("main"))
|
|
.await
|
|
.unwrap();
|
|
let entry = snap.entry("node:Person").unwrap();
|
|
let full_path = format!("{}/{}", uri.trim_end_matches('/'), entry.table_path);
|
|
let mut ds = Dataset::open(&full_path).await.unwrap();
|
|
let deleted = ds.delete("name = 'Alice'").await.unwrap();
|
|
assert_eq!(deleted.num_deleted_rows, 1);
|
|
let head = deleted.new_dataset.version().version;
|
|
assert!(head > entry.table_version);
|
|
(entry.table_version, head)
|
|
})
|
|
}
|
|
|
|
pub fn write_policy_config_fixture(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
|
|
let config = root.join("omnigraph.yaml");
|
|
let policy = root.join("policy.yaml");
|
|
fs::write(
|
|
&config,
|
|
r#"
|
|
project:
|
|
name: policy-test-graph
|
|
policy:
|
|
file: ./policy.yaml
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(&policy, POLICY_YAML).unwrap();
|
|
fs::write(root.join("policy.tests.yaml"), POLICY_TESTS_YAML).unwrap();
|
|
(config, policy)
|
|
}
|
|
|
|
pub fn write_cluster_config_fixture(root: &std::path::Path) {
|
|
fs::write(
|
|
root.join("people.pg"),
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
age: I32?
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
root.join("people.gq"),
|
|
r#"
|
|
query find_person($name: String) {
|
|
match { $p: Person { name: $name } }
|
|
return { $p.name, $p.age }
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap();
|
|
fs::write(
|
|
root.join("cluster.yaml"),
|
|
r#"
|
|
version: 1
|
|
metadata:
|
|
name: company-brain
|
|
state:
|
|
backend: cluster
|
|
lock: true
|
|
graphs:
|
|
knowledge:
|
|
schema: ./people.pg
|
|
queries:
|
|
find_person:
|
|
file: ./people.gq
|
|
policies:
|
|
base:
|
|
file: ./base.policy.yaml
|
|
applies_to: [knowledge]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
pub fn init_cluster_derived_graph(root: &std::path::Path) {
|
|
init_named_cluster_graph(root, "knowledge", "people.pg");
|
|
}
|
|
|
|
pub fn init_named_cluster_graph(root: &std::path::Path, graph_id: &str, schema_file: &str) {
|
|
let graph_dir = root.join("graphs");
|
|
fs::create_dir_all(&graph_dir).unwrap();
|
|
output_success(
|
|
cli()
|
|
.arg("init")
|
|
.arg("--schema")
|
|
.arg(root.join(schema_file))
|
|
.arg(graph_dir.join(format!("{graph_id}.omni"))),
|
|
);
|
|
}
|
|
|
|
pub fn write_cluster_lock(root: &std::path::Path, lock_id: &str, operation: &str) {
|
|
let state_dir = root.join("__cluster");
|
|
fs::create_dir_all(&state_dir).unwrap();
|
|
fs::write(
|
|
state_dir.join("lock.json"),
|
|
format!(
|
|
r#"{{"version":1,"lock_id":"{lock_id}","operation":"{operation}","created_at":"1970-01-01T00:00:00Z","pid":123}}"#
|
|
),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
pub fn write_cluster_applyable_state(root: &std::path::Path) -> serde_json::Value {
|
|
let validate = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("cluster")
|
|
.arg("validate")
|
|
.arg("--config")
|
|
.arg(root)
|
|
.arg("--json"),
|
|
));
|
|
let schema_digest = validate["resource_digests"]["schema.knowledge"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let state_dir = root.join("__cluster");
|
|
fs::create_dir_all(&state_dir).unwrap();
|
|
fs::write(
|
|
state_dir.join("state.json"),
|
|
format!(
|
|
r#"{{
|
|
"version": 1,
|
|
"state_revision": 1,
|
|
"applied_revision": {{
|
|
"resources": {{
|
|
"graph.knowledge": {{ "digest": "seed" }},
|
|
"schema.knowledge": {{ "digest": "{schema_digest}" }}
|
|
}}
|
|
}}
|
|
}}
|
|
"#
|
|
),
|
|
)
|
|
.unwrap();
|
|
validate
|
|
}
|
|
|
|
pub fn cluster_json(root: &std::path::Path, command: &str) -> serde_json::Value {
|
|
parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("cluster")
|
|
.arg(command)
|
|
.arg("--config")
|
|
.arg(root)
|
|
.arg("--json"),
|
|
))
|
|
}
|
|
|
|
pub fn write_multi_graph_cluster_fixture(root: &std::path::Path) {
|
|
write_cluster_config_fixture(root);
|
|
fs::write(
|
|
root.join("services.pg"),
|
|
r#"
|
|
node Service {
|
|
name: String @key
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
root.join("services.gq"),
|
|
r#"
|
|
query find_service($name: String) {
|
|
match { $s: Service { name: $name } }
|
|
return { $s.name }
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(root.join("cluster_wide.policy.yaml"), "rules: []\n").unwrap();
|
|
fs::write(root.join("shared.policy.yaml"), "rules: []\n").unwrap();
|
|
fs::write(
|
|
root.join("cluster.yaml"),
|
|
r#"
|
|
version: 1
|
|
metadata:
|
|
name: company-brain
|
|
state:
|
|
backend: cluster
|
|
lock: true
|
|
graphs:
|
|
knowledge:
|
|
schema: ./people.pg
|
|
queries:
|
|
find_person:
|
|
file: ./people.gq
|
|
engineering:
|
|
schema: ./services.pg
|
|
queries:
|
|
find_service:
|
|
file: ./services.gq
|
|
policies:
|
|
shared:
|
|
file: ./shared.policy.yaml
|
|
applies_to: [knowledge, engineering]
|
|
cluster_wide:
|
|
file: ./cluster_wide.policy.yaml
|
|
applies_to: [cluster]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
pub fn change_for<'j>(json: &'j serde_json::Value, resource: &str) -> &'j serde_json::Value {
|
|
json["changes"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|change| change["resource"] == resource)
|
|
.unwrap_or_else(|| panic!("missing change for {resource}: {json}"))
|
|
}
|
|
|
|
pub fn write_seed_fixture(root: &std::path::Path) -> std::path::PathBuf {
|
|
fs::create_dir_all(root.join("data")).unwrap();
|
|
fs::create_dir_all(root.join("build")).unwrap();
|
|
let raw_seed = root.join("data/seed.jsonl");
|
|
let seed = root.join("seed.yaml");
|
|
|
|
fs::write(
|
|
&raw_seed,
|
|
concat!(
|
|
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n",
|
|
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
fs::write(
|
|
&seed,
|
|
concat!(
|
|
"graph:\n",
|
|
" slug: mr-context-graph\n",
|
|
"sources:\n",
|
|
" raw_seed: ./data/seed.jsonl\n",
|
|
"artifacts:\n",
|
|
" embedded_seed: ./build/seed.embedded.jsonl\n",
|
|
"embeddings:\n",
|
|
" model: gemini-embedding-2-preview\n",
|
|
" dimension: 4\n",
|
|
" types:\n",
|
|
" Decision:\n",
|
|
" target: embedding\n",
|
|
" fields: [slug, intent]\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
seed
|
|
}
|
|
|
|
pub fn write_seed_fixture_with_edge(root: &std::path::Path) -> std::path::PathBuf {
|
|
let seed = write_seed_fixture(root);
|
|
let raw_seed = root.join("data/seed.jsonl");
|
|
fs::write(
|
|
&raw_seed,
|
|
concat!(
|
|
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n",
|
|
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n",
|
|
"{\"edge\":\"Triggered\",\"from\":\"sig-alpha\",\"to\":\"dec-alpha\"}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
seed
|
|
}
|
|
|
|
pub fn read_embedded_rows(path: std::path::PathBuf) -> Vec<Value> {
|
|
fs::read_to_string(path)
|
|
.unwrap()
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).unwrap())
|
|
.collect()
|
|
}
|
|
|
|
pub fn queries_test_config(graph_uri: &str, entry: &str, gq_file: &str) -> String {
|
|
format!(
|
|
"graphs:\n local:\n uri: '{}'\n queries:\n {entry}:\n file: ./{gq_file}\n\
|
|
cli:\n graph: local\npolicy: {{}}\n",
|
|
graph_uri.replace('\'', "''")
|
|
)
|
|
}
|
|
|
|
// ---- RFC-009 Phase 1: parity-matrix harness ----
|
|
|
|
/// Twin graphs for embedded-vs-remote comparison: the same loaded fixture
|
|
/// copied to two roots, so write verbs can run once per arm on identical
|
|
/// state. Returns (tempdir-guard, local_graph, remote_graph).
|
|
pub fn twin_graphs() -> (TempDir, PathBuf, PathBuf) {
|
|
let temp = tempdir().unwrap();
|
|
let seed = temp.path().join("seed");
|
|
fs::create_dir_all(&seed).unwrap();
|
|
let graph = seed.join("server.omni");
|
|
init_graph(&graph);
|
|
load_fixture(&graph);
|
|
let local = temp.path().join("local.omni");
|
|
let remote = temp.path().join("remote.omni");
|
|
copy_dir(&graph, &local);
|
|
copy_dir(&graph, &remote);
|
|
(temp, local, remote)
|
|
}
|
|
|
|
pub fn copy_dir(from: &Path, to: &Path) {
|
|
fs::create_dir_all(to).unwrap();
|
|
for entry in fs::read_dir(from).unwrap() {
|
|
let entry = entry.unwrap();
|
|
let target = to.join(entry.file_name());
|
|
if entry.file_type().unwrap().is_dir() {
|
|
copy_dir(&entry.path(), &target);
|
|
} else {
|
|
fs::copy(entry.path(), &target).unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scrub declared-volatile fields (RFC-009 Phase 1 allowlist) so the rest
|
|
/// of the JSON must match exactly. Key-based, recursive; both arms get the
|
|
/// same placeholders. Everything NOT listed here is contract.
|
|
pub fn scrub_volatile(value: &mut serde_json::Value) {
|
|
const VOLATILE_KEYS: &[&str] = &[
|
|
// identity-bearing per-instance values
|
|
"commit_id", "id", "parent_id", "merge_parent_id", "snapshot",
|
|
// wall-clock
|
|
"committed_at", "created_at", "timestamp",
|
|
// transport / location
|
|
"uri", "path",
|
|
];
|
|
match value {
|
|
serde_json::Value::Object(map) => {
|
|
for (key, val) in map.iter_mut() {
|
|
if VOLATILE_KEYS.contains(&key.as_str()) && !val.is_null() {
|
|
*val = serde_json::Value::String(format!("<volatile:{key}>"));
|
|
} else {
|
|
scrub_volatile(val);
|
|
}
|
|
}
|
|
}
|
|
serde_json::Value::Array(items) => {
|
|
for item in items {
|
|
scrub_volatile(item);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
pub const PARITY_ACTOR: &str = "act-parity";
|
|
pub const PARITY_TOKEN: &str = "parity-tok";
|
|
|
|
/// Identical Cedar bundle for BOTH arms — like-for-like enforcement is part
|
|
/// of the parity contract (a bare local arm is permissive while a
|
|
/// tokens-only server is default-deny; comparing those would measure
|
|
/// configuration, not the fork).
|
|
pub fn parity_policy_yaml() -> String {
|
|
r#"version: 1
|
|
groups:
|
|
parity: ["act-parity"]
|
|
protected_branches: []
|
|
rules:
|
|
- id: reads
|
|
allow:
|
|
actors: { group: parity }
|
|
actions: [read, export, invoke_query]
|
|
- id: read-scope
|
|
allow:
|
|
actors: { group: parity }
|
|
actions: [read, export]
|
|
branch_scope: any
|
|
- id: writes
|
|
allow:
|
|
actors: { group: parity }
|
|
actions: [change]
|
|
branch_scope: any
|
|
- id: branching
|
|
allow:
|
|
actors: { group: parity }
|
|
actions: [schema_apply, branch_create, branch_delete, branch_merge]
|
|
target_branch_scope: any
|
|
"#
|
|
.to_string()
|
|
}
|
|
|
|
/// Per-arm config files carrying the same policy. Both arms address the
|
|
/// graph by positional URI, so the TOP-LEVEL policy.file applies on each
|
|
/// side (single-graph semantics).
|
|
pub fn parity_configs(root: &Path, _local_graph: &Path, remote_graph: &Path) -> (PathBuf, PathBuf) {
|
|
let policy = root.join("parity.policy.yaml");
|
|
fs::write(&policy, parity_policy_yaml()).unwrap();
|
|
let local_cfg = root.join("local.omnigraph.yaml");
|
|
fs::write(
|
|
&local_cfg,
|
|
format!("policy:\n file: {}\n", policy.display()),
|
|
)
|
|
.unwrap();
|
|
let server_cfg = root.join("server.omnigraph.yaml");
|
|
fs::write(
|
|
&server_cfg,
|
|
format!(
|
|
"server:\n graph: parity\ngraphs:\n parity:\n uri: {}\n policy:\n file: {}\n",
|
|
remote_graph.display(),
|
|
policy.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
(local_cfg, server_cfg)
|
|
}
|
|
|
|
/// Run one CLI invocation per arm with identical verb args: locally against
|
|
/// `local_graph` (--as actor) and remotely against a server URL whose token
|
|
/// resolves to the same actor. Returns raw Outputs for exit-code + JSON
|
|
/// comparison by the caller.
|
|
pub fn run_both(
|
|
local_graph: &Path,
|
|
server_url: &str,
|
|
args: &[&str],
|
|
) -> (std::process::Output, std::process::Output) {
|
|
run_both_with_config(local_graph, None, server_url, args)
|
|
}
|
|
|
|
pub fn run_both_with_config(
|
|
local_graph: &Path,
|
|
local_config: Option<&Path>,
|
|
server_url: &str,
|
|
args: &[&str],
|
|
) -> (std::process::Output, std::process::Output) {
|
|
// Address both arms with GLOBAL flags (`--store` / `--server`) appended after
|
|
// the verb + its args, so the address is placed correctly regardless of
|
|
// subcommand nesting (a positional graph only works for top-level verbs;
|
|
// `schema show <graph>` etc. need the global flag). Local = embedded store,
|
|
// remote = served.
|
|
let mut local = cli();
|
|
local
|
|
.args(args)
|
|
.arg("--store")
|
|
.arg(local_graph)
|
|
.arg("--as")
|
|
.arg(PARITY_ACTOR);
|
|
if let Some(config) = local_config {
|
|
local.arg("--config").arg(config);
|
|
}
|
|
let local_out = local.output().unwrap();
|
|
|
|
let mut remote = cli();
|
|
remote
|
|
.env("OMNIGRAPH_BEARER_TOKEN", PARITY_TOKEN)
|
|
.args(args)
|
|
.arg("--server")
|
|
.arg(server_url);
|
|
let remote_out = remote.output().unwrap();
|
|
(local_out, remote_out)
|
|
}
|
|
|
|
/// Parse, scrub, and pretty-print for diffable assertion messages.
|
|
pub fn scrubbed_json(output: &std::process::Output) -> String {
|
|
let mut value: serde_json::Value = serde_json::from_slice(&output.stdout)
|
|
.unwrap_or_else(|e| panic!("non-JSON stdout ({e}): {output:?}"));
|
|
scrub_volatile(&mut value);
|
|
serde_json::to_string_pretty(&value).unwrap()
|
|
}
|