OXYGENHUB

State is Send + Sync; shared lane layout; log_window 119bbf50cb86 resolution

The closure cache sits behind a mutex so hosts can cache loaded states
across threads. oxy_core::lanes::layout is the one graph layout for oxy log
and the hub. Repo::log_window paginates a state's log.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 18:02 UTC
Signature
✔ signed by Andy McCall
Hash
119bbf50cb86995038a36a89f4315828bc40be3ecb6e7927b85f50c135048610
Depends on
1983686fb7a7 252e8cb23fbc 55e5a16a2b9c 750251b21a96 75167672ebcd 7aa2dc9369e3 83e0677b07fe 96f7610fa666 982864068a59 b0e64e81e4fb c3069537d7c5 e02bf0cd0533 fce44ae9ad29 ff0af1ee299d
Depended on by
c2e1d09047db a39754d02c58 aedfe465b611 d45dab0484ea 2bf22305bc48 f6451fbbae3f 01a47cd7f0c8 e8d38e47e9d7 0bdc3f91a39f eae595568832 9d333fc9ca1b
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
31 atoms, 1 tree operation, 10 files

Browse the tree at this change

added crates/oxy-core/src/lanes.rs +114 −0
1 +
//! Lane layout for drawing history as a graph: each change takes a lane,
2 +
//! its first dependency continues that lane, other dependencies take free
3 +
//! lanes, so edges flow from a change down to what it depends on. Shared
4 +
//! by `oxy log` and hosts, so every view draws the same picture.
5 +
6 +
use crate::hash::ChangeHash;
7 +
use std::collections::{HashMap, HashSet};
8 +
9 +
/// A row of the graph: the change's lane and the edges drawn from it.
10 +
#[derive(Clone, Debug, PartialEq, Eq)]
11 +
pub struct Row {
12 +
    pub lane: usize,
13 +
    /// `(from_lane, to_lane)` for each dependency drawn from this row to
14 +
    /// the rows below.
15 +
    pub edges: Vec<(usize, usize)>,
16 +
    /// Lanes that pass through this row without touching it.
17 +
    pub through: Vec<usize>,
18 +
}
19 +
20 +
/// `changes` newest first as `(hash, deps)`. A dependency outside the
21 +
/// list ends its lane at the bottom.
22 +
pub fn layout(changes: &[(ChangeHash, Vec<ChangeHash>)]) -> Vec<Row> {
23 +
    let mut active: Vec<Option<ChangeHash>> = Vec::new();
24 +
    let mut rows = Vec::with_capacity(changes.len());
25 +
    let known: HashSet<&ChangeHash> = changes.iter().map(|(h, _)| h).collect();
26 +
    let mut lanes: HashMap<ChangeHash, usize> = HashMap::new();
27 +
    for (hash, deps) in changes {
28 +
        let lane = match active.iter().position(|a| a.as_ref() == Some(hash)) {
29 +
            Some(l) => l,
30 +
            None => match active.iter().position(|a| a.is_none()) {
31 +
                Some(l) => {
32 +
                    active[l] = Some(*hash);
33 +
                    l
34 +
                }
35 +
                None => {
36 +
                    active.push(Some(*hash));
37 +
                    active.len() - 1
38 +
                }
39 +
            },
40 +
        };
41 +
        lanes.insert(*hash, lane);
42 +
        let mut edges = Vec::new();
43 +
        let deps: Vec<&ChangeHash> = deps.iter().filter(|d| known.contains(d)).collect();
44 +
        active[lane] = None;
45 +
        for (i, d) in deps.iter().enumerate() {
46 +
            let target = match active.iter().position(|a| a.as_ref() == Some(*d)) {
47 +
                Some(l) => l,
48 +
                None => {
49 +
                    let l = if i == 0 && active[lane].is_none() {
50 +
                        lane
51 +
                    } else {
52 +
                        match active.iter().position(|a| a.is_none()) {
53 +
                            Some(l) => l,
54 +
                            None => {
55 +
                                active.push(None);
56 +
                                active.len() - 1
57 +
                            }
58 +
                        }
59 +
                    };
60 +
                    active[l] = Some(**d);
61 +
                    l
62 +
                }
63 +
            };
64 +
            edges.push((lane, target));
65 +
        }
66 +
        let through: Vec<usize> = active
67 +
            .iter()
68 +
            .enumerate()
69 +
            .filter(|(l, a)| a.is_some() && *l != lane && !edges.iter().any(|e| e.1 == *l))
70 +
            .map(|(l, _)| l)
71 +
            .collect();
72 +
        rows.push(Row { lane, edges, through });
73 +
        while active.last().is_some_and(|a| a.is_none()) {
74 +
            active.pop();
75 +
        }
76 +
    }
77 +
    rows
78 +
}
79 +
80 +
/// The number of lanes the rows use.
81 +
pub fn width(rows: &[Row]) -> usize {
82 +
    rows.iter()
83 +
        .map(|r| r.lane.max(r.edges.iter().map(|e| e.1).max().unwrap_or(0)).max(r.through.iter().copied().max().unwrap_or(0)))
84 +
        .max()
85 +
        .map(|m| m + 1)
86 +
        .unwrap_or(1)
87 +
}
88 +
89 +
#[cfg(test)]
90 +
mod tests {
91 +
    use super::*;
92 +
    use crate::hash::ObjectRef;
93 +
94 +
    fn h(n: u8) -> ChangeHash {
95 +
        ObjectRef([n; 32])
96 +
    }
97 +
98 +
    #[test]
99 +
    fn linear_history_is_one_lane() {
100 +
        let rows = layout(&[(h(3), vec![h(2)]), (h(2), vec![h(1)]), (h(1), vec![])]);
101 +
        assert!(rows.iter().all(|r| r.lane == 0), "{rows:?}");
102 +
        assert_eq!(rows[0].edges, vec![(0, 0)]);
103 +
        assert_eq!(width(&rows), 1);
104 +
    }
105 +
106 +
    #[test]
107 +
    fn a_diamond_takes_two_lanes_and_rejoins() {
108 +
        let rows = layout(&[(h(4), vec![h(2), h(3)]), (h(2), vec![h(1)]), (h(3), vec![h(1)]), (h(1), vec![])]);
109 +
        assert_eq!(rows[0].edges, vec![(0, 0), (0, 1)]);
110 +
        assert_eq!(rows[2].lane, 1);
111 +
        assert_eq!(rows[2].edges, vec![(1, 0)], "the second branch joins the first lane");
112 +
        assert_eq!(width(&rows), 2);
113 +
    }
114 +
}
modified crates/oxy-core/src/identity.rs +19 −4
…
502 unchanged lines
503 503
}
504 504
505 505
/// Check a challenge signature against the identity that claims it.
506 -
pub fn verify_challenge(identity: &Identity, sig: &Signature, challenge: &[u8]) -> Result<(), VerifyError> {
506 +
pub fn verify_challenge(
507 +
    identity: &Identity,
508 +
    sig: &Signature,
509 +
    challenge: &[u8],
510 +
) -> Result<(), VerifyError> {
507 511
    if challenge.len() < 16 {
508 512
        return Err(VerifyError::ShortChallenge);
509 513
    }
510 514
    if sig.key != identity.hash() {
511 515
        return Err(VerifyError::KeyMismatch);
512 516
    }
513 -
    verify(identity, sig, Domain::Challenge, &challenge_target(challenge))
517 +
    verify(
518 +
        identity,
519 +
        sig,
520 +
        Domain::Challenge,
521 +
        &challenge_target(challenge),
522 +
    )
514 523
}
515 524
516 525
#[cfg(test)]
…
8 unchanged lines
525 534
        let challenge = [7u8; 32];
526 535
        let sig = sk.sign_challenge(&key, &challenge).unwrap();
527 536
        assert!(verify_challenge(&id, &sig, &challenge).is_ok());
528 -
        assert!(verify_challenge(&id, &sig, &[8u8; 32]).is_err(), "other bytes");
537 +
        assert!(
538 +
            verify_challenge(&id, &sig, &[8u8; 32]).is_err(),
539 +
            "other bytes"
540 +
        );
529 541
        let other = SecretKey::generate().identity("B", "b@example", 1);
530 -
        assert!(verify_challenge(&other, &sig, &challenge).is_err(), "other identity");
542 +
        assert!(
543 +
            verify_challenge(&other, &sig, &challenge).is_err(),
544 +
            "other identity"
545 +
        );
531 546
        assert!(sk.sign_challenge(&key, b"short").is_err());
532 547
        // A challenge target never equals a change signature's target for the same bytes.
533 548
        assert_ne!(challenge_target(&challenge), ObjectRef(challenge));
…
2 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
10 unchanged lines
11 11
pub mod hash;
12 12
pub mod identity;
13 13
pub mod ids;
14 +
pub mod lanes;
14 15
pub mod objects;
15 16
pub mod oplog;
16 17
pub mod policy;
…
7 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +8 −0
…
1079 unchanged lines
1080 1080
        Ok(entries)
1081 1081
    }
