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