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