mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-24 02:38:06 +02:00
MR-771: demote Run to direct-publish via expected_table_versions CAS
mutate_as and load now write directly to target tables and call the publisher once at the end with per-table expected versions; the Run state machine, _graph_runs.lance writers, __run__ staging branches, and server /runs/* endpoints are removed. Multi-statement mutations remain atomic at the manifest level via an in-memory MutationStaging accumulator that gives read-your-writes within a query and a single publish at the end. Concurrent-writer conflicts surface as ExpectedVersionMismatch (HTTP 409 manifest_conflict) instead of the old DivergentUpdate merge shape. Documents one known limitation in docs/runs.md: a multi-statement mid-query failure where op-N writes a Lance fragment and op-N+1 fails leaves Lance HEAD ahead of the manifest until a follow-up introduces per-table Lance branches. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4e5374a85e
commit
35be20cb05
28 changed files with 1188 additions and 3216 deletions
|
|
@ -1,6 +1,4 @@
|
|||
use omnigraph::db::{
|
||||
GraphCommit, MergeOutcome, ReadTarget, RunRecord, SchemaApplyResult, Snapshot,
|
||||
};
|
||||
use omnigraph::db::{GraphCommit, MergeOutcome, ReadTarget, SchemaApplyResult, Snapshot};
|
||||
use omnigraph::error::{MergeConflict, MergeConflictKind};
|
||||
use omnigraph::loader::{IngestResult, LoadMode};
|
||||
use omnigraph_compiler::SchemaMigrationStep;
|
||||
|
|
@ -41,30 +39,6 @@ pub struct SnapshotOutput {
|
|||
pub tables: Vec<SnapshotTableOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RunOutput {
|
||||
pub run_id: String,
|
||||
pub target_branch: String,
|
||||
pub run_branch: String,
|
||||
pub base_snapshot_id: String,
|
||||
pub base_manifest_version: u64,
|
||||
pub operation_hash: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub status: String,
|
||||
pub published_snapshot_id: Option<String>,
|
||||
/// Run creation time as Unix epoch microseconds.
|
||||
#[schema(example = 1714000000000000i64)]
|
||||
pub created_at: i64,
|
||||
/// Last status change as Unix epoch microseconds.
|
||||
#[schema(example = 1714000000000000i64)]
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RunListOutput {
|
||||
pub runs: Vec<RunOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchCreateRequest {
|
||||
/// Parent branch to fork from. Defaults to `main`.
|
||||
|
|
@ -368,6 +342,17 @@ pub enum ErrorCode {
|
|||
Internal,
|
||||
}
|
||||
|
||||
/// Structured details for a publisher-level OCC failure. Surfaces alongside
|
||||
/// HTTP 409 when a write was rejected because the caller's pre-write view of
|
||||
/// one table's manifest version was stale relative to the current head. The
|
||||
/// expected/actual fields tell the client which table to refresh.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ManifestConflictOutput {
|
||||
pub table_key: String,
|
||||
pub expected: u64,
|
||||
pub actual: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ErrorOutput {
|
||||
pub error: String,
|
||||
|
|
@ -375,6 +360,12 @@ pub struct ErrorOutput {
|
|||
pub code: Option<ErrorCode>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub merge_conflicts: Vec<MergeConflictOutput>,
|
||||
/// Set when the conflict is a publisher CAS rejection
|
||||
/// (`ManifestConflictDetails::ExpectedVersionMismatch`). The caller's
|
||||
/// pre-write view of `table_key` was at version `expected` but the
|
||||
/// manifest is now at `actual`. Refresh and retry.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_conflict: Option<ManifestConflictOutput>,
|
||||
}
|
||||
|
||||
pub fn snapshot_payload(branch: &str, snapshot: &Snapshot) -> SnapshotOutput {
|
||||
|
|
@ -408,22 +399,6 @@ pub fn schema_apply_output(uri: &str, result: SchemaApplyResult) -> SchemaApplyO
|
|||
}
|
||||
}
|
||||
|
||||
pub fn run_output(run: &RunRecord) -> RunOutput {
|
||||
RunOutput {
|
||||
run_id: run.run_id.as_str().to_string(),
|
||||
target_branch: run.target_branch.clone(),
|
||||
run_branch: run.run_branch.clone(),
|
||||
base_snapshot_id: run.base_snapshot_id.as_str().to_string(),
|
||||
base_manifest_version: run.base_manifest_version,
|
||||
operation_hash: run.operation_hash.clone(),
|
||||
actor_id: run.actor_id.clone(),
|
||||
status: run.status.as_str().to_string(),
|
||||
published_snapshot_id: run.published_snapshot_id.clone(),
|
||||
created_at: run.created_at,
|
||||
updated_at: run.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit_output(commit: &GraphCommit) -> CommitOutput {
|
||||
CommitOutput {
|
||||
graph_commit_id: commit.graph_commit_id.clone(),
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ use api::{
|
|||
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
|
||||
BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput,
|
||||
CommitListQuery, ErrorCode, ErrorOutput, ExportRequest, HealthOutput, IngestOutput,
|
||||
IngestRequest, ReadOutput, ReadRequest, RunListOutput, SchemaApplyOutput, SchemaApplyRequest,
|
||||
SchemaOutput, SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload,
|
||||
IngestRequest, ReadOutput, ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput,
|
||||
SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload,
|
||||
};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
|
|
@ -33,8 +33,8 @@ pub use config::{
|
|||
load_config,
|
||||
};
|
||||
use futures::stream;
|
||||
use omnigraph::db::{Omnigraph, ReadTarget, RunId};
|
||||
use omnigraph::error::{ManifestErrorKind, OmniError};
|
||||
use omnigraph::db::{Omnigraph, ReadTarget};
|
||||
use omnigraph::error::{ManifestConflictDetails, ManifestErrorKind, OmniError};
|
||||
use omnigraph_compiler::json_params_to_param_map;
|
||||
use omnigraph_compiler::query::parser::parse_query;
|
||||
use omnigraph_compiler::{JsonParamMode, ParamMap};
|
||||
|
|
@ -82,10 +82,6 @@ fn hash_bearer_token(token: &str) -> BearerTokenHash {
|
|||
server_branch_create,
|
||||
server_branch_delete,
|
||||
server_branch_merge,
|
||||
server_run_list,
|
||||
server_run_show,
|
||||
server_run_publish,
|
||||
server_run_abort,
|
||||
server_commit_list,
|
||||
server_commit_show,
|
||||
),
|
||||
|
|
@ -159,6 +155,7 @@ pub struct ApiError {
|
|||
code: ErrorCode,
|
||||
message: String,
|
||||
merge_conflicts: Vec<api::MergeConflictOutput>,
|
||||
manifest_conflict: Option<api::ManifestConflictOutput>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -280,6 +277,7 @@ impl ApiError {
|
|||
code: ErrorCode::Unauthorized,
|
||||
message: message.into(),
|
||||
merge_conflicts: Vec::new(),
|
||||
manifest_conflict: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,6 +287,7 @@ impl ApiError {
|
|||
code: ErrorCode::Forbidden,
|
||||
message: message.into(),
|
||||
merge_conflicts: Vec::new(),
|
||||
manifest_conflict: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +297,7 @@ impl ApiError {
|
|||
code: ErrorCode::BadRequest,
|
||||
message: message.into(),
|
||||
merge_conflicts: Vec::new(),
|
||||
manifest_conflict: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +307,7 @@ impl ApiError {
|
|||
code: ErrorCode::NotFound,
|
||||
message: message.into(),
|
||||
merge_conflicts: Vec::new(),
|
||||
manifest_conflict: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -316,6 +317,7 @@ impl ApiError {
|
|||
code: ErrorCode::Conflict,
|
||||
message: message.into(),
|
||||
merge_conflicts: Vec::new(),
|
||||
manifest_conflict: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +327,7 @@ impl ApiError {
|
|||
code: ErrorCode::Internal,
|
||||
message: message.into(),
|
||||
merge_conflicts: Vec::new(),
|
||||
manifest_conflict: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +337,20 @@ impl ApiError {
|
|||
code: ErrorCode::Conflict,
|
||||
message: summarize_merge_conflicts(&conflicts),
|
||||
merge_conflicts: conflicts,
|
||||
manifest_conflict: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_version_conflict(
|
||||
message: String,
|
||||
details: api::ManifestConflictOutput,
|
||||
) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: ErrorCode::Conflict,
|
||||
message,
|
||||
merge_conflicts: Vec::new(),
|
||||
manifest_conflict: Some(details),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -344,7 +361,21 @@ impl ApiError {
|
|||
OmniError::Manifest(err) => match err.kind {
|
||||
ManifestErrorKind::BadRequest => Self::bad_request(err.message),
|
||||
ManifestErrorKind::NotFound => Self::not_found(err.message),
|
||||
ManifestErrorKind::Conflict => Self::conflict(err.message),
|
||||
ManifestErrorKind::Conflict => match err.details {
|
||||
Some(ManifestConflictDetails::ExpectedVersionMismatch {
|
||||
table_key,
|
||||
expected,
|
||||
actual,
|
||||
}) => Self::manifest_version_conflict(
|
||||
err.message,
|
||||
api::ManifestConflictOutput {
|
||||
table_key,
|
||||
expected,
|
||||
actual,
|
||||
},
|
||||
),
|
||||
_ => Self::conflict(err.message),
|
||||
},
|
||||
ManifestErrorKind::Internal => Self::internal(err.message),
|
||||
},
|
||||
OmniError::MergeConflicts(conflicts) => Self::merge_conflict(
|
||||
|
|
@ -395,6 +426,7 @@ impl IntoResponse for ApiError {
|
|||
error: self.message,
|
||||
code: Some(self.code),
|
||||
merge_conflicts: self.merge_conflicts,
|
||||
manifest_conflict: self.manifest_conflict,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
|
|
@ -443,10 +475,6 @@ pub fn build_app(state: AppState) -> Router {
|
|||
)
|
||||
.route("/branches/{branch}", delete(server_branch_delete))
|
||||
.route("/branches/merge", post(server_branch_merge))
|
||||
.route("/runs", get(server_run_list))
|
||||
.route("/runs/{run_id}", get(server_run_show))
|
||||
.route("/runs/{run_id}/publish", post(server_run_publish))
|
||||
.route("/runs/{run_id}/abort", post(server_run_abort))
|
||||
.route("/commits", get(server_commit_list))
|
||||
.route("/commits/{commit_id}", get(server_commit_show))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
|
|
@ -1219,203 +1247,6 @@ async fn server_branch_merge(
|
|||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/runs",
|
||||
tag = "runs",
|
||||
operation_id = "listRuns",
|
||||
responses(
|
||||
(status = 200, description = "List of runs", body = RunListOutput),
|
||||
(status = 401, description = "Unauthorized", body = ErrorOutput),
|
||||
(status = 403, description = "Forbidden", body = ErrorOutput),
|
||||
),
|
||||
security(("bearer_token" = [])),
|
||||
)]
|
||||
/// List all runs.
|
||||
///
|
||||
/// A run is an ephemeral branch produced by an agent or background job. The
|
||||
/// list includes pending, in-progress, published, and aborted runs across
|
||||
/// all target branches. Read-only.
|
||||
async fn server_run_list(
|
||||
State(state): State<AppState>,
|
||||
actor: Option<Extension<AuthenticatedActor>>,
|
||||
) -> std::result::Result<Json<RunListOutput>, ApiError> {
|
||||
authorize_request(
|
||||
&state,
|
||||
actor.as_ref().map(|Extension(actor)| actor),
|
||||
PolicyRequest {
|
||||
actor_id: actor
|
||||
.as_ref()
|
||||
.map(|Extension(actor)| actor.as_str().to_string())
|
||||
.unwrap_or_default(),
|
||||
action: PolicyAction::Read,
|
||||
branch: None,
|
||||
target_branch: None,
|
||||
},
|
||||
)?;
|
||||
let runs = {
|
||||
let db = Arc::clone(&state.db).read_owned().await;
|
||||
db.list_runs().await.map_err(ApiError::from_omni)?
|
||||
};
|
||||
Ok(Json(RunListOutput {
|
||||
runs: runs.iter().map(api::run_output).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/runs/{run_id}",
|
||||
tag = "runs",
|
||||
operation_id = "getRun",
|
||||
params(
|
||||
("run_id" = String, Path, description = "Run identifier"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Run details", body = api::RunOutput),
|
||||
(status = 401, description = "Unauthorized", body = ErrorOutput),
|
||||
(status = 403, description = "Forbidden", body = ErrorOutput),
|
||||
(status = 404, description = "Run not found", body = ErrorOutput),
|
||||
),
|
||||
security(("bearer_token" = [])),
|
||||
)]
|
||||
/// Get a single run.
|
||||
///
|
||||
/// Returns the run's status, target/run branches, base snapshot, and (if
|
||||
/// published) the resulting snapshot id. Read-only.
|
||||
async fn server_run_show(
|
||||
State(state): State<AppState>,
|
||||
actor: Option<Extension<AuthenticatedActor>>,
|
||||
Path(run_id): Path<String>,
|
||||
) -> std::result::Result<Json<api::RunOutput>, ApiError> {
|
||||
authorize_request(
|
||||
&state,
|
||||
actor.as_ref().map(|Extension(actor)| actor),
|
||||
PolicyRequest {
|
||||
actor_id: actor
|
||||
.as_ref()
|
||||
.map(|Extension(actor)| actor.as_str().to_string())
|
||||
.unwrap_or_default(),
|
||||
action: PolicyAction::Read,
|
||||
branch: None,
|
||||
target_branch: None,
|
||||
},
|
||||
)?;
|
||||
let run = {
|
||||
let db = Arc::clone(&state.db).read_owned().await;
|
||||
db.get_run(&RunId::new(run_id))
|
||||
.await
|
||||
.map_err(ApiError::from_omni)?
|
||||
};
|
||||
Ok(Json(api::run_output(&run)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/runs/{run_id}/publish",
|
||||
tag = "runs",
|
||||
operation_id = "publishRun",
|
||||
params(
|
||||
("run_id" = String, Path, description = "Run identifier"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Run published", body = api::RunOutput),
|
||||
(status = 401, description = "Unauthorized", body = ErrorOutput),
|
||||
(status = 403, description = "Forbidden", body = ErrorOutput),
|
||||
(status = 404, description = "Run not found", body = ErrorOutput),
|
||||
),
|
||||
security(("bearer_token" = [])),
|
||||
)]
|
||||
/// Publish a run to its target branch.
|
||||
///
|
||||
/// Promotes the run's snapshot onto its `target_branch` as a new commit. The
|
||||
/// run must be in a publishable state. **Destructive** to the target branch.
|
||||
async fn server_run_publish(
|
||||
State(state): State<AppState>,
|
||||
actor: Option<Extension<AuthenticatedActor>>,
|
||||
Path(run_id): Path<String>,
|
||||
) -> std::result::Result<Json<api::RunOutput>, ApiError> {
|
||||
let run_id = RunId::new(run_id);
|
||||
let actor_id = actor.as_ref().map(|Extension(actor)| actor.as_str());
|
||||
let target_branch = {
|
||||
let db = Arc::clone(&state.db).read_owned().await;
|
||||
db.get_run(&run_id)
|
||||
.await
|
||||
.map_err(ApiError::from_omni)?
|
||||
.target_branch
|
||||
};
|
||||
authorize_request(
|
||||
&state,
|
||||
actor.as_ref().map(|Extension(actor)| actor),
|
||||
PolicyRequest {
|
||||
actor_id: actor_id.map(str::to_string).unwrap_or_default(),
|
||||
action: PolicyAction::RunPublish,
|
||||
branch: None,
|
||||
target_branch: Some(target_branch),
|
||||
},
|
||||
)?;
|
||||
let run = {
|
||||
let mut db = Arc::clone(&state.db).write_owned().await;
|
||||
db.publish_run_as(&run_id, actor_id)
|
||||
.await
|
||||
.map_err(ApiError::from_omni)?;
|
||||
db.get_run(&run_id).await.map_err(ApiError::from_omni)?
|
||||
};
|
||||
Ok(Json(api::run_output(&run)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/runs/{run_id}/abort",
|
||||
tag = "runs",
|
||||
operation_id = "abortRun",
|
||||
params(
|
||||
("run_id" = String, Path, description = "Run identifier"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Run aborted", body = api::RunOutput),
|
||||
(status = 401, description = "Unauthorized", body = ErrorOutput),
|
||||
(status = 403, description = "Forbidden", body = ErrorOutput),
|
||||
(status = 404, description = "Run not found", body = ErrorOutput),
|
||||
),
|
||||
security(("bearer_token" = [])),
|
||||
)]
|
||||
/// Abort a run.
|
||||
///
|
||||
/// Marks the run as aborted and releases its working branch. **Irreversible**:
|
||||
/// the run cannot be resumed once aborted.
|
||||
async fn server_run_abort(
|
||||
State(state): State<AppState>,
|
||||
actor: Option<Extension<AuthenticatedActor>>,
|
||||
Path(run_id): Path<String>,
|
||||
) -> std::result::Result<Json<api::RunOutput>, ApiError> {
|
||||
let run_id = RunId::new(run_id);
|
||||
let target_branch = {
|
||||
let db = Arc::clone(&state.db).read_owned().await;
|
||||
db.get_run(&run_id)
|
||||
.await
|
||||
.map_err(ApiError::from_omni)?
|
||||
.target_branch
|
||||
};
|
||||
authorize_request(
|
||||
&state,
|
||||
actor.as_ref().map(|Extension(actor)| actor),
|
||||
PolicyRequest {
|
||||
actor_id: actor
|
||||
.as_ref()
|
||||
.map(|Extension(actor)| actor.as_str().to_string())
|
||||
.unwrap_or_default(),
|
||||
action: PolicyAction::RunAbort,
|
||||
branch: None,
|
||||
target_branch: Some(target_branch),
|
||||
},
|
||||
)?;
|
||||
let run = {
|
||||
let mut db = Arc::clone(&state.db).write_owned().await;
|
||||
db.abort_run(&run_id).await.map_err(ApiError::from_omni)?
|
||||
};
|
||||
Ok(Json(api::run_output(&run)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/commits",
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ pub enum PolicyAction {
|
|||
BranchCreate,
|
||||
BranchDelete,
|
||||
BranchMerge,
|
||||
RunPublish,
|
||||
RunAbort,
|
||||
Admin,
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +36,6 @@ impl PolicyAction {
|
|||
Self::BranchCreate => "branch_create",
|
||||
Self::BranchDelete => "branch_delete",
|
||||
Self::BranchMerge => "branch_merge",
|
||||
Self::RunPublish => "run_publish",
|
||||
Self::RunAbort => "run_abort",
|
||||
Self::Admin => "admin",
|
||||
}
|
||||
}
|
||||
|
|
@ -51,12 +47,7 @@ impl PolicyAction {
|
|||
fn uses_target_branch_scope(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::BranchCreate
|
||||
| Self::SchemaApply
|
||||
| Self::BranchDelete
|
||||
| Self::BranchMerge
|
||||
| Self::RunPublish
|
||||
| Self::RunAbort
|
||||
Self::BranchCreate | Self::SchemaApply | Self::BranchDelete | Self::BranchMerge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -79,8 +70,6 @@ impl FromStr for PolicyAction {
|
|||
"branch_create" => Ok(Self::BranchCreate),
|
||||
"branch_delete" => Ok(Self::BranchDelete),
|
||||
"branch_merge" => Ok(Self::BranchMerge),
|
||||
"run_publish" => Ok(Self::RunPublish),
|
||||
"run_abort" => Ok(Self::RunAbort),
|
||||
"admin" => Ok(Self::Admin),
|
||||
other => bail!("unknown policy action '{other}'"),
|
||||
}
|
||||
|
|
@ -599,8 +588,6 @@ namespace Omnigraph {
|
|||
action "branch_create" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "branch_delete" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "branch_merge" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "run_publish" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "run_abort" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "admin" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
}
|
||||
"#
|
||||
|
|
@ -732,7 +719,7 @@ rules:
|
|||
- id: admins-promote
|
||||
allow:
|
||||
actors: { group: admins }
|
||||
actions: [branch_delete, branch_merge, run_publish]
|
||||
actions: [branch_delete, branch_merge]
|
||||
target_branch_scope: protected
|
||||
"#,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -167,10 +167,6 @@ const EXPECTED_PATHS: &[&str] = &[
|
|||
"/branches",
|
||||
"/branches/{branch}",
|
||||
"/branches/merge",
|
||||
"/runs",
|
||||
"/runs/{run_id}",
|
||||
"/runs/{run_id}/publish",
|
||||
"/runs/{run_id}/abort",
|
||||
"/commits",
|
||||
"/commits/{commit_id}",
|
||||
];
|
||||
|
|
@ -256,30 +252,6 @@ fn openapi_branch_merge_is_post() {
|
|||
assert!(doc["paths"]["/branches/merge"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_runs_is_get() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/runs"]["get"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_run_show_is_get() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/runs/{run_id}"]["get"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_run_publish_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/runs/{run_id}/publish"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_run_abort_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/runs/{run_id}/abort"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_commits_is_get() {
|
||||
let doc = openapi_json();
|
||||
|
|
@ -321,10 +293,9 @@ const EXPECTED_SCHEMAS: &[&str] = &[
|
|||
"ReadOutput",
|
||||
"ReadRequest",
|
||||
"ReadTargetOutput",
|
||||
"ManifestConflictOutput",
|
||||
"SchemaApplyOutput",
|
||||
"SchemaApplyRequest",
|
||||
"RunListOutput",
|
||||
"RunOutput",
|
||||
"SnapshotOutput",
|
||||
"SnapshotTableOutput",
|
||||
];
|
||||
|
|
@ -490,19 +461,17 @@ fn error_output_schema_has_expected_fields() {
|
|||
assert!(props.contains_key("error"));
|
||||
assert!(props.contains_key("code"));
|
||||
assert!(props.contains_key("merge_conflicts"));
|
||||
assert!(props.contains_key("manifest_conflict"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_output_schema_has_expected_fields() {
|
||||
fn manifest_conflict_output_schema_has_expected_fields() {
|
||||
let doc = openapi_json();
|
||||
let schema = &doc["components"]["schemas"]["RunOutput"];
|
||||
let schema = &doc["components"]["schemas"]["ManifestConflictOutput"];
|
||||
let props = schema["properties"].as_object().unwrap();
|
||||
assert!(props.contains_key("run_id"));
|
||||
assert!(props.contains_key("target_branch"));
|
||||
assert!(props.contains_key("run_branch"));
|
||||
assert!(props.contains_key("status"));
|
||||
assert!(props.contains_key("created_at"));
|
||||
assert!(props.contains_key("updated_at"));
|
||||
assert!(props.contains_key("table_key"));
|
||||
assert!(props.contains_key("expected"));
|
||||
assert!(props.contains_key("actual"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -621,10 +590,6 @@ fn protected_endpoints_reference_bearer_token_security() {
|
|||
("/branches", "post"),
|
||||
("/branches/{branch}", "delete"),
|
||||
("/branches/merge", "post"),
|
||||
("/runs", "get"),
|
||||
("/runs/{run_id}", "get"),
|
||||
("/runs/{run_id}/publish", "post"),
|
||||
("/runs/{run_id}/abort", "post"),
|
||||
("/commits", "get"),
|
||||
("/commits/{commit_id}", "get"),
|
||||
];
|
||||
|
|
@ -667,18 +632,6 @@ fn branch_delete_has_branch_path_parameter() {
|
|||
assert!(has_branch, "DELETE /branches/{{branch}} must have 'branch' path parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_show_has_run_id_path_parameter() {
|
||||
let doc = openapi_json();
|
||||
let params = doc["paths"]["/runs/{run_id}"]["get"]["parameters"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
let has_run_id = params.iter().any(|p| {
|
||||
p["name"].as_str() == Some("run_id") && p["in"].as_str() == Some("path")
|
||||
});
|
||||
assert!(has_run_id, "GET /runs/{{run_id}} must have 'run_id' path parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_show_has_commit_id_path_parameter() {
|
||||
let doc = openapi_json();
|
||||
|
|
@ -914,7 +867,6 @@ async fn auth_mode_spec_has_security_on_protected_operations() {
|
|||
("/change", "post"),
|
||||
("/snapshot", "get"),
|
||||
("/branches", "get"),
|
||||
("/runs", "get"),
|
||||
("/commits", "get"),
|
||||
];
|
||||
for (path, method) in protected_paths {
|
||||
|
|
|
|||
|
|
@ -54,11 +54,6 @@ rules:
|
|||
actors: { group: admins }
|
||||
actions: [branch_delete, branch_merge]
|
||||
target_branch_scope: protected
|
||||
- id: admins-publish
|
||||
allow:
|
||||
actors: { group: admins }
|
||||
actions: [run_publish]
|
||||
target_branch_scope: protected
|
||||
"#;
|
||||
|
||||
const POLICY_PROTECTED_READ_YAML: &str = r#"
|
||||
|
|
@ -766,7 +761,7 @@ async fn protected_routes_require_bearer_token() {
|
|||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/runs")
|
||||
.uri("/branches")
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
|
|
@ -801,7 +796,7 @@ async fn protected_routes_accept_valid_bearer_token_while_healthz_stays_open() {
|
|||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/runs")
|
||||
.uri("/branches")
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer demo-token")
|
||||
.body(Body::empty())
|
||||
|
|
@ -810,7 +805,7 @@ async fn protected_routes_accept_valid_bearer_token_while_healthz_stays_open() {
|
|||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["runs"].is_array());
|
||||
assert!(body["branches"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
|
@ -882,7 +877,7 @@ async fn protected_routes_accept_any_configured_team_bearer_token() {
|
|||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/runs")
|
||||
.uri("/branches")
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-two")
|
||||
.body(Body::empty())
|
||||
|
|
@ -891,7 +886,7 @@ async fn protected_routes_accept_any_configured_team_bearer_token() {
|
|||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["runs"].is_array());
|
||||
assert!(body["branches"].is_array());
|
||||
}
|
||||
|
||||
/// Verifies the hashed-token lookup correctly resolves each bearer to its
|
||||
|
|
@ -1350,66 +1345,9 @@ async fn policy_blocks_non_admin_merge_to_main_and_allows_admin() {
|
|||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_blocks_non_admin_run_publish_to_main() {
|
||||
let temp = init_loaded_repo().await;
|
||||
let repo = repo_path(temp.path());
|
||||
let run_id = {
|
||||
let mut db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap();
|
||||
db.begin_run("main", Some("policy-publish"))
|
||||
.await
|
||||
.unwrap()
|
||||
.run_id
|
||||
.as_str()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
fs::write(&policy_path, POLICY_YAML).unwrap();
|
||||
let state = AppState::open_with_bearer_tokens_and_policy(
|
||||
repo.to_string_lossy().to_string(),
|
||||
vec![
|
||||
("act-bruno".to_string(), "team-token".to_string()),
|
||||
("act-ragnor".to_string(), "admin-token".to_string()),
|
||||
],
|
||||
Some(&policy_path),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let (deny_status, deny_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(format!("/runs/{run_id}/publish"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer team-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let deny_error: ErrorOutput = serde_json::from_value(deny_body).unwrap();
|
||||
assert_eq!(deny_status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
deny_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
||||
);
|
||||
|
||||
let (allow_status, allow_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(format!("/runs/{run_id}/publish"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(allow_status, StatusCode::OK);
|
||||
assert_eq!(allow_body["target_branch"], "main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authenticated_change_stamps_actor_on_runs_and_commits() {
|
||||
async fn authenticated_change_stamps_actor_on_commits() {
|
||||
// MR-771: with the Run state machine removed, actor_id is recorded
|
||||
// directly on the commit graph (no intermediate run record).
|
||||
let (_temp, app) = app_for_loaded_repo_with_auth_tokens(&[("act-andrew", "token-one")]).await;
|
||||
|
||||
let change = ChangeRequest {
|
||||
|
|
@ -1432,26 +1370,6 @@ async fn authenticated_change_stamps_actor_on_runs_and_commits() {
|
|||
assert_eq!(change_status, StatusCode::OK);
|
||||
assert_eq!(change_body["actor_id"], "act-andrew");
|
||||
|
||||
let (runs_status, runs_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/runs")
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-one")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(runs_status, StatusCode::OK);
|
||||
let run = runs_body["runs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|run| run["operation_hash"] == "mutation:insert_person:branch=main")
|
||||
.expect("mutation run should be present");
|
||||
assert_eq!(run["actor_id"], "act-andrew");
|
||||
assert_eq!(run["status"], "published");
|
||||
|
||||
let (commits_status, commits_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
|
|
@ -2189,94 +2107,74 @@ query vector_search_string($q: String) {
|
|||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn missing_run_returns_not_found() {
|
||||
let (_temp, app) = app_for_loaded_repo().await;
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/runs/missing-run")
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(error.code, Some(omnigraph_server::api::ErrorCode::NotFound));
|
||||
assert!(error.error.contains("run 'missing-run' not found"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn publish_conflict_returns_conflict_status() {
|
||||
async fn change_conflict_returns_manifest_conflict_409() {
|
||||
// MR-771: a write that races with another writer surfaces as HTTP 409
|
||||
// with a structured `manifest_conflict` body — `table_key`, `expected`,
|
||||
// and `actual` — so clients can detect-and-retry without parsing the
|
||||
// message. (Replaces the old run-publish merge-conflict shape.)
|
||||
let temp = init_loaded_repo().await;
|
||||
let repo = repo_path(temp.path());
|
||||
let mut db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap();
|
||||
|
||||
let run_a = db
|
||||
.begin_run("main", Some("server-conflict-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
let run_b = db
|
||||
.begin_run("main", Some("server-conflict-b"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.mutate(
|
||||
&run_a.run_branch,
|
||||
MUTATION_QUERIES,
|
||||
"set_age",
|
||||
&omnigraph_compiler::json_params_to_param_map(
|
||||
Some(&json!({"name": "Alice", "age": 31 })),
|
||||
&omnigraph_compiler::find_named_query(MUTATION_QUERIES, "set_age")
|
||||
.unwrap()
|
||||
.params,
|
||||
omnigraph_compiler::JsonParamMode::Standard,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.mutate(
|
||||
&run_b.run_branch,
|
||||
MUTATION_QUERIES,
|
||||
"set_age",
|
||||
&omnigraph_compiler::json_params_to_param_map(
|
||||
Some(&json!({"name": "Alice", "age": 32 })),
|
||||
&omnigraph_compiler::find_named_query(MUTATION_QUERIES, "set_age")
|
||||
.unwrap()
|
||||
.params,
|
||||
omnigraph_compiler::JsonParamMode::Standard,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.publish_run(&run_a.run_id).await.unwrap();
|
||||
drop(db);
|
||||
|
||||
// Build the server first so its handle pins the pre-mutation manifest
|
||||
// version. Then advance the manifest from outside the server. The
|
||||
// server's next /change call will capture stale `expected_versions`
|
||||
// (from its still-pinned snapshot) and the publisher's CAS rejects.
|
||||
let state = AppState::open(repo.to_string_lossy().to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
{
|
||||
let mut db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap();
|
||||
db.mutate(
|
||||
"main",
|
||||
MUTATION_QUERIES,
|
||||
"set_age",
|
||||
&omnigraph_compiler::json_params_to_param_map(
|
||||
Some(&json!({"name": "Alice", "age": 31 })),
|
||||
&omnigraph_compiler::find_named_query(MUTATION_QUERIES, "set_age")
|
||||
.unwrap()
|
||||
.params,
|
||||
omnigraph_compiler::JsonParamMode::Standard,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(format!("/runs/{}/publish", run_b.run_id.as_str()))
|
||||
.uri("/change")
|
||||
.method(Method::POST)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(b"{}" as &[u8]))
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&ChangeRequest {
|
||||
query_source: MUTATION_QUERIES.to_string(),
|
||||
query_name: Some("set_age".to_string()),
|
||||
params: Some(json!({ "name": "Alice", "age": 33 })),
|
||||
branch: Some("main".to_string()),
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(error.code, Some(omnigraph_server::api::ErrorCode::Conflict));
|
||||
assert!(error.merge_conflicts.iter().any(|conflict| {
|
||||
conflict.table_key == "node:Person"
|
||||
&& conflict.row_id.as_deref() == Some("Alice")
|
||||
&& conflict.kind == omnigraph_server::api::MergeConflictKindOutput::DivergentUpdate
|
||||
}));
|
||||
let conflict = error
|
||||
.manifest_conflict
|
||||
.expect("publisher CAS rejection must populate manifest_conflict body");
|
||||
assert_eq!(conflict.table_key, "node:Person");
|
||||
assert!(
|
||||
conflict.actual > conflict.expected,
|
||||
"actual ({}) should be ahead of expected ({})",
|
||||
conflict.actual,
|
||||
conflict.expected,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue