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