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 |