feat(server): expose typed semantic transition review

This commit is contained in:
2026-09-06 05:46:51 +03:00
parent 29df024a7f
commit d130013155
8 changed files with 6555 additions and 19 deletions
+170
View File
@@ -72,6 +72,18 @@ pub(crate) fn router() -> Router<AppState> {
"/api/v1/projects/{id}/transitions/apply",
post(commit_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/query",
post(query_semantic_transitions),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/preview",
post(preview_semantic_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/apply",
post(commit_semantic_transition),
)
.fallback(|| async {
ApiError::new(
StatusCode::NOT_FOUND,
@@ -1089,6 +1101,164 @@ struct TransitionApplyRequest {
plan: oa_core::transitions::TransitionPlan,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticQueryRequest {
traces: Vec<Id>,
#[serde(default)]
references: BTreeMap<Id, Id>,
revision: Option<u64>,
}
fn semantic_rejected(error: impl std::fmt::Display) -> ApiError {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"semantic_transition_rejected",
error.to_string(),
)
}
async fn query_semantic_transitions(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticQueryRequest = json_body(&body)?;
if request.traces.is_empty() || request.traces.len() > 100 || request.references.len() > 5_000 {
return Err(ApiError::bad_request("Semantic queries require 1–100 trace IDs and at most 5,000 reference mappings."));
}
let selected: BTreeSet<_> = request.traces.iter().copied().collect();
if selected.len() != request.traces.len() {
return Err(ApiError::bad_request("Semantic query trace IDs must be unique."));
}
let current = store.get(id)?;
check_read_revision(&current, request.revision)?;
let mut items = Vec::new();
for trace_id in request.traces {
let trace = current.model.traces.get(&trace_id).ok_or_else(|| {
ApiError::new(StatusCode::NOT_FOUND, "trace_not_found", "Selected trace does not exist.")
})?;
let facets = oa_core::semantic::semantic_facets(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let drift = oa_core::semantic::trace_semantic_drift(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let linked_impacts = oa_core::semantic::linked_semantic_impacts(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
items.push(json!({"trace":trace.id,"oa":trace.oa,"sa":trace.sa,"facets":facets,"drift":drift,"linked_impacts":linked_impacts}));
}
Ok(response(&current, StatusCode::OK, json!({"revision":current.revision,"items":items})))
}).await
}
fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest) -> ApiResult<()> {
let transition = &request.transition;
let decisions = request.reviews.iter().try_fold(0usize, |count, review| {
count
.checked_add(review.resolutions.len())?
.checked_add(review.reset.len())
});
if transition.sources.len() > 100
|| transition.mappings.len() > 200
|| transition.resolutions.len() > 400
|| request.traces.len() > 100
|| request.references.len() > 5_000
|| request.reviews.len() > 100
|| decisions.is_none_or(|count| count > 5_000)
|| request.candidates.len() > 200
|| request.accepted_impacts.len() > 200
{
return Err(ApiError::bad_request(
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
));
}
Ok(())
}
async fn preview_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: oa_core::semantic::SemanticTransitionRequest = json_body(&body)?;
check_semantic_request(&request)?;
let current = store.checked(id, &expected)?;
let plan = oa_core::semantic::plan_semantic_transition(&current.model, request)
.map_err(semantic_rejected)?;
if !plan.commands.is_empty() {
check_commands(&plan.commands)?;
}
if plan.conflicts.is_empty() {
let candidate = oa_core::semantic::prepare_semantic_transition(&current.model, &plan)
.map_err(semantic_rejected)?;
PreparedProject::from_command(&current, candidate)?;
}
Ok(response(
&current,
StatusCode::OK,
json!({"base_revision":current.revision,"plan":plan}),
))
})
.await
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticApplyRequest {
base_revision: u64,
plan: oa_core::semantic::SemanticTransitionPlan,
}
async fn commit_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticApplyRequest = json_body(&body)?;
let current = store.checked(id, &expected)?;
if current.revision != request.base_revision {
let mut error = ApiError::new(
StatusCode::PRECONDITION_FAILED,
"plan_revision_conflict",
"Semantic preview revision changed. Create a new preview.",
);
error.current_revision = Some(current.revision);
return Err(error);
}
check_semantic_request(&request.plan.request)?;
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate =
oa_core::semantic::prepare_semantic_transition(&current.model, &request.plan)
.map_err(semantic_rejected)?;
let prepared = PreparedProject::from_command(&current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
))
})
.await
}
async fn commit_transition(
State(state): State<AppState>,
id: ProjectPath,