1082 1082
1083 +
    /// A window of `log_state`: entries `offset..offset+limit` and the total
1084 +
    /// count, for paginated views.
1085 +
    pub fn log_window(&self, heads: &[ChangeHash], offset: usize, limit: usize) -> Result<(Vec<LogEntry>, usize)> {
1086 +
        let all = self.log_state(heads)?;
1087 +
        let total = all.len();
1088 +
        Ok((all.into_iter().skip(offset).take(limit).collect(), total))
1089 +
    }
1090 +
1083 1091
    /// The log of the state with these heads, newest first, with no working
1084 1092
    /// copy involved (for hosted repositories and any explicit state).
1085 1093
    pub fn log_state(&self, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> {
…
136 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +14 −3
…
465 unchanged lines
466 466
        policy: Option<SignedPolicy>,
467 467
        tags: Vec<crate::protocol::TagRef>,
468 468
    ) -> Result<Vec<RefResult>> {
469 -
        Ok(self.receive_push_report(bundle, updates, policy, tags)?.results)
469 +
        Ok(self
470 +
            .receive_push_report(bundle, updates, policy, tags)?
471 +
            .results)
470 472
    }
471 473
472 474
    /// `receive_push` with what a host wants to log: each accepted
…
59 unchanged lines
532 534
            };
