OXYGENHUB

oxy-server: a host can add a note to a reference a push moved (closes #148) 642fa213de80

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 051a09622ebe
When
· 2026-10-26 16:57 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
642fa213de80028beaac55764ddb218936e6ee784f3c884ff7533308ec5edcf8
Depends on
67a4fe1aa2d5 750251b21a96 75167672ebcd 80b069facc69 8fd78eb52ad2 be451356e3ef e4595038502a ee06a6c557b7 ee130dd9eef2 f8c695764c95
Depended on by
78b49d4d01f0 7a3cc5149a14
In bookmarks
identical-sides main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.8.0 v0.9.0
Edits
6 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A server can add a note to a reference a push moved (#148).** `oxy-server`'s `Backend` has a `push_notes` step: each note is added to that accepted reference's result, which `oxy push` shows in place of "updated". OxygenHub will use it to say when a push moves the bookmark of a proposal that has already landed (oxygenhub#329).
6 +
5 7
- **A shallow clone fetches its checkpoint in parts (#137).** The checkpoint of a big state came as one answer, the whole state's graph blobs in one body (about 2 GB for the VS Code history), built and held whole on both sides. A server now answers `checkpoint-index` (the checkpoint and its history, no graphs), and the client fetches the graph blobs it lacks with `objects`, 2,048 at a time; `objects` reads them in pack order. An older server answers 404 and gets the one-piece request as before.
6 8
7 9
- **A keyless agent follows its owner's newest identity (#82).** Every delegation rotates its owner's identity, so the `author.key` an agent's home was given goes stale, and its changes named an identity two rotations old (verifying only because a grant from before a rotation still counts). With `author.for = ACCOUNT` in the home, the hub sends the account's newest linked identity with each certificate, with its chain back to the grant's key; oxy keeps it in the home (`keys/author.for`), stores the chain beside the change so the grant checks, and authors as it from then on. A repository's own `author.key` pin still wins.
…
283 unchanged lines
modified crates/oxy-server/src/lib.rs +28 −0
…
63 unchanged lines
64 64
    /// between, and the changes stored, for logs and cache invalidation.
65 65
    fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {}
66 66
67 +
    /// Notes for the pusher on references this push moved: each
68 +
    /// `(reference, note)` is added to that accepted reference's result,
69 +
    /// which a client shows beside it (`Pushed origin/x - note`). For a
70 +
    /// push that did what the pusher may not expect, say moving the
71 +
    /// bookmark of a proposal that has landed (oxygenhub#329, #148). Called
72 +
    /// before [`Backend::after_push`], which sees the notes.
73 +
    fn push_notes(
74 +
        &self,
75 +
        _name: &str,
76 +
        _report: &oxy_core::repo::PushReport,
77 +
        _headers: &HeaderMap,
78 +
    ) -> Vec<(String, String)> {
79 +
        Vec::new()
80 +
    }
81 +
67 82
    /// Vet a push by what it adds before anything of it is stored or any
68 83
    /// reference moves (spec §16.1): `bundle` is the push as decoded, its
69 84
    /// changes with their lines, `updates` the references it would move.
…
316 unchanged lines
386 401
                        "oxy-server: push to {name}: {} refused: {}",
387 402
                        r.name, r.reason
388 403
                    );
404 +
                }
405 +
                for (reference, note) in self.backend.push_notes(name, &report, headers) {
406 +
                    if let Some(r) = report
407 +
                        .results
408 +
                        .iter_mut()
409 +
                        .find(|r| r.ok && r.name == reference)
410 +
                    {
411 +
                        r.reason = if r.reason.is_empty() {
412 +
                            note
413 +
                        } else {
414 +
                            format!("{}; {note}", r.reason)
415 +
                        };
416 +
                    }
389 417
                }
390 418
                self.backend.after_push(name, &report, headers);
391 419
                ok(wire::encode_push_response(&report.results))
…
130 unchanged lines
modified crates/oxy-server/tests/http.rs +116 −0
…
885 unchanged lines
886 886
    );
