Skip to main content

aft/hashline/integration/
binding.rs

1//! Session-keyed hashline bindings: registration, effective mode, and lifetime.
2//!
3//! Registration computes edit-slot eligibility independently of schema selection,
4//! derives `effective = configured_enabled AND edit_slot_survives`, and installs
5//! the binding for `(canonical project root, session id)`. Request handlers capture
6//! a binding guard for the duration of the call so concurrent sessions under one
7//! root never share tags, stores, or schemas. Effective-value changes drain
8//! in-flight guards before clearing stores.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Condvar, Mutex, MutexGuard, Weak};
13
14use crate::hashline::apply::RegisterStore;
15use crate::hashline::snapshot::SnapshotStore;
16
17/// Stable identity for one session under one project root.
18#[derive(Clone, Debug, Eq, Hash, PartialEq)]
19pub struct SessionKey {
20    pub root: PathBuf,
21    pub session_id: String,
22}
23
24impl SessionKey {
25    pub fn new(root: impl Into<PathBuf>, session_id: impl Into<String>) -> Self {
26        Self {
27            root: root.into(),
28            session_id: session_id.into(),
29        }
30    }
31}
32
33/// Configure-channel warning emitted when hashline is configured on but `edit`
34/// did not survive final surface selection.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct DowngradeWarning {
37    pub code: &'static str,
38    pub reason: &'static str,
39}
40
41impl DowngradeWarning {
42    pub const EDIT_NOT_REGISTERED: Self = Self {
43        code: "hashline_downgraded",
44        reason: "edit_not_registered",
45    };
46
47    /// JSON object for the configure-warnings channel.
48    pub fn to_json(&self) -> serde_json::Value {
49        serde_json::json!({
50            "code": self.code,
51            "reason": self.reason,
52        })
53    }
54}
55
56/// Inputs the host supplies when registering (or re-registering) a session.
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub struct RegistrationRequest {
59    /// Resolved `hashline.enabled` from Rust config_resolve.
60    pub configured_enabled: bool,
61    /// Host-computed flag: `edit` survived final surface selection, pruning,
62    /// hoisting, and `disabled_tools`. Sessions without a host pruning layer
63    /// (MCP, daemon-direct) default this to `true`.
64    pub edit_slot_survives: bool,
65}
66
67impl RegistrationRequest {
68    pub const fn effective(self) -> bool {
69        self.configured_enabled && self.edit_slot_survives
70    }
71
72    pub const fn should_downgrade(self) -> bool {
73        self.configured_enabled && !self.edit_slot_survives
74    }
75}
76
77/// Outcome of a completed registration attempt.
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct RegistrationOutcome {
80    pub configured_enabled: bool,
81    pub edit_slot_survives: bool,
82    pub effective: bool,
83    /// Present when configured on but edit was not registered.
84    pub downgrade: Option<DowngradeWarning>,
85    /// True when stores were cleared because the effective value changed.
86    pub stores_cleared: bool,
87    /// True when same-effective re-registration preserved snapshot/register state.
88    pub stores_preserved: bool,
89}
90
91/// Session-owned hashline state installed atomically at registration.
92#[derive(Debug)]
93pub struct HashlineBinding {
94    key: SessionKey,
95    configured_enabled: bool,
96    edit_slot_survives: bool,
97    effective: bool,
98    snapshots: SnapshotStore,
99    registers: RegisterStore,
100    /// In-flight request guards holding this binding.
101    in_flight: usize,
102}
103
104impl HashlineBinding {
105    fn new(key: SessionKey, request: RegistrationRequest) -> Self {
106        Self {
107            key,
108            configured_enabled: request.configured_enabled,
109            edit_slot_survives: request.edit_slot_survives,
110            effective: request.effective(),
111            snapshots: SnapshotStore::new(),
112            registers: RegisterStore::new(),
113            in_flight: 0,
114        }
115    }
116
117    pub fn key(&self) -> &SessionKey {
118        &self.key
119    }
120
121    pub fn configured_enabled(&self) -> bool {
122        self.configured_enabled
123    }
124
125    pub fn edit_slot_survives(&self) -> bool {
126        self.edit_slot_survives
127    }
128
129    pub fn effective(&self) -> bool {
130        self.effective
131    }
132
133    pub fn snapshots(&self) -> &SnapshotStore {
134        &self.snapshots
135    }
136
137    pub fn snapshots_mut(&mut self) -> &mut SnapshotStore {
138        &mut self.snapshots
139    }
140
141    pub fn registers(&self) -> &RegisterStore {
142        &self.registers
143    }
144
145    pub fn registers_mut(&mut self) -> &mut RegisterStore {
146        &mut self.registers
147    }
148
149    /// Borrow both session stores for one atomic request pipeline.
150    pub fn stores_mut(&mut self) -> (&mut SnapshotStore, &mut RegisterStore) {
151        (&mut self.snapshots, &mut self.registers)
152    }
153
154    pub fn in_flight(&self) -> usize {
155        self.in_flight
156    }
157
158    fn clear_stores(&mut self) {
159        self.snapshots.clear();
160        *self.registers_mut() = RegisterStore::new();
161    }
162}
163
164/// Shared handle to an installed binding. Capture one per request.
165#[derive(Clone, Debug)]
166pub struct BindingHandle {
167    inner: Arc<Mutex<HashlineBinding>>,
168}
169
170impl BindingHandle {
171    pub fn with_binding<R>(&self, f: impl FnOnce(&HashlineBinding) -> R) -> R {
172        let guard = self.inner.lock().unwrap_or_else(|p| p.into_inner());
173        f(&guard)
174    }
175
176    pub fn with_binding_mut<R>(&self, f: impl FnOnce(&mut HashlineBinding) -> R) -> R {
177        let mut guard = self.inner.lock().unwrap_or_else(|p| p.into_inner());
178        f(&mut guard)
179    }
180
181    pub fn effective(&self) -> bool {
182        self.with_binding(|b| b.effective())
183    }
184
185    pub fn session_key(&self) -> SessionKey {
186        self.with_binding(|b| b.key().clone())
187    }
188}
189
190/// RAII guard that keeps a binding alive for one request and participates in
191/// the rebind drain refcount.
192pub struct BindingGuard {
193    handle: BindingHandle,
194    registry: Weak<BindingRegistryInner>,
195}
196
197impl BindingGuard {
198    pub fn handle(&self) -> &BindingHandle {
199        &self.handle
200    }
201
202    pub fn effective(&self) -> bool {
203        self.handle.effective()
204    }
205
206    pub fn with_binding<R>(&self, f: impl FnOnce(&HashlineBinding) -> R) -> R {
207        self.handle.with_binding(f)
208    }
209
210    pub fn with_binding_mut<R>(&self, f: impl FnOnce(&mut HashlineBinding) -> R) -> R {
211        self.handle.with_binding_mut(f)
212    }
213}
214
215impl Drop for BindingGuard {
216    fn drop(&mut self) {
217        if let Some(registry) = self.registry.upgrade() {
218            registry.release_guard(&self.handle);
219        } else {
220            // Registry gone: still decrement local refcount so tests that drop
221            // the registry after guards do not leave a poisoned counter.
222            let mut binding = self.handle.inner.lock().unwrap_or_else(|p| p.into_inner());
223            binding.in_flight = binding.in_flight.saturating_sub(1);
224        }
225    }
226}
227
228struct BindingRegistryInner {
229    state: Mutex<RegistryState>,
230    drain: Condvar,
231}
232
233#[derive(Default)]
234struct RegistryState {
235    bindings: HashMap<SessionKey, Arc<Mutex<HashlineBinding>>>,
236}
237
238/// Process-wide (or test-local) registry of session hashline bindings.
239pub struct BindingRegistry {
240    inner: Arc<BindingRegistryInner>,
241}
242
243impl Default for BindingRegistry {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl BindingRegistry {
250    pub fn new() -> Self {
251        Self {
252            inner: Arc::new(BindingRegistryInner {
253                state: Mutex::new(RegistryState::default()),
254                drain: Condvar::new(),
255            }),
256        }
257    }
258
259    fn lock(&self) -> MutexGuard<'_, RegistryState> {
260        self.inner
261            .state
262            .lock()
263            .unwrap_or_else(|poisoned| poisoned.into_inner())
264    }
265
266    /// Atomically install or replace the binding for one session.
267    ///
268    /// Same-effective re-registration preserves snapshot and register stores.
269    /// Effective-value changes drain in-flight guards, clear stores, then install.
270    /// Failed callers must not call this with a partial request — the install is
271    /// all-or-nothing once invoked.
272    pub fn register(
273        &self,
274        root: impl AsRef<Path>,
275        session_id: impl Into<String>,
276        request: RegistrationRequest,
277    ) -> RegistrationOutcome {
278        let key = SessionKey::new(root.as_ref().to_path_buf(), session_id.into());
279        self.register_key(key, request, || {})
280    }
281
282    fn register_key(
283        &self,
284        key: SessionKey,
285        request: RegistrationRequest,
286        after_existing_read: impl FnOnce(),
287    ) -> RegistrationOutcome {
288        let effective = request.effective();
289        let downgrade = request
290            .should_downgrade()
291            .then_some(DowngradeWarning::EDIT_NOT_REGISTERED);
292
293        // Serialize the existing-value read, comparison, and binding update. A
294        // guard may finish while this lock is held because guard release only
295        // takes the binding lock and signals the drain condition variable.
296        let mut state = self.lock();
297        let existing = state.bindings.get(&key).cloned();
298        let previous_effective = existing.as_ref().map(|binding| {
299            binding
300                .lock()
301                .unwrap_or_else(|p| p.into_inner())
302                .effective()
303        });
304        after_existing_read();
305
306        let (stores_cleared, stores_preserved) = if let Some(existing) = existing {
307            if previous_effective != Some(effective) {
308                self.drain_in_flight(&existing);
309                {
310                    let mut binding = existing.lock().unwrap_or_else(|p| p.into_inner());
311                    binding.configured_enabled = request.configured_enabled;
312                    binding.edit_slot_survives = request.edit_slot_survives;
313                    binding.effective = effective;
314                    binding.clear_stores();
315                }
316                state.bindings.insert(key, existing);
317                (true, false)
318            } else {
319                {
320                    let mut binding = existing.lock().unwrap_or_else(|p| p.into_inner());
321                    binding.configured_enabled = request.configured_enabled;
322                    binding.edit_slot_survives = request.edit_slot_survives;
323                    // effective unchanged; stores preserved.
324                }
325                state.bindings.insert(key, existing);
326                (false, true)
327            }
328        } else {
329            let binding = Arc::new(Mutex::new(HashlineBinding::new(key.clone(), request)));
330            state.bindings.insert(key, binding);
331            (false, false)
332        };
333
334        RegistrationOutcome {
335            configured_enabled: request.configured_enabled,
336            edit_slot_survives: request.edit_slot_survives,
337            effective,
338            downgrade,
339            stores_cleared,
340            stores_preserved,
341        }
342    }
343
344    /// Capture the installed binding for one request. Unregistered sessions
345    /// yield `None` and must behave as effective-off.
346    pub fn capture(
347        &self,
348        root: impl AsRef<Path>,
349        session_id: impl Into<String>,
350    ) -> Option<BindingGuard> {
351        let key = SessionKey::new(root.as_ref().to_path_buf(), session_id.into());
352        let handle = {
353            let state = self.lock();
354            let arc = state.bindings.get(&key)?.clone();
355            {
356                let mut binding = arc.lock().unwrap_or_else(|p| p.into_inner());
357                binding.in_flight = binding.in_flight.saturating_add(1);
358            }
359            BindingHandle { inner: arc }
360        };
361        Some(BindingGuard {
362            handle,
363            registry: Arc::downgrade(&self.inner),
364        })
365    }
366
367    /// Look up without incrementing the in-flight refcount (diagnostics only).
368    pub fn peek(
369        &self,
370        root: impl AsRef<Path>,
371        session_id: impl Into<String>,
372    ) -> Option<BindingHandle> {
373        let key = SessionKey::new(root.as_ref().to_path_buf(), session_id.into());
374        let state = self.lock();
375        state
376            .bindings
377            .get(&key)
378            .map(|arc| BindingHandle { inner: arc.clone() })
379    }
380
381    /// Remove one session binding (teardown / restart). In-flight guards drain first.
382    pub fn teardown(&self, root: impl AsRef<Path>, session_id: impl Into<String>) -> bool {
383        let key = SessionKey::new(root.as_ref().to_path_buf(), session_id.into());
384        let existing = {
385            let state = self.lock();
386            state.bindings.get(&key).cloned()
387        };
388        if let Some(existing) = existing {
389            self.drain_in_flight(&existing);
390        }
391        let mut state = self.lock();
392        state.bindings.remove(&key).is_some()
393    }
394
395    /// Number of installed bindings (test/diagnostics).
396    pub fn len(&self) -> usize {
397        self.lock().bindings.len()
398    }
399
400    pub fn is_empty(&self) -> bool {
401        self.len() == 0
402    }
403
404    fn drain_in_flight(&self, binding: &Arc<Mutex<HashlineBinding>>) {
405        let mut guard = binding.lock().unwrap_or_else(|p| p.into_inner());
406        while guard.in_flight > 0 {
407            guard = self
408                .inner
409                .drain
410                .wait(guard)
411                .unwrap_or_else(|p| p.into_inner());
412        }
413    }
414}
415
416impl BindingRegistryInner {
417    fn release_guard(&self, handle: &BindingHandle) {
418        {
419            let mut binding = handle.inner.lock().unwrap_or_else(|p| p.into_inner());
420            binding.in_flight = binding.in_flight.saturating_sub(1);
421        }
422        self.drain.notify_all();
423    }
424}
425
426/// Effective mode for a request: unregistered sessions are always off.
427pub fn effective_for_capture(guard: Option<&BindingGuard>) -> bool {
428    guard.map(|g| g.effective()).unwrap_or(false)
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::hashline::scan::scan_bytes;
435    use std::sync::mpsc::{self, RecvTimeoutError};
436    use std::thread;
437    use std::time::Duration;
438
439    #[test]
440    fn concurrent_same_session_registration_serializes_read_compare_write() {
441        let registry = Arc::new(BindingRegistry::new());
442        let key = SessionKey::new("/tmp/hashline-register-race", "shared-session");
443        registry.register(
444            &key.root,
445            key.session_id.clone(),
446            RegistrationRequest {
447                configured_enabled: true,
448                edit_slot_survives: true,
449            },
450        );
451        registry
452            .peek(&key.root, key.session_id.clone())
453            .expect("initial binding")
454            .with_binding_mut(|binding| {
455                binding
456                    .snapshots_mut()
457                    .publish("race.rs", scan_bytes(b"before race\n"));
458            });
459
460        let (first_read_tx, first_read_rx) = mpsc::channel();
461        let (release_first_tx, release_first_rx) = mpsc::channel();
462        let first_registry = Arc::clone(&registry);
463        let first_key = key.clone();
464        let first = thread::spawn(move || {
465            first_registry.register_key(
466                first_key,
467                RegistrationRequest {
468                    configured_enabled: false,
469                    edit_slot_survives: true,
470                },
471                || {
472                    first_read_tx.send(()).expect("signal first read");
473                    release_first_rx.recv().expect("release first registration");
474                },
475            )
476        });
477
478        first_read_rx
479            .recv()
480            .expect("first registration read existing binding");
481        let (second_started_tx, second_started_rx) = mpsc::channel();
482        let (second_read_tx, second_read_rx) = mpsc::channel();
483        let (second_done_tx, second_done_rx) = mpsc::channel();
484        let second_registry = Arc::clone(&registry);
485        let second_key = key.clone();
486        let second = thread::spawn(move || {
487            second_started_tx.send(()).expect("signal second start");
488            let outcome = second_registry.register_key(
489                second_key,
490                RegistrationRequest {
491                    configured_enabled: true,
492                    edit_slot_survives: false,
493                },
494                || second_read_tx.send(()).expect("signal second read"),
495            );
496            second_done_tx.send(outcome).expect("send second outcome");
497        });
498
499        second_started_rx
500            .recv()
501            .expect("second registration started");
502        assert!(matches!(
503            second_read_rx.recv_timeout(Duration::from_secs(1)),
504            Err(RecvTimeoutError::Timeout)
505        ));
506        release_first_tx
507            .send(())
508            .expect("release first registration");
509
510        let first_outcome = first.join().expect("first registration");
511        let second_outcome = second_done_rx
512            .recv_timeout(Duration::from_secs(2))
513            .expect("second registration completes after first");
514        second.join().expect("second registration");
515
516        assert!(first_outcome.stores_cleared);
517        assert!(second_outcome.stores_preserved);
518        let final_binding = registry
519            .peek(&key.root, key.session_id)
520            .expect("final binding");
521        final_binding.with_binding(|binding| {
522            assert!(binding.configured_enabled());
523            assert!(!binding.edit_slot_survives());
524            assert!(!binding.effective());
525            assert!(binding.snapshots().is_empty());
526        });
527    }
528}