@6 Server: HTTP API, tokens for people and agents, remote CLI, deploy to grove.host
Files
adeploy/Dockerfile+12 −0adeploy/deploy.sh+19 −0adeploy/grove.caddy+17 −0asrc/api.rs+251 −0asrc/backend.rs+271 −0msrc/cli.rs+289 −167asrc/client.rs+93 −0asrc/http.rs+164 −0msrc/lib.rs+5 −0msrc/model.rs+45 −0msrc/objects.rs+49 −0msrc/store.rs+24 −0asrc/wire.rs+316 −0
deploy/Dockerfileadded+12 −0
| 1 | # grove server. Built on grove.host by deploy.sh; the store lives in /data (a host volume). | |
| 2 | FROM rust:1-alpine AS build | |
| 3 | WORKDIR /src | |
| 4 | COPY Cargo.toml ./ | |
| 5 | COPY src ./src | |
| 6 | RUN cargo build --release | |
| 7 | ||
| 8 | FROM alpine:3 | |
| 9 | COPY --from=build /src/target/release/grove /usr/local/bin/grove | |
| 10 | ENV GROVE_STORE=/data | |
| 11 | EXPOSE 4800 | |
| 12 | CMD ["grove", "serve", "--addr", "0.0.0.0:4800"] |
deploy/deploy.shadded+19 −0
| 1 | #!/bin/sh | |
| 2 | # Build and run grove on grove.host. Run from the project root: deploy/deploy.sh | |
| 3 | # The server's store is /opt/grove2/data; it's never overwritten here. | |
| 4 | set -eu | |
| 5 | HOST=${GROVE_HOST:-root@grove.host} | |
| 6 | ||
| 7 | rsync -a --delete --exclude target --exclude .grove ./ "$HOST:/opt/grove2/src/" | |
| 8 | ssh "$HOST" sh -eu <<'REMOTE' | |
| 9 | cd /opt/grove2/src | |
| 10 | docker build -q -t grove2 -f deploy/Dockerfile . >/dev/null | |
| 11 | mkdir -p /opt/grove2/data | |
| 12 | docker rm -f grove2 >/dev/null 2>&1 || true | |
| 13 | docker run -d --name grove2 --restart unless-stopped --network grove_default -v /opt/grove2/data:/data grove2 >/dev/null | |
| 14 | cp deploy/grove.caddy /opt/grove/sites/grove.caddy | |
| 15 | docker exec grove-caddy-1 caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile | |
| 16 | sleep 1 | |
| 17 | docker exec grove2 wget -qO- http://127.0.0.1:4800/api/health | |
| 18 | echo | |
| 19 | REMOTE |
deploy/grove.caddyadded+17 −0
| 1 | # grove's API. Copied to /opt/grove/sites/grove.caddy by deploy.sh. | |
| 2 | # Cloudflare IP ranges: https://www.cloudflare.com/ips/ (as in rally.caddy). | |
| 3 | grove.letterpresslabs.ca { | |
| 4 | @cloudflare remote_ip 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32 | |
| 5 | handle @cloudflare { | |
| 6 | reverse_proxy grove2:4800 { | |
| 7 | header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} | |
| 8 | } | |
| 9 | } | |
| 10 | handle { | |
| 11 | reverse_proxy grove2:4800 | |
| 12 | } | |
| 13 | header { | |
| 14 | X-Content-Type-Options nosniff | |
| 15 | Strict-Transport-Security "max-age=31536000" | |
| 16 | } | |
| 17 | } |
src/api.rsadded+251 −0
| 1 | //! The HTTP API: the store's operations, with the actor taken from the request's token. | |
| 2 | //! | |
| 3 | //! `Authorization: Bearer <token>`. A person's token can do anything that person can; an | |
| 4 | //! agent's token acts as that agent, so the model refuses it the decisions a person must make | |
| 5 | //! in person (landing, declining, groups, tokens). Public projects can be read without a token. | |
| 6 | ||
| 7 | use crate::http::{Request, Response}; | |
| 8 | use crate::json::{self, obj, Value}; | |
| 9 | use crate::model::{Actor, ChangeState, Step}; | |
| 10 | use crate::objects::{Id, Objects}; | |
| 11 | use crate::store::{self, Content, Store}; | |
| 12 | use crate::{hex, wire}; | |
| 13 | use std::sync::Mutex; | |
| 14 | ||
| 15 | pub struct Api { | |
| 16 | store: Mutex<Store>, | |
| 17 | /// Object reads and writes don't need the store's lock. | |
| 18 | objects: Objects, | |
| 19 | } | |
| 20 | ||
| 21 | type Out = Result<Response, Response>; | |
| 22 | ||
| 23 | fn err(status: u16, msg: impl AsRef<str>) -> Response { | |
| 24 | Response::error(status, msg.as_ref()) | |
| 25 | } | |
| 26 | ||
| 27 | fn from_store(e: store::Error) -> Response { | |
| 28 | match e { | |
| 29 | store::Error::Refused(m) => err(400, m), | |
| 30 | store::Error::Behind { .. } => err(409, e.to_string()), | |
| 31 | other => err(500, other.to_string()), | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | fn body(req: &Request) -> Result<Value, Response> { | |
| 36 | if req.body.is_empty() { | |
| 37 | return Ok(Value::Object(Vec::new())); | |
| 38 | } | |
| 39 | let text = std::str::from_utf8(&req.body).map_err(|_| err(400, "body isn't UTF-8"))?; | |
| 40 | json::parse(text).map_err(|e| err(400, e.to_string())) | |
| 41 | } | |
| 42 | ||
| 43 | fn field<'a>(v: &'a Value, k: &str) -> Result<&'a str, Response> { | |
| 44 | v.get(k).and_then(Value::as_str).ok_or_else(|| err(400, format!("missing {k}"))) | |
| 45 | } | |
| 46 | ||
| 47 | fn num(v: &Value, k: &str) -> Result<u64, Response> { | |
| 48 | v.get(k).and_then(Value::as_i64).filter(|n| *n >= 0).map(|n| n as u64).ok_or_else(|| err(400, format!("missing {k}"))) | |
| 49 | } | |
| 50 | ||
| 51 | fn parse_id(h: &str) -> Result<Id, Response> { | |
| 52 | hex::decode(h).and_then(|b| b.try_into().ok()).ok_or_else(|| err(400, format!("{h:?} isn't an id"))) | |
| 53 | } | |
| 54 | ||
| 55 | fn ids(v: &Value, k: &str) -> Result<Vec<Id>, Response> { | |
| 56 | v.get(k).and_then(Value::as_array).ok_or_else(|| err(400, format!("missing {k}")))?.iter().map(|x| parse_id(x.as_str().unwrap_or(""))).collect() | |
| 57 | } | |
| 58 | ||
| 59 | fn ok(v: Value) -> Out { | |
| 60 | Ok(Response::json(200, &v)) | |
| 61 | } | |
| 62 | ||
| 63 | impl Api { | |
| 64 | pub fn new(store: Store) -> std::io::Result<Api> { | |
| 65 | let objects = Objects::open(&store.dir().join("objects")).map_err(|e| std::io::Error::other(e.to_string()))?; | |
| 66 | Ok(Api { store: Mutex::new(store), objects }) | |
| 67 | } | |
| 68 | ||
| 69 | pub fn handle(&self, req: Request) -> Response { | |
| 70 | match self.route(&req) { | |
| 71 | Ok(r) | Err(r) => r, | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | fn actor(&self, req: &Request) -> Result<Option<Actor>, Response> { | |
| 76 | let Some(h) = req.header("authorization") else { return Ok(None) }; | |
| 77 | let token = h.strip_prefix("Bearer ").ok_or_else(|| err(401, "use Authorization: Bearer <token>"))?; | |
| 78 | let store = self.store.lock().unwrap(); | |
| 79 | store.authenticate(token.trim()).map(Some).ok_or_else(|| err(401, "unknown or revoked token")) | |
| 80 | } | |
| 81 | ||
| 82 | fn required(&self, req: &Request) -> Result<Actor, Response> { | |
| 83 | self.actor(req)?.ok_or_else(|| err(401, "sign in: send a token")) | |
| 84 | } | |
| 85 | ||
| 86 | fn route(&self, req: &Request) -> Out { | |
| 87 | let segs: Vec<&str> = req.path.trim_matches('/').split('/').collect(); | |
| 88 | let m = req.method.as_str(); | |
| 89 | match (m, segs.as_slice()) { | |
| 90 | ("GET", ["api", "health"]) => ok(obj([("ok", true.into())])), | |
| 91 | ("GET", ["api", "whoami"]) => { | |
| 92 | let a = self.required(req)?; | |
| 93 | let store = self.store.lock().unwrap(); | |
| 94 | let agent_name = a.agent.as_ref().and_then(|id| store.state().agent(id)).map(|g| g.name.clone()); | |
| 95 | ok(obj([("person", a.person.clone().into()), ("agent", a.agent.clone().into()), ("agent_name", agent_name.into())])) | |
| 96 | } | |
| 97 | ||
| 98 | // ── Objects ── | |
| 99 | ("POST", ["api", "objects", "missing"]) => { | |
| 100 | self.required(req)?; | |
| 101 | let wanted = ids(&body(req)?, "ids")?; | |
| 102 | let missing: Vec<Value> = wanted.iter().filter(|i| !self.objects.has(i)).map(|i| Value::Str(hex::encode(i))).collect(); | |
| 103 | ok(obj([("missing", Value::Array(missing))])) | |
| 104 | } | |
| 105 | ("POST", ["api", "objects"]) => { | |
| 106 | self.required(req)?; | |
| 107 | let mut stored = 0u64; | |
| 108 | for raw in wire::unframe_objects(&req.body).map_err(|e| err(400, e))? { | |
| 109 | self.objects.put_raw(raw).map_err(|e| err(400, e.to_string()))?; | |
| 110 | stored += 1; | |
| 111 | } | |
| 112 | ok(obj([("stored", stored.into())])) | |
| 113 | } | |
| 114 | ("POST", ["api", "objects", "list"]) => { | |
| 115 | let root = parse_id(field(&body(req)?, "root")?)?; | |
| 116 | let all = self.objects.ids_under(&root).map_err(|e| err(404, e.to_string()))?; | |
| 117 | ok(obj([("ids", Value::Array(all.iter().map(|i| Value::Str(hex::encode(i))).collect()))])) | |
| 118 | } | |
| 119 | ("POST", ["api", "objects", "fetch"]) => { | |
| 120 | let wanted = ids(&body(req)?, "ids")?; | |
| 121 | let raws = wanted.iter().map(|i| self.objects.get_raw(i)).collect::<Result<Vec<_>, _>>().map_err(|e| err(404, e.to_string()))?; | |
| 122 | Ok(Response::bytes(wire::frame_objects(&raws))) | |
| 123 | } | |
| 124 | ||
| 125 | // ── People, agents, tokens, groups ── | |
| 126 | ("POST", ["api", "agents"]) => { | |
| 127 | let a = self.required(req)?; | |
| 128 | if a.agent.is_some() { | |
| 129 | return Err(err(403, "agents are registered in person")); | |
| 130 | } | |
| 131 | let name = field(&body(req)?, "name")?.to_string(); | |
| 132 | let id = self.store.lock().unwrap().add_agent(&a.person, &name).map_err(from_store)?; | |
| 133 | ok(obj([("id", id.into())])) | |
| 134 | } | |
| 135 | ("POST", ["api", "tokens"]) => { | |
| 136 | let a = self.required(req)?; | |
| 137 | if a.agent.is_some() { | |
| 138 | return Err(err(403, "tokens are issued in person")); | |
| 139 | } | |
| 140 | let b = body(req)?; | |
| 141 | let agent = b.get("agent").and_then(Value::as_str).map(str::to_string); | |
| 142 | let token = self.store.lock().unwrap().issue_token(&a.person, agent.as_deref()).map_err(from_store)?; | |
| 143 | ok(obj([("token", token.into())])) | |
| 144 | } | |
| 145 | ("POST", ["api", "groups"]) => { | |
| 146 | let a = self.required(req)?; | |
| 147 | let name = field(&body(req)?, "name")?.to_string(); | |
| 148 | self.store.lock().unwrap().create_group(&a, &name).map_err(from_store)?; | |
| 149 | ok(obj([("name", name.into())])) | |
| 150 | } | |
| 151 | ("POST", ["api", "groups", group, "members"]) => { | |
| 152 | let a = self.required(req)?; | |
| 153 | let person = field(&body(req)?, "person")?.to_string(); | |
| 154 | self.store.lock().unwrap().add_member(&a, group, &person).map_err(from_store)?; | |
| 155 | ok(obj([("added", person.into())])) | |
| 156 | } | |
| 157 | ||
| 158 | // ── Projects ── | |
| 159 | ("POST", ["api", "projects"]) => { | |
| 160 | let a = self.required(req)?; | |
| 161 | let b = body(req)?; | |
| 162 | let owner = b.get("owner").and_then(Value::as_str).unwrap_or(&a.person).to_string(); | |
| 163 | let name = field(&b, "name")?.to_string(); | |
| 164 | let public = !matches!(b.get("public"), Some(Value::Bool(false))); | |
| 165 | self.store.lock().unwrap().create_project(&a, &owner, &name, public).map_err(from_store)?; | |
| 166 | ok(obj([("owner", owner.into()), ("name", name.into())])) | |
| 167 | } | |
| 168 | (_, ["api", "projects", owner, name, rest @ ..]) => self.project_route(req, m, owner, name, rest), | |
| 169 | _ => Err(err(404, "no such endpoint")), | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | fn project_route(&self, req: &Request, m: &str, owner: &str, name: &str, rest: &[&str]) -> Out { | |
| 174 | let actor = self.actor(req)?; | |
| 175 | let mut store = self.store.lock().unwrap(); | |
| 176 | { | |
| 177 | let p = store.project(owner, name).map_err(|_| err(404, format!("no project {owner}/{name}")))?; | |
| 178 | let can_read = p.public || actor.as_ref().is_some_and(|a| store.state().owns(&p.owner, &a.person)); | |
| 179 | if !can_read { | |
| 180 | return Err(err(404, format!("no project {owner}/{name}"))); | |
| 181 | } | |
| 182 | } | |
| 183 | if m == "GET" && rest.is_empty() { | |
| 184 | let p = store.project(owner, name).unwrap(); | |
| 185 | return ok(wire::project_json(store.state(), p)); | |
| 186 | } | |
| 187 | let a = actor.ok_or_else(|| err(401, "sign in: send a token"))?; | |
| 188 | let b = body(req)?; | |
| 189 | match (m, rest) { | |
| 190 | ("POST", ["points"]) => { | |
| 191 | let snapshot = parse_id(field(&b, "snapshot")?)?; | |
| 192 | let message = field(&b, "message")?.to_string(); | |
| 193 | let n = store.record_point(&a, owner, name, &snapshot, &message, None).map_err(from_store)?; | |
| 194 | ok(obj([("n", n.into())])) | |
| 195 | } | |
| 196 | ("POST", ["move"]) => { | |
| 197 | let to = field(&b, "to")?.to_string(); | |
| 198 | store.move_project(&a, owner, name, &to).map_err(from_store)?; | |
| 199 | ok(obj([("owner", to.into())])) | |
| 200 | } | |
| 201 | ("POST", ["changes"]) => { | |
| 202 | let intent = field(&b, "intent")?.to_string(); | |
| 203 | let n = store.open_change(&a, owner, name, &intent).map_err(from_store)?; | |
| 204 | ok(obj([("number", n.into())])) | |
| 205 | } | |
| 206 | ("POST", ["changes", c, sub]) => { | |
| 207 | let c: u64 = c.parse().map_err(|_| err(400, "bad change number"))?; | |
| 208 | match *sub { | |
| 209 | "revisions" => { | |
| 210 | let base = num(&b, "base")?; | |
| 211 | let steps = b | |
| 212 | .get("steps") | |
| 213 | .and_then(Value::as_array) | |
| 214 | .ok_or_else(|| err(400, "missing steps"))? | |
| 215 | .iter() | |
| 216 | .map(|st| Ok(Step { id: field(st, "id")?.to_string(), snapshot: parse_id(field(st, "snapshot")?)?, message: field(st, "message")?.to_string() })) | |
| 217 | .collect::<Result<Vec<_>, Response>>()?; | |
| 218 | let note = b.get("note").and_then(Value::as_str); | |
| 219 | let n = store.revise(&a, owner, name, c, base, &steps, note).map_err(from_store)?; | |
| 220 | ok(obj([("number", (n as u64).into())])) | |
| 221 | } | |
| 222 | "state" => { | |
| 223 | let to = ChangeState::parse(field(&b, "state")?).ok_or_else(|| err(400, "bad state"))?; | |
| 224 | store.set_state(&a, owner, name, c, to).map_err(from_store)?; | |
| 225 | ok(obj([("state", to.name().into())])) | |
| 226 | } | |
| 227 | "land" => { | |
| 228 | let (first, last) = store.land(&a, owner, name, c).map_err(from_store)?; | |
| 229 | ok(obj([("first", first.into()), ("last", last.into())])) | |
| 230 | } | |
| 231 | "messages" => { | |
| 232 | store.say(&a, owner, name, c, field(&b, "body")?).map_err(from_store)?; | |
| 233 | ok(obj([("ok", true.into())])) | |
| 234 | } | |
| 235 | "evidence" => { | |
| 236 | let revision = num(&b, "revision")? as u32; | |
| 237 | let content = match (b.get("blob").and_then(Value::as_str), b.get("url").and_then(Value::as_str)) { | |
| 238 | (Some(blob), None) => Content::Blob(parse_id(blob)?), | |
| 239 | (None, Some(url)) => Content::Url(url), | |
| 240 | _ => return Err(err(400, "evidence is a blob or a url")), | |
| 241 | }; | |
| 242 | store.attach(&a, owner, name, c, revision, field(&b, "title")?, field(&b, "type")?, content).map_err(from_store)?; | |
| 243 | ok(obj([("ok", true.into())])) | |
| 244 | } | |
| 245 | _ => Err(err(404, "no such endpoint")), | |
| 246 | } | |
| 247 | } | |
| 248 | _ => Err(err(404, "no such endpoint")), | |
| 249 | } | |
| 250 | } | |
| 251 | } |
src/backend.rsadded+271 −0
| 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 | } |
src/cli.rsmodified+289 −167
| 1 | //! The `grove` command. For now it works directly on a store on this machine; the same | |
| 2 | //! operations will go over HTTP once grove runs as a server. | |
| 1 | //! The `grove` command, against a store on this machine or a grove server. | |
| 3 | 2 | //! |
| 4 | //! Who's acting: `~/.grove2/config.json` names a person and, usually, the agent acting for them. | |
| 5 | //! (`~/.grove2`, not `~/.grove`: the old grove CLI keeps its own config in `~/.grove`.) | |
| 6 | //! Every command acts as that agent, except `land` and `decline`, which are decisions and act | |
| 7 | //! as the person. Locally this rests on trust; on a server, tokens will enforce it. | |
| 3 | //! `~/.grove2/config.json` (not `~/.grove`, which the old grove CLI uses) says who's acting and | |
| 4 | //! where. With `server` and `token` set, commands go to that server, acting as the token's | |
| 5 | //! agent; `person_token` is used for decisions made in person (land, decline, groups). Without | |
| 6 | //! a server, commands work on the local store at `store`. Environment variables override the | |
| 7 | //! file: GROVE_STORE, GROVE_SERVER, GROVE_TOKEN, GROVE_PERSON_TOKEN, GROVE_PERSON, GROVE_AGENT. | |
| 8 | 8 | |
| 9 | use crate::backend::{Backend, Local as LocalStore, Remote}; | |
| 9 | 10 | use crate::diff; |
| 10 | 11 | use crate::json::{self, obj, Value}; |
| 11 | 12 | use crate::model::{Actor, Change, ChangeState, Event, Project, Step}; |
| 12 | use crate::objects::Id; | |
| 13 | use crate::objects::{Id, Objects}; | |
| 13 | 14 | use crate::store::{self, Content, Store}; |
| 14 | 15 | use crate::workdir::{self, FileChange}; |
| 15 | 16 | use crate::{hex, random}; |
| 61 | 62 | } |
| 62 | 63 | } |
| 63 | 64 | |
| 64 | // ── Config and working directory state ───────────────────── | |
| 65 | // ── Config ───────────────────────────────────────────────── | |
| 65 | 66 | |
| 66 | 67 | fn home() -> PathBuf { |
| 67 | 68 | PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())) |
| 76 | 77 | } |
| 77 | 78 | |
| 78 | 79 | struct Config { |
| 79 | store: PathBuf, | |
| 80 | store: Option<PathBuf>, | |
| 81 | server: Option<String>, | |
| 82 | token: Option<String>, | |
| 83 | person_token: Option<String>, | |
| 80 | 84 | person: String, |
| 81 | 85 | agent: Option<String>, |
| 82 | 86 | } |
| 83 | 87 | |
| 84 | 88 | impl Config { |
| 85 | 89 | fn load() -> Result<Config> { |
| 86 | let text = fs::read_to_string(config_path()).map_err(|_| "not set up yet: run `grove setup <username>`".to_string())?; | |
| 87 | let v = json::parse(&text).map_err(|e| format!("{}: {e}", config_path().display()))?; | |
| 88 | let store = std::env::var("GROVE_STORE").ok().or_else(|| v.get("store").and_then(Value::as_str).map(str::to_string)).ok_or("config has no store")?; | |
| 90 | let file = match fs::read_to_string(config_path()) { | |
| 91 | Ok(text) => json::parse(&text).map_err(|e| format!("{}: {e}", config_path().display()))?, | |
| 92 | Err(_) => Value::Object(Vec::new()), | |
| 93 | }; | |
| 94 | // An empty value (in the environment or the file) counts as not set. | |
| 95 | let get = |env: &str, key: &str| { | |
| 96 | std::env::var(env).ok().or_else(|| file.get(key).and_then(Value::as_str).map(str::to_string)).filter(|v| !v.is_empty()) | |
| 97 | }; | |
| 89 | 98 | Ok(Config { |
| 90 | store: PathBuf::from(store), | |
| 91 | person: v.get("person").and_then(Value::as_str).ok_or("config has no person")?.to_string(), | |
| 92 | agent: v.get("agent").and_then(Value::as_str).map(str::to_string), | |
| 99 | store: get("GROVE_STORE", "store").map(PathBuf::from), | |
| 100 | server: get("GROVE_SERVER", "server"), | |
| 101 | token: get("GROVE_TOKEN", "token"), | |
| 102 | person_token: get("GROVE_PERSON_TOKEN", "person_token"), | |
| 103 | person: get("GROVE_PERSON", "person").ok_or("not set up yet: run `grove setup <username>` or `grove login <server>`")?, | |
| 104 | agent: get("GROVE_AGENT", "agent"), | |
| 93 | 105 | }) |
| 94 | 106 | } |
| 95 | 107 | fn actor(&self) -> Actor { |
| 98 | 110 | fn in_person(&self) -> Actor { |
| 99 | 111 | Actor::person(&self.person) |
| 100 | 112 | } |
| 113 | fn store(&self) -> Result<Store> { | |
| 114 | let dir = self.store.as_ref().ok_or("no local store configured")?; | |
| 115 | let store = Store::open(dir).map_err(|e| e.to_string())?; | |
| 116 | if store.recovery.truncated > 0 { | |
| 117 | eprintln!("note: recovered from an interrupted write ({} bytes discarded)", store.recovery.truncated); | |
| 118 | } | |
| 119 | Ok(store) | |
| 120 | } | |
| 101 | 121 | } |
| 102 | 122 | |
| 123 | /// The server, if one is configured; otherwise the local store. | |
| 124 | fn connect(cfg: &Config) -> Result<Box<dyn Backend>> { | |
| 125 | match (&cfg.server, &cfg.token) { | |
| 126 | (Some(server), Some(token)) => { | |
| 127 | let cache = Objects::open(&grove_home().join("cache")).map_err(|e| e.to_string())?; | |
| 128 | Ok(Box::new(Remote::new(server, token, cfg.person_token.as_deref(), cache))) | |
| 129 | } | |
| 130 | (Some(_), None) => e!("a server is configured but no token; run `grove login`"), | |
| 131 | _ => Ok(Box::new(LocalStore(cfg.store()?))), | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | fn write_config(fields: Vec<(&str, Value)>) -> Result<()> { | |
| 136 | let v = Value::Object(fields.into_iter().filter(|(_, v)| !v.is_null()).map(|(k, v)| (k.to_string(), v)).collect()); | |
| 137 | fs::create_dir_all(grove_home()).map_err(|e| e.to_string())?; | |
| 138 | let path = config_path(); | |
| 139 | fs::write(&path, v.encode() + "\n").map_err(|e| e.to_string())?; | |
| 140 | #[cfg(unix)] | |
| 141 | { | |
| 142 | use std::os::unix::fs::PermissionsExt; | |
| 143 | let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); | |
| 144 | } | |
| 145 | Ok(()) | |
| 146 | } | |
| 147 | ||
| 148 | /// Never overwrite a config grove didn't write. | |
| 149 | fn check_config_is_ours() -> Result<()> { | |
| 150 | if let Ok(existing) = fs::read_to_string(config_path()) { | |
| 151 | let ours = json::parse(&existing).map(|v| v.get("person").is_some()).unwrap_or(false); | |
| 152 | if !ours { | |
| 153 | return e!("{} exists and isn't a grove config; move it aside first", config_path().display()); | |
| 154 | } | |
| 155 | } | |
| 156 | Ok(()) | |
| 157 | } | |
| 158 | ||
| 159 | // ── Working directory state ──────────────────────────────── | |
| 160 | ||
| 103 | 161 | /// A working directory's local state: which project, which point it's on, and the steps |
| 104 | 162 | /// not yet sent anywhere. |
| 105 | 163 | struct Local { |
| 135 | 193 | .unwrap_or(&[]) |
| 136 | 194 | .iter() |
| 137 | 195 | .map(|s| { |
| 138 | let id = |k: &str| -> Result<Id> { | |
| 139 | let h = s.get(k).and_then(Value::as_str).ok_or("bad step")?; | |
| 140 | hex::decode(h).and_then(|b| b.try_into().ok()).ok_or_else(|| "bad step".to_string()) | |
| 141 | }; | |
| 196 | let h = s.get("snapshot").and_then(Value::as_str).ok_or("bad step")?; | |
| 142 | 197 | Ok(Step { |
| 143 | 198 | id: s.get("id").and_then(Value::as_str).ok_or("bad step")?.to_string(), |
| 144 | snapshot: id("snapshot")?, | |
| 199 | snapshot: hex::decode(h).and_then(|b| b.try_into().ok()).ok_or("bad step")?, | |
| 145 | 200 | message: s.get("message").and_then(Value::as_str).unwrap_or("").to_string(), |
| 146 | 201 | }) |
| 147 | 202 | }) |
| 177 | 232 | None => project.point(self.base).map(|p| p.snapshot).ok_or_else(|| format!("point {} doesn't exist", self.base)), |
| 178 | 233 | } |
| 179 | 234 | } |
| 180 | } | |
| 181 | 235 | |
| 182 | fn open_store(cfg: &Config) -> Result<Store> { | |
| 183 | let store = Store::open(&cfg.store).map_err(|e| e.to_string())?; | |
| 184 | if store.recovery.truncated > 0 { | |
| 185 | eprintln!("note: recovered from an interrupted write ({} bytes discarded)", store.recovery.truncated); | |
| 236 | fn project(&self, b: &mut dyn Backend) -> Result<Project> { | |
| 237 | b.project(&self.owner, &self.project) | |
| 186 | 238 | } |
| 187 | Ok(store) | |
| 188 | 239 | } |
| 189 | 240 | |
| 190 | fn s<T>(r: store::Result<T>) -> Result<T> { | |
| 191 | r.map_err(|e| e.to_string()) | |
| 192 | } | |
| 193 | ||
| 194 | 241 | // ── Formatting ───────────────────────────────────────────── |
| 195 | 242 | |
| 196 | 243 | fn ago(ms: i64) -> String { |
| 204 | 251 | } |
| 205 | 252 | |
| 206 | 253 | /// "claude for anton", using the agent's name rather than its id. |
| 207 | fn who(store: &Store, a: &Actor) -> String { | |
| 254 | fn who(b: &dyn Backend, a: &Actor) -> String { | |
| 208 | 255 | match &a.agent { |
| 209 | Some(id) => format!("{} for {}", store.state().agent(id).map(|g| g.name.as_str()).unwrap_or(id), a.person), | |
| 256 | Some(id) => format!("{} for {}", b.agent_name(id).unwrap_or_else(|| id.clone()), a.person), | |
| 210 | 257 | None => a.person.clone(), |
| 211 | 258 | } |
| 212 | 259 | } |
| 215 | 262 | hex::encode(&id[..6]) |
| 216 | 263 | } |
| 217 | 264 | |
| 265 | fn plural(n: usize, word: &str) -> String { | |
| 266 | format!("{n} {word}{}", if n == 1 { "" } else { "s" }) | |
| 267 | } | |
| 268 | ||
| 218 | 269 | fn change_number(arg: Option<&str>, local: Option<&Local>) -> Result<u64> { |
| 219 | 270 | match arg { |
| 220 | 271 | Some(a) => a.trim_start_matches('#').parse().map_err(|_| format!("{a:?} isn't a change number")), |
| 222 | 273 | } |
| 223 | 274 | } |
| 224 | 275 | |
| 276 | fn err<E: std::fmt::Display>(e: E) -> String { | |
| 277 | e.to_string() | |
| 278 | } | |
| 279 | ||
| 225 | 280 | // ── Commands ─────────────────────────────────────────────── |
| 226 | 281 | |
| 227 | 282 | pub fn run(argv: Vec<String>) -> Result<()> { |
| 228 | 283 | let cmd = argv.get(1).map(String::as_str).unwrap_or("help"); |
| 229 | 284 | let rest = &argv[argv.len().min(2)..]; |
| 230 | let args = Args::parse(rest, &["-m", "--message", "--note", "--agent", "--display", "--store", "--type", "--title", "--url", "--owner"]); | |
| 285 | let args = Args::parse( | |
| 286 | rest, | |
| 287 | &["-m", "--message", "--note", "--agent", "--display", "--store", "--type", "--title", "--url", "--owner", "--token", "--person-token", "--addr"], | |
| 288 | ); | |
| 231 | 289 | match cmd { |
| 232 | 290 | "setup" => setup(&args), |
| 291 | "login" => login(&args), | |
| 233 | 292 | "init" => init(&args), |
| 234 | 293 | "checkout" => checkout(&args), |
| 235 | 294 | "status" | "st" => status(), |
| 247 | 306 | "log" => log(), |
| 248 | 307 | "sync" => sync(), |
| 249 | 308 | "restart" => restart(), |
| 250 | "verify" => verify(), | |
| 251 | 309 | "group" => group(&args), |
| 252 | 310 | "move" => move_project(&args), |
| 311 | "agent" => agent_cmd(&args), | |
| 312 | "token" => token_cmd(&args), | |
| 313 | "verify" => verify(), | |
| 314 | "serve" => serve(&args), | |
| 253 | 315 | "help" | "--help" | "-h" => { |
| 254 | 316 | print!("{HELP}"); |
| 255 | 317 | Ok(()) |
| 260 | 322 | |
| 261 | 323 | const HELP: &str = "grove: where agents build software for people |
| 262 | 324 | |
| 263 | setup <username> [--agent NAME] [--store DIR] who you are, and the agent acting for you | |
| 325 | login <server> --token T [--person-token P] work against a grove server | |
| 264 | 326 | init <name> [--owner GROUP] [--private] [-m M] make this folder a new project |
| 265 | 327 | checkout <owner/name> [DIR] get a project's files |
| 266 | 328 | status what changed since the last step |
| 267 | diff [--stat] [#N [rM]] working changes, or a change's revision, line by line | |
| 329 | diff [--stat] [#N [rM]] working changes, or a change's revision | |
| 268 | 330 | step -m MESSAGE record the working directory as a new step |
| 269 | 331 | amend [-m MESSAGE] replace the last step with the working directory |
| 270 | 332 | stack the local steps |
| 271 | 333 | propose -m INTENT [--note NOTE] send the steps as a change (or a new revision) |
| 272 | 334 | say [#N] TEXT add to a change's discussion |
| 273 | attach [#N] FILE [--type TYPE] [--title T] attach evidence to the latest revision | |
| 335 | attach [#N] FILE [--type T] [--title T] | --url U evidence for the latest revision | |
| 274 | 336 | changes a project's changes |
| 275 | 337 | show [#N] one change: intent, revisions, discussion |
| 276 | land #N land a change (you, in person) | |
| 277 | decline #N decline a change (you, in person) | |
| 338 | land #N land a change (in person) | |
| 339 | decline #N decline a change (in person) | |
| 278 | 340 | log main, newest first |
| 279 | 341 | sync move the working directory to the newest main |
| 280 | restart move to the newest main, dropping proposed local steps | |
| 281 | verify check every stored object against its id | |
| 342 | restart move to the newest main, dropping proposed steps | |
| 282 | 343 | group create NAME | group add NAME PERSON groups own projects together (in person) |
| 283 | 344 | move OWNER give this project to another owner (in person) |
| 345 | ||
| 346 | On the machine that holds a store: | |
| 347 | setup <username> [--agent NAME] [--store DIR] create a person (and agent) in a local store | |
| 348 | agent add NAME register an agent acting for you | |
| 349 | token [--agent ID] issue a token for you or one of your agents | |
| 350 | serve [--addr HOST:PORT] run the HTTP API over the local store | |
| 351 | verify check every stored object against its id | |
| 284 | 352 | "; |
| 285 | 353 | |
| 286 | 354 | fn setup(args: &Args) -> Result<()> { |
| 355 | check_config_is_ours()?; | |
| 287 | 356 | let username = args.pos(0).ok_or("usage: grove setup <username> [--agent NAME]")?; |
| 288 | // Never overwrite a config grove didn't write. | |
| 289 | if let Ok(existing) = fs::read_to_string(config_path()) { | |
| 290 | let ours = json::parse(&existing).map(|v| v.get("store").is_some() && v.get("person").is_some()).unwrap_or(false); | |
| 291 | if !ours { | |
| 292 | return e!("{} exists and isn't a grove config; move it aside first", config_path().display()); | |
| 293 | } | |
| 294 | } | |
| 295 | 357 | let store_dir = args.value(&["--store"]).map(PathBuf::from).unwrap_or_else(|| grove_home().join("store")); |
| 296 | let mut store = Store::open(&store_dir).map_err(|e| e.to_string())?; | |
| 358 | let mut store = Store::open(&store_dir).map_err(err)?; | |
| 297 | 359 | if store.state().person(username).is_none() { |
| 298 | s(store.add_person(username, args.value(&["--display"]).unwrap_or(username)))?; | |
| 360 | store.add_person(username, args.value(&["--display"]).unwrap_or(username)).map_err(err)?; | |
| 299 | 361 | println!("created person {username}"); |
| 300 | 362 | } |
| 301 | 363 | let agent = match args.value(&["--agent"]) { |
| 302 | Some(name) => { | |
| 303 | let existing = store.state().agents.iter().find(|a| a.person == username && a.name == name && !a.revoked).map(|a| a.id.clone()); | |
| 304 | Some(match existing { | |
| 305 | Some(id) => id, | |
| 306 | None => { | |
| 307 | let id = s(store.add_agent(username, name))?; | |
| 308 | println!("registered agent {name} ({id}) acting for {username}"); | |
| 309 | id | |
| 310 | } | |
| 311 | }) | |
| 312 | } | |
| 364 | Some(name) => Some(ensure_agent(&mut store, username, name)?), | |
| 313 | 365 | None => None, |
| 314 | 366 | }; |
| 315 | let cfg = obj([("store", store_dir.display().to_string().into()), ("person", username.into()), ("agent", agent.into())]); | |
| 316 | fs::create_dir_all(config_path().parent().unwrap()).map_err(|e| e.to_string())?; | |
| 317 | fs::write(config_path(), cfg.encode() + "\n").map_err(|e| e.to_string())?; | |
| 367 | write_config(vec![("store", store_dir.display().to_string().into()), ("person", username.into()), ("agent", agent.into())])?; | |
| 318 | 368 | println!("config written to {}", config_path().display()); |
| 319 | 369 | Ok(()) |
| 320 | 370 | } |
| 321 | 371 | |
| 372 | fn ensure_agent(store: &mut Store, person: &str, name: &str) -> Result<String> { | |
| 373 | if let Some(a) = store.state().agents.iter().find(|a| a.person == person && a.name == name && !a.revoked) { | |
| 374 | return Ok(a.id.clone()); | |
| 375 | } | |
| 376 | let id = store.add_agent(person, name).map_err(err)?; | |
| 377 | println!("registered agent {name} ({id}) acting for {person}"); | |
| 378 | Ok(id) | |
| 379 | } | |
| 380 | ||
| 381 | fn login(args: &Args) -> Result<()> { | |
| 382 | check_config_is_ours()?; | |
| 383 | let server = args.pos(0).ok_or("usage: grove login <server> --token T [--person-token P]")?; | |
| 384 | let token = args.value(&["--token"]).ok_or("give a token with --token")?; | |
| 385 | let me = crate::client::Client::new(server, Some(token.to_string())).get("/api/whoami")?; | |
| 386 | let person = me.get("person").and_then(Value::as_str).ok_or("the server didn't say who the token is")?.to_string(); | |
| 387 | let agent = me.get("agent").and_then(Value::as_str).map(str::to_string); | |
| 388 | if let Some(pt) = args.value(&["--person-token"]) { | |
| 389 | let p = crate::client::Client::new(server, Some(pt.to_string())).get("/api/whoami")?; | |
| 390 | if p.get("person").and_then(Value::as_str) != Some(&person) || !p.get("agent").is_none_or(Value::is_null) { | |
| 391 | return e!("--person-token must be {person}'s own token, not an agent's"); | |
| 392 | } | |
| 393 | } | |
| 394 | let previous_store = Config::load().ok().and_then(|c| c.store).map(|p| p.display().to_string()); | |
| 395 | write_config(vec![ | |
| 396 | ("server", server.into()), | |
| 397 | ("token", token.into()), | |
| 398 | ("person_token", args.value(&["--person-token"]).map(str::to_string).into()), | |
| 399 | ("person", person.clone().into()), | |
| 400 | ("agent", agent.clone().into()), | |
| 401 | ("store", previous_store.into()), | |
| 402 | ])?; | |
| 403 | let as_who = match me.get("agent_name").and_then(Value::as_str) { | |
| 404 | Some(name) => format!("{name} for {person}"), | |
| 405 | None => person, | |
| 406 | }; | |
| 407 | println!("signed in to {server} as {as_who}"); | |
| 408 | Ok(()) | |
| 409 | } | |
| 410 | ||
| 322 | 411 | fn init(args: &Args) -> Result<()> { |
| 323 | 412 | let cfg = Config::load()?; |
| 324 | 413 | let name = args.pos(0).ok_or("usage: grove init <name>")?; |
| 325 | let root = std::env::current_dir().map_err(|e| e.to_string())?; | |
| 414 | let root = std::env::current_dir().map_err(err)?; | |
| 326 | 415 | if root.join(".grove").exists() { |
| 327 | 416 | return e!("this folder is already a grove working directory"); |
| 328 | 417 | } |
| 329 | let mut store = open_store(&cfg)?; | |
| 418 | let mut b = connect(&cfg)?; | |
| 330 | 419 | let me = cfg.in_person(); |
| 331 | 420 | let owner = args.value(&["--owner"]).unwrap_or(&cfg.person).to_string(); |
| 332 | s(store.create_project(&me, &owner, name, !args.has("--private")))?; | |
| 333 | let snap = workdir::snapshot(&store.objects, &root).map_err(|e| e.to_string())?; | |
| 334 | let message = args.value(&["-m", "--message"]).unwrap_or("Start"); | |
| 335 | let n = s(store.record_point(&me, &owner, name, &snap, message, Some(&cfg.actor())))?; | |
| 336 | let files = store.objects.flatten(&snap).map_err(|e| e.to_string())?.len(); | |
| 421 | b.create_project(&me, &owner, name, !args.has("--private"))?; | |
| 422 | let snap = workdir::snapshot(b.objects(), &root).map_err(err)?; | |
| 423 | let n = b.record_point(&me, &owner, name, &snap, args.value(&["-m", "--message"]).unwrap_or("Start"))?; | |
| 424 | let files = b.objects().flatten(&snap).map_err(err)?.len(); | |
| 337 | 425 | Local { root, owner: owner.clone(), project: name.to_string(), base: n, change: None, steps: Vec::new() }.save()?; |
| 338 | println!("{owner}/{name}@{n}: {files} files"); | |
| 426 | println!("{owner}/{name}@{n}: {}", plural(files, "file")); | |
| 339 | 427 | Ok(()) |
| 340 | 428 | } |
| 341 | 429 | |
| 347 | 435 | if dir.join(".grove").exists() { |
| 348 | 436 | return e!("{} is already a working directory", dir.display()); |
| 349 | 437 | } |
| 350 | let store = open_store(&cfg)?; | |
| 351 | let p = s(store.project(owner, name))?; | |
| 438 | let mut b = connect(&cfg)?; | |
| 439 | let p = b.project(owner, name)?; | |
| 352 | 440 | let tip = p.points.last().ok_or("the project has no points yet")?; |
| 353 | fs::create_dir_all(&dir).map_err(|e| e.to_string())?; | |
| 354 | workdir::materialize(&store.objects, &tip.snapshot, &dir, None).map_err(|e| e.to_string())?; | |
| 441 | b.need(&tip.snapshot)?; | |
| 442 | fs::create_dir_all(&dir).map_err(err)?; | |
| 443 | workdir::materialize(b.objects(), &tip.snapshot, &dir, None).map_err(err)?; | |
| 355 | 444 | Local { root: dir.clone(), owner: owner.into(), project: name.into(), base: tip.n, change: None, steps: Vec::new() }.save()?; |
| 356 | 445 | println!("{owner}/{name}@{} in {}", tip.n, dir.display()); |
| 357 | 446 | Ok(()) |
| 358 | 447 | } |
| 359 | 448 | |
| 360 | fn working_changes(store: &Store, local: &Local) -> Result<(Id, Id, Vec<(String, FileChange)>)> { | |
| 361 | let p = s(store.project(&local.owner, &local.project))?; | |
| 449 | fn working_changes(b: &mut dyn Backend, p: &Project, local: &Local) -> Result<(Id, Id, Vec<(String, FileChange)>)> { | |
| 362 | 450 | let current = local.current(p)?; |
| 363 | let now = workdir::snapshot(&store.objects, &local.root).map_err(|e| e.to_string())?; | |
| 364 | let changes = workdir::compare(&store.objects, ¤t, &now).map_err(|e| e.to_string())?; | |
| 451 | b.need(¤t)?; | |
| 452 | let now = workdir::snapshot(b.objects(), &local.root).map_err(err)?; | |
| 453 | let changes = workdir::compare(b.objects(), ¤t, &now).map_err(err)?; | |
| 365 | 454 | Ok((current, now, changes)) |
| 366 | 455 | } |
| 367 | 456 | |
| 368 | 457 | fn status() -> Result<()> { |
| 369 | 458 | let cfg = Config::load()?; |
| 370 | 459 | let local = Local::find()?; |
| 371 | let store = open_store(&cfg)?; | |
| 372 | let p = s(store.project(&local.owner, &local.project))?; | |
| 460 | let mut b = connect(&cfg)?; | |
| 461 | let p = local.project(&mut *b)?; | |
| 373 | 462 | let behind = p.tip().saturating_sub(local.base); |
| 374 | 463 | println!( |
| 375 | 464 | "{}/{} on @{}{}{}", |
| 380 | 469 | local.change.map(|c| format!(", change #{c}")).unwrap_or_default() |
| 381 | 470 | ); |
| 382 | 471 | if !local.steps.is_empty() { |
| 383 | println!("{} local step{}", local.steps.len(), if local.steps.len() == 1 { "" } else { "s" }); | |
| 472 | println!("{}", plural(local.steps.len(), "local step")); | |
| 384 | 473 | } |
| 385 | let (_, _, changes) = working_changes(&store, &local)?; | |
| 474 | let (_, _, changes) = working_changes(&mut *b, &p, &local)?; | |
| 386 | 475 | if changes.is_empty() { |
| 387 | 476 | println!("no changes since the last step"); |
| 388 | 477 | } |
| 397 | 486 | Ok(()) |
| 398 | 487 | } |
| 399 | 488 | |
| 400 | fn print_diff(store: &Store, a: &Id, b: &Id, stat: bool) -> Result<()> { | |
| 401 | let files_a: std::collections::BTreeMap<_, _> = store.objects.flatten(a).map_err(|e| e.to_string())?.into_iter().map(|(p, _, i)| (p, i)).collect(); | |
| 402 | let files_b: std::collections::BTreeMap<_, _> = store.objects.flatten(b).map_err(|e| e.to_string())?.into_iter().map(|(p, _, i)| (p, i)).collect(); | |
| 403 | for (path, _) in workdir::compare(&store.objects, a, b).map_err(|e| e.to_string())? { | |
| 404 | let read = |m: &std::collections::BTreeMap<String, Id>| -> Result<Vec<u8>> { | |
| 405 | m.get(&path).map(|id| store.objects.get_blob(id).map_err(|e| e.to_string())).transpose().map(Option::unwrap_or_default) | |
| 406 | }; | |
| 407 | let (old, new) = (read(&files_a)?, read(&files_b)?); | |
| 489 | fn print_diff(b: &dyn Backend, a: &Id, c: &Id, stat: bool) -> Result<()> { | |
| 490 | let o = b.objects(); | |
| 491 | let files = |t: &Id| -> Result<std::collections::BTreeMap<String, Id>> { Ok(o.flatten(t).map_err(err)?.into_iter().map(|(p, _, i)| (p, i)).collect()) }; | |
| 492 | let (fa, fc) = (files(a)?, files(c)?); | |
| 493 | for (path, _) in workdir::compare(o, a, c).map_err(err)? { | |
| 494 | let read = |m: &std::collections::BTreeMap<String, Id>| -> Result<Vec<u8>> { m.get(&path).map(|id| o.get_blob(id).map_err(err)).transpose().map(Option::unwrap_or_default) }; | |
| 495 | let (old, new) = (read(&fa)?, read(&fc)?); | |
| 408 | 496 | match (std::str::from_utf8(&old), std::str::from_utf8(&new)) { |
| 409 | (Ok(o), Ok(n)) => { | |
| 410 | let d = diff::unified(o, n, 3); | |
| 497 | (Ok(x), Ok(y)) => { | |
| 498 | let d = diff::unified(x, y, 3); | |
| 411 | 499 | if stat { |
| 412 | 500 | let adds = d.lines().filter(|l| l.starts_with('+')).count(); |
| 413 | 501 | let dels = d.lines().filter(|l| l.starts_with('-')).count(); |
| 426 | 514 | fn diff_cmd(args: &Args) -> Result<()> { |
| 427 | 515 | let cfg = Config::load()?; |
| 428 | 516 | let local = Local::find()?; |
| 429 | let store = open_store(&cfg)?; | |
| 517 | let mut b = connect(&cfg)?; | |
| 518 | let p = local.project(&mut *b)?; | |
| 430 | 519 | if let Some(n) = args.pos(0).filter(|a| a.starts_with('#')) { |
| 431 | 520 | // A revision of a change: from its base to its last step. |
| 432 | 521 | let number = change_number(Some(n), None)?; |
| 433 | let p = s(store.project(&local.owner, &local.project))?; | |
| 434 | 522 | let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?; |
| 435 | 523 | let r = match args.pos(1) { |
| 436 | 524 | Some(rv) => { |
| 440 | 528 | None => c.latest().ok_or_else(|| format!("#{number} has no revisions"))?, |
| 441 | 529 | }; |
| 442 | 530 | let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?; |
| 531 | let last = r.steps.last().unwrap().snapshot; | |
| 532 | b.need(&base)?; | |
| 533 | b.need(&last)?; | |
| 443 | 534 | println!("#{number} r{} on @{}", r.number, r.base); |
| 444 | return print_diff(&store, &base, &r.steps.last().unwrap().snapshot, args.has("--stat")); | |
| 535 | return print_diff(&*b, &base, &last, args.has("--stat")); | |
| 445 | 536 | } |
| 446 | let (current, now, _) = working_changes(&store, &local)?; | |
| 447 | print_diff(&store, ¤t, &now, args.has("--stat")) | |
| 537 | let (current, now, _) = working_changes(&mut *b, &p, &local)?; | |
| 538 | print_diff(&*b, ¤t, &now, args.has("--stat")) | |
| 448 | 539 | } |
| 449 | 540 | |
| 450 | 541 | fn step(args: &Args) -> Result<()> { |
| 451 | 542 | let cfg = Config::load()?; |
| 452 | 543 | let mut local = Local::find()?; |
| 453 | let store = open_store(&cfg)?; | |
| 544 | let mut b = connect(&cfg)?; | |
| 454 | 545 | let message = args.value(&["-m", "--message"]).ok_or("give the step a message with -m")?; |
| 455 | let (_, now, changes) = working_changes(&store, &local)?; | |
| 546 | let p = local.project(&mut *b)?; | |
| 547 | let (_, now, changes) = working_changes(&mut *b, &p, &local)?; | |
| 456 | 548 | if changes.is_empty() { |
| 457 | 549 | return e!("nothing changed since the last step"); |
| 458 | 550 | } |
| 459 | 551 | local.steps.push(Step { id: random::hex(4), snapshot: now, message: message.to_string() }); |
| 460 | 552 | local.save()?; |
| 461 | println!("step {} recorded ({} file{})", local.steps.len(), changes.len(), if changes.len() == 1 { "" } else { "s" }); | |
| 553 | println!("step {} recorded ({})", local.steps.len(), plural(changes.len(), "file")); | |
| 462 | 554 | Ok(()) |
| 463 | 555 | } |
| 464 | 556 | |
| 465 | 557 | fn amend(args: &Args) -> Result<()> { |
| 466 | 558 | let cfg = Config::load()?; |
| 467 | 559 | let mut local = Local::find()?; |
| 468 | let store = open_store(&cfg)?; | |
| 560 | let b = connect(&cfg)?; | |
| 469 | 561 | if local.steps.is_empty() { |
| 470 | 562 | return e!("no local step to amend; use `grove step`"); |
| 471 | 563 | } |
| 472 | let now = workdir::snapshot(&store.objects, &local.root).map_err(|e| e.to_string())?; | |
| 564 | let now = workdir::snapshot(b.objects(), &local.root).map_err(err)?; | |
| 473 | 565 | let last = local.steps.last_mut().unwrap(); |
| 474 | 566 | last.snapshot = now; |
| 475 | 567 | if let Some(m) = args.value(&["-m", "--message"]) { |
| 497 | 589 | fn propose(args: &Args) -> Result<()> { |
| 498 | 590 | let cfg = Config::load()?; |
| 499 | 591 | let mut local = Local::find()?; |
| 500 | let mut store = open_store(&cfg)?; | |
| 592 | let mut b = connect(&cfg)?; | |
| 501 | 593 | if local.steps.is_empty() { |
| 502 | 594 | return e!("no steps to propose; record some with `grove step`"); |
| 503 | 595 | } |
| 507 | 599 | Some(n) => n, |
| 508 | 600 | None => { |
| 509 | 601 | let intent = args.value(&["-m", "--message"]).ok_or("say what the change is for with -m")?; |
| 510 | s(store.open_change(&me, &owner, &name, intent))? | |
| 602 | b.open_change(&me, &owner, &name, intent)? | |
| 511 | 603 | } |
| 512 | 604 | }; |
| 513 | let rev = s(store.revise(&me, &owner, &name, number, local.base, &local.steps, args.value(&["--note"])))?; | |
| 514 | let state = s(store.project(&owner, &name))?.change(number).unwrap().state; | |
| 515 | if state == ChangeState::Draft { | |
| 516 | s(store.set_state(&me, &owner, &name, number, ChangeState::Proposed))?; | |
| 605 | let rev = b.revise(&me, &owner, &name, number, local.base, &local.steps, args.value(&["--note"]))?; | |
| 606 | let state = b.project(&owner, &name)?.change(number).map(|c| c.state); | |
| 607 | if state == Some(ChangeState::Draft) { | |
| 608 | b.set_state(&me, &owner, &name, number, ChangeState::Proposed)?; | |
| 517 | 609 | } |
| 518 | 610 | local.change = Some(number); |
| 519 | 611 | local.save()?; |
| 520 | println!("{owner}/{name}#{number} r{rev} proposed ({} step{})", local.steps.len(), if local.steps.len() == 1 { "" } else { "s" }); | |
| 612 | println!("{owner}/{name}#{number} r{rev} proposed ({})", plural(local.steps.len(), "step")); | |
| 521 | 613 | Ok(()) |
| 522 | 614 | } |
| 523 | 615 | |
| 524 | 616 | fn say(args: &Args) -> Result<()> { |
| 525 | 617 | let cfg = Config::load()?; |
| 526 | let local = Local::find().ok(); | |
| 618 | let local = Local::find()?; | |
| 527 | 619 | let (number, text) = match args.pos(0) { |
| 528 | 620 | Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.positional[1..].join(" ")), |
| 529 | _ => (change_number(None, local.as_ref())?, args.positional.join(" ")), | |
| 621 | _ => (change_number(None, Some(&local))?, args.positional.join(" ")), | |
| 530 | 622 | }; |
| 531 | 623 | if text.trim().is_empty() { |
| 532 | 624 | return e!("say what?"); |
| 533 | 625 | } |
| 534 | let local = local.ok_or("run this inside the project's working directory")?; | |
| 535 | let mut store = open_store(&cfg)?; | |
| 536 | s(store.say(&cfg.actor(), &local.owner, &local.project, number, &text))?; | |
| 626 | connect(&cfg)?.say(&cfg.actor(), &local.owner, &local.project, number, &text)?; | |
| 537 | 627 | println!("added to #{number}"); |
| 538 | 628 | Ok(()) |
| 539 | 629 | } |
| 545 | 635 | Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.pos(1)), |
| 546 | 636 | _ => (change_number(None, Some(&local))?, args.pos(0)), |
| 547 | 637 | }; |
| 548 | let mut store = open_store(&cfg)?; | |
| 549 | let rev = s(store.project(&local.owner, &local.project))? | |
| 550 | .change(number) | |
| 551 | .and_then(Change::latest) | |
| 552 | .map(|r| r.number) | |
| 553 | .ok_or("that change has no revision to attach to")?; | |
| 638 | let mut b = connect(&cfg)?; | |
| 639 | let rev = local.project(&mut *b)?.change(number).and_then(Change::latest).map(|r| r.number).ok_or("that change has no revision to attach to")?; | |
| 554 | 640 | let me = cfg.actor(); |
| 555 | 641 | if let Some(url) = args.value(&["--url"]) { |
| 556 | 642 | let title = args.value(&["--title"]).unwrap_or(url); |
| 557 | s(store.attach(&me, &local.owner, &local.project, number, rev, title, "text/uri-list", Content::Url(url)))?; | |
| 643 | b.attach(&me, &local.owner, &local.project, number, rev, title, "text/uri-list", Content::Url(url))?; | |
| 558 | 644 | } else { |
| 559 | 645 | let file = file.ok_or("attach a file, or a link with --url")?; |
| 560 | 646 | let bytes = fs::read(file).map_err(|e| format!("{file}: {e}"))?; |
| 561 | 647 | let media = args.value(&["--type"]).map(str::to_string).unwrap_or_else(|| guess_type(file)); |
| 562 | 648 | let title = args.value(&["--title"]).unwrap_or(file); |
| 563 | s(store.attach(&me, &local.owner, &local.project, number, rev, title, &media, Content::Bytes(&bytes)))?; | |
| 649 | b.attach(&me, &local.owner, &local.project, number, rev, title, &media, Content::Bytes(&bytes))?; | |
| 564 | 650 | } |
| 565 | 651 | println!("attached to #{number} r{rev}"); |
| 566 | 652 | Ok(()) |
| 586 | 672 | fn changes() -> Result<()> { |
| 587 | 673 | let cfg = Config::load()?; |
| 588 | 674 | let local = Local::find()?; |
| 589 | let store = open_store(&cfg)?; | |
| 590 | let p = s(store.project(&local.owner, &local.project))?; | |
| 675 | let mut b = connect(&cfg)?; | |
| 676 | let p = local.project(&mut *b)?; | |
| 591 | 677 | if p.changes.is_empty() { |
| 592 | 678 | println!("no changes yet"); |
| 593 | 679 | } |
| 594 | 680 | for c in p.changes.iter().rev() { |
| 595 | println!("#{:<4} {:<10} {} ({}, {})", c.number, c.state.name(), c.intent, who(&store, &c.proposer), ago(c.at)); | |
| 681 | println!("#{:<4} {:<10} {} ({}, {})", c.number, c.state.name(), c.intent, who(&*b, &c.proposer), ago(c.at)); | |
| 596 | 682 | } |
| 597 | 683 | Ok(()) |
| 598 | 684 | } |
| 601 | 687 | let cfg = Config::load()?; |
| 602 | 688 | let local = Local::find()?; |
| 603 | 689 | let number = change_number(args.pos(0), Some(&local))?; |
| 604 | let store = open_store(&cfg)?; | |
| 605 | let p = s(store.project(&local.owner, &local.project))?; | |
| 690 | let mut b = connect(&cfg)?; | |
| 691 | let p = local.project(&mut *b)?; | |
| 606 | 692 | let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?; |
| 607 | 693 | println!("#{} {} [{}]", c.number, c.intent, c.state.name()); |
| 608 | println!("proposed by {}, {}", who(&store, &c.proposer), ago(c.at)); | |
| 694 | println!("proposed by {}, {}", who(&*b, &c.proposer), ago(c.at)); | |
| 609 | 695 | for (at, by, ev) in &c.timeline { |
| 610 | 696 | let line = match ev { |
| 611 | 697 | Event::Opened => "opened".to_string(), |
| 612 | 698 | Event::Revised(n) => { |
| 613 | 699 | let r = &c.revisions[*n as usize - 1]; |
| 614 | let mut l = format!("r{n} on @{}: {} step{}", r.base, r.steps.len(), if r.steps.len() == 1 { "" } else { "s" }); | |
| 700 | let mut l = format!("r{n} on @{}: {}", r.base, plural(r.steps.len(), "step")); | |
| 615 | 701 | for st in &r.steps { |
| 616 | 702 | l += &format!("\n {} {}", short(&st.snapshot), st.message); |
| 617 | 703 | } |
| 628 | 714 | } |
| 629 | 715 | Event::Landed { revision, points } => format!("landed r{revision} as @{}..@{}", points.0, points.1), |
| 630 | 716 | }; |
| 631 | println!(" {:<10} {}: {}", ago(*at), who(&store, by), line); | |
| 717 | println!(" {:<10} {}: {}", ago(*at), who(&*b, by), line); | |
| 632 | 718 | } |
| 633 | if let Some(r) = c.latest() { | |
| 634 | if c.state.is_open() { | |
| 635 | let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?; | |
| 636 | println!(); | |
| 637 | print_diff(&store, &base, &r.steps.last().unwrap().snapshot, true)?; | |
| 638 | } | |
| 719 | if let (Some(r), true) = (c.latest(), c.state.is_open()) { | |
| 720 | let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?; | |
| 721 | let last = r.steps.last().unwrap().snapshot; | |
| 722 | b.need(&base)?; | |
| 723 | b.need(&last)?; | |
| 724 | println!(); | |
| 725 | print_diff(&*b, &base, &last, true)?; | |
| 639 | 726 | } |
| 640 | 727 | Ok(()) |
| 641 | 728 | } |
| 644 | 731 | let cfg = Config::load()?; |
| 645 | 732 | let local = Local::find()?; |
| 646 | 733 | let number = change_number(args.pos(0), None)?; |
| 647 | let mut store = open_store(&cfg)?; | |
| 648 | let (first, last) = s(store.land(&cfg.in_person(), &local.owner, &local.project, number))?; | |
| 734 | let (first, last) = connect(&cfg)?.land(&cfg.in_person(), &local.owner, &local.project, number)?; | |
| 649 | 735 | println!("#{number} landed as @{first}..@{last}"); |
| 650 | 736 | Ok(()) |
| 651 | 737 | } |
| 654 | 740 | let cfg = Config::load()?; |
| 655 | 741 | let local = Local::find()?; |
| 656 | 742 | let number = change_number(args.pos(0), None)?; |
| 657 | let mut store = open_store(&cfg)?; | |
| 658 | s(store.set_state(&cfg.in_person(), &local.owner, &local.project, number, ChangeState::Declined))?; | |
| 743 | connect(&cfg)?.set_state(&cfg.in_person(), &local.owner, &local.project, number, ChangeState::Declined)?; | |
| 659 | 744 | println!("#{number} declined"); |
| 660 | 745 | Ok(()) |
| 661 | 746 | } |
| 663 | 748 | fn log() -> Result<()> { |
| 664 | 749 | let cfg = Config::load()?; |
| 665 | 750 | let local = Local::find()?; |
| 666 | let store = open_store(&cfg)?; | |
| 667 | let p = s(store.project(&local.owner, &local.project))?; | |
| 751 | let mut b = connect(&cfg)?; | |
| 752 | let p = local.project(&mut *b)?; | |
| 668 | 753 | for pt in p.points.iter().rev() { |
| 669 | 754 | let from = pt.change.map(|c| format!(" #{c}")).unwrap_or_default(); |
| 670 | println!("@{:<5} {}{} ({}, {})", pt.n, pt.message, from, who(&store, &pt.author), ago(pt.at)); | |
| 755 | println!("@{:<5} {}{} ({}, {})", pt.n, pt.message, from, who(&*b, &pt.author), ago(pt.at)); | |
| 671 | 756 | } |
| 672 | 757 | Ok(()) |
| 673 | 758 | } |
| 675 | 760 | fn sync() -> Result<()> { |
| 676 | 761 | let cfg = Config::load()?; |
| 677 | 762 | let mut local = Local::find()?; |
| 678 | let store = open_store(&cfg)?; | |
| 679 | let p = s(store.project(&local.owner, &local.project))?; | |
| 763 | let mut b = connect(&cfg)?; | |
| 764 | let p = local.project(&mut *b)?; | |
| 680 | 765 | // A change of ours that landed. If what landed is the start of our local stack, the base |
| 681 | 766 | // moves to the last landed point and any steps recorded after it stay, unsent. |
| 682 | 767 | if let Some(c) = local.change.and_then(|n| p.change(n)) { |
| 688 | 773 | if let Some((rev, last_point)) = landed { |
| 689 | 774 | let landed_steps = &c.revisions[rev as usize - 1].steps; |
| 690 | 775 | let k = landed_steps.len(); |
| 691 | let prefix = local.steps.len() >= k && local.steps[..k].iter().zip(landed_steps).all(|(a, b)| a.snapshot == b.snapshot); | |
| 692 | if prefix { | |
| 693 | local.base = last_point; | |
| 694 | local.steps.drain(..k); | |
| 695 | } else { | |
| 776 | let prefix = local.steps.len() >= k && local.steps[..k].iter().zip(landed_steps).all(|(x, y)| x.snapshot == y.snapshot); | |
| 777 | if !prefix { | |
| 696 | 778 | return e!("#{} landed as something other than the local steps; look at `grove show {}` before syncing", c.number, c.number); |
| 697 | 779 | } |
| 780 | local.base = last_point; | |
| 781 | local.steps.drain(..k); | |
| 698 | 782 | } |
| 699 | 783 | local.change = None; |
| 700 | 784 | } |
| 708 | 792 | let change = local.change.map(|c| format!("#{c}")).unwrap_or_else(|| "your steps".into()); |
| 709 | 793 | return e!( |
| 710 | 794 | "main moved to @{} while your steps sit on @{}. grove doesn't merge: run `grove restart` to move to @{} and redo the work there (`grove diff {change}` shows what it was), then propose again", |
| 711 | p.tip(), local.base, p.tip() | |
| 795 | p.tip(), | |
| 796 | local.base, | |
| 797 | p.tip() | |
| 712 | 798 | ); |
| 713 | 799 | } |
| 714 | let (current, _, pending) = working_changes(&store, &local)?; | |
| 800 | let (current, _, pending) = working_changes(&mut *b, &p, &local)?; | |
| 715 | 801 | if !pending.is_empty() { |
| 716 | 802 | return e!("the working directory has changes; record them as a step first"); |
| 717 | 803 | } |
| 718 | 804 | let tip = p.points.last().unwrap(); |
| 719 | workdir::materialize(&store.objects, &tip.snapshot, &local.root, Some(¤t)).map_err(|e| e.to_string())?; | |
| 805 | b.need(&tip.snapshot)?; | |
| 806 | workdir::materialize(b.objects(), &tip.snapshot, &local.root, Some(¤t)).map_err(err)?; | |
| 720 | 807 | local.base = tip.n; |
| 721 | 808 | local.save()?; |
| 722 | 809 | println!("now at @{}", tip.n); |
| 723 | 810 | Ok(()) |
| 724 | 811 | } |
| 725 | 812 | |
| 726 | /// Move to the newest main, dropping the local steps (they stay in the store as revisions of | |
| 813 | /// Move to the newest main, dropping the local steps (they stay on the server as revisions of | |
| 727 | 814 | /// the change). The change stays linked, so the next `propose` adds a revision built on the tip. |
| 728 | 815 | fn restart() -> Result<()> { |
| 729 | 816 | let cfg = Config::load()?; |
| 730 | 817 | let mut local = Local::find()?; |
| 731 | let store = open_store(&cfg)?; | |
| 732 | let p = s(store.project(&local.owner, &local.project))?; | |
| 733 | let (current, _, pending) = working_changes(&store, &local)?; | |
| 818 | let mut b = connect(&cfg)?; | |
| 819 | let p = local.project(&mut *b)?; | |
| 820 | let (current, _, pending) = working_changes(&mut *b, &p, &local)?; | |
| 734 | 821 | if !pending.is_empty() { |
| 735 | 822 | return e!("the working directory has changes; record them as a step first, so they aren't lost"); |
| 736 | 823 | } |
| 741 | 828 | } |
| 742 | 829 | } |
| 743 | 830 | let tip = p.points.last().ok_or("no points")?; |
| 744 | workdir::materialize(&store.objects, &tip.snapshot, &local.root, Some(¤t)).map_err(|e| e.to_string())?; | |
| 831 | b.need(&tip.snapshot)?; | |
| 832 | workdir::materialize(b.objects(), &tip.snapshot, &local.root, Some(¤t)).map_err(err)?; | |
| 745 | 833 | local.steps.clear(); |
| 746 | 834 | local.base = tip.n; |
| 747 | 835 | local.save()?; |
| 751 | 839 | |
| 752 | 840 | fn group(args: &Args) -> Result<()> { |
| 753 | 841 | let cfg = Config::load()?; |
| 754 | let mut store = open_store(&cfg)?; | |
| 842 | let mut b = connect(&cfg)?; | |
| 755 | 843 | let me = cfg.in_person(); |
| 756 | 844 | match (args.pos(0), args.pos(1), args.pos(2)) { |
| 757 | 845 | (Some("create"), Some(name), None) => { |
| 758 | s(store.create_group(&me, name))?; | |
| 846 | b.create_group(&me, name)?; | |
| 759 | 847 | println!("group {name} created; members: {}", cfg.person); |
| 760 | 848 | } |
| 761 | 849 | (Some("add"), Some(name), Some(person)) => { |
| 762 | s(store.add_member(&me, name, person))?; | |
| 850 | b.add_member(&me, name, person)?; | |
| 763 | 851 | println!("{person} added to {name}"); |
| 764 | 852 | } |
| 765 | 853 | _ => return e!("usage: grove group create NAME | grove group add NAME PERSON"), |
| 771 | 859 | let cfg = Config::load()?; |
| 772 | 860 | let mut local = Local::find()?; |
| 773 | 861 | let to = args.pos(0).ok_or("usage: grove move OWNER")?; |
| 774 | let mut store = open_store(&cfg)?; | |
| 775 | s(store.move_project(&cfg.in_person(), &local.owner, &local.project, to))?; | |
| 862 | connect(&cfg)?.move_project(&cfg.in_person(), &local.owner, &local.project, to)?; | |
| 776 | 863 | println!("{}/{} is now {to}/{}", local.owner, local.project, local.project); |
| 777 | 864 | local.owner = to.to_string(); |
| 778 | 865 | local.save() |
| 779 | 866 | } |
| 780 | 867 | |
| 868 | // ── On the machine that holds the store ──────────────────── | |
| 869 | ||
| 870 | fn agent_cmd(args: &Args) -> Result<()> { | |
| 871 | let cfg = Config::load()?; | |
| 872 | let mut store = cfg.store()?; | |
| 873 | match (args.pos(0), args.pos(1)) { | |
| 874 | (Some("add"), Some(name)) => { | |
| 875 | let id = ensure_agent(&mut store, &cfg.person, name)?; | |
| 876 | println!("{id}"); | |
| 877 | Ok(()) | |
| 878 | } | |
| 879 | _ => e!("usage: grove agent add NAME"), | |
| 880 | } | |
| 881 | } | |
| 882 | ||
| 883 | fn token_cmd(args: &Args) -> Result<()> { | |
| 884 | let cfg = Config::load()?; | |
| 885 | let mut store = cfg.store()?; | |
| 886 | let token = store.issue_token(&cfg.person, args.value(&["--agent"])).map_err(err)?; | |
| 887 | let for_whom = args.value(&["--agent"]).map(|a| format!("agent {a} for {}", cfg.person)).unwrap_or_else(|| cfg.person.clone()); | |
| 888 | eprintln!("token for {for_whom} (shown once):"); | |
| 889 | println!("{token}"); | |
| 890 | Ok(()) | |
| 891 | } | |
| 892 | ||
| 781 | 893 | fn verify() -> Result<()> { |
| 782 | 894 | let cfg = Config::load()?; |
| 783 | let store = open_store(&cfg)?; | |
| 784 | let n = s(store.verify())?; | |
| 895 | let store = cfg.store()?; | |
| 896 | let n = store.verify().map_err(err)?; | |
| 785 | 897 | println!("{n} objects checked, all match their ids; {} log records", store.state().records); |
| 786 | 898 | Ok(()) |
| 899 | } | |
| 900 | ||
| 901 | fn serve(args: &Args) -> Result<()> { | |
| 902 | let store_dir = std::env::var("GROVE_STORE").map(PathBuf::from).or_else(|_| Config::load().and_then(|c| c.store.ok_or_else(|| "no store".into())))?; | |
| 903 | let store = Store::open(&store_dir).map_err(err)?; | |
| 904 | let addr = args.value(&["--addr"]).unwrap_or("127.0.0.1:4800").to_string(); | |
| 905 | let listener = std::net::TcpListener::bind(&addr).map_err(|e| format!("{addr}: {e}"))?; | |
| 906 | eprintln!("grove serving {} on http://{addr} ({} records)", store_dir.display(), store.state().records); | |
| 907 | let api = std::sync::Arc::new(crate::api::Api::new(store).map_err(err)?); | |
| 908 | crate::http::serve(listener, std::sync::Arc::new(move |req| api.handle(req))).map_err(err) | |
| 787 | 909 | } |
src/client.rsadded+93 −0
| 1 | //! Talking to a grove server over HTTPS. | |
| 2 | //! | |
| 3 | //! The transport is the system's `curl`: TLS is cryptography grove shouldn't write itself, and | |
| 4 | //! curl is on every machine. Settings and the token go to curl on stdin (`--config -`), never | |
| 5 | //! on its command line, so they don't show up in process lists. | |
| 6 | ||
| 7 | use crate::json::{self, Value}; | |
| 8 | use std::io::Write; | |
| 9 | use std::process::{Command, Stdio}; | |
| 10 | ||
| 11 | pub struct Client { | |
| 12 | base: String, | |
| 13 | token: Option<String>, | |
| 14 | } | |
| 15 | ||
| 16 | fn quote(s: &str) -> String { | |
| 17 | format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) | |
| 18 | } | |
| 19 | ||
| 20 | impl Client { | |
| 21 | pub fn new(base: &str, token: Option<String>) -> Client { | |
| 22 | Client { base: base.trim_end_matches('/').to_string(), token } | |
| 23 | } | |
| 24 | ||
| 25 | /// One request. Returns the status and the response body. | |
| 26 | pub fn call(&self, method: &str, path: &str, body: Option<(&[u8], &str)>) -> Result<(u16, Vec<u8>), String> { | |
| 27 | let body_file = match body { | |
| 28 | Some((bytes, _)) => { | |
| 29 | let path = std::env::temp_dir().join(format!("grove-body-{}", crate::random::hex(8))); | |
| 30 | std::fs::write(&path, bytes).map_err(|e| e.to_string())?; | |
| 31 | Some(path) | |
| 32 | } | |
| 33 | None => None, | |
| 34 | }; | |
| 35 | let mut config = String::new(); | |
| 36 | config += &format!("request = {}\n", quote(method)); | |
| 37 | config += &format!("url = {}\n", quote(&format!("{}{path}", self.base))); | |
| 38 | config += "silent\nshow-error\n"; | |
| 39 | config += &format!("write-out = {}\n", quote("%{stderr}%{http_code}")); | |
| 40 | if let Some(t) = &self.token { | |
| 41 | config += &format!("header = {}\n", quote(&format!("Authorization: Bearer {t}"))); | |
| 42 | } | |
| 43 | if let (Some(path), Some((_, ctype))) = (&body_file, body) { | |
| 44 | config += &format!("header = {}\n", quote(&format!("Content-Type: {ctype}"))); | |
| 45 | config += &format!("data-binary = {}\n", quote(&format!("@{}", path.display()))); | |
| 46 | } | |
| 47 | let mut child = Command::new("curl") | |
| 48 | .args(["--config", "-"]) | |
| 49 | .stdin(Stdio::piped()) | |
| 50 | .stdout(Stdio::piped()) | |
| 51 | .stderr(Stdio::piped()) | |
| 52 | .spawn() | |
| 53 | .map_err(|e| format!("couldn't run curl: {e}"))?; | |
| 54 | child.stdin.take().unwrap().write_all(config.as_bytes()).map_err(|e| e.to_string())?; | |
| 55 | let out = child.wait_with_output().map_err(|e| e.to_string())?; | |
| 56 | if let Some(p) = body_file { | |
| 57 | let _ = std::fs::remove_file(p); | |
| 58 | } | |
| 59 | let stderr = String::from_utf8_lossy(&out.stderr).to_string(); | |
| 60 | // The status code is the last thing curl writes to stderr. | |
| 61 | let (msg, code) = stderr.split_at(stderr.len().saturating_sub(3)); | |
| 62 | match code.parse::<u16>() { | |
| 63 | Ok(status) if status != 0 && out.status.success() => Ok((status, out.stdout)), | |
| 64 | _ => Err(format!("couldn't reach {}: {}", self.base, msg.trim().trim_start_matches("curl: "))), | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | fn check(&self, status: u16, bytes: Vec<u8>) -> Result<Vec<u8>, String> { | |
| 69 | if status == 200 { | |
| 70 | return Ok(bytes); | |
| 71 | } | |
| 72 | let text = String::from_utf8_lossy(&bytes); | |
| 73 | let msg = json::parse(&text).ok().and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_string)).unwrap_or_else(|| text.to_string()); | |
| 74 | Err(msg) | |
| 75 | } | |
| 76 | ||
| 77 | pub fn get(&self, path: &str) -> Result<Value, String> { | |
| 78 | let (status, bytes) = self.call("GET", path, None)?; | |
| 79 | let bytes = self.check(status, bytes)?; | |
| 80 | json::parse(&String::from_utf8_lossy(&bytes)).map_err(|e| e.to_string()) | |
| 81 | } | |
| 82 | ||
| 83 | pub fn post(&self, path: &str, body: &Value) -> Result<Value, String> { | |
| 84 | let (status, bytes) = self.call("POST", path, Some((body.encode().as_bytes(), "application/json")))?; | |
| 85 | let bytes = self.check(status, bytes)?; | |
| 86 | json::parse(&String::from_utf8_lossy(&bytes)).map_err(|e| e.to_string()) | |
| 87 | } | |
| 88 | ||
| 89 | pub fn post_bytes(&self, path: &str, body: &[u8], ctype: &str) -> Result<Vec<u8>, String> { | |
| 90 | let (status, bytes) = self.call("POST", path, Some((body, ctype)))?; | |
| 91 | self.check(status, bytes) | |
| 92 | } | |
| 93 | } |
src/http.rsadded+164 −0
| 1 | //! A small HTTP/1.1 server: one request per connection, bodies by Content-Length. | |
| 2 | //! | |
| 3 | //! grove sits behind Caddy, which handles TLS, HTTP/2 and keep-alive towards clients; this | |
| 4 | //! side only has to be correct and bounded. | |
| 5 | ||
| 6 | use std::io::{self, BufRead, BufReader, Read, Write}; | |
| 7 | use std::net::{TcpListener, TcpStream}; | |
| 8 | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 9 | use std::sync::Arc; | |
| 10 | use std::time::Duration; | |
| 11 | ||
| 12 | const MAX_HEAD: usize = 64 << 10; | |
| 13 | pub const MAX_BODY: usize = 512 << 20; | |
| 14 | const MAX_CONNECTIONS: usize = 128; | |
| 15 | ||
| 16 | pub struct Request { | |
| 17 | pub method: String, | |
| 18 | pub path: String, | |
| 19 | pub query: String, | |
| 20 | pub headers: Vec<(String, String)>, | |
| 21 | pub body: Vec<u8>, | |
| 22 | } | |
| 23 | ||
| 24 | impl Request { | |
| 25 | pub fn header(&self, name: &str) -> Option<&str> { | |
| 26 | self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | pub struct Response { | |
| 31 | pub status: u16, | |
| 32 | pub content_type: &'static str, | |
| 33 | pub body: Vec<u8>, | |
| 34 | } | |
| 35 | ||
| 36 | impl Response { | |
| 37 | pub fn json(status: u16, v: &crate::json::Value) -> Response { | |
| 38 | Response { status, content_type: "application/json", body: v.encode().into_bytes() } | |
| 39 | } | |
| 40 | pub fn bytes(body: Vec<u8>) -> Response { | |
| 41 | Response { status: 200, content_type: "application/octet-stream", body } | |
| 42 | } | |
| 43 | pub fn error(status: u16, msg: &str) -> Response { | |
| 44 | Response::json(status, &crate::json::obj([("error", msg.into())])) | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | fn reason(status: u16) -> &'static str { | |
| 49 | match status { | |
| 50 | 200 => "OK", | |
| 51 | 400 => "Bad Request", | |
| 52 | 401 => "Unauthorized", | |
| 53 | 403 => "Forbidden", | |
| 54 | 404 => "Not Found", | |
| 55 | 405 => "Method Not Allowed", | |
| 56 | 409 => "Conflict", | |
| 57 | 411 => "Length Required", | |
| 58 | 413 => "Payload Too Large", | |
| 59 | 500 => "Internal Server Error", | |
| 60 | 503 => "Service Unavailable", | |
| 61 | _ => "", | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | fn bad(msg: &str) -> io::Error { | |
| 66 | io::Error::new(io::ErrorKind::InvalidData, msg.to_string()) | |
| 67 | } | |
| 68 | ||
| 69 | /// Read one request. Errors carry the status to answer with. | |
| 70 | fn read_request(stream: &TcpStream) -> Result<Request, (u16, String)> { | |
| 71 | let mut reader = BufReader::new(stream); | |
| 72 | let mut head = Vec::new(); | |
| 73 | loop { | |
| 74 | let mut line = Vec::new(); | |
| 75 | let n = reader.read_until(b'\n', &mut line).map_err(|e| (400, e.to_string()))?; | |
| 76 | if n == 0 { | |
| 77 | return Err((400, "connection closed".into())); | |
| 78 | } | |
| 79 | head.extend_from_slice(&line); | |
| 80 | if head.len() > MAX_HEAD { | |
| 81 | return Err((413, "headers too large".into())); | |
| 82 | } | |
| 83 | if line == b"\r\n" || line == b"\n" { | |
| 84 | break; | |
| 85 | } | |
| 86 | } | |
| 87 | let head = String::from_utf8(head).map_err(|_| (400, "headers aren't UTF-8".to_string()))?; | |
| 88 | let mut lines = head.lines(); | |
| 89 | let first = lines.next().unwrap_or(""); | |
| 90 | let mut parts = first.split(' '); | |
| 91 | let (method, target, version) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or("")); | |
| 92 | if method.is_empty() || !target.starts_with('/') || !version.starts_with("HTTP/1.") { | |
| 93 | return Err((400, "bad request line".into())); | |
| 94 | } | |
| 95 | let mut headers = Vec::new(); | |
| 96 | for line in lines { | |
| 97 | if line.is_empty() { | |
| 98 | break; | |
| 99 | } | |
| 100 | let (k, v) = line.split_once(':').ok_or((400, "bad header".to_string()))?; | |
| 101 | headers.push((k.trim().to_string(), v.trim().to_string())); | |
| 102 | } | |
| 103 | let find = |name: &str| headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()); | |
| 104 | if find("transfer-encoding").is_some() { | |
| 105 | return Err((411, "send a Content-Length".into())); | |
| 106 | } | |
| 107 | let len = match find("content-length") { | |
| 108 | Some(v) => v.parse::<usize>().map_err(|_| (400, "bad Content-Length".to_string()))?, | |
| 109 | None => 0, | |
| 110 | }; | |
| 111 | if len > MAX_BODY { | |
| 112 | return Err((413, "body too large".into())); | |
| 113 | } | |
| 114 | let mut body = vec![0u8; len]; | |
| 115 | reader.read_exact(&mut body).map_err(|e| (400, e.to_string()))?; | |
| 116 | let (path, query) = match target.split_once('?') { | |
| 117 | Some((p, q)) => (p.to_string(), q.to_string()), | |
| 118 | None => (target.to_string(), String::new()), | |
| 119 | }; | |
| 120 | Ok(Request { method: method.to_string(), path, query, headers, body }) | |
| 121 | } | |
| 122 | ||
| 123 | fn write_response(stream: &mut TcpStream, r: &Response) -> io::Result<()> { | |
| 124 | let head = format!( | |
| 125 | "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", | |
| 126 | r.status, | |
| 127 | reason(r.status), | |
| 128 | r.content_type, | |
| 129 | r.body.len() | |
| 130 | ); | |
| 131 | stream.write_all(head.as_bytes())?; | |
| 132 | stream.write_all(&r.body)?; | |
| 133 | stream.flush() | |
| 134 | } | |
| 135 | ||
| 136 | pub type Handler = Arc<dyn Fn(Request) -> Response + Send + Sync>; | |
| 137 | ||
| 138 | /// Accept connections forever, each on its own thread, at most MAX_CONNECTIONS at once. | |
| 139 | pub fn serve(listener: TcpListener, handler: Handler) -> io::Result<()> { | |
| 140 | let active = Arc::new(AtomicUsize::new(0)); | |
| 141 | for stream in listener.incoming() { | |
| 142 | let mut stream = match stream { | |
| 143 | Ok(s) => s, | |
| 144 | Err(_) => continue, | |
| 145 | }; | |
| 146 | if active.load(Ordering::SeqCst) >= MAX_CONNECTIONS { | |
| 147 | let _ = write_response(&mut stream, &Response::error(503, "busy")); | |
| 148 | continue; | |
| 149 | } | |
| 150 | active.fetch_add(1, Ordering::SeqCst); | |
| 151 | let (handler, active) = (handler.clone(), active.clone()); | |
| 152 | std::thread::spawn(move || { | |
| 153 | let _ = stream.set_read_timeout(Some(Duration::from_secs(60))); | |
| 154 | let _ = stream.set_write_timeout(Some(Duration::from_secs(60))); | |
| 155 | let response = match read_request(&stream) { | |
| 156 | Ok(req) => handler(req), | |
| 157 | Err((status, msg)) => Response::error(status, &msg), | |
| 158 | }; | |
| 159 | let _ = write_response(&mut stream, &response); | |
| 160 | active.fetch_sub(1, Ordering::SeqCst); | |
| 161 | }); | |
| 162 | } | |
| 163 | Err(bad("listener closed")) | |
| 164 | } |
src/lib.rsmodified+5 −0
| 1 | 1 | //! grove: where agents build software for people. See VISION.md and docs/model.md. |
| 2 | 2 | |
| 3 | pub mod api; | |
| 4 | pub mod backend; | |
| 3 | 5 | pub mod blake3; |
| 4 | 6 | pub mod cli; |
| 7 | pub mod client; | |
| 5 | 8 | pub mod diff; |
| 6 | 9 | pub mod hex; |
| 10 | pub mod http; | |
| 7 | 11 | pub mod ignore; |
| 8 | 12 | pub mod json; |
| 9 | 13 | pub mod log; |
| 11 | 15 | pub mod objects; |
| 12 | 16 | pub mod random; |
| 13 | 17 | pub mod store; |
| 18 | pub mod wire; | |
| 14 | 19 | pub mod workdir; |
| 15 | 20 | |
| 16 | 21 | #[cfg(test)] |
src/model.rsmodified+45 −0
| 101 | 101 | pub at: i64, |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | /// An access token, stored as the BLAKE3 hash of its secret. A token acts as its person, or | |
| 105 | /// as one of their agents. | |
| 104 | 106 | #[derive(Clone, Debug)] |
| 107 | pub struct Token { | |
| 108 | pub hash: String, | |
| 109 | pub person: String, | |
| 110 | pub agent: Option<String>, | |
| 111 | pub revoked: bool, | |
| 112 | pub at: i64, | |
| 113 | } | |
| 114 | ||
| 115 | #[derive(Clone, Debug)] | |
| 105 | 116 | pub struct Agent { |
| 106 | 117 | pub id: String, |
| 107 | 118 | pub person: String, |
| 219 | 230 | pub people: Vec<Person>, |
| 220 | 231 | pub agents: Vec<Agent>, |
| 221 | 232 | pub groups: Vec<Group>, |
| 233 | pub tokens: Vec<Token>, | |
| 222 | 234 | pub projects: Vec<Project>, |
| 223 | 235 | pub records: u64, |
| 224 | 236 | } |
| 284 | 296 | pub fn owns(&self, owner: &str, person: &str) -> bool { |
| 285 | 297 | owner == person || self.group(owner).is_some_and(|g| g.members.iter().any(|m| m == person)) |
| 286 | 298 | } |
| 299 | /// Who a token acts as, if it's valid. | |
| 300 | pub fn token_actor(&self, hash: &str) -> Option<Actor> { | |
| 301 | let t = self.tokens.iter().find(|t| t.hash == hash && !t.revoked)?; | |
| 302 | if let Some(a) = &t.agent { | |
| 303 | if self.agent(a).is_none_or(|a| a.revoked) { | |
| 304 | return None; | |
| 305 | } | |
| 306 | } | |
| 307 | Some(Actor { person: t.person.clone(), agent: t.agent.clone() }) | |
| 308 | } | |
| 287 | 309 | pub fn agent(&self, id: &str) -> Option<&Agent> { |
| 288 | 310 | self.agents.iter().find(|a| a.id == id) |
| 289 | 311 | } |
| 330 | 352 | let id = str_field(rec, "id")?; |
| 331 | 353 | let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?; |
| 332 | 354 | a.revoked = true; |
| 355 | } | |
| 356 | "token" => { | |
| 357 | // Tokens are issued by a person, in person, for themselves or one of their agents. | |
| 358 | if by.agent.is_some() { | |
| 359 | return Err("tokens are issued in person".into()); | |
| 360 | } | |
| 361 | let hash = str_field(rec, "hash")?; | |
| 362 | let agent = opt_str(rec, "agent")?; | |
| 363 | if let Some(a) = &agent { | |
| 364 | match self.agent(a) { | |
| 365 | Some(ag) if ag.person == by.person && !ag.revoked => {} | |
| 366 | _ => return Err(format!("no agent {a} of {}", by.person)), | |
| 367 | } | |
| 368 | } | |
| 369 | if self.tokens.iter().any(|t| t.hash == hash) { | |
| 370 | return Err("token exists".into()); | |
| 371 | } | |
| 372 | self.tokens.push(Token { hash, person: by.person.clone(), agent, revoked: false, at }); | |
| 373 | } | |
| 374 | "token.revoke" => { | |
| 375 | let hash = str_field(rec, "hash")?; | |
| 376 | let t = self.tokens.iter_mut().find(|t| t.hash == hash && t.person == by.person).ok_or("no such token")?; | |
| 377 | t.revoked = true; | |
| 333 | 378 | } |
| 334 | 379 | "group" => { |
| 335 | 380 | let name = str_field(rec, "name")?; |
src/objects.rsmodified+49 −0
| 223 | 223 | Ok(()) |
| 224 | 224 | } |
| 225 | 225 | |
| 226 | /// An object's stored bytes (type prefix and content), checked against its id. For transfer. | |
| 227 | pub fn get_raw(&self, id: &Id) -> Result<Vec<u8>> { | |
| 228 | let bytes = match fs::read(self.path(id)) { | |
| 229 | Ok(b) => b, | |
| 230 | Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(Error::Missing(*id)), | |
| 231 | Err(e) => return Err(e.into()), | |
| 232 | }; | |
| 233 | if crate::blake3::hash(&bytes) != *id { | |
| 234 | return Err(Error::Corrupt(*id)); | |
| 235 | } | |
| 236 | Ok(bytes) | |
| 237 | } | |
| 238 | ||
| 239 | /// Store an object received from elsewhere. Its id is the hash of its bytes; the bytes must | |
| 240 | /// be a blob, or a tree that decodes. | |
| 241 | pub fn put_raw(&self, bytes: &[u8]) -> Result<Id> { | |
| 242 | let id = crate::blake3::hash(bytes); | |
| 243 | if let Some(content) = bytes.strip_prefix(BLOB) { | |
| 244 | return self.put(BLOB, content).map(|_| id); | |
| 245 | } | |
| 246 | if let Some(content) = bytes.strip_prefix(TREE) { | |
| 247 | decode_tree(&id, content)?; | |
| 248 | return self.put(TREE, content).map(|_| id); | |
| 249 | } | |
| 250 | Err(Error::Corrupt(id)) | |
| 251 | } | |
| 252 | ||
| 253 | /// Every object id under a tree (trees and blobs), the tree itself first. | |
| 254 | pub fn ids_under(&self, tree: &Id) -> Result<Vec<Id>> { | |
| 255 | let mut out = vec![*tree]; | |
| 256 | let mut seen = std::collections::HashSet::new(); | |
| 257 | seen.insert(*tree); | |
| 258 | let mut i = 0; | |
| 259 | while i < out.len() { | |
| 260 | let id = out[i]; | |
| 261 | i += 1; | |
| 262 | // Only trees have children; blobs fail to decode as trees and are skipped. | |
| 263 | let Ok(bytes) = self.get_raw(&id) else { return Err(Error::Missing(id)) }; | |
| 264 | if let Some(content) = bytes.strip_prefix(TREE) { | |
| 265 | for e in decode_tree(&id, content)? { | |
| 266 | if seen.insert(e.id) { | |
| 267 | out.push(e.id); | |
| 268 | } | |
| 269 | } | |
| 270 | } | |
| 271 | } | |
| 272 | Ok(out) | |
| 273 | } | |
| 274 | ||
| 226 | 275 | /// Confirm every object under a tree exists and matches its id. |
| 227 | 276 | pub fn verify_tree(&self, tree: &Id) -> Result<usize> { |
| 228 | 277 | let mut count = 1; |
src/store.rsmodified+24 −0
| 64 | 64 | /// What a piece of evidence holds. |
| 65 | 65 | pub enum Content<'a> { |
| 66 | 66 | Bytes(&'a [u8]), |
| 67 | /// A blob already stored (uploaded beforehand). | |
| 68 | Blob(Id), | |
| 67 | 69 | Url(&'a str), |
| 68 | 70 | } |
| 69 | 71 | |
| 72 | pub fn token_hash(secret: &str) -> String { | |
| 73 | crate::hex::encode(&crate::blake3::hash(secret.as_bytes())) | |
| 74 | } | |
| 75 | ||
| 70 | 76 | pub fn now_ms() -> i64 { |
| 71 | 77 | SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) |
| 72 | 78 | } |
| 156 | 162 | Ok(id) |
| 157 | 163 | } |
| 158 | 164 | |
| 165 | /// Issue a token acting as `person`, or as one of their agents. Returns the secret, which | |
| 166 | /// is shown once; only its hash is kept. | |
| 167 | pub fn issue_token(&mut self, person: &str, agent: Option<&str>) -> Result<String> { | |
| 168 | let secret = format!("grove_{}", crate::random::hex(24)); | |
| 169 | let hash = token_hash(&secret); | |
| 170 | self.write(&Actor::person(person), Self::rec("token", vec![("hash", hash.into()), ("agent", agent.map(str::to_string).into())]))?; | |
| 171 | Ok(secret) | |
| 172 | } | |
| 173 | ||
| 174 | /// Who a token's secret acts as. | |
| 175 | pub fn authenticate(&self, secret: &str) -> Option<Actor> { | |
| 176 | self.state.token_actor(&token_hash(secret)) | |
| 177 | } | |
| 178 | ||
| 159 | 179 | pub fn revoke_agent(&mut self, person: &str, id: &str) -> Result<()> { |
| 160 | 180 | self.write(&Actor::person(person), Self::rec("agent.revoke", vec![("id", id.into())])) |
| 161 | 181 | } |
| 291 | 311 | pub fn attach(&mut self, by: &Actor, owner: &str, name: &str, change: u64, revision: u32, title: &str, media_type: &str, content: Content) -> Result<()> { |
| 292 | 312 | let (blob, url) = match content { |
| 293 | 313 | Content::Bytes(bytes) => (Value::from(crate::hex::encode(&self.objects.put_blob(bytes)?)), Value::Null), |
| 314 | Content::Blob(id) => { | |
| 315 | self.objects.get_blob(&id)?; | |
| 316 | (Value::from(crate::hex::encode(&id)), Value::Null) | |
| 317 | } | |
| 294 | 318 | Content::Url(url) => (Value::Null, Value::from(url)), |
| 295 | 319 | }; |
| 296 | 320 | self.write( |
src/wire.rsadded+316 −0
| 1 | //! The model as JSON, for the API: what the server sends and the CLI reads back. | |
| 2 | ||
| 3 | use crate::hex; | |
| 4 | use crate::json::{obj, Value}; | |
| 5 | use crate::model::{Actor, Change, ChangeState, Event, Evidence, Message, Point, Project, Revision, State, Step}; | |
| 6 | use crate::objects::Id; | |
| 7 | ||
| 8 | fn id(i: &Id) -> Value { | |
| 9 | Value::Str(hex::encode(i)) | |
| 10 | } | |
| 11 | ||
| 12 | fn actor(a: &Actor) -> Value { | |
| 13 | a.to_json() | |
| 14 | } | |
| 15 | ||
| 16 | fn step(s: &Step) -> Value { | |
| 17 | crate::model::step_to_json(s) | |
| 18 | } | |
| 19 | ||
| 20 | pub fn project_json(state: &State, p: &Project) -> Value { | |
| 21 | // Names of every agent that appears, so clients can say "claude for anton". | |
| 22 | let mut agents: Vec<(String, Value)> = Vec::new(); | |
| 23 | let mut note = |a: &Actor| { | |
| 24 | if let Some(aid) = &a.agent { | |
| 25 | if !agents.iter().any(|(k, _)| k == aid) { | |
| 26 | let name = state.agent(aid).map(|g| g.name.clone()).unwrap_or_else(|| aid.clone()); | |
| 27 | agents.push((aid.clone(), name.into())); | |
| 28 | } | |
| 29 | } | |
| 30 | }; | |
| 31 | for pt in &p.points { | |
| 32 | note(&pt.author); | |
| 33 | note(&pt.landed_by); | |
| 34 | } | |
| 35 | for c in &p.changes { | |
| 36 | for (_, by, _) in &c.timeline { | |
| 37 | note(by); | |
| 38 | } | |
| 39 | } | |
| 40 | obj([ | |
| 41 | ("owner", p.owner.clone().into()), | |
| 42 | ("name", p.name.clone().into()), | |
| 43 | ("public", p.public.into()), | |
| 44 | ("at", p.at.into()), | |
| 45 | ("points", Value::Array(p.points.iter().map(point).collect())), | |
| 46 | ("changes", Value::Array(p.changes.iter().map(change).collect())), | |
| 47 | ("agents", Value::Object(agents)), | |
| 48 | ]) | |
| 49 | } | |
| 50 | ||
| 51 | fn point(p: &Point) -> Value { | |
| 52 | obj([ | |
| 53 | ("n", p.n.into()), | |
| 54 | ("snapshot", id(&p.snapshot)), | |
| 55 | ("message", p.message.clone().into()), | |
| 56 | ("author", actor(&p.author)), | |
| 57 | ("landed_by", actor(&p.landed_by)), | |
| 58 | ("change", p.change.into()), | |
| 59 | ("step", p.step.clone().into()), | |
| 60 | ("at", p.at.into()), | |
| 61 | ]) | |
| 62 | } | |
| 63 | ||
| 64 | fn change(c: &Change) -> Value { | |
| 65 | obj([ | |
| 66 | ("number", c.number.into()), | |
| 67 | ("intent", c.intent.clone().into()), | |
| 68 | ("proposer", actor(&c.proposer)), | |
| 69 | ("state", c.state.name().into()), | |
| 70 | ("at", c.at.into()), | |
| 71 | ( | |
| 72 | "revisions", | |
| 73 | Value::Array( | |
| 74 | c.revisions | |
| 75 | .iter() | |
| 76 | .map(|r| { | |
| 77 | obj([ | |
| 78 | ("number", (r.number as u64).into()), | |
| 79 | ("base", r.base.into()), | |
| 80 | ("steps", Value::Array(r.steps.iter().map(step).collect())), | |
| 81 | ("note", r.note.clone().into()), | |
| 82 | ("by", actor(&r.by)), | |
| 83 | ("at", r.at.into()), | |
| 84 | ]) | |
| 85 | }) | |
| 86 | .collect(), | |
| 87 | ), | |
| 88 | ), | |
| 89 | ( | |
| 90 | "messages", | |
| 91 | Value::Array(c.messages.iter().map(|m| obj([("id", (m.id as u64).into()), ("body", m.body.clone().into()), ("by", actor(&m.by)), ("at", m.at.into())])).collect()), | |
| 92 | ), | |
| 93 | ( | |
| 94 | "evidence", | |
| 95 | Value::Array( | |
| 96 | c.evidence | |
| 97 | .iter() | |
| 98 | .map(|e| { | |
| 99 | obj([ | |
| 100 | ("revision", (e.revision as u64).into()), | |
| 101 | ("title", e.title.clone().into()), | |
| 102 | ("type", e.media_type.clone().into()), | |
| 103 | ("blob", e.blob.as_ref().map(|b| hex::encode(b)).into()), | |
| 104 | ("url", e.url.clone().into()), | |
| 105 | ("by", actor(&e.by)), | |
| 106 | ("at", e.at.into()), | |
| 107 | ]) | |
| 108 | }) | |
| 109 | .collect(), | |
| 110 | ), | |
| 111 | ), | |
| 112 | ("timeline", Value::Array(c.timeline.iter().map(|(at, by, ev)| event(*at, by, ev)).collect())), | |
| 113 | ]) | |
| 114 | } | |
| 115 | ||
| 116 | fn event(at: i64, by: &Actor, ev: &Event) -> Value { | |
| 117 | let (kind, detail): (&str, Value) = match ev { | |
| 118 | Event::Opened => ("opened", Value::Null), | |
| 119 | Event::Revised(n) => ("revised", (*n as u64).into()), | |
| 120 | Event::State(s) => ("state", s.name().into()), | |
| 121 | Event::Said(n) => ("said", (*n as u64).into()), | |
| 122 | Event::Attached(i) => ("attached", (*i as u64).into()), | |
| 123 | Event::Landed { revision, points } => ( | |
| 124 | "landed", | |
| 125 | obj([("revision", (*revision as u64).into()), ("first", points.0.into()), ("last", points.1.into())]), | |
| 126 | ), | |
| 127 | }; | |
| 128 | obj([("at", at.into()), ("by", actor(by)), ("event", kind.into()), ("detail", detail)]) | |
| 129 | } | |
| 130 | ||
| 131 | // ── Reading it back ──────────────────────────────────────── | |
| 132 | ||
| 133 | type R<T> = Result<T, String>; | |
| 134 | ||
| 135 | fn s(v: &Value, k: &str) -> R<String> { | |
| 136 | v.get(k).and_then(Value::as_str).map(str::to_string).ok_or_else(|| format!("missing {k}")) | |
| 137 | } | |
| 138 | fn os(v: &Value, k: &str) -> Option<String> { | |
| 139 | v.get(k).and_then(Value::as_str).map(str::to_string) | |
| 140 | } | |
| 141 | fn n(v: &Value, k: &str) -> R<i64> { | |
| 142 | v.get(k).and_then(Value::as_i64).ok_or_else(|| format!("missing {k}")) | |
| 143 | } | |
| 144 | fn arr<'a>(v: &'a Value, k: &str) -> &'a [Value] { | |
| 145 | v.get(k).and_then(Value::as_array).unwrap_or(&[]) | |
| 146 | } | |
| 147 | fn id_of(v: &Value, k: &str) -> R<Id> { | |
| 148 | hex::decode(&s(v, k)?).and_then(|b| b.try_into().ok()).ok_or_else(|| format!("bad id in {k}")) | |
| 149 | } | |
| 150 | fn actor_of(v: &Value, k: &str) -> R<Actor> { | |
| 151 | let a = v.get(k).ok_or_else(|| format!("missing {k}"))?; | |
| 152 | Ok(Actor { person: s(a, "person")?, agent: os(a, "agent") }) | |
| 153 | } | |
| 154 | ||
| 155 | /// A project as sent by `project_json`, plus agent names by id. | |
| 156 | pub fn project_from_json(v: &Value) -> R<(Project, Vec<(String, String)>)> { | |
| 157 | let points = arr(v, "points") | |
| 158 | .iter() | |
| 159 | .map(|p| { | |
| 160 | Ok(Point { | |
| 161 | n: n(p, "n")? as u64, | |
| 162 | snapshot: id_of(p, "snapshot")?, | |
| 163 | message: s(p, "message")?, | |
| 164 | author: actor_of(p, "author")?, | |
| 165 | landed_by: actor_of(p, "landed_by")?, | |
| 166 | change: p.get("change").and_then(Value::as_i64).map(|x| x as u64), | |
| 167 | step: os(p, "step"), | |
| 168 | at: n(p, "at")?, | |
| 169 | }) | |
| 170 | }) | |
| 171 | .collect::<R<Vec<_>>>()?; | |
| 172 | let changes = arr(v, "changes") | |
| 173 | .iter() | |
| 174 | .map(|c| { | |
| 175 | let revisions = arr(c, "revisions") | |
| 176 | .iter() | |
| 177 | .map(|r| { | |
| 178 | Ok(Revision { | |
| 179 | number: n(r, "number")? as u32, | |
| 180 | base: n(r, "base")? as u64, | |
| 181 | steps: arr(r, "steps") | |
| 182 | .iter() | |
| 183 | .map(|st| Ok(Step { id: s(st, "id")?, snapshot: id_of(st, "snapshot")?, message: s(st, "message")? })) | |
| 184 | .collect::<R<Vec<_>>>()?, | |
| 185 | note: os(r, "note"), | |
| 186 | by: actor_of(r, "by")?, | |
| 187 | at: n(r, "at")?, | |
| 188 | }) | |
| 189 | }) | |
| 190 | .collect::<R<Vec<_>>>()?; | |
| 191 | let messages = arr(c, "messages") | |
| 192 | .iter() | |
| 193 | .map(|m| Ok(Message { id: n(m, "id")? as u32, body: s(m, "body")?, by: actor_of(m, "by")?, at: n(m, "at")? })) | |
| 194 | .collect::<R<Vec<_>>>()?; | |
| 195 | let evidence = arr(c, "evidence") | |
| 196 | .iter() | |
| 197 | .map(|e| { | |
| 198 | Ok(Evidence { | |
| 199 | revision: n(e, "revision")? as u32, | |
| 200 | title: s(e, "title")?, | |
| 201 | media_type: s(e, "type")?, | |
| 202 | blob: os(e, "blob").and_then(|h| hex::decode(&h)).and_then(|b| b.try_into().ok()), | |
| 203 | url: os(e, "url"), | |
| 204 | by: actor_of(e, "by")?, | |
| 205 | at: n(e, "at")?, | |
| 206 | }) | |
| 207 | }) | |
| 208 | .collect::<R<Vec<_>>>()?; | |
| 209 | let timeline = arr(c, "timeline") | |
| 210 | .iter() | |
| 211 | .map(|t| { | |
| 212 | let d = t.get("detail").cloned().unwrap_or(Value::Null); | |
| 213 | let num = |v: &Value| v.as_i64().unwrap_or(0); | |
| 214 | let ev = match s(t, "event")?.as_str() { | |
| 215 | "opened" => Event::Opened, | |
| 216 | "revised" => Event::Revised(num(&d) as u32), | |
| 217 | "state" => Event::State(ChangeState::parse(d.as_str().unwrap_or("")).ok_or("bad state")?), | |
| 218 | "said" => Event::Said(num(&d) as u32), | |
| 219 | "attached" => Event::Attached(num(&d) as usize), | |
| 220 | "landed" => Event::Landed { | |
| 221 | revision: d.get("revision").map(num).unwrap_or(0) as u32, | |
| 222 | points: (d.get("first").map(num).unwrap_or(0) as u64, d.get("last").map(num).unwrap_or(0) as u64), | |
| 223 | }, | |
| 224 | other => return Err(format!("unknown event {other}")), | |
| 225 | }; | |
| 226 | Ok((n(t, "at")?, actor_of(t, "by")?, ev)) | |
| 227 | }) | |
| 228 | .collect::<R<Vec<_>>>()?; | |
| 229 | Ok(Change { | |
| 230 | number: n(c, "number")? as u64, | |
| 231 | intent: s(c, "intent")?, | |
| 232 | proposer: actor_of(c, "proposer")?, | |
| 233 | state: ChangeState::parse(&s(c, "state")?).ok_or("bad state")?, | |
| 234 | revisions, | |
| 235 | messages, | |
| 236 | evidence, | |
| 237 | timeline, | |
| 238 | at: n(c, "at")?, | |
| 239 | }) | |
| 240 | }) | |
| 241 | .collect::<R<Vec<_>>>()?; | |
| 242 | let agents = match v.get("agents") { | |
| 243 | Some(Value::Object(f)) => f.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or(k).to_string())).collect(), | |
| 244 | _ => Vec::new(), | |
| 245 | }; | |
| 246 | let public = matches!(v.get("public"), Some(Value::Bool(true))); | |
| 247 | Ok((Project { owner: s(v, "owner")?, name: s(v, "name")?, public, points, changes, at: n(v, "at")? }, agents)) | |
| 248 | } | |
| 249 | ||
| 250 | /// Objects in transit: each is `length (u32 LE), stored bytes`. | |
| 251 | pub fn frame_objects(objects: &[Vec<u8>]) -> Vec<u8> { | |
| 252 | let mut out = Vec::new(); | |
| 253 | for o in objects { | |
| 254 | out.extend_from_slice(&(o.len() as u32).to_le_bytes()); | |
| 255 | out.extend_from_slice(o); | |
| 256 | } | |
| 257 | out | |
| 258 | } | |
| 259 | ||
| 260 | pub fn unframe_objects(mut bytes: &[u8]) -> R<Vec<&[u8]>> { | |
| 261 | let mut out = Vec::new(); | |
| 262 | while !bytes.is_empty() { | |
| 263 | let len = u32::from_le_bytes(bytes.get(..4).ok_or("short frame")?.try_into().unwrap()) as usize; | |
| 264 | let body = bytes.get(4..4 + len).ok_or("short object")?; | |
| 265 | out.push(body); | |
| 266 | bytes = &bytes[4 + len..]; | |
| 267 | } | |
| 268 | Ok(out) | |
| 269 | } | |
| 270 | ||
| 271 | #[cfg(test)] | |
| 272 | mod tests { | |
| 273 | use super::*; | |
| 274 | use crate::model::Actor; | |
| 275 | use crate::objects::{Entry, Kind}; | |
| 276 | use crate::store::{Content, Store}; | |
| 277 | use crate::testdir::TestDir; | |
| 278 | ||
| 279 | #[test] | |
| 280 | fn project_round_trips() { | |
| 281 | let dir = TestDir::new("wire"); | |
| 282 | let mut store = Store::open(dir.path()).unwrap(); | |
| 283 | store.add_person("a", "A").unwrap(); | |
| 284 | let agent = store.add_agent("a", "claude").unwrap(); | |
| 285 | let me = Actor::agent("a", &agent); | |
| 286 | store.create_project(&Actor::person("a"), "a", "p", true).unwrap(); | |
| 287 | let blob = store.objects.put_blob(b"x").unwrap(); | |
| 288 | let t = store.objects.put_tree(&[Entry { name: "f".into(), kind: Kind::File, id: blob }]).unwrap(); | |
| 289 | store.record_point(&Actor::person("a"), "a", "p", &t, "start", None).unwrap(); | |
| 290 | let c = store.open_change(&me, "a", "p", "do it").unwrap(); | |
| 291 | store.revise(&me, "a", "p", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], Some("note")).unwrap(); | |
| 292 | store.set_state(&me, "a", "p", c, ChangeState::Proposed).unwrap(); | |
| 293 | store.say(&me, "a", "p", c, "hi").unwrap(); | |
| 294 | store.attach(&me, "a", "p", c, 1, "proof", "text/plain", Content::Bytes(b"ok")).unwrap(); | |
| 295 | store.land(&Actor::person("a"), "a", "p", c).unwrap(); | |
| 296 | ||
| 297 | let p = store.project("a", "p").unwrap(); | |
| 298 | let json = project_json(store.state(), p).encode(); | |
| 299 | let (back, agents) = project_from_json(&crate::json::parse(&json).unwrap()).unwrap(); | |
| 300 | assert_eq!(agents, vec![(agent.clone(), "claude".to_string())]); | |
| 301 | assert_eq!(back.points.len(), 2); | |
| 302 | assert_eq!(back.changes[0].state, ChangeState::Landed); | |
| 303 | assert_eq!(back.changes[0].revisions[0].steps, p.changes[0].revisions[0].steps); | |
| 304 | assert_eq!(back.changes[0].timeline.len(), p.changes[0].timeline.len()); | |
| 305 | // Encoding the decoded project gives the same JSON. | |
| 306 | assert_eq!(project_json(store.state(), &back).encode(), json); | |
| 307 | } | |
| 308 | ||
| 309 | #[test] | |
| 310 | fn frames() { | |
| 311 | let objs = vec![b"one".to_vec(), Vec::new(), b"three".to_vec()]; | |
| 312 | let framed = frame_objects(&objs); | |
| 313 | assert_eq!(unframe_objects(&framed).unwrap(), vec![&b"one"[..], &b""[..], &b"three"[..]]); | |
| 314 | assert!(unframe_objects(&framed[..framed.len() - 1]).is_err()); | |
| 315 | } | |
| 316 | } |