fix(backend): exclusion mutuelle des backends OpenCode (llamacpp vs cloud) (#97)
Les builders `with_opencode` / `with_opencode_provider` s'évacuent réciproquement (dernier appel gagnant) pour honorer l'invariant `opencode_backend_is_consistent`. Le use case SaveOpenCodeProviderProfile route désormais via le builder au lieu de muter le champ directement — c'est ce qui dupliquait `opencode` + `opencodeProvider` et forçait un repli sur llamacpp même quand l'utilisateur choisissait un provider cloud. Défense en profondeur côté store : - `read_doc` répare les profils corrompus sur disque (drop du stale `opencode`) - `save` refuse de persister un profil violant l'invariant via le nouveau variant `StoreError::Invalid` (mappé vers `AppError::Invalid`) Tests verts : builders last-wins (domain), save rejets + read repair (infra, profile_store 10/10). Refs #97
This commit is contained in:
@ -6,8 +6,8 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, SecretRef, StoreError};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter};
|
||||
use infrastructure::{FsProfileStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -168,3 +168,88 @@ async fn profiles_file_is_camelcase_versioned() {
|
||||
assert_eq!(entry["contextInjection"]["strategy"], "conventionFile");
|
||||
assert_eq!(entry["contextInjection"]["target"], "CLAUDE.md");
|
||||
}
|
||||
|
||||
/// Fixture pair for ticket #97: the local llama.cpp backend (`opencode`) and the
|
||||
/// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode
|
||||
/// backends. A profile carrying both is inconsistent.
|
||||
fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) {
|
||||
let local =
|
||||
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder-30b", None, None).unwrap();
|
||||
let cloud =
|
||||
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-cloud"))
|
||||
.unwrap();
|
||||
(local, cloud)
|
||||
}
|
||||
|
||||
/// Ticket #97 — read repair (`read_doc`, profile.rs:90-105): a profile corrupted
|
||||
/// on disk (both `opencode` and `opencodeProvider` present) is silently repaired
|
||||
/// on read by dropping the stale `opencode`, converging with the enforced
|
||||
/// builder invariant. The cloud provider — the user's actual intent — is kept.
|
||||
#[tokio::test]
|
||||
async fn read_doc_repairs_stale_opencode_when_both_sections_present() {
|
||||
let tmp = TempDir::new();
|
||||
let (local, cloud) = opencode_backends();
|
||||
|
||||
// The `save` guard would refuse this, so plant the corruption directly on
|
||||
// disk (bypassing the guard), exactly like a stale file from an older build.
|
||||
let mut corrupted = sample(1, "OpenCode Cloud", "opencode")
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode(local);
|
||||
corrupted.opencode_provider = Some(cloud.clone());
|
||||
assert!(
|
||||
!corrupted.opencode_backend_is_consistent(),
|
||||
"fixture must violate the invariant"
|
||||
);
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] });
|
||||
fs.write(&tmp.child("profiles.json"), &serde_json::to_vec_pretty(&doc).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let store = store(&tmp);
|
||||
let listed = store.list().await.expect("read repairs instead of failing");
|
||||
assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry");
|
||||
let repaired = &listed[0];
|
||||
assert!(repaired.opencode.is_none(), "stale local `opencode` dropped on read");
|
||||
assert_eq!(
|
||||
repaired.opencode_provider.as_ref().unwrap().provider_id,
|
||||
"anthropic",
|
||||
"cloud `opencodeProvider` preserved"
|
||||
);
|
||||
assert!(
|
||||
repaired.opencode_backend_is_consistent(),
|
||||
"read repair restored the mutual-exclusion invariant"
|
||||
);
|
||||
}
|
||||
|
||||
/// Ticket #97 — write guard (`save`, profile.rs:129-139): persisting a profile
|
||||
/// that violates the OpenCode backend mutual-exclusion invariant is rejected
|
||||
/// with [`StoreError::Invalid`], and nothing is written to disk.
|
||||
#[tokio::test]
|
||||
async fn save_rejects_profile_violating_backend_exclusion() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
let (local, cloud) = opencode_backends();
|
||||
|
||||
// Only reachable via direct mutation: the builders enforce exclusion
|
||||
// (last-wins), so an inconsistent profile cannot be built through the API.
|
||||
let mut inconsistent = sample(1, "OpenCode Both", "opencode")
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode(local);
|
||||
inconsistent.opencode_provider = Some(cloud);
|
||||
assert!(!inconsistent.opencode_backend_is_consistent());
|
||||
|
||||
let err = store
|
||||
.save(&inconsistent)
|
||||
.await
|
||||
.expect_err("save must reject an invariant-violating profile");
|
||||
assert!(
|
||||
matches!(err, StoreError::Invalid(_)),
|
||||
"defense-in-depth guard maps to Invalid; got {err:?}"
|
||||
);
|
||||
assert!(
|
||||
!store.is_configured().await.unwrap(),
|
||||
"rejected save must not materialise profiles.json"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user