diff --git a/crates/utopia-core/src/error.rs b/crates/utopia-core/src/error.rs index 4247219fd..edbb7e4ca 100644 --- a/crates/utopia-core/src/error.rs +++ b/crates/utopia-core/src/error.rs @@ -8,6 +8,9 @@ pub enum AppError { Forbidden, #[error("{0}")] Conflict(String), + /// Localizable conflict; legacy internal callers may still use Conflict. + #[error("{message}")] + CodedConflict { code: &'static str, message: String }, #[error("{0}")] Validation(String), /// 带稳定 code 的校验错误。**message 仍是英文原句**——它是给不做本地化的 diff --git a/crates/utopia-server/src/error.rs b/crates/utopia-server/src/error.rs index 5224c2be5..6afcb3eee 100644 --- a/crates/utopia-server/src/error.rs +++ b/crates/utopia-server/src/error.rs @@ -17,7 +17,7 @@ pub type ApiResult = Result; impl IntoResponse for ApiErr { fn into_response(self) -> Response { - // code 与 detail 只有 Invalid 才有;其余保持原样,转换可以一条条推进 + // Legacy errors keep their response; localizable conflicts share the code envelope. let mut code: Option<&'static str> = None; let mut detail: Option = None; let (status, message) = match &self.0 { @@ -33,6 +33,10 @@ impl IntoResponse for ApiErr { AppError::NotFound => (StatusCode::NOT_FOUND, self.0.to_string()), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.0.to_string()), AppError::Forbidden => (StatusCode::FORBIDDEN, self.0.to_string()), + AppError::CodedConflict { code: c, message } => { + code = Some(c); + (StatusCode::CONFLICT, message.clone()) + } AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()), AppError::Validation(m) => (StatusCode::UNPROCESSABLE_ENTITY, m.clone()), AppError::Db(e) => { @@ -60,3 +64,54 @@ impl IntoResponse for ApiErr { (status, Json(body)).into_response() } } + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn conflict_codes_preserve_other_error_mappings() { + for (error, status, code) in [ + ( + AppError::CodedConflict { + code: "alignment_busy", + message: "reworded".into(), + }, + 409, + Some("alignment_busy"), + ), + ( + AppError::CodedConflict { + code: "another_conflict", + message: "busy".into(), + }, + 409, + Some("another_conflict"), + ), + (AppError::Conflict("legacy".into()), 409, None), + (AppError::Unauthorized, 401, None), + (AppError::Forbidden, 403, None), + (AppError::NotFound, 404, None), + ( + AppError::invalid("bad_input", "invalid"), + 422, + Some("bad_input"), + ), + ( + AppError::Other(anyhow::anyhow!("private detail")), + 500, + None, + ), + ] { + let response = ApiErr(error).into_response(); + assert_eq!(response.status().as_u16(), status); + let bytes = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["code"].as_str(), code); + if status == 500 { + assert_eq!(body["error"], "Internal server error"); + } + } + } +} diff --git a/crates/utopia-server/src/type_alignment_tests.rs b/crates/utopia-server/src/type_alignment_tests.rs index d010b49a1..74b4da126 100644 --- a/crates/utopia-server/src/type_alignment_tests.rs +++ b/crates/utopia-server/src/type_alignment_tests.rs @@ -501,7 +501,7 @@ mod review_locks { let (status, body) = tokio::time::timeout(Duration::from_secs(6), tasks.join_next()) .await?.expect("request task")??; anyhow::ensure!(status == StatusCode::CONFLICT, "expected 409, got {status}: {body}"); - anyhow::ensure!(body["error"].as_str().unwrap().contains("try again")); + anyhow::ensure!(body["code"] == "alignment_busy"); anyhow::ensure!(snapshot(&f).await? == before, "timeout left a partial write"); anyhow::ensure!(events.try_recv().is_err(), "failed request emitted success"); anyhow::ensure!(session(&pool).await? == original_session, "session setting leaked"); diff --git a/crates/utopia-store/src/type_bindings.rs b/crates/utopia-store/src/type_bindings.rs index 05efae044..dd0ae1bbf 100644 --- a/crates/utopia-store/src/type_bindings.rs +++ b/crates/utopia-store/src/type_bindings.rs @@ -262,10 +262,11 @@ pub async fn decide_and_apply_human( if matches!(&error, AppError::Db(sqlx::Error::Database(e)) if e.code().as_deref() == Some("55P03")) { - return Err(AppError::Conflict( - "This kind word is being updated by another operation. Please try again shortly." + return Err(AppError::CodedConflict { + code: "alignment_busy", + message: "This kind word is being updated by another operation. Please try again shortly." .into(), - )); + }); } Err(error) } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c3282f756..a01a07029 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1922,6 +1922,7 @@ export const en = { alignmentStatements: (n: number) => (n === 1 ? "1 statement" : `${n} statements`), alignmentEntities: (n: number) => (n === 1 ? "1 thing" : `${n} things`), alignmentVotes: (first: string, second: string) => `Votes: ${first} · ${second}`, + alignmentConflict: "This decision conflicts with the current state. Refresh and review it before trying again.", alignmentKindWordBusy: "This kind word is being updated by another operation. Please try again shortly.", alignmentTyped: (kept: number, retired: number) => `Typed graph recomputed: ${kept} statements typed, ${retired} rows retired`, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b809c1a74..0bf28c2fa 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1681,6 +1681,7 @@ export const zh: Strings = { alignmentStatements: (n: number) => `${n} 条陈述`, alignmentEntities: (n: number) => `${n} 样东西`, alignmentVotes: (first: string, second: string) => `两票:${first} · ${second}`, + alignmentConflict: "此决定与当前状态冲突。请刷新并核对后再试。", alignmentKindWordBusy: "这个类别词正在被其他操作更新,请稍后重试。", alignmentTyped: (kept: number, retired: number) => `类型化图谱已重算:${kept} 条成了类型化事实,${retired} 行作废`, defects: "本体自相矛盾", diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index ab749e0a7..79b5b50bf 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -1,10 +1,10 @@ +import { alignmentErrorMessage } from "./reviewErrors"; import { useEffect, useState } from "react"; import { LayoutDashboard } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { api, - ApiError, type AgentDecision, type AgentPrecedent, type AxiomViolation, @@ -1365,9 +1365,7 @@ export function Review() { mutationFn: ({ kindWord, cls }: { kindWord: string; cls: string | null }) => api.decideAlignmentKindWord(kb!.id, kindWord, cls), onError: (e) => toast.error( - e instanceof ApiError && e.status === 409 - ? S.review.alignmentKindWordBusy - : (e as Error).message, + alignmentErrorMessage(e), ), onSettled: invalidate, }); diff --git a/web/src/pages/reviewErrors.test.ts b/web/src/pages/reviewErrors.test.ts new file mode 100644 index 000000000..5a7790e6e --- /dev/null +++ b/web/src/pages/reviewErrors.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); }); +for (const lang of ["en", "zh"]) { + describe(lang, () => { + it("uses the stable busy code and gives other conflicts a distinct fallback", async () => { + vi.stubGlobal("localStorage", { getItem: () => lang }); + const { ApiError } = await import("../api"); + const { S } = await import("../i18n"); + const { alignmentErrorMessage: message } = await import("./reviewErrors"); + expect(message(new ApiError(409, "changed server wording", "alignment_busy"))).toBe(S.review.alignmentKindWordBusy); + for (const code of [undefined, "already_decided", "future_code"]) { + expect(message(new ApiError(409, "busy, try again", code))).toBe(S.review.alignmentConflict); + expect(message(new ApiError(409, "busy", code))).not.toBe(S.review.alignmentKindWordBusy); + } + for (const status of [401, 403, 404, 422, 500]) { + expect(message(new ApiError(status, "original error", "alignment_busy"))).toBe("original error"); + } + }); + }); +} diff --git a/web/src/pages/reviewErrors.ts b/web/src/pages/reviewErrors.ts new file mode 100644 index 000000000..853c80313 --- /dev/null +++ b/web/src/pages/reviewErrors.ts @@ -0,0 +1,12 @@ +import { ApiError } from "../api"; +import { S } from "../i18n"; + +/** A 409 says conflict, not which operation conflicted. Never infer busy from prose. */ +export function alignmentErrorMessage(error: unknown): string { + if (error instanceof ApiError && error.status === 409) { + return error.code === "alignment_busy" + ? S.review.alignmentKindWordBusy + : S.review.alignmentConflict; + } + return (error as Error).message; +}