feat(app): harden cancellable LAPP turns
verify / verify (push) Has been cancelled

This commit is contained in:
Codex
2026-07-28 08:44:53 -04:00
parent 4831db1763
commit 23672e857b
26 changed files with 2822 additions and 167 deletions
+587 -34
View File
@@ -1,27 +1,41 @@
use std::{fs, path::Path, sync::Mutex};
use std::{
fs,
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use nana_domain::{
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary,
CheckDifficulty, CheckRecord, CheckResult, DOMAIN_SCHEMA_VERSION, DemoPackSummary,
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
ItemPlacement, KnowledgeCertainty, KnowledgeRecord, LappMode, LappModelOption, LappSettings,
PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot,
Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipDimension,
RenameBranchRequest, ResourceBundle, ResourceId, RuntimeState, StateDelta, StateOp, StoryNode,
SwitchBranchRequest, SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest,
TurnResult, UpdateLappSettingsRequest, ValidationIssue, VisualDirective, stable_json_hash,
validate_bundle,
ItemPlacement, KnowledgeCertainty, KnowledgeRecord, LappConnectionTestResult, LappMode,
LappModelOption, LappSettings, PlayerView, PresentationBeat, PresentationCharacter,
PresentationScene, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight,
RelationshipAdjustment, RelationshipDimension, RenameBranchRequest, ResourceBundle, ResourceId,
RuntimeState, StateDelta, StateOp, StoryNode, SwitchBranchRequest, SwitchBranchResult,
TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, UpdateLappSettingsRequest,
ValidationIssue, VisualDirective, stable_json_hash, validate_bundle,
};
use nana_engine::{
SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view,
};
use nana_runtime::{
AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, OpenLappChatExecutor,
ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
load_default_lapp_profile,
AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, LappNativeCallGate,
LappNativeCallPermit, OpenLappChatExecutor, ProviderError, TurnControl, TurnEngine, TurnPlan,
TurnPlanProvider, TurnProjector, load_default_lapp_profile,
};
use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore};
use openlapp::{connection::ListModelsOptions, list_models};
use openlapp::{
ModelSelector,
client::{Client, TestConnectionResult},
connection::ListModelsOptions,
credential::{CredentialResolver, DefaultCredentialResolver},
list_models,
};
use serde::Serialize;
use tauri::Manager;
@@ -34,6 +48,32 @@ const PLAYER_ID: &str = "player";
const CHARACTER_ID: &str = "nana";
const LAPP_PROVIDER_SETTING: &str = "lapp.provider_id";
const LAPP_MODEL_SETTING: &str = "lapp.model_id";
const TURN_TIMEOUT: Duration = Duration::from_secs(90);
const CONNECTION_TEST_TIMEOUT: Duration = Duration::from_secs(35);
fn story_data_dir(
default_path: PathBuf,
override_path: Option<PathBuf>,
) -> Result<PathBuf, std::io::Error> {
let Some(path) = override_path else {
return Ok(default_path);
};
if !path.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"NANA_STORY_SMOKE_DATA_DIR must be an absolute path",
));
}
let resolved = fs::canonicalize(path)?;
let temporary_root = fs::canonicalize(std::env::temp_dir())?;
if resolved == temporary_root || !resolved.starts_with(&temporary_root) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"NANA_STORY_SMOKE_DATA_DIR must be a child of the temporary directory",
));
}
Ok(resolved)
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -99,6 +139,51 @@ impl CommandError {
}
}
fn turn_in_progress() -> Self {
Self {
code: "turn_in_progress".to_owned(),
message: "已有一轮故事正在生成。".to_owned(),
retryable: true,
issues: Vec::new(),
}
}
fn background_task() -> Self {
Self {
code: "internal".to_owned(),
message: "故事回合未能完成。".to_owned(),
retryable: true,
issues: Vec::new(),
}
}
fn connection_test_in_progress() -> Self {
Self {
code: "connection_test_in_progress".to_owned(),
message: "已有一项模型连接测试正在进行。".to_owned(),
retryable: true,
issues: Vec::new(),
}
}
fn connection_test_timed_out() -> Self {
Self {
code: "timed_out".to_owned(),
message: "模型连接测试等待超时。".to_owned(),
retryable: true,
issues: Vec::new(),
}
}
fn connection_background_task() -> Self {
Self {
code: "internal".to_owned(),
message: "模型连接测试未能完成。".to_owned(),
retryable: true,
issues: Vec::new(),
}
}
fn fork(error: &ForkError) -> Self {
match error {
ForkError::BranchAlreadyExists { .. } => Self::stale_branch(),
@@ -121,6 +206,50 @@ struct DemoAppState {
bundle: ResourceBundle,
operation_lock: Mutex<()>,
provider: Mutex<RuntimePlanProvider>,
in_flight_turn: Arc<Mutex<Option<InFlightTurn>>>,
connection_test_in_flight: Arc<AtomicBool>,
lapp_native_call_gate: LappNativeCallGate,
}
#[derive(Debug, Clone)]
struct InFlightTurn {
action_id: String,
control: TurnControl,
}
#[derive(Debug)]
struct InFlightTurnGuard {
slot: Arc<Mutex<Option<InFlightTurn>>>,
action_id: String,
}
impl Drop for InFlightTurnGuard {
fn drop(&mut self) {
if let Ok(mut slot) = self.slot.lock()
&& slot
.as_ref()
.is_some_and(|turn| turn.action_id == self.action_id)
{
*slot = None;
}
}
}
#[derive(Debug)]
struct ConnectionTestGuard {
in_flight: Arc<AtomicBool>,
}
impl Drop for ConnectionTestGuard {
fn drop(&mut self) {
self.in_flight.store(false, Ordering::Release);
}
}
struct AppliedLappTarget {
profile: openlapp::Profile,
provider_id: String,
model_id: String,
}
type LappRuntimeProvider =
@@ -130,10 +259,26 @@ enum RuntimePlanProvider {
Demo(DemoPlanProvider),
Lapp(Box<LappRuntimeProvider>),
Unavailable,
#[cfg(test)]
RetiredTest,
}
impl RuntimePlanProvider {
fn configured(bundle: &ResourceBundle, store: &SqliteStoryStore) -> Self {
#[cfg(test)]
fn configured(
_bundle: &ResourceBundle,
_store: &SqliteStoryStore,
_native_call_gate: LappNativeCallGate,
) -> Self {
Self::Demo(DemoPlanProvider)
}
#[cfg(not(test))]
fn configured(
bundle: &ResourceBundle,
store: &SqliteStoryStore,
native_call_gate: LappNativeCallGate,
) -> Self {
if demo_provider_requested() {
return Self::Demo(DemoPlanProvider);
}
@@ -158,17 +303,27 @@ impl RuntimePlanProvider {
{
return Self::Unavailable;
}
let model = LappAdjudicationModel::from_profile_and_model(
let model = LappAdjudicationModel::from_profile_and_model_with_gate(
&profile,
&provider_id,
&model_id,
bundle.clone(),
native_call_gate,
);
let Ok(model) = model else {
return Self::Unavailable;
};
Self::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog)))
}
fn is_retired(&self) -> bool {
match self {
Self::Lapp(provider) => provider.model().executor().is_retired(),
#[cfg(test)]
Self::RetiredTest => true,
Self::Demo(_) | Self::Unavailable => false,
}
}
}
fn demo_provider_requested() -> bool {
@@ -185,6 +340,23 @@ impl TurnPlanProvider for RuntimePlanProvider {
Self::Demo(provider) => provider.plan_turn(request, state),
Self::Lapp(provider) => provider.plan_turn(request, state),
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
#[cfg(test)]
Self::RetiredTest => Err(ProviderError::Cancelled),
}
}
fn plan_turn_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
match self {
Self::Demo(provider) => provider.plan_turn_with_control(request, state, control),
Self::Lapp(provider) => provider.plan_turn_with_control(request, state, control),
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
#[cfg(test)]
Self::RetiredTest => Err(ProviderError::Cancelled),
}
}
}
@@ -225,10 +397,11 @@ impl DemoAppState {
Err(error) => return Err(CommandError::storage(&error)),
}
let lapp_native_call_gate = LappNativeCallGate::new();
let provider = if cfg!(test) {
RuntimePlanProvider::Demo(DemoPlanProvider)
} else {
RuntimePlanProvider::configured(&bundle, &store)
RuntimePlanProvider::configured(&bundle, &store, lapp_native_call_gate.clone())
};
Ok(Self {
@@ -236,6 +409,9 @@ impl DemoAppState {
bundle,
operation_lock: Mutex::new(()),
provider: Mutex::new(provider),
in_flight_turn: Arc::new(Mutex::new(None)),
connection_test_in_flight: Arc::new(AtomicBool::new(false)),
lapp_native_call_gate,
})
}
@@ -271,7 +447,18 @@ impl DemoAppState {
Ok(self.project_view_with_lineage(&state, &node, &lineage))
}
#[cfg(test)]
fn submit_turn(&self, request: &TurnRequest) -> Result<TurnResult, CommandError> {
let control = TurnControl::with_timeout(TURN_TIMEOUT);
let _in_flight = self.register_turn(&request.action_id, &control)?;
self.submit_turn_with_control(request, &control)
}
fn submit_turn_with_control(
&self,
request: &TurnRequest,
control: &TurnControl,
) -> Result<TurnResult, CommandError> {
let _operation = self
.operation_lock
.lock()
@@ -301,9 +488,55 @@ impl DemoAppState {
.provider
.lock()
.map_err(|_| CommandError::operation_lock())?;
let projector = DemoProjector { app: self };
let mut engine = TurnEngine::new(&self.store, &mut *provider, projector);
engine.submit_turn(request).map_err(CommandError::turn)
let result = {
let projector = DemoProjector { app: self };
let mut engine = TurnEngine::new(&self.store, &mut *provider, projector);
engine.submit_turn_with_control(request, control)
};
if provider.is_retired() {
// An interrupted LAPP request may leave one isolated native Vault
// call running. Replace the retired executor before the UI can
// offer its single safe retry.
*provider = RuntimePlanProvider::configured(
&self.bundle,
&self.store,
self.lapp_native_call_gate.clone(),
);
}
result.map_err(CommandError::turn)
}
fn register_turn(
&self,
action_id: &str,
control: &TurnControl,
) -> Result<InFlightTurnGuard, CommandError> {
let mut slot = self
.in_flight_turn
.lock()
.map_err(|_| CommandError::operation_lock())?;
if slot.is_some() {
return Err(CommandError::turn_in_progress());
}
*slot = Some(InFlightTurn {
action_id: action_id.to_owned(),
control: control.clone(),
});
Ok(InFlightTurnGuard {
slot: Arc::clone(&self.in_flight_turn),
action_id: action_id.to_owned(),
})
}
fn cancel_turn(&self, action_id: &str) -> Result<bool, CommandError> {
let slot = self
.in_flight_turn
.lock()
.map_err(|_| CommandError::operation_lock())?;
Ok(slot
.as_ref()
.filter(|turn| turn.action_id == action_id)
.is_some_and(|turn| turn.control.cancel()))
}
fn fork_branch(&self, request: &ForkBranchRequest) -> Result<ForkBranchResult, CommandError> {
@@ -496,7 +729,7 @@ impl DemoAppState {
let (mode, status_message) = if selected_is_available {
(
LappMode::Lapp,
"已读取 LAPP profile;凭据只会在生成时由系统 Vault 解析。".to_owned(),
"已读取 LAPP profile;凭据在生成或你主动测试连接时由系统 Vault 解析。".to_owned(),
)
} else {
(
@@ -543,11 +776,12 @@ impl DemoAppState {
retryable: false,
issues: Vec::new(),
})?;
let model = LappAdjudicationModel::from_profile_and_model(
let model = LappAdjudicationModel::from_profile_and_model_with_gate(
&profile,
&request.provider_id,
&request.model_id,
self.bundle.clone(),
self.lapp_native_call_gate.clone(),
)
.map_err(|_| CommandError {
code: "provider_unavailable".to_owned(),
@@ -577,6 +811,60 @@ impl DemoAppState {
Ok(self.lapp_settings())
}
fn applied_lapp_target(&self) -> Result<AppliedLappTarget, CommandError> {
if demo_provider_requested() {
return Err(CommandError::invalid_input(
"确定性演示模式不连接外部模型。",
));
}
let _operation = self
.operation_lock
.try_lock()
.map_err(|_| CommandError::connection_test_in_progress())?;
let profile = load_default_lapp_profile().map_err(|_| CommandError {
code: "provider_unavailable".to_owned(),
message: "未找到可用的 LAPP profile。".to_owned(),
retryable: true,
issues: Vec::new(),
})?;
let (provider_id, model_id) = selected_lapp_model(
&profile,
self.store
.get_app_setting(LAPP_PROVIDER_SETTING)
.map_err(|error| CommandError::storage(&error))?,
self.store
.get_app_setting(LAPP_MODEL_SETTING)
.map_err(|error| CommandError::storage(&error))?,
);
let Some((provider_id, model_id)) = provider_id.zip(model_id) else {
return Err(CommandError::invalid_input(
"请先应用一个支持聊天与工具调用的模型。",
));
};
if !lapp_model_options(&profile)
.iter()
.any(|model| model.provider_id == provider_id && model.model_id == model_id)
{
return Err(CommandError::invalid_input(
"当前应用的模型不可用,或未声明聊天与工具调用能力。",
));
}
Ok(AppliedLappTarget {
profile,
provider_id,
model_id,
})
}
fn register_connection_test(&self) -> Result<ConnectionTestGuard, CommandError> {
self.connection_test_in_flight
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| CommandError::connection_test_in_progress())?;
Ok(ConnectionTestGuard {
in_flight: Arc::clone(&self.connection_test_in_flight),
})
}
fn project_view(&self, state: &RuntimeState, node: &StoryNode) -> PlayerView {
let lineage = self
.load_lineage(node)
@@ -1414,6 +1702,10 @@ const fn turn_failure_code(code: &TurnFailureCode) -> &'static str {
TurnFailureCode::StaleNode => "stale_node",
TurnFailureCode::InvalidInput => "invalid_input",
TurnFailureCode::InvalidModelOutput => "invalid_model_output",
TurnFailureCode::ProviderConfiguration => "provider_configuration",
TurnFailureCode::ProviderCredentials => "provider_credentials",
TurnFailureCode::ProviderRateLimited => "provider_rate_limited",
TurnFailureCode::ProviderRejected => "provider_rejected",
TurnFailureCode::ProviderUnavailable => "provider_unavailable",
TurnFailureCode::Cancelled => "cancelled",
TurnFailureCode::TimedOut => "timed_out",
@@ -1421,6 +1713,46 @@ const fn turn_failure_code(code: &TurnFailureCode) -> &'static str {
}
}
fn project_connection_test(result: TestConnectionResult) -> LappConnectionTestResult {
let message = if result.ok {
"连接成功,模型能够响应最小请求。".to_owned()
} else {
"连接失败,请检查 LAPP 配置与网络。".to_owned()
};
LappConnectionTestResult {
ok: result.ok,
provider_id: result.provider_id,
model_id: result.model_id,
message,
diagnostic_code: result.code.map(|code| code.to_string()),
}
}
fn run_lapp_connection_test(
target: AppliedLappTarget,
native_call_permit: LappNativeCallPermit,
) -> Result<LappConnectionTestResult, CommandError> {
let _native_call_permit = native_call_permit;
let selector = ModelSelector::Explicit {
provider_id: target.provider_id,
model: target.model_id,
};
let resolver: Arc<dyn CredentialResolver> = Arc::new(DefaultCredentialResolver::system());
let client = Client::new(&target.profile, &selector, resolver).map_err(|_| CommandError {
code: "provider_configuration".to_owned(),
message: "当前应用的 LAPP 模型无法初始化。".to_owned(),
retryable: false,
issues: Vec::new(),
})?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|_| CommandError::connection_background_task())?;
Ok(project_connection_test(
runtime.block_on(client.test_connection()),
))
}
#[tauri::command]
fn get_app_info() -> AppInfo {
AppInfo {
@@ -1432,26 +1764,28 @@ fn get_app_info() -> AppInfo {
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
fn get_demo_player_view(state: tauri::State<'_, DemoAppState>) -> Result<PlayerView, CommandError> {
fn get_demo_player_view(
state: tauri::State<'_, Arc<DemoAppState>>,
) -> Result<PlayerView, CommandError> {
state.current_player_view()
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
fn get_demo_pack_summary(state: tauri::State<'_, DemoAppState>) -> DemoPackSummary {
fn get_demo_pack_summary(state: tauri::State<'_, Arc<DemoAppState>>) -> DemoPackSummary {
state.pack_summary()
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
fn get_branch_list(state: tauri::State<'_, DemoAppState>) -> Result<BranchList, CommandError> {
fn get_branch_list(state: tauri::State<'_, Arc<DemoAppState>>) -> Result<BranchList, CommandError> {
state.branch_list()
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
fn switch_branch(
state: tauri::State<'_, DemoAppState>,
state: tauri::State<'_, Arc<DemoAppState>>,
request: SwitchBranchRequest,
) -> Result<SwitchBranchResult, CommandError> {
state.switch_branch(&request)
@@ -1460,7 +1794,7 @@ fn switch_branch(
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
fn rename_branch(
state: tauri::State<'_, DemoAppState>,
state: tauri::State<'_, Arc<DemoAppState>>,
request: RenameBranchRequest,
) -> Result<BranchList, CommandError> {
state.rename_branch(&request)
@@ -1468,32 +1802,77 @@ fn rename_branch(
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
fn get_lapp_settings(state: tauri::State<'_, DemoAppState>) -> LappSettings {
fn get_lapp_settings(state: tauri::State<'_, Arc<DemoAppState>>) -> LappSettings {
state.lapp_settings()
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
fn update_lapp_settings(
state: tauri::State<'_, DemoAppState>,
state: tauri::State<'_, Arc<DemoAppState>>,
request: UpdateLappSettingsRequest,
) -> Result<LappSettings, CommandError> {
state.update_lapp_settings(&request)
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
async fn test_lapp_connection(
state: tauri::State<'_, Arc<DemoAppState>>,
) -> Result<LappConnectionTestResult, CommandError> {
let state = Arc::clone(state.inner());
let target = state.applied_lapp_target()?;
let in_flight = state.register_connection_test()?;
let native_call_permit = state
.lapp_native_call_gate
.try_acquire()
.ok_or_else(CommandError::connection_test_in_progress)?;
let (sender, receiver) = tokio::sync::oneshot::channel();
std::thread::Builder::new()
.name("nana-lapp-connection-test".into())
.spawn(move || {
let _in_flight = in_flight;
let _ = sender.send(run_lapp_connection_test(target, native_call_permit));
})
.map_err(|_| CommandError::connection_background_task())?;
match tokio::time::timeout(CONNECTION_TEST_TIMEOUT, receiver).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(CommandError::connection_background_task()),
Err(_) => Err(CommandError::connection_test_timed_out()),
}
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
fn submit_turn(
state: tauri::State<'_, DemoAppState>,
async fn submit_turn(
state: tauri::State<'_, Arc<DemoAppState>>,
request: TurnRequest,
) -> Result<TurnResult, CommandError> {
state.submit_turn(&request)
let state = Arc::clone(state.inner());
let control = TurnControl::with_timeout(TURN_TIMEOUT);
let in_flight = state.register_turn(&request.action_id, &control)?;
tauri::async_runtime::spawn_blocking(move || {
let _in_flight = in_flight;
state.submit_turn_with_control(&request, &control)
})
.await
.map_err(|_| CommandError::background_task())?
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state and owned command data.
fn cancel_turn(
state: tauri::State<'_, Arc<DemoAppState>>,
action_id: String,
) -> Result<bool, CommandError> {
state.cancel_turn(&action_id)
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
fn fork_branch(
state: tauri::State<'_, DemoAppState>,
state: tauri::State<'_, Arc<DemoAppState>>,
request: ForkBranchRequest,
) -> Result<ForkBranchResult, CommandError> {
state.fork_branch(&request)
@@ -1508,11 +1887,14 @@ fn fork_branch(
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let app_data = app.path().app_data_dir()?;
let app_data = story_data_dir(
app.path().app_data_dir()?,
std::env::var_os("NANA_STORY_SMOKE_DATA_DIR").map(PathBuf::from),
)?;
fs::create_dir_all(&app_data)?;
let demo = DemoAppState::open(app_data.join("nana-story.sqlite3"))
.map_err(|error| std::io::Error::other(error.message))?;
app.manage(demo);
app.manage(Arc::new(demo));
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -1524,7 +1906,9 @@ pub fn run() {
rename_branch,
get_lapp_settings,
update_lapp_settings,
test_lapp_connection,
submit_turn,
cancel_turn,
fork_branch
])
.run(tauri::generate_context!())
@@ -1536,6 +1920,8 @@ mod tests {
use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
thread,
time::{SystemTime, UNIX_EPOCH},
};
@@ -1544,10 +1930,13 @@ mod tests {
RelationshipAdjustment, RelationshipBand, RelationshipDimension, RenameBranchRequest,
StateDelta, StateOp, StoryNode, SwitchBranchRequest, TurnIntent, TurnRequest,
};
use nana_runtime::TurnControl;
use nana_store::StoryStore;
use openlapp::{ErrorCode, client::TestConnectionResult};
use super::{
DEMO_BRANCH_ID, DEMO_STORY_ID, DemoAppState, history_label, last_player_relationship_update,
DEMO_BRANCH_ID, DEMO_STORY_ID, DemoAppState, RuntimePlanProvider, history_label,
last_player_relationship_update, project_connection_test, story_data_dir,
};
struct TemporaryDatabase {
@@ -1581,6 +1970,170 @@ mod tests {
}
}
#[test]
fn lapp_connection_result_keeps_only_safe_diagnostics() {
let projected = project_connection_test(TestConnectionResult {
ok: false,
provider_id: "provider.safe".to_owned(),
model_id: "model.safe".to_owned(),
protocol: "openai-responses".to_owned(),
code: Some(ErrorCode::HttpStatus),
message: Some("api_key=must-not-cross-app-boundary".to_owned()),
});
assert!(!projected.ok);
assert_eq!(projected.provider_id, "provider.safe");
assert_eq!(projected.model_id, "model.safe");
assert_eq!(projected.diagnostic_code.as_deref(), Some("HTTP_STATUS"));
assert_eq!(projected.message, "连接失败,请检查 LAPP 配置与网络。");
assert!(!projected.message.contains("must-not-cross"));
}
#[test]
fn smoke_data_override_requires_an_existing_absolute_temporary_child() {
let default = PathBuf::from("default-data");
assert_eq!(
story_data_dir(default.clone(), None).expect("default path"),
default
);
assert!(story_data_dir(PathBuf::from("default"), Some(std::env::temp_dir())).is_err());
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let smoke = std::env::temp_dir().join(format!(
"nana-story-smoke-dir-{}-{nonce}",
std::process::id()
));
fs::create_dir_all(&smoke).expect("create smoke directory");
let resolved = story_data_dir(PathBuf::from("unused"), Some(smoke.clone()))
.expect("temporary override");
assert_eq!(resolved, fs::canonicalize(&smoke).expect("canonical smoke"));
assert!(story_data_dir(PathBuf::from("default"), Some(PathBuf::from("relative"))).is_err());
fs::remove_dir(&smoke).expect("remove smoke directory");
}
#[cfg(unix)]
#[test]
fn smoke_data_override_rejects_a_symlink_escape() {
use std::os::unix::fs::symlink;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let outside = std::env::current_dir()
.expect("current directory")
.join(format!("nana-story-smoke-outside-{nonce}"));
fs::create_dir_all(&outside).expect("create outside target");
let temporary_root = fs::canonicalize(std::env::temp_dir()).expect("temporary root");
let outside_resolved = fs::canonicalize(&outside).expect("outside target");
if !outside_resolved.starts_with(&temporary_root) {
let link = std::env::temp_dir().join(format!(
"nana-story-smoke-link-{}-{nonce}",
std::process::id()
));
symlink(&outside, &link).expect("create escape symlink");
assert!(story_data_dir(PathBuf::from("default"), Some(link.clone())).is_err());
fs::remove_file(link).expect("remove escape symlink");
}
fs::remove_dir(outside).expect("remove outside target");
}
#[test]
fn in_flight_turn_registry_accepts_cancel_and_rejects_overlap() {
let app = DemoAppState::open_in_memory().expect("app");
let control = TurnControl::new();
let guard = app
.register_turn("action_in_flight", &control)
.expect("register turn");
let overlapping = app
.register_turn("action_overlap", &TurnControl::new())
.expect_err("only one turn may run");
assert_eq!(overlapping.code, "turn_in_progress");
assert!(app.cancel_turn("action_in_flight").expect("cancel"));
assert!(control.is_cancelled());
assert!(!app.cancel_turn("action_other").expect("wrong action"));
drop(guard);
assert!(
!app.cancel_turn("action_in_flight")
.expect("cleared registry")
);
}
#[test]
fn connection_test_registry_allows_only_one_worker() {
let app = DemoAppState::open_in_memory().expect("app");
let guard = app
.register_connection_test()
.expect("first connection test");
let overlapping = app
.register_connection_test()
.expect_err("connection tests must not overlap");
assert_eq!(overlapping.code, "connection_test_in_progress");
drop(guard);
app.register_connection_test()
.expect("connection slot should be released");
}
#[test]
fn cancellation_before_worker_start_does_not_move_the_branch_head() {
let app = Arc::new(DemoAppState::open_in_memory().expect("app"));
let before = app.current_player_view().expect("initial view");
let control = TurnControl::new();
let request = promise_request(&before.node_id);
let in_flight = app
.register_turn(&request.action_id, &control)
.expect("register before scheduling");
assert!(app.cancel_turn(&request.action_id).expect("cancel pending"));
let worker_app = Arc::clone(&app);
let failure = thread::spawn(move || {
let _in_flight = in_flight;
worker_app.submit_turn_with_control(&request, &control)
})
.join()
.expect("worker")
.expect_err("cancelled turn");
assert_eq!(failure.code, "cancelled");
assert!(failure.retryable);
assert_eq!(
app.current_player_view().expect("unchanged view").node_id,
before.node_id
);
}
#[test]
fn retired_lapp_provider_is_rebuilt_before_same_action_retry() {
let app = DemoAppState::open_in_memory().expect("app");
assert!(!RuntimePlanProvider::Unavailable.is_retired());
let before = app.current_player_view().expect("initial view");
let request = promise_request(&before.node_id);
*app.provider.lock().expect("provider") = RuntimePlanProvider::RetiredTest;
let failure = app
.submit_turn(&request)
.expect_err("retired provider should surface its interruption");
assert_eq!(failure.code, "cancelled");
assert_eq!(
app.current_player_view().expect("unchanged view").node_id,
before.node_id
);
let retried = app
.submit_turn(&request)
.expect("same action should reach rebuilt provider");
assert_ne!(retried.committed_node_id, before.node_id);
assert_eq!(retried.player_view.node_id, retried.committed_node_id);
}
fn promise_request(expected_node_id: &str) -> TurnRequest {
TurnRequest {
story_id: DEMO_STORY_ID.to_owned(),