Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/utopia-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ struct ManifestDataDir {
/// not a side effect of a code change.
// 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current`
// 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71
const CURRENT_SCHEMA_VERSION: u32 = 104;
const CURRENT_SCHEMA_VERSION: u32 = 105;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
12 changes: 11 additions & 1 deletion crates/utopia-server/src/api/mcp_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,17 @@ impl Fixture {
.await?;
let dir = self.dir.clone();
drop(self);
std::fs::remove_dir_all(dir)?;
// Tantivy can finish a background file operation after the state is dropped.
// Retry only that teardown race; persistent or unrelated IO errors still fail.
for attempt in 0..10 {
match std::fs::remove_dir_all(&dir) {
Ok(()) => break,
Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty && attempt < 9 => {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
}
Expand Down
27 changes: 24 additions & 3 deletions crates/utopia-server/src/api/ontology_regression_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,21 @@ use utopia_core::models::Role;
use utopia_store::{access::require_kb, ontology_regressions as cases};
use uuid::Uuid;

#[derive(Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExpectedBinding {
#[default]
Bound,
None,
}

#[derive(Deserialize)]
pub struct Create {
pub statement_id: Uuid,
pub relation_type_id: Uuid,
pub direction: String,
#[serde(default)]
pub expected_binding: ExpectedBinding,
pub relation_type_id: Option<Uuid>,
pub direction: Option<String>,
}

pub async fn list(
Expand All @@ -34,13 +44,24 @@ pub async fn create(
Json(req): Json<Create>,
) -> ApiResult<(StatusCode, Json<Value>)> {
require_kb(&state.pool, &user, kb, Role::Editor).await?;
let valid = match req.expected_binding {
ExpectedBinding::Bound => req.relation_type_id.is_some() && req.direction.is_some(),
ExpectedBinding::None => req.relation_type_id.is_none() && req.direction.is_none(),
};
if !valid {
return Err(utopia_core::AppError::invalid(
"bad_case_expectation",
"A bound expectation needs a property and direction; a none expectation has neither.",
)
.into());
}
let id = cases::add(
&state.pool,
kb,
cases::NewCase {
statement_id: req.statement_id,
expected_property_id: req.relation_type_id,
expected_direction: &req.direction,
expected_direction: req.direction.as_deref(),
created_by: user.id,
origin: "person",
},
Expand Down
164 changes: 151 additions & 13 deletions crates/utopia-server/src/api/ontology_regression_routes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,30 @@ async fn post(
token: &str,
statement: Uuid,
property: Uuid,
) -> anyhow::Result<axum::response::Response> {
post_body(
app,
kb,
token,
json!({ "statement_id": statement,
"relation_type_id": property, "direction": "forward" }),
)
.await
}

async fn post_body(
app: &Router,
kb: Uuid,
token: &str,
body: serde_json::Value,
) -> anyhow::Result<axum::response::Response> {
Ok(app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/kbs/{kb}/ontology/regressions"))
Request::post(format!("/api/v1/kbs/{kb}/ontology/regressions"))
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(
json!({
"statement_id": statement,
"relation_type_id": property,
"direction": "forward"
})
.to_string(),
))?,
.body(Body::from(body.to_string()))?,
)
.await?)
}
Expand Down Expand Up @@ -134,12 +141,143 @@ async fn editor_adds_regression_case_while_viewer_and_cross_kb_statement_are_rej
.await?;
anyhow::ensure!(saved);

let negative = graph::insert_open_statement(
&pool,
kb,
subject,
"mentioned alongside",
graph::FactObject::Entity(object),
None,
1.0,
)
.await?
.0;
for fields in [
json!({}),
json!({"expected_binding":"bound"}),
json!({"expected_binding":"unknown"}),
json!({"expected_binding":null}),
json!({"relation_type_id":property}),
json!({"direction":"forward"}),
json!({"expected_binding":"none","relation_type_id":property}),
json!({"expected_binding":"none","direction":"reverse"}),
json!({"relation_type_id":property,"direction":"sideways"}),
] {
let mut body = fields;
body["statement_id"] = json!(negative);
// The JSON extractor rejects an explicit null enum before the handler.
let expected = if body.get("expected_binding") == Some(&serde_json::Value::Null) {
StatusCode::BAD_REQUEST
} else {
StatusCode::UNPROCESSABLE_ENTITY
};
assert_eq!(
post_body(&app, kb, &editor_token, body.clone())
.await?
.status(),
expected,
"{body}"
);
}
let mut negative_id = serde_json::Value::Null;
for targets in [json!({}), json!({"relation_type_id":null,"direction":null})] {
let mut request = targets;
request["statement_id"] = json!(negative);
request["expected_binding"] = json!("none");
let response = post_body(&app, kb, &editor_token, request).await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 65536).await?)?;
if negative_id.is_null() {
negative_id = body["id"].clone();
}
assert_eq!(body["id"], negative_id);
}
for request in [
json!({"statement_id":statement,"expected_binding":"none"}),
json!({"statement_id":negative,"relation_type_id":property,"direction":"forward"}),
] {
let response = post_body(&app, kb, &editor_token, request).await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 65536).await?)?;
assert_ne!(
body["id"], negative_id,
"opposite cases do not overwrite a confirmation"
);
}
let sig = utopia_store::phrase_bindings::signatures(&pool, kb)
.await?
.into_iter()
.find(|s| s.phrase == "mentioned alongside")
.unwrap();
utopia_store::phrase_bindings::decide(
&pool,
kb,
&sig,
utopia_store::phrase_bindings::Decision {
relation_type_id: Some(property),
direction: Some("forward"),
status: "bound",
decided_by: "person",
votes: &serde_json::Value::Null,
basis: None,
marks: None,
marks_asked: false,
},
)
.await?;
// Negative expectations remain visible even though they have no property to join.
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/kbs/{kb}/ontology/regressions"))
.header("authorization", format!("Bearer {viewer_token}"))
.body(Body::empty())?,
)
.await?;
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 65536).await?)?;
let case = body["cases"]
.as_array()
.unwrap()
.iter()
.find(|c| c["id"] == negative_id)
.unwrap();
assert_eq!(case["expected_binding"], "none");
for key in [
"expected_property_id",
"expected_direction",
"expected_property_label",
"expected_property_key",
] {
assert!(case[key].is_null(), "{key}: {case}");
}
assert_eq!(case["last_result"]["conflict"], "human_binding");
assert_eq!(case["last_result"]["passed"], false);
assert_eq!(case["last_result"]["actual_property_id"], json!(property));
assert_eq!(case["actual_property_key"], "worksFor");
assert_eq!(case["subject_label"], "Alice");
assert_eq!(case["statement_id"], json!(negative));