533 535
            if ok {
534 536
                names.push(u.name.clone());
535 -
                let heads = self.store.get_state(&u.new_state).map(|s| s.heads).unwrap_or_default();
536 -
                report.updated.push(RefUpdated { name: u.name.clone(), old_state: u.old_state, new_state: u.new_state, heads });
537 +
                let heads = self
538 +
                    .store
539 +
                    .get_state(&u.new_state)
540 +
                    .map(|s| s.heads)
541 +
                    .unwrap_or_default();
542 +
                report.updated.push(RefUpdated {
543 +
                    name: u.name.clone(),
544 +
                    old_state: u.old_state,
545 +
                    new_state: u.new_state,
546 +
                    heads,
547 +
                });
537 548
            }
538 549
            results.push(RefResult {
539 550
                name: u.name,
…
877 unchanged lines
modified crates/oxy-core/src/state.rs +22 −9
…
9 unchanged lines
10 10
    self as o, AtomRef, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, ObjectError, TreeOp,
11 11
};
12 12
use crate::tree::{TreeGraph, TreeView};
13 -
use std::cell::RefCell;
13 +
use std::sync::{Arc, Mutex};
14 14
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
15 -
use std::rc::Rc;
16 15
17 16
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18 17
pub enum StateError {
…
29 unchanged lines
48 47
    }
49 48
}
50 49
51 -
#[derive(Default, Clone)]
50 +
#[derive(Default)]
52 51
pub struct State {
53 52
    changes: HashMap<ChangeHash, ChangeMeta>,
54 53
    tree: TreeGraph,
55 54
    files: HashMap<NodeId, FileGraph>,
56 55
    replaces: HashMap<NodeId, Vec<ReplaceRecord>>,
57 -
    closure: RefCell<HashMap<ChangeHash, Rc<HashSet<ChangeHash>>>>,
56 +
    /// Memoised transitive dependencies. A mutex, not a `RefCell`, so a
57 +
    /// loaded state can be shared across threads by a host that caches it.
58 +
    closure: Mutex<HashMap<ChangeHash, Arc<HashSet<ChangeHash>>>>,
58 59
}
59 60
61 +
impl Clone for State {
62 +
    fn clone(&self) -> State {
63 +
        State {
64 +
            changes: self.changes.clone(),
65 +
            tree: self.tree.clone(),
66 +
            files: self.files.clone(),
67 +
            replaces: self.replaces.clone(),
68 +
            closure: Mutex::new(self.closure.lock().unwrap().clone()),
69 +
        }
70 +
    }
71 +
}
72 +
60 73
#[derive(Clone, Debug, PartialEq, Eq)]
61 74
pub enum FileContent {
62 75
    Text {
…
130 unchanged lines
193 206
        }
194 207
        self.apply(h, &change);
195 208
        self.changes.insert(h, ChangeMeta::of(&change));
196 -
        self.closure.borrow_mut().clear();
209 +
        self.closure.lock().unwrap().clear();
197 210
        h
198 211
    }
199 212
…
179 unchanged lines
379 392
    }
380 393
381 394
    /// Transitive dependencies of `h`, excluding `h` itself.
382 -
    pub fn closure(&self, h: &ChangeHash) -> Rc<HashSet<ChangeHash>> {
383 -
        if let Some(c) = self.closure.borrow().get(h) {
395 +
    pub fn closure(&self, h: &ChangeHash) -> Arc<HashSet<ChangeHash>> {
396 +
        if let Some(c) = self.closure.lock().unwrap().get(h) {
384 397
            return c.clone();
385 398
        }
386 399
        let mut out = HashSet::new();
…
9 unchanged lines
396 409
                }
397 410
            }
398 411
        }
399 -
        let rc = Rc::new(out);
400 -
        self.closure.borrow_mut().insert(*h, rc.clone());
412 +
        let rc = Arc::new(out);