887 887
}
888 888
889 +
/// A host adds a note to a reference a push moved (`Backend::push_notes`):
890 +
/// the client gets it as that reference's result, accepted as before,
891 +
/// and the host's `after_push` sees it too; a reference with no note
892 +
/// says nothing more.
893 +
#[test]
894 +
fn a_host_adds_a_note_to_an_accepted_reference() {
895 +
    use axum::extract::{Path as AxPath, State};
896 +
    use axum::http::{HeaderMap, Method};
897 +
    use axum::response::Response;
898 +
    use oxy_core::repo::PushReport;
899 +
    use oxy_server::{Access, Backend, DirBackend, Endpoint, Failure, Server};
900 +
    use std::sync::{Arc, Mutex};
901 +
902 +
    struct Noting(DirBackend, Arc<Mutex<Vec<String>>>);
903 +
    impl Backend for Noting {
904 +
        fn open(&self, name: &str) -> Result<Repo, Failure> {
905 +
            self.0.open(name)
906 +
        }
907 +
        fn authorize(
908 +
            &self,
909 +
            name: &str,
910 +
            access: Access,
911 +
            headers: &HeaderMap,
912 +
        ) -> Result<(), Failure> {
913 +
            self.0.authorize(name, access, headers)
914 +
        }
915 +
        fn push_notes(
916 +
            &self,
917 +
            _name: &str,
918 +
            report: &PushReport,
919 +
            _headers: &HeaderMap,
920 +
        ) -> Vec<(String, String)> {
921 +
            report
922 +
                .updated
923 +
                .iter()
924 +
                .filter(|u| u.name == "feature")
925 +
                .map(|u| (u.name.clone(), "its proposal #3 has landed".to_string()))
926 +
                .collect()
927 +
        }
928 +
        fn after_push(&self, _name: &str, report: &PushReport, _headers: &HeaderMap) {
929 +
            let mut seen = self.1.lock().unwrap();
930 +
            seen.extend(report.results.iter().map(|r| r.reason.clone()));
931 +
        }
932 +
    }
933 +
    async fn route(
934 +
        State(server): State<Arc<Server>>,
935 +
        AxPath((repo, endpoint)): AxPath<(String, String)>,
936 +
        method: Method,
937 +
        headers: HeaderMap,
938 +
        body: axum::body::Bytes,
939 +
    ) -> Response {
940 +
        let e = Endpoint::parse(&endpoint).unwrap();
941 +
        server.handle(repo, e, &method, &headers, body).await
942 +
    }
943 +
944 +
    hermetic();
945 +
    let dir = tempfile::tempdir().unwrap();
946 +
    let root = dir.path();
947 +
    let served = root.join("served");
948 +
    Repo::init_bare_unbound(&served.join("lib")).unwrap();
949 +
    let seen = Arc::new(Mutex::new(Vec::new()));
950 +
    let server = Server::new(Noting(
951 +
        DirBackend {
952 +
            root: served.clone(),
953 +
            tokens: vec![],
954 +
        },
955 +
        seen.clone(),
956 +
    ));
957 +
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
958 +
    let port = listener.local_addr().unwrap().port();
959 +
    std::thread::spawn(move || {
960 +
        let rt = tokio::runtime::Builder::new_multi_thread()
961 +
            .enable_all()
962 +
            .build()
963 +
            .unwrap();
964 +
        rt.block_on(async move {
965 +
            listener.set_nonblocking(true).unwrap();
966 +
            let app = axum::Router::new()
967 +
                .route("/{repo}/oxy/v1/{endpoint}", axum::routing::any(route))
968 +
                .with_state(server);
969 +
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
970 +
                .await
971 +
                .unwrap();
972 +
        });
973 +
    });
974 +
    std::thread::sleep(std::time::Duration::from_millis(50));
975 +
    let url = format!("http://127.0.0.1:{port}/lib");
976 +
977 +
    let work = root.join("work");
978 +
    let mut local = Repo::init(&work).unwrap();
979 +
    local.config.set("remote.origin.url", &url).unwrap();
980 +
    local.save_config().unwrap();
981 +
    write(&work, "a.txt", b"one\n");
982 +
    local.snapshot("test").unwrap();
983 +
    let base = local.commit("a", &[]).unwrap();
984 +
    let results = local.push("origin", false).unwrap();
985 +
    assert!(
986 +
        results.iter().all(|r| r.ok && r.reason.is_empty()),
987 +
        "{results:?}"
988 +
    );
989 +
    local.bookmark_create("feature", Some(&[base])).unwrap();
990 +
    local.bookmark_switch("feature", false).unwrap();
991 +
    write(&work, "a.txt", b"one\ntwo\n");
992 +
    local.snapshot("test").unwrap();
993 +
    local.commit("two", &[]).unwrap();
994 +
    let results = local.push_bookmark("origin", "feature", false).unwrap();
995 +
    let feature = results.iter().find(|r| r.name == "feature").unwrap();
996 +
    assert!(feature.ok, "{results:?}");
997 +
    assert_eq!(feature.reason, "its proposal #3 has landed");
998 +
    assert!(seen
999 +
        .lock()
1000 +
        .unwrap()
1001 +
        .iter()
1002 +
        .any(|r| r == "its proposal #3 has landed"));
1003 +
}
1004 +
889 1005
/// oxygen#131: oxy 0.5.0 and older authorize a tag only by a key the
890 1006
/// policy lists (or its delegate or predecessor) and fail the whole clone
891 1007
/// on any other, so a client that does not declare `certified-tags` is
…
197 unchanged lines
modified docs/protocol.md +1 −1
…
93 unchanged lines
94 94
`after_push` hook, `adjust_refs`, which tailors a refs answer to the
95 95
request's headers and by default applies the rule above, and `vet_push`, with which a host refuses a push by
96 96
what it adds before anything is stored: an HTTP error, `422` say, whose
97 -
text, several lines perhaps, the client shows as it is; spec §16.4), so a hub with `owner/name` paths and its own accounts
97 +
text, several lines perhaps, the client shows as it is; spec §16.4; and `push_notes`, with which a host adds a note to a reference the push moved, which the client shows in place of "updated", #148), so a hub with `owner/name` paths and its own accounts
98 98
routes `/{owner}/{name}/oxy/v1/{endpoint}` to `Server::handle`; `oxy serve`
99 99
is `DirBackend` on that same server.
100 100
…
128 unchanged lines