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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user