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:
2026-07-24 19:52:34 +02:00
parent 7fee56acf5
commit 0f0a76d806
6 changed files with 173 additions and 10 deletions

View File

@ -81,10 +81,23 @@ impl FsProfileStore {
}
/// Reads and parses the doc, returning an empty default if the file is absent.
///
/// Runs a one-pass **repair** (ticket #97): a profile corrupted on disk may
/// carry both `opencode` (llamacpp) and `opencodeProvider` (cloud). The cloud
/// provider is the user's actual intent (the bug duplicated the local backend
/// even when a provider was chosen), so the stale `opencode` is dropped. This
/// makes the on-disk state converge with the now-enforced builder invariant.
async fn read_doc(&self) -> Result<ProfilesDoc, StoreError> {
match self.fs.read(&self.path()).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
let mut doc: ProfilesDoc =
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
for profile in &mut doc.profiles {
if !profile.opencode_backend_is_consistent() {
profile.opencode = None;
}
}
Ok(doc)
}
Err(domain::ports::FsError::NotFound(_)) => Ok(ProfilesDoc::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
@ -114,6 +127,16 @@ impl ProfileStore for FsProfileStore {
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
// Defense-in-depth (ticket #97): refuse to persist a profile that violates
// the OpenCode backend mutual-exclusion invariant. The application layer
// now routes through the builders (which enforce exclusion), so this guard
// only ever fires for a future/buggy caller that mutates fields directly.
if !profile.opencode_backend_is_consistent() {
return Err(StoreError::Invalid(format!(
"profile `{}` carries both `opencode` and `opencodeProvider` (mutually exclusive)",
profile.name
)));
}
let mut doc = self.read_doc().await?;
if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) {
*slot = profile.clone();