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

@ -796,6 +796,12 @@ pub enum StoreError {
/// Underlying I/O error.
#[error("store io failed: {0}")]
Io(String),
/// A persisted item violated a domain invariant — surfaced by defense-in-depth
/// guards (e.g. [`crate::profile::AgentProfile::opencode_backend_is_consistent`],
/// ticket #97). Mapped upstream to an *invalid input* error (not a generic
/// store failure) so callers can distinguish invariant violations.
#[error("invalid persisted item: {0}")]
Invalid(String),
}
/// Errors from the [`MemoryStore`].

View File

@ -1127,18 +1127,28 @@ impl AgentProfile {
self
}
/// Builder : fixe la configuration OpenCode process-backed.
/// Builder : fixe la configuration OpenCode process-backed (provider custom
/// `llamacpp`). Garantit l'invariant [`Self::opencode_backend_is_consistent`]
/// (ticket #97) : positionner le backend local évacue tout backend cloud
/// précédemment fixé — les deux configurent le même champ `opencode.json`
/// `provider`/`model` et n'ont aucun sens combinées. Dernier appel gagnant.
#[must_use]
pub fn with_opencode(mut self, config: OpenCodeConfig) -> Self {
self.opencode = Some(config);
self.opencode_provider = None;
self
}
/// Builder : fixe la configuration OpenCode **cloud** (provider BUILT-IN,
/// ticket #92, lot B1).
/// ticket #92, lot B1). Garantit l'invariant
/// [`Self::opencode_backend_is_consistent`] (ticket #97) : positionner le
/// backend cloud évacue tout backend local (`llamacpp`) précédemment fixé —
/// sans quoi le stale `opencode` forcerait un repli sur llamacpp au lieu du
/// provider cloud choisi par l'utilisateur. Dernier appel gagnant.
#[must_use]
pub fn with_opencode_provider(mut self, config: OpenCodeProviderConfig) -> Self {
self.opencode_provider = Some(config);
self.opencode = None;
self
}
@ -1474,13 +1484,47 @@ mod mcp_tests {
let neither = profile_without_mcp().with_structured_adapter(StructuredAdapter::OpenCode);
assert!(neither.opencode_backend_is_consistent());
let both = profile_without_mcp()
let mut both = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(local)
.with_opencode_provider(cloud);
.with_opencode(local.clone());
// The builders now enforce mutual exclusion (ticket #97), so an
// inconsistent profile is only reachable via direct mutation or a
// corrupted on-disk file — simulate that to exercise the predicate.
both.opencode_provider = Some(cloud.clone());
assert!(!both.opencode_backend_is_consistent());
}
#[test]
fn opencode_builders_enforce_mutual_exclusion_last_wins() {
let local = OpenCodeConfig::new(
"http://localhost:8080/v1",
None,
"qwen3-coder-30b",
None,
None,
)
.unwrap();
let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap();
// Setting the cloud provider clears a previously-set local one.
let cloud_wins = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(local.clone())
.with_opencode_provider(cloud.clone());
assert!(cloud_wins.opencode_backend_is_consistent());
assert!(cloud_wins.opencode.is_none(), "stale local backend dropped");
assert_eq!(cloud_wins.opencode_provider.as_ref().unwrap().provider_id, "anthropic");
// Setting the local provider clears a previously-set cloud one.
let local_wins = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode_provider(cloud)
.with_opencode(local);
assert!(local_wins.opencode_backend_is_consistent());
assert!(local_wins.opencode_provider.is_none(), "stale cloud backend dropped");
assert!(local_wins.opencode.is_some());
}
#[test]
fn opencode_provider_config_serialises_no_local_model_server_id_leak() {
let config = OpenCodeProviderConfig::new("openrouter", "some-model", crate::ports::SecretRef::new("secret-openrouter")).unwrap();