Skip to main content

strop_engine/editor/
workspaces.rs

1//! Workspace registry (0042 slice 2): the editor's bound workspace
2//! contexts — one per filesystem namespace in use — with stable
3//! generational identity and an incarnation counter that bumps when an
4//! endpoint disconnects and later reconnects, so results from before a
5//! break are never mistaken for the new session's.
6//!
7//! Jobs keep capturing the concrete targets they already capture; this
8//! registry is the identity hub the explain surface reads and later
9//! capability scoping (execution bindings, container contexts) builds on.
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14use strop_core::id::{Arena, WorkspaceId, WorkspaceKind};
15use strop_workspace::Filesystem;
16
17/// One bound context: which filesystem, anchored where, which incarnation.
18pub struct WorkspaceContext {
19    pub filesystem: Filesystem,
20    /// Local: the process cwd. Remote: no project-root concept yet — the
21    /// endpoint is the context; real roots arrive with container and
22    /// worktree bindings (0037).
23    pub root: Option<PathBuf>,
24    /// Bumps on disconnect; a reconnect binds a fresh incarnation.
25    pub incarnation: u64,
26}
27
28#[derive(Default)]
29pub struct WorkspaceRegistry {
30    arena: Arena<WorkspaceKind, WorkspaceContext>,
31    by_filesystem: HashMap<Filesystem, WorkspaceId>,
32}
33
34impl WorkspaceRegistry {
35    /// The context for a filesystem, binding it on first use. Idempotent:
36    /// an already-bound filesystem keeps its identity and incarnation.
37    pub fn bind(&mut self, filesystem: Filesystem, root: Option<PathBuf>) -> WorkspaceId {
38        if let Some(id) = self.by_filesystem.get(&filesystem) {
39            return *id;
40        }
41        let id = self.arena.insert(WorkspaceContext {
42            filesystem: filesystem.clone(),
43            root,
44            incarnation: 0,
45        });
46        self.by_filesystem.insert(filesystem, id);
47        id
48    }
49
50    /// A disconnect ends the incarnation: the next bind/reconnect observes
51    /// a bumped counter rather than silently continuing the old session.
52    pub fn note_disconnect(&mut self, filesystem: &Filesystem) {
53        if let Some(id) = self.by_filesystem.get(filesystem) {
54            if let Some(context) = self.arena.get_mut(*id) {
55                context.incarnation += 1;
56            }
57        }
58    }
59
60    pub fn iter(&self) -> impl Iterator<Item = (WorkspaceId, &WorkspaceContext)> {
61        self.arena.iter()
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use strop_workspace::RemoteEndpoint;
69
70    #[test]
71    fn rebinding_keeps_identity_and_disconnect_bumps_incarnation() {
72        let mut registry = WorkspaceRegistry::default();
73        let local = registry.bind(Filesystem::Local, Some(PathBuf::from("/work")));
74        assert_eq!(registry.bind(Filesystem::Local, None), local, "idempotent");
75        let endpoint = RemoteEndpoint::parse("ssh://dev@example.com:2222").unwrap();
76        let remote = registry.bind(Filesystem::Remote(endpoint.clone()), None);
77        assert_ne!(local, remote, "namespaces never share a slot");
78        let remote_fs = Filesystem::Remote(endpoint.clone());
79        let incarnation = |registry: &WorkspaceRegistry| {
80            registry
81                .iter()
82                .find(|(_, context)| context.filesystem == remote_fs)
83                .map(|(_, context)| context.incarnation)
84        };
85        assert_eq!(incarnation(&registry), Some(0));
86        registry.note_disconnect(&remote_fs);
87        assert_eq!(incarnation(&registry), Some(1));
88        assert_eq!(
89            registry.bind(Filesystem::Remote(endpoint), None),
90            remote,
91            "reconnect reuses the slot with the bumped incarnation"
92        );
93        let other = RemoteEndpoint::parse("ssh://other.example.com").unwrap();
94        registry.note_disconnect(&Filesystem::Remote(other));
95    }
96}