memstead_base/engine/drift.rs
1//! Drift detection and per-mem change synthesis.
2//!
3//! `reload_if_stale` probes each candidate mount's `current_head()`
4//! cursor on every operation (no throttle), reloads mems whose
5//! on-disk state has advanced past the engine's cached head, and
6//! surfaces `MemReloaded` warnings for handlers that need to
7//! re-derive conclusions from a now-reloaded snapshot. `changes_since`
8//! produces
9//! the per-entity diff between a stored cursor and the backend's
10//! current state — folder mounts synthesise from the changelog, the
11//! git-branch hook walks the tree with rename detection, archive
12//! mounts return empty.
13
14use crate::backend::BackendError;
15use crate::workspace::MountStorage;
16
17use super::mutation::lookup_title_and_type;
18use super::{Engine, EngineError};
19
20impl Engine {
21 /// Reload-before-operation: before any read or write executes,
22 /// check the mem ref; if it advanced past the engine's cached
23 /// `last_known_head`, reload the affected mem(s) and return one
24 /// [`WarningHint::MemReloaded`] per reload so the caller can
25 /// surface the drift to the agent (the response *itself* already
26 /// carries fresh content — the warning explains why state
27 /// shifted).
28 ///
29 /// The ref check runs on **every** call — there is no throttle
30 /// window. A per-operation `current_head()` read is microseconds,
31 /// effectively free at LLM latencies, and a throttle that let an
32 /// operation execute against an already-moved ref would reintroduce
33 /// the exact silent-staleness this guards against. This is the
34 /// correctness floor: no operation acts on a projection that is
35 /// behind git truth.
36 ///
37 /// `mem = Some(name)` scopes the probe to one mount; `None`
38 /// scans every mount. Read handlers that target a known mem
39 /// (`memstead_entity` derives the mem from the id;
40 /// `memstead_changes_since` takes it as a param) and every mutation
41 /// (which knows its target mem) pass a name; tools that scan
42 /// multi-mem (`memstead_search` without a mem filter,
43 /// `memstead_overview`, `memstead_health`) pass `None`.
44 ///
45 /// Behaviour matrix per mount:
46 /// - cached `Some(old)` + on-disk `Some(new)`, `old != new` →
47 /// reload the mem, emit `MemReloaded`, refresh the cached
48 /// head to `new`.
49 /// - cached `None` + on-disk `Some(new)` → silently capture the
50 /// first observed head as the baseline (no warning — there's
51 /// no prior in-memory snapshot to be stale against).
52 /// - cached / on-disk match, on-disk `None` (folder, archive,
53 /// refdb hiccup), or `current_head` errors → no-op.
54 ///
55 /// Reload errors are warn-logged and the affected mem is
56 /// skipped — the caller's response is still served from the (now
57 /// stale) in-memory snapshot rather than failing the entire
58 /// request. The next operation retries.
59 ///
60 /// Cache invalidation rides on `reload_one_mem` — community
61 /// and search-index memos drop when any mem reloads.
62 pub fn reload_if_stale(&mut self, mem: Option<&str>) -> Vec<crate::ops::WarningHint> {
63 // Phase 1 — pick candidate mem names that match the filter.
64 // Cloned so the immutable borrow doesn't survive into the
65 // mutation phase.
66 let candidates: Vec<String> = self
67 .mounts
68 .iter()
69 .filter(|m| mem.is_none_or(|v| m.mount.mem == v))
70 .map(|m| m.mount.mem.clone())
71 .collect();
72
73 if candidates.is_empty() {
74 return Vec::new();
75 }
76
77 // Phase 2 — probe every candidate's current head via the
78 // backend. Errors collapse to None so a transient backend
79 // hiccup doesn't surface as a warning; the next operation
80 // retries.
81 let probes: Vec<(String, Option<String>, Option<String>)> = candidates
82 .iter()
83 .filter_map(|name| {
84 let m = self.mounts.iter().find(|m| &m.mount.mem == name)?;
85 let new_head = m.backend.current_head().ok().flatten();
86 let cached = m.last_known_head.clone();
87 Some((name.clone(), cached, new_head))
88 })
89 .collect();
90
91 // Phase 3 — act on each probe. The drift case is the only
92 // one that calls `reload_one_mem`; every other arm just
93 // (for first-observation) captures the baseline head silently.
94 let mut warnings = Vec::new();
95 for (name, cached, new_head) in probes {
96 match (cached, new_head.clone()) {
97 (Some(old), Some(new)) if old != new => {
98 match self.reload_one_mem(&name) {
99 Ok(report) => {
100 warnings.push(crate::ops::WarningHint::MemReloaded {
101 mem: name.clone(),
102 old_head: old.clone(),
103 new_head: new.clone(),
104 entities_loaded: report.added.len() + report.changed.len(),
105 });
106 if let Some(state) =
107 self.mounts.iter_mut().find(|m| m.mount.mem == name)
108 {
109 state.last_known_head = Some(new.clone());
110 }
111 // Build the structured notice now — the
112 // backend's current head equals `new` and no
113 // follow-on write in this operation has
114 // committed yet, so the `old → new` delta
115 // describes only the sibling's change. Stashed
116 // for the response layer to drain.
117 let notice = self.mem_changed_notice(&name, &old, &new);
118 self.pending_mem_changed.push(notice);
119 }
120 Err(e) => {
121 tracing::warn!(
122 mem = %name,
123 error = %e,
124 "drift-detected reload_one_mem failed; serving \
125 stale snapshot — will retry on the next operation"
126 );
127 }
128 }
129 }
130 _ => {
131 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name)
132 && state.last_known_head.is_none()
133 {
134 state.last_known_head = new_head;
135 }
136 }
137 }
138 }
139
140 warnings
141 }
142
143 /// Mark `mount_idx`'s on-disk head as advanced by *this* engine's
144 /// own write so the next `reload_if_stale` doesn't surface
145 /// `MEM_RELOADED` for the commit we just produced. Mutation
146 /// paths call this immediately after `backend.commit` returns —
147 /// the cached `last_known_head` jumps straight to the new SHA
148 /// without going through a reload. Because every mutation runs
149 /// `reload_if_stale` for its target mem *before* committing,
150 /// the cached `last_known_head` is current at commit time, so
151 /// this advance is over a verified parent — it can never jump
152 /// the cache past an unobserved sibling commit. Cross-session and
153 /// out-of-band advances (sibling engine, manual `git pull`) that
154 /// land before the next operation still mismatch the cached value
155 /// and fire the warning as before.
156 ///
157 /// Empty SHA is a no-op (no commit landed — e.g. duplicate-add
158 /// relate). Backends that don't track a head (folder, archive)
159 /// leave `last_known_head` at `None` and still no-op via the
160 /// drift-check's `cached: None` branch.
161 pub(crate) fn record_self_write(&mut self, mount_idx: usize, commit_sha: &str) {
162 if commit_sha.is_empty() {
163 return;
164 }
165 // Capture the pre-write head + mem name so the
166 // `MemChangedEvent` we emit reflects the transition the
167 // current commit produced. We do this before mutating
168 // `last_known_head` because that field is the previous SHA
169 // from the event's point of view.
170 let (mem, previous) = match self.mounts.get(mount_idx) {
171 Some(state) => (
172 state.mount.mem.clone(),
173 state.last_known_head.clone().unwrap_or_default(),
174 ),
175 None => return,
176 };
177 if let Some(state) = self.mounts.get_mut(mount_idx) {
178 state.last_known_head = Some(commit_sha.to_string());
179 }
180 // Skip emit when no SHA actually advanced — folder backends
181 // (and archive backends) carry `last_known_head: None` and
182 // pass `commit_sha = ""` in some paths; the early-return at
183 // the top already catches the explicit empty case, but
184 // `previous == commit_sha` covers idempotent re-writes that
185 // pass through the same write path (e.g. a relate that
186 // re-applies the same edge). Skipping keeps the event stream
187 // a stream of *changes* rather than a stream of *writes*.
188 if previous == commit_sha {
189 return;
190 }
191 let event = crate::engine::events::MemChangedEvent {
192 mem,
193 head: commit_sha.to_string(),
194 previous,
195 n_commits: 1,
196 };
197 self.emit_mem_changed(&event);
198 }
199
200 /// Drain the reload-before-operation notices accumulated since the
201 /// last drain. The response layer calls this after an operation
202 /// completes to attach the structured `mem_changed` notice. Every
203 /// handler that can trigger a reload (directly via
204 /// [`Self::reload_if_stale`] or indirectly through a mutation) must
205 /// drain, or an undrained notice leaks into the next operation's
206 /// response.
207 pub fn take_mem_changed_notices(&mut self) -> Vec<crate::ops::MemChangedNotice> {
208 std::mem::take(&mut self.pending_mem_changed)
209 }
210
211 /// Build a [`crate::ops::MemChangedNotice`] describing the
212 /// per-entity delta a reload applied to `mem` (from `from_head`
213 /// to `to_head`). Derived from [`Self::changes_since`] so it
214 /// carries rename detection on git-branch mounts; on any backend
215 /// error (e.g. an unresolvable cursor) it falls back to an empty
216 /// delta — the heads alone still tell the agent the mem moved.
217 ///
218 /// Callers pair this with [`Self::reload_if_stale`]: a returned
219 /// [`crate::ops::WarningHint::MemReloaded`] carries the
220 /// `old_head` / `new_head` to pass here. The delta matches the
221 /// transition the reload applied (`changes_since` walks the same
222 /// `from_head → current` range).
223 pub fn mem_changed_notice(
224 &self,
225 mem: &str,
226 from_head: &str,
227 to_head: &str,
228 ) -> crate::ops::MemChangedNotice {
229 let changes = self
230 .changes_since(mem, from_head, None)
231 .map(|r| r.changes)
232 .unwrap_or_default();
233 crate::ops::MemChangedNotice::from_delta(
234 mem.to_string(),
235 from_head.to_string(),
236 to_head.to_string(),
237 changes,
238 )
239 }
240
241 /// Per-entity events for `mem` between `since` and the backend's
242 /// current state.
243 ///
244 /// 1. Resolves the mount (returns [`EngineError::UnknownMem`]
245 /// on unknown mem).
246 /// 2. Validates `rename_similarity` against
247 /// `[RENAME_SIMILARITY_MIN, RENAME_SIMILARITY_MAX]`. Out-of-range
248 /// values refuse with [`EngineError::InvalidInput`] carrying
249 /// `details.allowed_range` and `details.requested`. `None` falls
250 /// back to [`crate::ops::RENAME_SIMILARITY_DEFAULT`].
251 /// 3. Dispatches on the mount's `MountStorage`:
252 /// - Folder mounts synthesize from the JSONL changelog via
253 /// [`crate::ops::folder_changes_since`].
254 /// - Git-branch mounts call the registered
255 /// [`GitBranchOps::changes_since`] hook (real tree-diff with
256 /// rename detection); missing hook = full flavour not loaded
257 /// and the report comes back empty.
258 /// - Archive mounts return an empty report.
259 /// 4. Enriches each envelope's `title` / `entity_type` from the
260 /// in-memory store (best-effort — `Removed` envelopes always
261 /// leave both `None`; missing-from-store entities also leave
262 /// them `None`).
263 /// 5. Returns [`crate::ops::ChangesReport`] with `mem`,
264 /// `since` (echoed), `head` (backend-resolved current
265 /// cursor), enriched `changes`, and any clamping warnings.
266 pub fn changes_since(
267 &self,
268 mem: &str,
269 since: &str,
270 rename_similarity: Option<f32>,
271 ) -> Result<crate::ops::ChangesReport, EngineError> {
272 let m = self.find_mount(mem)?;
273
274 // Reject out-of-range `rename_similarity` early so CLI and MCP
275 // share one refusal surface.
276 // The prior clamp+warn shape silently accepted nonsense values
277 // (e.g. 1.5 ≡ 1.0); typed refusal gives the agent a recoverable
278 // signal.
279 if let Some(v) = rename_similarity
280 && !(crate::ops::RENAME_SIMILARITY_MIN..=crate::ops::RENAME_SIMILARITY_MAX).contains(&v)
281 {
282 return Err(EngineError::RenameSimilarityOutOfRange {
283 requested: v,
284 allowed_min: crate::ops::RENAME_SIMILARITY_MIN,
285 allowed_max: crate::ops::RENAME_SIMILARITY_MAX,
286 });
287 }
288 let clamped = rename_similarity.unwrap_or(crate::ops::RENAME_SIMILARITY_DEFAULT);
289
290 let backend_changes = match &m.mount.storage {
291 MountStorage::Folder { path } => {
292 crate::ops::folder_changes_since(path, mem, since).map_err(EngineError::Backend)?
293 }
294 MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
295 Some(hook) => match (hook.changes_since)(gitdir, branch, mem, since, clamped) {
296 Ok(c) => c,
297 // Lift the backend's typed bad-`since` marker to a typed
298 // engine error carrying the untruncated SHA, parallel
299 // to the UNKNOWN_REMOTE / LOCAL_DIVERGENCE prefixes.
300 Err(BackendError::Other(msg)) if msg.starts_with("COMMIT_NOT_FOUND:") => {
301 let since = msg
302 .strip_prefix("COMMIT_NOT_FOUND:")
303 .unwrap_or_default()
304 .to_string();
305 return Err(EngineError::InvalidChangesCursor {
306 mem: mem.to_string(),
307 since,
308 });
309 }
310 Err(e) => return Err(EngineError::Backend(e)),
311 },
312 None => crate::ops::BackendChanges::empty_at(since),
313 },
314 // Archive is sealed; the in-memory backend keeps a
315 // provenance log but no cursor-addressable change history,
316 // so both yield no backend-derived changes here (the live
317 // playground stream rides the engine's event broadcast, not
318 // this path).
319 MountStorage::Archive { .. } | MountStorage::InMemory => {
320 crate::ops::BackendChanges::empty_at(since)
321 }
322 };
323
324 // Enrich each id-only envelope from the engine's store.
325 // `Removed` always leaves title / entity_type None — the
326 // entity is gone by definition; the post-reload store does
327 // not have it. Other variants populate when the lookup
328 // succeeds; missing ids stay None.
329 let enriched: Vec<crate::ops::ChangeEnvelope> = backend_changes
330 .changes
331 .into_iter()
332 .map(|env| match env {
333 crate::ops::ChangeEnvelope::Added { id, .. } => {
334 let (title, entity_type) = lookup_title_and_type(&self.store, &id);
335 crate::ops::ChangeEnvelope::Added {
336 id,
337 title,
338 entity_type,
339 }
340 }
341 crate::ops::ChangeEnvelope::Updated { id, .. } => {
342 let (title, entity_type) = lookup_title_and_type(&self.store, &id);
343 crate::ops::ChangeEnvelope::Updated {
344 id,
345 title,
346 entity_type,
347 }
348 }
349 crate::ops::ChangeEnvelope::Removed { id, .. } => {
350 crate::ops::ChangeEnvelope::Removed {
351 id,
352 title: None,
353 entity_type: None,
354 }
355 }
356 crate::ops::ChangeEnvelope::Renamed { from_id, to_id, .. } => {
357 let (title, entity_type) = lookup_title_and_type(&self.store, &to_id);
358 crate::ops::ChangeEnvelope::Renamed {
359 from_id,
360 to_id,
361 title,
362 entity_type,
363 }
364 }
365 })
366 .collect();
367
368 // Out-of-range `rename_similarity` is now a hard refusal (see
369 // early-return above); the response carries no clamping warning.
370 let warnings: Vec<crate::ops::WarningHint> = Vec::new();
371
372 // The backend populates
373 // notes + memstead_ref on every git-branch call (folder + archive
374 // backends leave them empty / None). Surface them
375 // unconditionally; the MCP `include_notes` parameter becomes
376 // a renderer-side filter rather than a separate engine call.
377 let notes = if backend_changes.notes.is_empty() && backend_changes.memstead_ref.is_none() {
378 None
379 } else {
380 Some(backend_changes.notes)
381 };
382 Ok(crate::ops::ChangesReport {
383 mem: mem.to_string(),
384 since: backend_changes.since,
385 head: backend_changes.head,
386 changes: enriched,
387 warnings,
388 notes,
389 memstead_ref: backend_changes.memstead_ref,
390 })
391 }
392
393 /// Fetch updates from `remote` into the workspace's mem-repo.
394 /// Advances remote-tracking refs only; the local branch pointer
395 /// is not moved.
396 ///
397 /// `refspecs` is forwarded verbatim to `git fetch`. An empty list
398 /// uses the remote's configured defaults.
399 ///
400 /// Refusal codes: `UNKNOWN_MEM`, `UNKNOWN_REMOTE`,
401 /// `INVALID_INPUT` (folder / archive mounts).
402 ///
403 /// V1 atomicity: schema-validation quarantine for
404 /// fetched commits is not yet wired. The remote-tracking refs
405 /// advance unconditionally on a successful fetch; downstream
406 /// schema validation runs on read via the engine's existing
407 /// reload pipeline.
408 pub fn fetch(
409 &self,
410 mem: &str,
411 remote: &str,
412 refspecs: &[String],
413 ) -> Result<crate::ops::FetchOutcome, EngineError> {
414 let m = self.find_mount(mem)?;
415 match &m.mount.storage {
416 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
417 Err(EngineError::InvalidInput(format!(
418 "mem '{mem}' is not git-backed — `memstead_fetch` requires a git-branch mount",
419 )))
420 }
421 MountStorage::GitBranch { gitdir, .. } => match self.git_branch_ops.as_ref() {
422 Some(hook) => (hook.fetch)(gitdir, remote, refspecs).map_err(|e| match e {
423 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
424 EngineError::UnknownRemote(
425 msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
426 )
427 }
428 other => EngineError::Backend(other),
429 }),
430 None => Err(EngineError::Backend(BackendError::Other(
431 "git-branch fetch hook not installed (full flavour not loaded)".to_string(),
432 ))),
433 },
434 }
435 }
436
437 /// Pull updates from `remote` into the named mem's branch.
438 /// Fetches into the remote-tracking ref, runs a pre-merge schema
439 /// validation pass against the prospective state, then
440 /// fast-forwards the local branch. Refuses with
441 /// `LOCAL_DIVERGENCE` for diverged local branches and with
442 /// `SCHEMA_VIOLATION_IN_FETCH` when the prospective state fails
443 /// schema validation — in both refusal cases the local branch
444 /// pointer is untouched (the underlying fetch has updated
445 /// `refs/remotes/*` but the engine has not promoted the new
446 /// state).
447 pub fn pull(
448 &mut self,
449 mem: &str,
450 remote: &str,
451 ) -> Result<crate::ops::PullOutcome, EngineError> {
452 let mount_idx = self
453 .mounts
454 .iter()
455 .position(|m| m.mount.mem == mem)
456 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
457
458 // Run the fetch step alone first so we can validate the
459 // prospective state against the schema before letting the
460 // pull's fast-forward land. Errors map to the typed surface
461 // just like a standalone `memstead_fetch` call.
462 let gitdir = match &self.mounts[mount_idx].mount.storage {
463 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
464 return Err(EngineError::InvalidInput(format!(
465 "mem '{mem}' is not git-backed — `memstead_pull` requires a git-branch mount",
466 )));
467 }
468 MountStorage::GitBranch { gitdir, .. } => gitdir.clone(),
469 };
470 let hook = self.git_branch_ops.ok_or_else(|| {
471 EngineError::Backend(BackendError::Other(
472 "git-branch pull hook not installed (full flavour not loaded)".to_string(),
473 ))
474 })?;
475 (hook.fetch)(&gitdir, remote, &[]).map_err(|e| match e {
476 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
477 EngineError::UnknownRemote(
478 msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
479 )
480 }
481 other => EngineError::Backend(other),
482 })?;
483
484 // Pre-merge schema validation. The remote-tracking ref now
485 // points at the fetched tip; we walk it, parse every `.md`
486 // blob against the mem's pinned schema, and refuse the
487 // pull if any parse fails. The local branch pointer is still
488 // unchanged at this point — the refusal is fully atomic.
489 let remote_ref = format!("refs/remotes/{remote}/{mem}");
490 self.validate_ref_against_schema(&hook, &gitdir, mem, &remote_ref)?;
491
492 // Run the underlying pull (re-runs the fetch via git CLI, but
493 // that's a no-op cache-wise and keeps the fast-forward logic
494 // co-located with the rest of the transport implementation).
495 let outcome = (hook.pull)(&gitdir, remote, mem).map_err(|e| match e {
496 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
497 EngineError::UnknownRemote(
498 msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
499 )
500 }
501 BackendError::Other(msg) if msg.starts_with("LOCAL_DIVERGENCE:") => {
502 let payload = msg.trim_start_matches("LOCAL_DIVERGENCE:");
503 let mut parts = payload.splitn(2, ':');
504 let v = parts.next().unwrap_or(mem).to_string();
505 let remote_ref = parts.next().unwrap_or("refs/remotes/?/?").to_string();
506 EngineError::LocalDivergence { mem: v, remote_ref }
507 }
508 other => EngineError::Backend(other),
509 })?;
510
511 // Rewind cached head + emit change event.
512 if outcome.previous_sha != outcome.new_sha {
513 if let Some(state) = self.mounts.get_mut(mount_idx) {
514 state.last_known_head = Some(outcome.new_sha.clone());
515 }
516 let event = crate::engine::events::MemChangedEvent {
517 mem: mem.to_string(),
518 head: outcome.new_sha.clone(),
519 previous: outcome.previous_sha.clone(),
520 n_commits: 1,
521 };
522 self.emit_mem_changed(&event);
523 }
524 Ok(outcome)
525 }
526
527 /// Push the named mem's branch to `remote`. Runs a pre-push
528 /// schema validation pass against the local branch tree; refuses
529 /// with `LOCAL_INVALID_STATE` when the local state fails schema
530 /// validation (the remote is not contacted in that case). Refuses
531 /// with `NON_FAST_FORWARD` when the push is not a fast-forward
532 /// and `force: false`; with `force: true` runs a
533 /// `--force-with-lease` push instead.
534 pub fn push(
535 &self,
536 mem: &str,
537 remote: &str,
538 force: bool,
539 ) -> Result<crate::ops::PushOutcome, EngineError> {
540 let m = self.find_mount(mem)?;
541 let gitdir = match &m.mount.storage {
542 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
543 return Err(EngineError::InvalidInput(format!(
544 "mem '{mem}' is not git-backed — `memstead_push` requires a git-branch mount",
545 )));
546 }
547 MountStorage::GitBranch { gitdir, .. } => gitdir.clone(),
548 };
549 let hook = self.git_branch_ops.ok_or_else(|| {
550 EngineError::Backend(BackendError::Other(
551 "git-branch push hook not installed (full flavour not loaded)".to_string(),
552 ))
553 })?;
554
555 // Pre-push schema validation: walk the local branch tree, run
556 // the mem's pinned schema over every `.md` blob. Any parse
557 // failure refuses the push with `LOCAL_INVALID_STATE` — the
558 // remote is not contacted.
559 let local_ref = format!("refs/heads/{mem}");
560 if let Err(EngineError::SchemaViolationInFetch { violations, .. }) =
561 self.validate_ref_against_schema(&hook, &gitdir, mem, &local_ref)
562 {
563 return Err(EngineError::LocalInvalidState {
564 mem: mem.to_string(),
565 remote: remote.to_string(),
566 detail: format!(
567 "{} violation(s) in local branch: {}",
568 violations.len(),
569 violations.join("; "),
570 ),
571 });
572 }
573
574 (hook.push)(&gitdir, remote, mem, force).map_err(|e| match e {
575 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
576 EngineError::UnknownRemote(
577 msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
578 )
579 }
580 BackendError::Other(msg) if msg.starts_with("NON_FAST_FORWARD:") => {
581 let payload = msg.trim_start_matches("NON_FAST_FORWARD:");
582 let mut parts = payload.splitn(2, ':');
583 let v = parts.next().unwrap_or(mem).to_string();
584 let r = parts.next().unwrap_or(remote).to_string();
585 EngineError::NonFastForward { mem: v, remote: r }
586 }
587 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
588 EngineError::UnknownRef(msg.trim_start_matches("UNKNOWN_REF:").trim().to_string())
589 }
590 other => EngineError::Backend(other),
591 })
592 }
593
594 /// Configure (or re-point) a named remote on the workspace's
595 /// mem-repo, so `fetch` / `pull` / `push` have somewhere to go.
596 /// Upsert semantics — safe to re-run with a new URL. The mem-repo
597 /// is shared by every git-branch mount, so the op is
598 /// workspace-level: any git-branch mount locates it; refuses
599 /// `INVALID_INPUT` when the workspace has none.
600 pub fn remote_add(
601 &self,
602 name: &str,
603 url: &str,
604 ) -> Result<crate::ops::RemoteAddOutcome, EngineError> {
605 // Both values become git subprocess arguments — refuse shapes
606 // that would parse as flags.
607 if name.is_empty() || name.starts_with('-') || url.is_empty() || url.starts_with('-') {
608 return Err(EngineError::InvalidInput(format!(
609 "remote name and url must be non-empty and must not start with '-' \
610 (got name '{name}', url '{url}')",
611 )));
612 }
613 let gitdir = self
614 .mounts
615 .iter()
616 .find_map(|m| match &m.mount.storage {
617 MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
618 _ => None,
619 })
620 .ok_or_else(|| {
621 EngineError::InvalidInput(
622 "no git-branch mounts — `remote-add` requires a mem-repo workspace".to_string(),
623 )
624 })?;
625 let hook = self.git_branch_ops.ok_or_else(|| {
626 EngineError::Backend(BackendError::Other(
627 "git-branch remote_add hook not installed (full flavour not loaded)".to_string(),
628 ))
629 })?;
630 (hook.remote_add)(&gitdir, name, url).map_err(EngineError::Backend)
631 }
632
633 /// Pre-merge schema validation pass: walks every `.md` blob at
634 /// `ref_name` and runs `parse_entries` with the mem's pinned
635 /// schema. Returns `Ok(())` when the tree is schema-clean;
636 /// returns `EngineError::SchemaViolationInFetch` with the list of
637 /// per-entity violation messages otherwise. The validation is
638 /// strict on parse-time errors — any `(path, error)` pair from
639 /// `parse_entries` triggers a refusal.
640 ///
641 /// `ref_name` is the prospective state (a `refs/remotes/*` ref
642 /// for pull, `refs/heads/*` for push). The engine layer maps the
643 /// returned error into the surface code it needs
644 /// (`SCHEMA_VIOLATION_IN_FETCH` for pull, `LOCAL_INVALID_STATE`
645 /// for push).
646 fn validate_ref_against_schema(
647 &self,
648 hook: &crate::engine::GitBranchOps,
649 gitdir: &std::path::Path,
650 mem: &str,
651 ref_name: &str,
652 ) -> Result<(), EngineError> {
653 let schema = self
654 .schemas
655 .get(mem)
656 .ok_or_else(|| EngineError::SchemaNotFound {
657 mem: mem.to_string(),
658 pin: "<missing engine-side resolution>".to_string(),
659 // Internal invariant breach (an already-resolved schema
660 // absent from the per-mem map), not a source-resolution
661 // failure — no per-source diagnostics apply.
662 sources: Vec::new(),
663 })?
664 .clone();
665
666 let blobs = (hook.read_tree)(gitdir, ref_name).map_err(|e| match e {
667 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
668 EngineError::UnknownRef(msg.trim_start_matches("UNKNOWN_REF:").trim().to_string())
669 }
670 other => EngineError::Backend(other),
671 })?;
672
673 let mut source_entries: Vec<crate::entity::source::SourceEntry> = Vec::new();
674 for (rel_path, content) in blobs {
675 source_entries.push(crate::entity::source::SourceEntry {
676 relative_path: rel_path.clone(),
677 source_path: std::path::PathBuf::from(rel_path),
678 content,
679 });
680 }
681
682 // First pass: permissive parse via the engine's loader so we
683 // can build Entity values for the strict validator. The
684 // loader silently absorbs frontmatter / title / section
685 // drift; the strict pass below is what catches it.
686 let load_result = crate::entity::loader::parse_entries(
687 source_entries.clone(),
688 Vec::new(),
689 mem,
690 schema.as_ref(),
691 );
692 let mut violations: Vec<String> = load_result
693 .errors
694 .iter()
695 .map(|(path, msg)| format!("{}: {msg}", path.display()))
696 .collect();
697
698 // Strict per-entity validator: enforces "looks like a mem
699 // entity" invariants (frontmatter shape, title presence,
700 // required sections, unknown sections, relationship syntax,
701 // wiki-link shape) that the permissive loader doesn't refuse.
702 // Re-runs against the same source bytes so unparseable
703 // frontmatter surfaces here even when the loader's tolerant
704 // path produces an Entity stub.
705 let entities_by_path: std::collections::HashMap<String, &crate::entity::Entity> =
706 load_result
707 .entities
708 .iter()
709 .map(|p| (p.entity.file_path.clone(), &p.entity))
710 .collect();
711 for source in &source_entries {
712 let Some(entity) = entities_by_path.get(&source.relative_path) else {
713 continue;
714 };
715 let type_def = match schema.get_type(&entity.entity_type) {
716 Some(t) => t,
717 None => {
718 violations.push(format!(
719 "{}: unknown entity_type '{}' in schema",
720 source.relative_path, entity.entity_type,
721 ));
722 continue;
723 }
724 };
725 if let Err(e) = crate::validator::strict::validate_strict(
726 &source.content,
727 entity,
728 type_def.as_ref(),
729 &source.relative_path,
730 ) {
731 violations.push(format!("{}: {e}", source.relative_path));
732 }
733 }
734
735 if violations.is_empty() {
736 Ok(())
737 } else {
738 Err(EngineError::SchemaViolationInFetch {
739 mem: mem.to_string(),
740 ref_name: ref_name.to_string(),
741 violations,
742 })
743 }
744 }
745
746 /// Reset a mem's branch pointer to `target_sha`. The only
747 /// engine surface that moves a branch pointer over existing
748 /// commits — every other mutation appends. Refuses if any commit
749 /// that would be discarded by the reset is already reachable from
750 /// a `refs/remotes/*` ref (the engine's definition of "pushed").
751 ///
752 /// `target_sha` accepts anything `gix::rev_parse_single` admits:
753 /// a SHA, an abbreviated SHA, a branch name, a tag. The branch
754 /// itself (`refs/heads/<mem>`) must exist.
755 ///
756 /// Refusal codes:
757 /// - [`EngineError::UnknownMem`] (`UNKNOWN_MEM`)
758 /// - [`EngineError::UnknownRef`] (`UNKNOWN_REF`) — branch or
759 /// target ref does not resolve.
760 /// - [`EngineError::PushedCommitsProtected`]
761 /// (`PUSHED_COMMITS_PROTECTED`) — at least one discarded commit
762 /// is pushed. The error carries the offending SHAs verbatim.
763 /// - [`EngineError::InvalidInput`] (`INVALID_INPUT`) — mem is
764 /// folder / archive-backed (history rewriting only makes sense
765 /// for git-branch mounts).
766 ///
767 /// Emits a [`crate::engine::events::MemChangedEvent`] on
768 /// success when the SHA actually changed; the reset's effect is
769 /// observable through the same change-event surface every commit
770 /// flows through. Engine's cached `last_known_head` for the
771 /// affected mount is rewound to the new SHA so the next drift
772 /// probe doesn't flag the reset as a sibling-writer surprise.
773 pub fn branch_reset(
774 &mut self,
775 mem: &str,
776 target_sha: &str,
777 expected_head: Option<&str>,
778 ) -> Result<crate::ops::BranchResetOutcome, EngineError> {
779 let mount_idx = self
780 .mounts
781 .iter()
782 .position(|m| m.mount.mem == mem)
783 .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
784
785 // History rewriting is a write — read-only and archive mounts
786 // refuse before any dispatch (parity with the mutation surface).
787 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
788 return Err(EngineError::ReadOnlyMount(mem.to_string()));
789 }
790
791 let outcome = match &self.mounts[mount_idx].mount.storage {
792 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
793 return Err(EngineError::InvalidInput(format!(
794 "mem '{mem}' is not git-backed — `memstead_branch_reset` requires a git-branch mount",
795 )));
796 }
797 MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
798 Some(hook) => (hook.branch_reset)(gitdir, branch, target_sha, expected_head)
799 .map_err(|e| match e {
800 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
801 let raw = msg.trim_start_matches("UNKNOWN_REF:").trim().to_string();
802 EngineError::UnknownRef(raw)
803 }
804 BackendError::Other(msg) if msg.starts_with("EXPECTED_HEAD_MISMATCH:") => {
805 let current = msg
806 .trim_start_matches("EXPECTED_HEAD_MISMATCH:")
807 .trim()
808 .to_string();
809 EngineError::BranchResetHeadMoved {
810 mem: mem.to_string(),
811 expected: expected_head.unwrap_or_default().to_string(),
812 current,
813 }
814 }
815 BackendError::Other(msg)
816 if msg.starts_with("PUSHED_COMMITS_PROTECTED:") =>
817 {
818 let payload =
819 msg.trim_start_matches("PUSHED_COMMITS_PROTECTED:").trim();
820 let pushed_shas = payload
821 .split(',')
822 .map(|s| s.trim().to_string())
823 .filter(|s| !s.is_empty())
824 .collect();
825 EngineError::PushedCommitsProtected {
826 mem: mem.to_string(),
827 target_sha: target_sha.to_string(),
828 pushed_shas,
829 }
830 }
831 other => EngineError::Backend(other),
832 })?,
833 None => {
834 return Err(EngineError::Backend(BackendError::Other(
835 "git-branch branch_reset hook not installed (full flavour not loaded)"
836 .to_string(),
837 )));
838 }
839 },
840 };
841
842 // Rewind the engine's cached HEAD so subsequent drift probes
843 // don't surface MEM_RELOADED for the reset we just made.
844 // Then emit a change event so subscribers see the transition
845 // (skipping the no-op case where previous == new).
846 if outcome.previous_sha != outcome.new_sha {
847 if let Some(state) = self.mounts.get_mut(mount_idx) {
848 state.last_known_head = Some(outcome.new_sha.clone());
849 }
850 let event = crate::engine::events::MemChangedEvent {
851 mem: mem.to_string(),
852 head: outcome.new_sha.clone(),
853 previous: outcome.previous_sha.clone(),
854 // n_commits stays at 1 for reset events. The wire
855 // shape is the same `MemChangedEvent` consumers
856 // already key on; semantics: "the head moved by this
857 // operation". Replay-aware consumers branch on the
858 // commit-vs-reset distinction by inspecting the
859 // produced commit (a reset's new head is an existing
860 // commit, not a freshly minted one).
861 n_commits: 1,
862 };
863 self.emit_mem_changed(&event);
864 }
865 Ok(outcome)
866 }
867
868 /// Cross-mem references that a reset of `mem` to `target_sha` would
869 /// strand: incoming edges from entities in *other* mems whose target
870 /// exists at the current head but would not exist at the target
871 /// commit — entities created after the target, or renamed to their
872 /// current id after it (the reset re-materialises the old id, so
873 /// references to the new id dangle either way).
874 ///
875 /// A read — computes against the live store and the commit history,
876 /// moves nothing. The human surface calls this fresh at
877 /// confirmation-dialog time and warns before `branch_reset`. Sorted
878 /// (from_id, to_id, rel_type) for stable rendering.
879 ///
880 /// Refusals mirror `changes_since`: `UnknownMem`, `InvalidCursor`
881 /// for an unresolvable `target_sha`, `InvalidInput` for
882 /// non-git-backed mounts.
883 pub fn branch_reset_stranded_refs(
884 &self,
885 mem: &str,
886 target_sha: &str,
887 ) -> Result<Vec<crate::ops::StrandedCrossMemRef>, EngineError> {
888 use crate::ops::ChangeEnvelope;
889
890 let report = self.changes_since(mem, target_sha, None)?;
891 let mut discarded: std::collections::HashSet<String> = std::collections::HashSet::new();
892 for change in &report.changes {
893 match change {
894 ChangeEnvelope::Added { id, .. } => {
895 discarded.insert(id.to_string());
896 }
897 ChangeEnvelope::Renamed { to_id, .. } => {
898 discarded.insert(to_id.to_string());
899 }
900 ChangeEnvelope::Updated { .. } | ChangeEnvelope::Removed { .. } => {}
901 }
902 }
903 if discarded.is_empty() {
904 return Ok(Vec::new());
905 }
906
907 let mut stranded: Vec<crate::ops::StrandedCrossMemRef> = self
908 .store
909 .all_entities()
910 .filter(|e| e.mem != mem)
911 .flat_map(|e| {
912 e.relationships
913 .iter()
914 .filter(|r| discarded.contains(&r.target.to_string()))
915 .map(|r| crate::ops::StrandedCrossMemRef {
916 from_id: e.id.to_string(),
917 from_mem: e.mem.clone(),
918 to_id: r.target.to_string(),
919 rel_type: r.rel_type.clone(),
920 })
921 .collect::<Vec<_>>()
922 })
923 .collect();
924 stranded.sort_by(|a, b| {
925 (&a.from_id, &a.to_id, &a.rel_type).cmp(&(&b.from_id, &b.to_id, &b.rel_type))
926 });
927 Ok(stranded)
928 }
929
930 /// Two-ref structural diff. Produces a per-entity [`crate::ops::Diff`]
931 /// comparing the trees at `ref_a` and `ref_b` for the named
932 /// mem's storage. Folder and archive backends carry no git
933 /// refs and refuse via [`EngineError::InvalidInput`]; the
934 /// git-branch backend routes through [`GitBranchOps::diff`] when
935 /// the full flavour is loaded.
936 ///
937 /// `mem` selects the storage context (the gitdir, for
938 /// git-branch mounts). `ref_a` / `ref_b` are arbitrary refs the
939 /// underlying git layer accepts — branch names, commit SHAs, tag
940 /// names — so cross-mem diffs work via fully-qualified refs
941 /// (`refs/heads/<other-mem>`) without a separate API.
942 ///
943 /// Refusal codes:
944 /// - [`EngineError::UnknownMem`] (`UNKNOWN_MEM`) — no mount
945 /// for `mem`.
946 /// - [`EngineError::UnknownRef`] (`UNKNOWN_REF`) — either ref
947 /// does not resolve. Surfaces verbatim from the git layer's
948 /// `rev_parse` refusal.
949 /// - [`EngineError::RenameSimilarityOutOfRange`] (`INVALID_INPUT`)
950 /// — `config.rename_similarity` outside `[0.1, 1.0]`.
951 /// - [`EngineError::InvalidInput`] (`INVALID_INPUT`) — mem is
952 /// folder or archive-backed (no refs to diff).
953 pub fn diff(
954 &self,
955 mem: &str,
956 ref_a: &str,
957 ref_b: &str,
958 config: Option<crate::ops::DiffConfig>,
959 ) -> Result<crate::ops::Diff, EngineError> {
960 let m = self.find_mount(mem)?;
961 let config = config.unwrap_or_default();
962
963 if config.rename_similarity < crate::ops::RENAME_SIMILARITY_MIN
964 || config.rename_similarity > crate::ops::RENAME_SIMILARITY_MAX
965 {
966 return Err(EngineError::RenameSimilarityOutOfRange {
967 requested: config.rename_similarity,
968 allowed_min: crate::ops::RENAME_SIMILARITY_MIN,
969 allowed_max: crate::ops::RENAME_SIMILARITY_MAX,
970 });
971 }
972
973 match &m.mount.storage {
974 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
975 Err(EngineError::InvalidInput(format!(
976 "mem '{mem}' is not git-backed — `memstead_diff` requires a git-branch mount",
977 )))
978 }
979 MountStorage::GitBranch { gitdir, .. } => match self.git_branch_ops.as_ref() {
980 Some(hook) => {
981 (hook.diff)(gitdir, mem, ref_a, ref_b, &config).map_err(|e| match e {
982 // Map the standard backend-side "ref not found" shape into the
983 // typed engine-level refusal. The git-branch dispatcher uses
984 // `BackendError::Other` with a leading marker so the engine can
985 // recover the typed code without re-parsing the message.
986 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
987 let raw = msg.trim_start_matches("UNKNOWN_REF:").trim().to_string();
988 EngineError::UnknownRef(raw)
989 }
990 other => EngineError::Backend(other),
991 })
992 }
993 None => Err(EngineError::Backend(BackendError::Other(
994 "git-branch diff hook not installed (full flavour not loaded)".to_string(),
995 ))),
996 },
997 }
998 }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use std::path::{Path, PathBuf};
1004
1005 use tempfile::TempDir;
1006
1007 use crate::backend::{BackendError, MemBackend};
1008 use crate::engine::test_helpers::*;
1009 use crate::engine::{DeleteEntityArgs, Engine, EngineError};
1010 use crate::entity::EntityId;
1011
1012 use crate::provenance::Provenance;
1013 use crate::storage::ArchiveBackend;
1014 use crate::vcs::CommitContext;
1015 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1016
1017 #[test]
1018 fn engine_diff_unknown_mem_returns_typed_error() {
1019 let tmp = TempDir::new().unwrap();
1020 let engine = build_demo_engine(&tmp);
1021 let err = engine.diff("nope", "a", "b", None).unwrap_err();
1022 assert!(matches!(err, EngineError::UnknownMem(v) if v == "nope"));
1023 }
1024
1025 #[test]
1026 fn engine_diff_folder_mount_refuses_with_invalid_input() {
1027 let tmp = TempDir::new().unwrap();
1028 let engine = build_demo_engine(&tmp);
1029 // Folder backend has no git refs — refuse cleanly via the
1030 // typed `INVALID_INPUT` code rather than collapsing through
1031 // the backend layer.
1032 let err = engine.diff("specs", "a", "b", None).unwrap_err();
1033 match err {
1034 EngineError::InvalidInput(msg) => {
1035 assert!(msg.contains("not git-backed"), "unexpected msg: {msg}");
1036 }
1037 other => panic!("expected InvalidInput, got {other:?}"),
1038 }
1039 }
1040
1041 #[test]
1042 fn engine_diff_rename_similarity_out_of_range_refuses() {
1043 let tmp = TempDir::new().unwrap();
1044 let engine = build_demo_engine(&tmp);
1045 let bad = crate::ops::DiffConfig {
1046 rename_similarity: 2.0,
1047 ..Default::default()
1048 };
1049 let err = engine.diff("specs", "a", "b", Some(bad)).unwrap_err();
1050 assert!(matches!(
1051 err,
1052 EngineError::RenameSimilarityOutOfRange { .. }
1053 ));
1054 }
1055
1056 #[test]
1057 fn engine_changes_since_archive_mount_returns_empty_report() {
1058 // Archive backends have no diff surface; the engine wrapper
1059 // produces an empty `ChangesReport` with the cursor echoed.
1060 let tmp = TempDir::new().unwrap();
1061 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
1062 let mount = archive_mount("ext", archive_path.clone());
1063 let engine = Engine::from_mounts(vec![(
1064 mount,
1065 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1066 )])
1067 .unwrap();
1068 let report = engine.changes_since("ext", "abc", None).expect("known mem");
1069 assert_eq!(report.mem, "ext");
1070 assert_eq!(report.since, "abc");
1071 assert_eq!(report.head, "abc");
1072 assert!(report.changes.is_empty());
1073 assert!(report.warnings.is_empty());
1074 }
1075
1076 #[test]
1077 fn engine_changes_since_unknown_mem_returns_typed_error() {
1078 let tmp = TempDir::new().unwrap();
1079 let engine = build_demo_engine(&tmp);
1080 let err = engine
1081 .changes_since("does-not-exist", "abc", None)
1082 .unwrap_err();
1083 assert!(matches!(err, EngineError::UnknownMem(_)));
1084 }
1085
1086 #[test]
1087 fn engine_changes_since_refuses_rename_similarity_below_min() {
1088 let tmp = TempDir::new().unwrap();
1089 let engine = build_demo_engine(&tmp);
1090 // 0.05 is below RENAME_SIMILARITY_MIN (0.1); typed refusal,
1091 // not a silent clamp.
1092 let err = engine
1093 .changes_since("specs", "abc", Some(0.05))
1094 .expect_err("out-of-range refuses");
1095 match err {
1096 EngineError::RenameSimilarityOutOfRange {
1097 requested,
1098 allowed_min,
1099 allowed_max,
1100 } => {
1101 assert!((requested - 0.05).abs() < f32::EPSILON);
1102 assert!((allowed_min - crate::ops::RENAME_SIMILARITY_MIN).abs() < f32::EPSILON);
1103 assert!((allowed_max - crate::ops::RENAME_SIMILARITY_MAX).abs() < f32::EPSILON);
1104 }
1105 other => panic!("expected RenameSimilarityOutOfRange, got {other:?}"),
1106 }
1107 }
1108
1109 #[test]
1110 fn engine_changes_since_refuses_rename_similarity_above_max() {
1111 let tmp = TempDir::new().unwrap();
1112 let engine = build_demo_engine(&tmp);
1113 // 1.5 is above RENAME_SIMILARITY_MAX (1.0); typed refusal.
1114 let err = engine
1115 .changes_since("specs", "abc", Some(1.5))
1116 .expect_err("out-of-range refuses");
1117 match err {
1118 EngineError::RenameSimilarityOutOfRange { requested, .. } => {
1119 assert!((requested - 1.5).abs() < f32::EPSILON);
1120 }
1121 other => panic!("expected RenameSimilarityOutOfRange, got {other:?}"),
1122 }
1123 }
1124
1125 #[test]
1126 fn engine_changes_since_no_warning_when_rename_similarity_in_range() {
1127 let tmp = TempDir::new().unwrap();
1128 let engine = build_demo_engine(&tmp);
1129 // 0.5 is comfortably inside the valid range; no warning.
1130 let report = engine
1131 .changes_since("specs", "abc", Some(0.5))
1132 .expect("known mem");
1133 assert!(report.warnings.is_empty());
1134 }
1135
1136 #[test]
1137 fn engine_changes_since_no_warning_when_rename_similarity_omitted() {
1138 // Caller passes None → wrapper falls back to the default;
1139 // no clamping, no warning.
1140 let tmp = TempDir::new().unwrap();
1141 let engine = build_demo_engine(&tmp);
1142 let report = engine
1143 .changes_since("specs", "abc", None)
1144 .expect("known mem");
1145 assert!(report.warnings.is_empty());
1146 }
1147
1148 #[test]
1149 fn engine_changes_since_enriches_envelope_title_and_type_from_store() {
1150 // `build_demo_engine` creates three entities via the engine's
1151 // mutation pipeline, which appends Create events to the folder
1152 // backend's changelog. `Engine::changes_since` synthesises
1153 // BackendChanges from the changelog (id-only envelopes), then
1154 // enriches title / entity_type from the in-memory store.
1155 let tmp = TempDir::new().unwrap();
1156 let engine = build_demo_engine(&tmp);
1157 let report = engine
1158 .changes_since("specs", crate::ops::EMPTY_TREE_SHA, None)
1159 .expect("known mem");
1160
1161 // Three Create events → three Added envelopes, each enriched.
1162 assert_eq!(report.changes.len(), 3);
1163 for env in &report.changes {
1164 match env {
1165 crate::ops::ChangeEnvelope::Added {
1166 id,
1167 title,
1168 entity_type,
1169 } => {
1170 assert!(title.is_some(), "title enriched for {id}");
1171 assert_eq!(entity_type.as_deref(), Some("spec"), "type for {id}");
1172 }
1173 other => panic!("expected Added envelope, got {other:?}"),
1174 }
1175 }
1176 }
1177
1178 #[test]
1179 fn engine_changes_since_removed_envelope_keeps_title_and_type_none() {
1180 // Create-then-delete net effect = Removed. Even though the
1181 // store may still know the entity, the engine wrapper
1182 // unconditionally strips title / entity_type on Removed.
1183 let tmp = TempDir::new().unwrap();
1184 let mut engine = build_demo_engine(&tmp);
1185 let (actor, client) = cli_actor();
1186 let id = EntityId::new("specs", "lonely-three");
1187 let hash = engine
1188 .get_entity(&id)
1189 .expect("seeded entity present")
1190 .content_hash
1191 .clone();
1192 engine
1193 .delete_entity(
1194 DeleteEntityArgs {
1195 id: id.clone(),
1196 expected_hash: Some(hash),
1197 },
1198 actor,
1199 Some(&client),
1200 None,
1201 )
1202 .unwrap();
1203 let report = engine
1204 .changes_since("specs", crate::ops::EMPTY_TREE_SHA, None)
1205 .unwrap();
1206 let removed = report
1207 .changes
1208 .iter()
1209 .find(|e| {
1210 matches!(e,
1211 crate::ops::ChangeEnvelope::Removed { id: rid, .. } if rid == &id)
1212 })
1213 .expect("removed envelope for lonely-three");
1214 match removed {
1215 crate::ops::ChangeEnvelope::Removed {
1216 title, entity_type, ..
1217 } => {
1218 assert!(title.is_none());
1219 assert!(entity_type.is_none());
1220 }
1221 other => panic!("expected Removed, got {other:?}"),
1222 }
1223 }
1224
1225 // ---- Engine::cross_mem_link_allowed ---------------------------
1226
1227 #[test]
1228 fn reload_if_stale_returns_empty_for_folder_only_engine() {
1229 // The folder backend inherits MemBackend::current_head's
1230 // default (Ok(None)). Drift detection sees no signal and
1231 // returns no warnings.
1232 let tmp = TempDir::new().unwrap();
1233 let mut engine = build_demo_engine(&tmp);
1234 let warnings = engine.reload_if_stale(None);
1235 assert!(warnings.is_empty());
1236 let warnings = engine.reload_if_stale(Some("specs"));
1237 assert!(warnings.is_empty());
1238 }
1239
1240 #[test]
1241 fn reload_if_stale_short_circuits_for_unknown_mem_filter() {
1242 // Filtering by an unknown mem produces zero candidates;
1243 // the method returns an empty Vec without panicking.
1244 let tmp = TempDir::new().unwrap();
1245 let mut engine = build_demo_engine(&tmp);
1246 let warnings = engine.reload_if_stale(Some("does-not-exist"));
1247 assert!(warnings.is_empty());
1248 }
1249
1250 /// Test fixture: a `MemBackend` whose `current_head` and
1251 /// (read-side) entity surface are externally mutable so a test
1252 /// can simulate a sibling writer advancing the head between
1253 /// drift-check probes. Write methods are no-ops; the engine's
1254 /// drift-check path never invokes them.
1255 struct ManualHeadBackend {
1256 head: std::sync::Mutex<Option<String>>,
1257 entities: std::sync::Mutex<Vec<(PathBuf, Vec<u8>)>>,
1258 }
1259
1260 impl ManualHeadBackend {
1261 fn new(initial_head: Option<&str>) -> Self {
1262 Self {
1263 head: std::sync::Mutex::new(initial_head.map(String::from)),
1264 entities: std::sync::Mutex::new(Vec::new()),
1265 }
1266 }
1267
1268 fn set_head(&self, head: Option<&str>) {
1269 *self.head.lock().unwrap() = head.map(String::from);
1270 }
1271 }
1272
1273 impl MemBackend for ManualHeadBackend {
1274 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1275 Ok(self
1276 .entities
1277 .lock()
1278 .unwrap()
1279 .iter()
1280 .map(|(p, _)| p.clone())
1281 .collect())
1282 }
1283 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1284 Ok(self
1285 .entities
1286 .lock()
1287 .unwrap()
1288 .iter()
1289 .find(|(p, _)| p == rel)
1290 .map(|(_, b)| b.clone()))
1291 }
1292 fn write_entity(&self, _: &Path, _: &[u8]) -> Result<(), BackendError> {
1293 Ok(())
1294 }
1295 fn delete_entity(&self, _: &Path) -> Result<(), BackendError> {
1296 Ok(())
1297 }
1298 fn move_entity(&self, _: &Path, _: &Path) -> Result<(), BackendError> {
1299 Ok(())
1300 }
1301 fn commit(
1302 &self,
1303 _: &str,
1304 _: &CommitContext<'_>,
1305 ) -> Result<crate::storage::CommitId, BackendError> {
1306 Ok("synthetic".to_string())
1307 }
1308 fn append_provenance(&self, _: &Provenance) -> Result<(), BackendError> {
1309 Ok(())
1310 }
1311 fn read_provenance(&self, _: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1312 Ok(Vec::new())
1313 }
1314 fn current_head(&self) -> Result<Option<String>, BackendError> {
1315 Ok(self.head.lock().unwrap().clone())
1316 }
1317 }
1318
1319 #[test]
1320 fn reload_if_stale_emits_mem_reloaded_when_head_advances() {
1321 // Use an Arc<ManualHeadBackend> so the test retains a handle
1322 // for mutation after the engine has taken ownership of a
1323 // Box<dyn MemBackend> wrapper around it.
1324 struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1325 impl MemBackend for ArcBackend {
1326 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1327 self.0.list_entities()
1328 }
1329 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1330 self.0.read_entity(rel)
1331 }
1332 fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1333 self.0.write_entity(p, b)
1334 }
1335 fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1336 self.0.delete_entity(p)
1337 }
1338 fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1339 self.0.move_entity(f, t)
1340 }
1341 fn commit(
1342 &self,
1343 m: &str,
1344 c: &CommitContext<'_>,
1345 ) -> Result<crate::storage::CommitId, BackendError> {
1346 self.0.commit(m, c)
1347 }
1348 fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1349 self.0.append_provenance(r)
1350 }
1351 fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1352 self.0.read_provenance(c)
1353 }
1354 fn current_head(&self) -> Result<Option<String>, BackendError> {
1355 self.0.current_head()
1356 }
1357 }
1358
1359 let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1360 let backend = Box::new(ArcBackend(shared.clone()));
1361 let mount = Mount {
1362 mem: "specs".to_string(),
1363 schema: Some(pin("default")),
1364 storage: MountStorage::Folder {
1365 path: PathBuf::from("/dev/null"),
1366 },
1367 capability: MountCapability::Write,
1368 lifecycle: MountLifecycle::Eager,
1369 cross_linkable: true,
1370 migration_target: None,
1371 };
1372 let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1373
1374 // No drift on first probe — cached==new.
1375 let warnings = engine.reload_if_stale(Some("specs"));
1376 assert!(warnings.is_empty());
1377
1378 // Sibling writer advances the head.
1379 shared.set_head(Some("bbb"));
1380
1381 let warnings = engine.reload_if_stale(Some("specs"));
1382 assert_eq!(warnings.len(), 1);
1383 match &warnings[0] {
1384 crate::ops::WarningHint::MemReloaded {
1385 mem,
1386 old_head,
1387 new_head,
1388 ..
1389 } => {
1390 assert_eq!(mem, "specs");
1391 assert_eq!(old_head, "aaa");
1392 assert_eq!(new_head, "bbb");
1393 }
1394 other => panic!("expected MemReloaded, got {other:?}"),
1395 }
1396
1397 // Drift cleared — the engine's cached head now matches the
1398 // backend's current head; another probe is a no-op.
1399 let warnings = engine.reload_if_stale(Some("specs"));
1400 assert!(warnings.is_empty());
1401 }
1402
1403 #[test]
1404 fn mem_drifted_tracks_sibling_advance_until_reload() {
1405 // The read-only drift probe used by the macOS roster: it reports
1406 // `true` once a sibling writer advances the backend past the
1407 // engine's cached head, *without* itself reloading, and clears
1408 // after the engine re-reads.
1409 struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1410 impl MemBackend for ArcBackend {
1411 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1412 self.0.list_entities()
1413 }
1414 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1415 self.0.read_entity(rel)
1416 }
1417 fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1418 self.0.write_entity(p, b)
1419 }
1420 fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1421 self.0.delete_entity(p)
1422 }
1423 fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1424 self.0.move_entity(f, t)
1425 }
1426 fn commit(
1427 &self,
1428 m: &str,
1429 c: &CommitContext<'_>,
1430 ) -> Result<crate::storage::CommitId, BackendError> {
1431 self.0.commit(m, c)
1432 }
1433 fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1434 self.0.append_provenance(r)
1435 }
1436 fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1437 self.0.read_provenance(c)
1438 }
1439 fn current_head(&self) -> Result<Option<String>, BackendError> {
1440 self.0.current_head()
1441 }
1442 }
1443
1444 let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1445 let backend = Box::new(ArcBackend(shared.clone()));
1446 let mount = Mount {
1447 mem: "specs".to_string(),
1448 schema: Some(pin("default")),
1449 storage: MountStorage::Folder {
1450 path: PathBuf::from("/dev/null"),
1451 },
1452 capability: MountCapability::Write,
1453 lifecycle: MountLifecycle::Eager,
1454 cross_linkable: true,
1455 migration_target: None,
1456 };
1457 let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1458
1459 // Fresh boot: cached == live, no drift.
1460 assert!(!engine.mem_drifted("specs").unwrap());
1461
1462 // Sibling writer advances the head — drift is visible WITHOUT a reload.
1463 shared.set_head(Some("bbb"));
1464 assert!(engine.mem_drifted("specs").unwrap());
1465 // Probing did not reload — still drifted on a second read.
1466 assert!(engine.mem_drifted("specs").unwrap());
1467
1468 // Re-reading through the engine clears it.
1469 let _ = engine.reload_if_stale(Some("specs"));
1470 assert!(!engine.mem_drifted("specs").unwrap());
1471
1472 // Unknown mem errors rather than reporting a bogus `false`.
1473 assert!(matches!(
1474 engine.mem_drifted("nope"),
1475 Err(EngineError::UnknownMem(_))
1476 ));
1477 }
1478
1479 #[test]
1480 fn reload_one_mem_report_head_before_is_prior_cursor_and_advances() {
1481 // Regression for the reload→changes_since recipe. `head_before`
1482 // must report the engine's PRIOR cursor (the SHA it last knew),
1483 // not the post-drift on-disk tip — otherwise
1484 // `changes_since(since=head_before)` spans an empty range in
1485 // exactly the sibling-drift case the recipe targets. The reload
1486 // must also advance the cursor to the new tip so the next
1487 // staleness probe is a no-op rather than a spurious reload.
1488 struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1489 impl MemBackend for ArcBackend {
1490 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1491 self.0.list_entities()
1492 }
1493 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1494 self.0.read_entity(rel)
1495 }
1496 fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1497 self.0.write_entity(p, b)
1498 }
1499 fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1500 self.0.delete_entity(p)
1501 }
1502 fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1503 self.0.move_entity(f, t)
1504 }
1505 fn commit(
1506 &self,
1507 m: &str,
1508 c: &CommitContext<'_>,
1509 ) -> Result<crate::storage::CommitId, BackendError> {
1510 self.0.commit(m, c)
1511 }
1512 fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1513 self.0.append_provenance(r)
1514 }
1515 fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1516 self.0.read_provenance(c)
1517 }
1518 fn current_head(&self) -> Result<Option<String>, BackendError> {
1519 self.0.current_head()
1520 }
1521 }
1522
1523 let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1524 let backend = Box::new(ArcBackend(shared.clone()));
1525 let mount = Mount {
1526 mem: "specs".to_string(),
1527 schema: Some(pin("default")),
1528 storage: MountStorage::Folder {
1529 path: PathBuf::from("/dev/null"),
1530 },
1531 capability: MountCapability::Write,
1532 lifecycle: MountLifecycle::Eager,
1533 cross_linkable: true,
1534 migration_target: None,
1535 };
1536 let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1537
1538 // Sibling writer advances the head past the engine's cursor.
1539 shared.set_head(Some("bbb"));
1540
1541 let report = engine.reload_one_mem_report("specs").unwrap();
1542 // head_before is the prior cursor "aaa", not the drifted tip.
1543 assert_eq!(report.head_before, "aaa");
1544 assert_eq!(report.head_after, "bbb");
1545
1546 // Cursor advanced to "bbb": a follow-up staleness probe is a
1547 // no-op, not a spurious MEM_RELOADED.
1548 let warnings = engine.reload_if_stale(Some("specs"));
1549 assert!(
1550 warnings.is_empty(),
1551 "cursor should have advanced to bbb, got {warnings:?}"
1552 );
1553 }
1554
1555 #[test]
1556 fn reload_if_stale_fires_every_call_no_throttle() {
1557 // Two back-to-back probes with the head advancing between
1558 // them: the second must reload and warn. There is no throttle
1559 // window — the ref check is the correctness floor.
1560 let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1561 struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1562 impl MemBackend for ArcBackend {
1563 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1564 self.0.list_entities()
1565 }
1566 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1567 self.0.read_entity(rel)
1568 }
1569 fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1570 self.0.write_entity(p, b)
1571 }
1572 fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1573 self.0.delete_entity(p)
1574 }
1575 fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1576 self.0.move_entity(f, t)
1577 }
1578 fn commit(
1579 &self,
1580 m: &str,
1581 c: &CommitContext<'_>,
1582 ) -> Result<crate::storage::CommitId, BackendError> {
1583 self.0.commit(m, c)
1584 }
1585 fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1586 self.0.append_provenance(r)
1587 }
1588 fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1589 self.0.read_provenance(c)
1590 }
1591 fn current_head(&self) -> Result<Option<String>, BackendError> {
1592 self.0.current_head()
1593 }
1594 }
1595
1596 let backend = Box::new(ArcBackend(shared.clone()));
1597 let mount = Mount {
1598 mem: "specs".to_string(),
1599 schema: Some(pin("default")),
1600 storage: MountStorage::Folder {
1601 path: PathBuf::from("/dev/null"),
1602 },
1603 capability: MountCapability::Write,
1604 lifecycle: MountLifecycle::Eager,
1605 cross_linkable: true,
1606 migration_target: None,
1607 };
1608 let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1609
1610 // First probe observes cached==new; no warning.
1611 let warnings = engine.reload_if_stale(Some("specs"));
1612 assert!(warnings.is_empty());
1613
1614 // Sibling advances head — the very next probe reloads and
1615 // warns, with no throttle window to mask it.
1616 shared.set_head(Some("bbb"));
1617 let warnings = engine.reload_if_stale(Some("specs"));
1618 assert_eq!(
1619 warnings.len(),
1620 1,
1621 "no throttle window — the moved ref reloads on the next probe"
1622 );
1623 }
1624}