for (token, statement, expected) in [
(&viewer_token, statement, StatusCode::FORBIDDEN),
(&editor_token, foreign_statement, StatusCode::NOT_FOUND),
] {
let response = post(&app, kb, token, statement, property).await?;
anyhow::ensure!(response.status() == expected);
assert_eq!(
post_body(
&app,
kb,
token,
json!({"statement_id":statement,"expected_binding":"none"})
)
.await?
.status(),
expected
);
}
anyhow::Ok(())
}
Expand Down Expand Up @@ -209,8 +347,8 @@ async fn viewer_reads_only_live_cases_in_the_authorized_kb() -> anyhow::Result<(
).await?.0;
utopia_store::ontology_regressions::add(&pool, case_kb,
utopia_store::ontology_regressions::NewCase {
statement_id: id, expected_property_id: expected,
expected_direction: "forward", created_by: viewer, origin: "person",
statement_id: id, expected_property_id: Some(expected),
expected_direction: Some("forward"), created_by: viewer, origin: "person",
},
).await?;
if case_kb == kb {
Expand Down
10 changes: 6 additions & 4 deletions crates/utopia-server/src/ontology_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1245,8 +1245,10 @@ async fn finish_adoption(
}
}
for (statement_id, direction) in examples {
// Removed examples cannot be checked; never substitute another statement.
let live: Option<Uuid> = sqlx::query_scalar("SELECT id FROM facts WHERE kb_id=$1 AND id=$2 AND layer='open' AND invalidated_at IS NULL FOR SHARE")
// A negative case observes disagreement; it must not veto adoption or gain an
// automatic positive counterpart. Removed examples are skipped too, never replaced.
let live: Option<Uuid> = sqlx::query_scalar("SELECT f.id FROM facts f WHERE f.kb_id=$1 AND f.id=$2 AND f.layer='open' AND f.invalidated_at IS NULL
AND NOT EXISTS (SELECT 1 FROM ontology_regression_cases c WHERE c.kb_id=f.kb_id AND c.statement_id=f.id AND c.expected_property_id IS NULL) FOR SHARE OF f")
.bind(kb_id).bind(statement_id).fetch_optional(&mut **tx).await?;
if live.is_none() {
continue;
Expand All @@ -1256,8 +1258,8 @@ async fn finish_adoption(
kb_id,
utopia_store::ontology_regressions::NewCase {
statement_id,
expected_property_id: property_id,
expected_direction: direction,
expected_property_id: Some(property_id),
expected_direction: Some(direction),
created_by: actor,
origin: "adoption",
},
Expand Down
Loading
Loading