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