413 +
        self.closure.lock().unwrap().insert(*h, rc.clone());
401 414
        rc
402 415
    }
403 416
…
461 unchanged lines
modified crates/oxy-server/tests/http.rs +4 −1
…
468 unchanged lines
469 469
    client.config.set("remote.origin.token", "s3cret").unwrap();
470 470
    let results = client.push("origin", false).unwrap();
471 471
    assert!(results.iter().all(|r| r.ok), "{results:?}");
472 -
    assert_eq!(pushes.lock().unwrap().as_slice(), ["andy/app: 1 ok, 1 updated to 1 head(s), 1 imported"]);
472 +
    assert_eq!(
473 +
        pushes.lock().unwrap().as_slice(),
474 +
        ["andy/app: 1 ok, 1 updated to 1 head(s), 1 imported"]
475 +
    );
473 476
474 477
    let hosted = Repo::open(&root.join("repos/andy/app")).unwrap();
475 478
    assert_eq!(hosted.bookmark_heads().unwrap().len(), 1);
…
134 unchanged lines
modified docs/plan.md +5 −5
…
88 unchanged lines
89 89
- [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3).
90 90
- [ ] `oxy proposal show|land URL` (hub 0.3).
91 91
- [ ] Delegate-signed changes from an edit set without a workspace (hub 0.6).
92 -
- [ ] `Repo::log_window` and a shared `graph::layout` (hub 0.1 uses `log_state` and its own layout until then).
92 +
- [x] `Repo::log_window` and the shared `oxy_core::lanes::layout`; `State` is `Send + Sync` (closure cache behind a mutex) so hosts can cache loaded states.
93 93
- [x] `receive_push_report` with `PushReport { results, updated: [RefUpdated { name, old_state, new_state, heads }], imported }`; `Backend::after_push` receives it.
94 -
<<<<<<< oxy conflict 1 of 1, side 1 of 2: deleted by change 75f82481 "Challenge signatures and a push report for hosts"
95 -
-------
96 -
- [ ] `receive_push` reporting old/new state and new changes per reference (hub 0.2 pushes log).
97 -
======= oxy conflict 1 of 1, side 2 of 2: change d900550d "Roadmap across oxygen, oxygen-vscode, oxygenhub, oxygenhub-vscode and the runner"
94 +
95 +
## Roadmap
96 +
97 +
The cross-product order of work is in `docs/roadmap.md`.
98 98
99 99
## Roadmap
100 100
…
2 unchanged lines
modified src/cli/identity.rs +14 −4
…
738 unchanged lines
739 739
    let Some((key, sk)) = repo.secret_key()? else {
740 740
        anyhow::bail!("no secret key: run `oxy identity create` first");
741 741
    };
742 -
    let challenge = hex_bytes(challenge_hex.trim()).ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
743 -
    let sig = sk.sign_challenge(&key, &challenge).map_err(|e| anyhow::anyhow!("{e}"))?;
742 +
    let challenge = hex_bytes(challenge_hex.trim())
743 +
        .ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
744 +
    let sig = sk
745 +
        .sign_challenge(&key, &challenge)
746 +
        .map_err(|e| anyhow::anyhow!("{e}"))?;
744 747
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
745 748
    if as_json {
746 -
        println!("{{\"key\":\"{}\",\"signature\":\"{}\"}}", key.to_hex(), encoded);
749 +
        println!(
750 +
            "{{\"key\":\"{}\",\"signature\":\"{}\"}}",
751 +
            key.to_hex(),
752 +
            encoded
753 +
        );
747 754
    } else {
748 755
        println!("{encoded}");
749 756
    }
…
4 unchanged lines
754 761
    if !s.len().is_multiple_of(2) {
755 762
        return None;
756 763
    }
757 -
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
764 +
    (0..s.len())
765 +
        .step_by(2)
766 +
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
767 +
        .collect()
758 768
}
759 769
760 770
fn hex_string(b: &[u8]) -> String {
…
2 unchanged lines
modified src/cli/mod.rs +3 −1
…
728 unchanged lines
729 729
            } => identity::create(name, email, local, expires),
730 730
            IdentityCmd::Show => identity::show(),
731 731
            IdentityCmd::Export { path } => identity::export(path),
732 -
            IdentityCmd::SignChallenge { challenge, json } => identity::sign_challenge(&challenge, json),
732 +
            IdentityCmd::SignChallenge { challenge, json } => {
733 +
                identity::sign_challenge(&challenge, json)
734 +
            }
733 735
            IdentityCmd::Rotate => identity::rotate(),
734 736
            IdentityCmd::Renew { expires } => identity::renew(expires),
735 737
            IdentityCmd::Revoke { key, reason } => identity::revoke(key.as_deref(), &reason),
…
46 unchanged lines