| 1 | //! What the CLI works against: a store on this machine, or a grove server. |
| 2 | //! |
| 3 | //! Both keep objects locally (the remote one as a cache), because snapshots are taken and |
| 4 | //! files written here. The remote backend uploads the objects a request refers to before |
| 5 | //! sending it, and downloads what it needs on demand. |
| 6 | |
| 7 | use crate::client::Client; |
| 8 | use crate::hex; |
| 9 | use crate::json::{obj, Value}; |
| 10 | use crate::model::{step_to_json, Actor, ChangeState, Project, Step}; |
| 11 | use crate::objects::{Id, Objects}; |
| 12 | use crate::store::{Content, Store}; |
| 13 | use crate::wire; |
| 14 | |
| 15 | pub type R<T> = Result<T, String>; |
| 16 | |
| 17 | pub trait Backend { |
| 18 | fn objects(&self) -> &Objects; |
| 19 | /// Make sure every object under `tree` is available locally. |
| 20 | fn need(&mut self, tree: &Id) -> R<()>; |
| 21 | fn project(&mut self, owner: &str, name: &str) -> R<Project>; |
| 22 | fn agent_name(&self, id: &str) -> Option<String>; |
| 23 | |
| 24 | fn create_project(&mut self, by: &Actor, owner: &str, name: &str, public: bool) -> R<()>; |
| 25 | fn record_point(&mut self, by: &Actor, owner: &str, name: &str, snapshot: &Id, message: &str) -> R<u64>; |
| 26 | fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> R<u64>; |
| 27 | fn revise(&mut self, by: &Actor, owner: &str, name: &str, change: u64, base: u64, steps: &[Step], note: Option<&str>) -> R<u32>; |
| 28 | fn set_state(&mut self, by: &Actor, owner: &str, name: &str, change: u64, to: ChangeState) -> R<()>; |
| 29 | fn land(&mut self, by: &Actor, owner: &str, name: &str, change: u64) -> R<(u64, u64)>; |
| 30 | fn say(&mut self, by: &Actor, owner: &str, name: &str, change: u64, body: &str) -> R<()>; |
| 31 | fn attach(&mut self, by: &Actor, owner: &str, name: &str, change: u64, revision: u32, title: &str, media_type: &str, content: Content) -> R<()>; |
| 32 | fn create_group(&mut self, by: &Actor, name: &str) -> R<()>; |
| 33 | fn add_member(&mut self, by: &Actor, group: &str, person: &str) -> R<()>; |
| 34 | fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> R<()>; |
| 35 | } |
| 36 | |
| 37 | // ── On this machine ──────────────────────────────────────── |
| 38 | |
| 39 | pub struct Local(pub Store); |
| 40 | |
| 41 | fn s<T>(r: crate::store::Result<T>) -> R<T> { |
| 42 | r.map_err(|e| e.to_string()) |
| 43 | } |
| 44 | |
| 45 | impl Backend for Local { |
| 46 | fn objects(&self) -> &Objects { |
| 47 | &self.0.objects |
| 48 | } |
| 49 | fn need(&mut self, _: &Id) -> R<()> { |
| 50 | Ok(()) |
| 51 | } |
| 52 | fn project(&mut self, owner: &str, name: &str) -> R<Project> { |
| 53 | s(self.0.project(owner, name)).cloned() |
| 54 | } |
| 55 | fn agent_name(&self, id: &str) -> Option<String> { |
| 56 | self.0.state().agent(id).map(|a| a.name.clone()) |
| 57 | } |
| 58 | fn create_project(&mut self, by: &Actor, owner: &str, name: &str, public: bool) -> R<()> { |
| 59 | s(self.0.create_project(by, owner, name, public)) |
| 60 | } |
| 61 | fn record_point(&mut self, by: &Actor, owner: &str, name: &str, snapshot: &Id, message: &str) -> R<u64> { |
| 62 | s(self.0.record_point(by, owner, name, snapshot, message, None)) |
| 63 | } |
| 64 | fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> R<u64> { |
| 65 | s(self.0.open_change(by, owner, name, intent)) |
| 66 | } |
| 67 | fn revise(&mut self, by: &Actor, owner: &str, name: &str, change: u64, base: u64, steps: &[Step], note: Option<&str>) -> R<u32> { |
| 68 | s(self.0.revise(by, owner, name, change, base, steps, note)) |
| 69 | } |
| 70 | fn set_state(&mut self, by: &Actor, owner: &str, name: &str, change: u64, to: ChangeState) -> R<()> { |
| 71 | s(self.0.set_state(by, owner, name, change, to)) |
| 72 | } |
| 73 | fn land(&mut self, by: &Actor, owner: &str, name: &str, change: u64) -> R<(u64, u64)> { |
| 74 | s(self.0.land(by, owner, name, change)) |
| 75 | } |
| 76 | fn say(&mut self, by: &Actor, owner: &str, name: &str, change: u64, body: &str) -> R<()> { |
| 77 | s(self.0.say(by, owner, name, change, body)) |
| 78 | } |
| 79 | fn attach(&mut self, by: &Actor, owner: &str, name: &str, change: u64, revision: u32, title: &str, media_type: &str, content: Content) -> R<()> { |
| 80 | s(self.0.attach(by, owner, name, change, revision, title, media_type, content)) |
| 81 | } |
| 82 | fn create_group(&mut self, by: &Actor, name: &str) -> R<()> { |
| 83 | s(self.0.create_group(by, name)) |
| 84 | } |
| 85 | fn add_member(&mut self, by: &Actor, group: &str, person: &str) -> R<()> { |
| 86 | s(self.0.add_member(by, group, person)) |
| 87 | } |
| 88 | fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> R<()> { |
| 89 | s(self.0.move_project(by, owner, name, to)) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // ── A grove server ───────────────────────────────────────── |
| 94 | |
| 95 | pub struct Remote { |
| 96 | /// Acts as the agent (or the person, if no agent is configured). |
| 97 | agent: Client, |
| 98 | /// The person's own token, for decisions made in person. Optional. |
| 99 | person: Option<Client>, |
| 100 | cache: Objects, |
| 101 | names: Vec<(String, String)>, |
| 102 | } |
| 103 | |
| 104 | /// Objects per upload or download request. |
| 105 | const BATCH: usize = 2000; |
| 106 | |
| 107 | impl Remote { |
| 108 | pub fn new(server: &str, token: &str, person_token: Option<&str>, cache: Objects) -> Remote { |
| 109 | Remote { |
| 110 | agent: Client::new(server, Some(token.to_string())), |
| 111 | person: person_token.map(|t| Client::new(server, Some(t.to_string()))), |
| 112 | cache, |
| 113 | names: Vec::new(), |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /// The client for an actor: in person needs the person's token. |
| 118 | fn client(&self, by: &Actor) -> R<&Client> { |
| 119 | if by.agent.is_none() { |
| 120 | return self.person.as_ref().ok_or_else(|| "this is a decision made in person, and no person token is configured".to_string()); |
| 121 | } |
| 122 | Ok(&self.agent) |
| 123 | } |
| 124 | |
| 125 | /// Send every object under `tree` the server doesn't have. |
| 126 | fn upload(&self, tree: &Id) -> R<()> { |
| 127 | let all = self.cache.ids_under(tree).map_err(|e| e.to_string())?; |
| 128 | for chunk in all.chunks(BATCH) { |
| 129 | let ids: Vec<Value> = chunk.iter().map(|i| Value::Str(hex::encode(i))).collect(); |
| 130 | let resp = self.agent.post("/api/objects/missing", &obj([("ids", Value::Array(ids))]))?; |
| 131 | let missing = resp.get("missing").and_then(Value::as_array).unwrap_or(&[]); |
| 132 | if missing.is_empty() { |
| 133 | continue; |
| 134 | } |
| 135 | let raws = missing |
| 136 | .iter() |
| 137 | .map(|m| { |
| 138 | let id: Id = hex::decode(m.as_str().unwrap_or("")).and_then(|b| b.try_into().ok()).ok_or("bad id from server")?; |
| 139 | self.cache.get_raw(&id).map_err(|e| e.to_string()) |
| 140 | }) |
| 141 | .collect::<R<Vec<_>>>()?; |
| 142 | self.agent.post_bytes("/api/objects", &wire::frame_objects(&raws), "application/octet-stream")?; |
| 143 | } |
| 144 | Ok(()) |
| 145 | } |
| 146 | |
| 147 | fn path(owner: &str, name: &str, rest: &str) -> String { |
| 148 | format!("/api/projects/{owner}/{name}{rest}") |
| 149 | } |
| 150 | |
| 151 | fn num(v: &Value, k: &str) -> R<u64> { |
| 152 | v.get(k).and_then(Value::as_i64).map(|n| n as u64).ok_or_else(|| format!("server sent no {k}")) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | impl Backend for Remote { |
| 157 | fn objects(&self) -> &Objects { |
| 158 | &self.cache |
| 159 | } |
| 160 | |
| 161 | fn need(&mut self, tree: &Id) -> R<()> { |
| 162 | if self.cache.has(tree) && self.cache.ids_under(tree).is_ok() { |
| 163 | return Ok(()); |
| 164 | } |
| 165 | let list = self.agent.post("/api/objects/list", &obj([("root", Value::Str(hex::encode(tree)))]))?; |
| 166 | let wanted: Vec<Value> = list |
| 167 | .get("ids") |
| 168 | .and_then(Value::as_array) |
| 169 | .unwrap_or(&[]) |
| 170 | .iter() |
| 171 | .filter(|v| hex::decode(v.as_str().unwrap_or("")).and_then(|b| <[u8; 32]>::try_from(b).ok()).is_some_and(|id| !self.cache.has(&id))) |
| 172 | .cloned() |
| 173 | .collect(); |
| 174 | for chunk in wanted.chunks(BATCH) { |
| 175 | let (status, bytes) = self.agent.call("POST", "/api/objects/fetch", Some((obj([("ids", Value::Array(chunk.to_vec()))]).encode().as_bytes(), "application/json")))?; |
| 176 | if status != 200 { |
| 177 | return Err(format!("fetching objects failed ({status})")); |
| 178 | } |
| 179 | for raw in wire::unframe_objects(&bytes)? { |
| 180 | self.cache.put_raw(raw).map_err(|e| e.to_string())?; |
| 181 | } |
| 182 | } |
| 183 | Ok(()) |
| 184 | } |
| 185 | |
| 186 | fn project(&mut self, owner: &str, name: &str) -> R<Project> { |
| 187 | let v = self.agent.get(&Self::path(owner, name, ""))?; |
| 188 | let (p, names) = wire::project_from_json(&v)?; |
| 189 | self.names = names; |
| 190 | Ok(p) |
| 191 | } |
| 192 | |
| 193 | fn agent_name(&self, id: &str) -> Option<String> { |
| 194 | self.names.iter().find(|(k, _)| k == id).map(|(_, v)| v.clone()) |
| 195 | } |
| 196 | |
| 197 | fn create_project(&mut self, by: &Actor, owner: &str, name: &str, public: bool) -> R<()> { |
| 198 | self.client(by)?.post("/api/projects", &obj([("owner", owner.into()), ("name", name.into()), ("public", public.into())]))?; |
| 199 | Ok(()) |
| 200 | } |
| 201 | |
| 202 | fn record_point(&mut self, by: &Actor, owner: &str, name: &str, snapshot: &Id, message: &str) -> R<u64> { |
| 203 | self.upload(snapshot)?; |
| 204 | let v = self.client(by)?.post(&Self::path(owner, name, "/points"), &obj([("snapshot", Value::Str(hex::encode(snapshot))), ("message", message.into())]))?; |
| 205 | Self::num(&v, "n") |
| 206 | } |
| 207 | |
| 208 | fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> R<u64> { |
| 209 | let v = self.client(by)?.post(&Self::path(owner, name, "/changes"), &obj([("intent", intent.into())]))?; |
| 210 | Self::num(&v, "number") |
| 211 | } |
| 212 | |
| 213 | fn revise(&mut self, by: &Actor, owner: &str, name: &str, change: u64, base: u64, steps: &[Step], note: Option<&str>) -> R<u32> { |
| 214 | for st in steps { |
| 215 | self.upload(&st.snapshot)?; |
| 216 | } |
| 217 | let body = obj([ |
| 218 | ("base", base.into()), |
| 219 | ("steps", Value::Array(steps.iter().map(step_to_json).collect())), |
| 220 | ("note", note.map(str::to_string).into()), |
| 221 | ]); |
| 222 | let v = self.client(by)?.post(&Self::path(owner, name, &format!("/changes/{change}/revisions")), &body)?; |
| 223 | Self::num(&v, "number").map(|n| n as u32) |
| 224 | } |
| 225 | |
| 226 | fn set_state(&mut self, by: &Actor, owner: &str, name: &str, change: u64, to: ChangeState) -> R<()> { |
| 227 | self.client(by)?.post(&Self::path(owner, name, &format!("/changes/{change}/state")), &obj([("state", to.name().into())]))?; |
| 228 | Ok(()) |
| 229 | } |
| 230 | |
| 231 | fn land(&mut self, by: &Actor, owner: &str, name: &str, change: u64) -> R<(u64, u64)> { |
| 232 | let v = self.client(by)?.post(&Self::path(owner, name, &format!("/changes/{change}/land")), &obj([]))?; |
| 233 | Ok((Self::num(&v, "first")?, Self::num(&v, "last")?)) |
| 234 | } |
| 235 | |
| 236 | fn say(&mut self, by: &Actor, owner: &str, name: &str, change: u64, body: &str) -> R<()> { |
| 237 | self.client(by)?.post(&Self::path(owner, name, &format!("/changes/{change}/messages")), &obj([("body", body.into())]))?; |
| 238 | Ok(()) |
| 239 | } |
| 240 | |
| 241 | fn attach(&mut self, by: &Actor, owner: &str, name: &str, change: u64, revision: u32, title: &str, media_type: &str, content: Content) -> R<()> { |
| 242 | let (blob, url) = match content { |
| 243 | Content::Bytes(bytes) => { |
| 244 | let id = self.cache.put_blob(bytes).map_err(|e| e.to_string())?; |
| 245 | let raw = self.cache.get_raw(&id).map_err(|e| e.to_string())?; |
| 246 | self.agent.post_bytes("/api/objects", &wire::frame_objects(&[raw]), "application/octet-stream")?; |
| 247 | (Value::Str(hex::encode(&id)), Value::Null) |
| 248 | } |
| 249 | Content::Blob(id) => (Value::Str(hex::encode(&id)), Value::Null), |
| 250 | Content::Url(u) => (Value::Null, Value::from(u)), |
| 251 | }; |
| 252 | let body = obj([("revision", (revision as u64).into()), ("title", title.into()), ("type", media_type.into()), ("blob", blob), ("url", url)]); |
| 253 | self.client(by)?.post(&Self::path(owner, name, &format!("/changes/{change}/evidence")), &body)?; |
| 254 | Ok(()) |
| 255 | } |
| 256 | |
| 257 | fn create_group(&mut self, by: &Actor, name: &str) -> R<()> { |
| 258 | self.client(by)?.post("/api/groups", &obj([("name", name.into())]))?; |
| 259 | Ok(()) |
| 260 | } |
| 261 | |
| 262 | fn add_member(&mut self, by: &Actor, group: &str, person: &str) -> R<()> { |
| 263 | self.client(by)?.post(&format!("/api/groups/{group}/members"), &obj([("person", person.into())]))?; |
| 264 | Ok(()) |
| 265 | } |
| 266 | |
| 267 | fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> R<()> { |
| 268 | self.client(by)?.post(&Self::path(owner, name, "/move"), &obj([("to", to.into())]))?; |
| 269 | Ok(()) |
| 270 | } |
| 271 | } |