grove

@6 Server: HTTP API, tokens for people and agents, remote CLI, deploy to grove.host

by claude for antonky · landed by antonky · · from #5 · files at @6

Files

deploy/Dockerfileadded+12 −0
1# grove server. Built on grove.host by deploy.sh; the store lives in /data (a host volume).
2FROM rust:1-alpine AS build
3WORKDIR /src
4COPY Cargo.toml ./
5COPY src ./src
6RUN cargo build --release
7
8FROM alpine:3
9COPY --from=build /src/target/release/grove /usr/local/bin/grove
10ENV GROVE_STORE=/data
11EXPOSE 4800
12CMD ["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.
4set -eu
5HOST=${GROVE_HOST:-root@grove.host}
6
7rsync -a --delete --exclude target --exclude .grove ./ "$HOST:/opt/grove2/src/"
8ssh "$HOST" sh -eu <<'REMOTE'
9cd /opt/grove2/src
10docker build -q -t grove2 -f deploy/Dockerfile . >/dev/null
11mkdir -p /opt/grove2/data
12docker rm -f grove2 >/dev/null 2>&1 || true
13docker run -d --name grove2 --restart unless-stopped --network grove_default -v /opt/grove2/data:/data grove2 >/dev/null
14cp deploy/grove.caddy /opt/grove/sites/grove.caddy
15docker exec grove-caddy-1 caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
16sleep 1
17docker exec grove2 wget -qO- http://127.0.0.1:4800/api/health
18echo
19REMOTE
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).
3grove.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
7use crate::http::{Request, Response};
8use crate::json::{self, obj, Value};
9use crate::model::{Actor, ChangeState, Step};
10use crate::objects::{Id, Objects};
11use crate::store::{self, Content, Store};
12use crate::{hex, wire};
13use std::sync::Mutex;
14
15pub struct Api {
16 store: Mutex<Store>,
17 /// Object reads and writes don't need the store's lock.
18 objects: Objects,
19}
20
21type Out = Result<Response, Response>;
22
23fn err(status: u16, msg: impl AsRef<str>) -> Response {
24 Response::error(status, msg.as_ref())
25}
26
27fn 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
35fn 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
43fn 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
47fn 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
51fn 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
55fn 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
59fn ok(v: Value) -> Out {
60 Ok(Response::json(200, &v))
61}
62
63impl 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
7use crate::client::Client;
8use crate::hex;
9use crate::json::{obj, Value};
10use crate::model::{step_to_json, Actor, ChangeState, Project, Step};
11use crate::objects::{Id, Objects};
12use crate::store::{Content, Store};
13use crate::wire;
14
15pub type R<T> = Result<T, String>;
16
17pub 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
39pub struct Local(pub Store);
40
41fn s<T>(r: crate::store::Result<T>) -> R<T> {
42 r.map_err(|e| e.to_string())
43}
44
45impl 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
95pub 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.
105const BATCH: usize = 2000;
106
107impl 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
156impl 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.
32//!
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.
88
9use crate::backend::{Backend, Local as LocalStore, Remote};
910use crate::diff;
1011use crate::json::{self, obj, Value};
1112use crate::model::{Actor, Change, ChangeState, Event, Project, Step};
12use crate::objects::Id;
13use crate::objects::{Id, Objects};
1314use crate::store::{self, Content, Store};
1415use crate::workdir::{self, FileChange};
1516use crate::{hex, random};
6162 }
6263}
6364
64// ── Config and working directory state ─────────────────────
65// ── Config ─────────────────────────────────────────────────
6566
6667fn home() -> PathBuf {
6768 PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into()))
7677}
7778
7879struct Config {
79 store: PathBuf,
80 store: Option<PathBuf>,
81 server: Option<String>,
82 token: Option<String>,
83 person_token: Option<String>,
8084 person: String,
8185 agent: Option<String>,
8286}
8387
8488impl Config {
8589 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 };
8998 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"),
93105 })
94106 }
95107 fn actor(&self) -> Actor {
98110 fn in_person(&self) -> Actor {
99111 Actor::person(&self.person)
100112 }
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 }
101121}
102122
123/// The server, if one is configured; otherwise the local store.
124fn 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
135fn 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.
149fn 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
103161/// A working directory's local state: which project, which point it's on, and the steps
104162/// not yet sent anywhere.
105163struct Local {
135193 .unwrap_or(&[])
136194 .iter()
137195 .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")?;
142197 Ok(Step {
143198 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")?,
145200 message: s.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
146201 })
147202 })
177232 None => project.point(self.base).map(|p| p.snapshot).ok_or_else(|| format!("point {} doesn't exist", self.base)),
178233 }
179234 }
180}
181235
182fn 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)
186238 }
187 Ok(store)
188239}
189240
190fn s<T>(r: store::Result<T>) -> Result<T> {
191 r.map_err(|e| e.to_string())
192}
193
194241// ── Formatting ─────────────────────────────────────────────
195242
196243fn ago(ms: i64) -> String {
204251}
205252
206253/// "claude for anton", using the agent's name rather than its id.
207fn who(store: &Store, a: &Actor) -> String {
254fn who(b: &dyn Backend, a: &Actor) -> String {
208255 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),
210257 None => a.person.clone(),
211258 }
212259}
215262 hex::encode(&id[..6])
216263}
217264
265fn plural(n: usize, word: &str) -> String {
266 format!("{n} {word}{}", if n == 1 { "" } else { "s" })
267}
268
218269fn change_number(arg: Option<&str>, local: Option<&Local>) -> Result<u64> {
219270 match arg {
220271 Some(a) => a.trim_start_matches('#').parse().map_err(|_| format!("{a:?} isn't a change number")),
222273 }
223274}
224275
276fn err<E: std::fmt::Display>(e: E) -> String {
277 e.to_string()
278}
279
225280// ── Commands ───────────────────────────────────────────────
226281
227282pub fn run(argv: Vec<String>) -> Result<()> {
228283 let cmd = argv.get(1).map(String::as_str).unwrap_or("help");
229284 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 );
231289 match cmd {
232290 "setup" => setup(&args),
291 "login" => login(&args),
233292 "init" => init(&args),
234293 "checkout" => checkout(&args),
235294 "status" | "st" => status(),
247306 "log" => log(),
248307 "sync" => sync(),
249308 "restart" => restart(),
250 "verify" => verify(),
251309 "group" => group(&args),
252310 "move" => move_project(&args),
311 "agent" => agent_cmd(&args),
312 "token" => token_cmd(&args),
313 "verify" => verify(),
314 "serve" => serve(&args),
253315 "help" | "--help" | "-h" => {
254316 print!("{HELP}");
255317 Ok(())
260322
261323const HELP: &str = "grove: where agents build software for people
262324
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
264326 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
265327 checkout <owner/name> [DIR] get a project's files
266328 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
268330 step -m MESSAGE record the working directory as a new step
269331 amend [-m MESSAGE] replace the last step with the working directory
270332 stack the local steps
271333 propose -m INTENT [--note NOTE] send the steps as a change (or a new revision)
272334 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
274336 changes a project's changes
275337 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)
278340 log main, newest first
279341 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
282343 group create NAME | group add NAME PERSON groups own projects together (in person)
283344 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
284352";
285353
286354fn setup(args: &Args) -> Result<()> {
355 check_config_is_ours()?;
287356 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 }
295357 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)?;
297359 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)?;
299361 println!("created person {username}");
300362 }
301363 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)?),
313365 None => None,
314366 };
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())])?;
318368 println!("config written to {}", config_path().display());
319369 Ok(())
320370}
321371
372fn 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
381fn 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
322411fn init(args: &Args) -> Result<()> {
323412 let cfg = Config::load()?;
324413 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)?;
326415 if root.join(".grove").exists() {
327416 return e!("this folder is already a grove working directory");
328417 }
329 let mut store = open_store(&cfg)?;
418 let mut b = connect(&cfg)?;
330419 let me = cfg.in_person();
331420 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();
337425 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"));
339427 Ok(())
340428}
341429
347435 if dir.join(".grove").exists() {
348436 return e!("{} is already a working directory", dir.display());
349437 }
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)?;
352440 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)?;
355444 Local { root: dir.clone(), owner: owner.into(), project: name.into(), base: tip.n, change: None, steps: Vec::new() }.save()?;
356445 println!("{owner}/{name}@{} in {}", tip.n, dir.display());
357446 Ok(())
358447}
359448
360fn working_changes(store: &Store, local: &Local) -> Result<(Id, Id, Vec<(String, FileChange)>)> {
361 let p = s(store.project(&local.owner, &local.project))?;
449fn working_changes(b: &mut dyn Backend, p: &Project, local: &Local) -> Result<(Id, Id, Vec<(String, FileChange)>)> {
362450 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, &current, &now).map_err(|e| e.to_string())?;
451 b.need(&current)?;
452 let now = workdir::snapshot(b.objects(), &local.root).map_err(err)?;
453 let changes = workdir::compare(b.objects(), &current, &now).map_err(err)?;
365454 Ok((current, now, changes))
366455}
367456
368457fn status() -> Result<()> {
369458 let cfg = Config::load()?;
370459 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)?;
373462 let behind = p.tip().saturating_sub(local.base);
374463 println!(
375464 "{}/{} on @{}{}{}",
380469 local.change.map(|c| format!(", change #{c}")).unwrap_or_default()
381470 );
382471 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"));
384473 }
385 let (_, _, changes) = working_changes(&store, &local)?;
474 let (_, _, changes) = working_changes(&mut *b, &p, &local)?;
386475 if changes.is_empty() {
387476 println!("no changes since the last step");
388477 }
397486 Ok(())
398487}
399488
400fn 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)?);
489fn 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)?);
408496 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);
411499 if stat {
412500 let adds = d.lines().filter(|l| l.starts_with('+')).count();
413501 let dels = d.lines().filter(|l| l.starts_with('-')).count();
426514fn diff_cmd(args: &Args) -> Result<()> {
427515 let cfg = Config::load()?;
428516 let local = Local::find()?;
429 let store = open_store(&cfg)?;
517 let mut b = connect(&cfg)?;
518 let p = local.project(&mut *b)?;
430519 if let Some(n) = args.pos(0).filter(|a| a.starts_with('#')) {
431520 // A revision of a change: from its base to its last step.
432521 let number = change_number(Some(n), None)?;
433 let p = s(store.project(&local.owner, &local.project))?;
434522 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
435523 let r = match args.pos(1) {
436524 Some(rv) => {
440528 None => c.latest().ok_or_else(|| format!("#{number} has no revisions"))?,
441529 };
442530 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)?;
443534 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"));
445536 }
446 let (current, now, _) = working_changes(&store, &local)?;
447 print_diff(&store, &current, &now, args.has("--stat"))
537 let (current, now, _) = working_changes(&mut *b, &p, &local)?;
538 print_diff(&*b, &current, &now, args.has("--stat"))
448539}
449540
450541fn step(args: &Args) -> Result<()> {
451542 let cfg = Config::load()?;
452543 let mut local = Local::find()?;
453 let store = open_store(&cfg)?;
544 let mut b = connect(&cfg)?;
454545 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)?;
456548 if changes.is_empty() {
457549 return e!("nothing changed since the last step");
458550 }
459551 local.steps.push(Step { id: random::hex(4), snapshot: now, message: message.to_string() });
460552 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"));
462554 Ok(())
463555}
464556
465557fn amend(args: &Args) -> Result<()> {
466558 let cfg = Config::load()?;
467559 let mut local = Local::find()?;
468 let store = open_store(&cfg)?;
560 let b = connect(&cfg)?;
469561 if local.steps.is_empty() {
470562 return e!("no local step to amend; use `grove step`");
471563 }
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)?;
473565 let last = local.steps.last_mut().unwrap();
474566 last.snapshot = now;
475567 if let Some(m) = args.value(&["-m", "--message"]) {
497589fn propose(args: &Args) -> Result<()> {
498590 let cfg = Config::load()?;
499591 let mut local = Local::find()?;
500 let mut store = open_store(&cfg)?;
592 let mut b = connect(&cfg)?;
501593 if local.steps.is_empty() {
502594 return e!("no steps to propose; record some with `grove step`");
503595 }
507599 Some(n) => n,
508600 None => {
509601 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)?
511603 }
512604 };
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)?;
517609 }
518610 local.change = Some(number);
519611 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"));
521613 Ok(())
522614}
523615
524616fn say(args: &Args) -> Result<()> {
525617 let cfg = Config::load()?;
526 let local = Local::find().ok();
618 let local = Local::find()?;
527619 let (number, text) = match args.pos(0) {
528620 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(" ")),
530622 };
531623 if text.trim().is_empty() {
532624 return e!("say what?");
533625 }
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)?;
537627 println!("added to #{number}");
538628 Ok(())
539629}
545635 Some(a) if a.starts_with('#') => (change_number(Some(a), None)?, args.pos(1)),
546636 _ => (change_number(None, Some(&local))?, args.pos(0)),
547637 };
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")?;
554640 let me = cfg.actor();
555641 if let Some(url) = args.value(&["--url"]) {
556642 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))?;
558644 } else {
559645 let file = file.ok_or("attach a file, or a link with --url")?;
560646 let bytes = fs::read(file).map_err(|e| format!("{file}: {e}"))?;
561647 let media = args.value(&["--type"]).map(str::to_string).unwrap_or_else(|| guess_type(file));
562648 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))?;
564650 }
565651 println!("attached to #{number} r{rev}");
566652 Ok(())
586672fn changes() -> Result<()> {
587673 let cfg = Config::load()?;
588674 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)?;
591677 if p.changes.is_empty() {
592678 println!("no changes yet");
593679 }
594680 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));
596682 }
597683 Ok(())
598684}
601687 let cfg = Config::load()?;
602688 let local = Local::find()?;
603689 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)?;
606692 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
607693 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));
609695 for (at, by, ev) in &c.timeline {
610696 let line = match ev {
611697 Event::Opened => "opened".to_string(),
612698 Event::Revised(n) => {
613699 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"));
615701 for st in &r.steps {
616702 l += &format!("\n {} {}", short(&st.snapshot), st.message);
617703 }
628714 }
629715 Event::Landed { revision, points } => format!("landed r{revision} as @{}..@{}", points.0, points.1),
630716 };
631 println!(" {:<10} {}: {}", ago(*at), who(&store, by), line);
717 println!(" {:<10} {}: {}", ago(*at), who(&*b, by), line);
632718 }
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)?;
639726 }
640727 Ok(())
641728}
644731 let cfg = Config::load()?;
645732 let local = Local::find()?;
646733 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)?;
649735 println!("#{number} landed as @{first}..@{last}");
650736 Ok(())
651737}
654740 let cfg = Config::load()?;
655741 let local = Local::find()?;
656742 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)?;
659744 println!("#{number} declined");
660745 Ok(())
661746}
663748fn log() -> Result<()> {
664749 let cfg = Config::load()?;
665750 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)?;
668753 for pt in p.points.iter().rev() {
669754 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));
671756 }
672757 Ok(())
673758}
675760fn sync() -> Result<()> {
676761 let cfg = Config::load()?;
677762 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)?;
680765 // A change of ours that landed. If what landed is the start of our local stack, the base
681766 // moves to the last landed point and any steps recorded after it stay, unsent.
682767 if let Some(c) = local.change.and_then(|n| p.change(n)) {
688773 if let Some((rev, last_point)) = landed {
689774 let landed_steps = &c.revisions[rev as usize - 1].steps;
690775 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 {
696778 return e!("#{} landed as something other than the local steps; look at `grove show {}` before syncing", c.number, c.number);
697779 }
780 local.base = last_point;
781 local.steps.drain(..k);
698782 }
699783 local.change = None;
700784 }
708792 let change = local.change.map(|c| format!("#{c}")).unwrap_or_else(|| "your steps".into());
709793 return e!(
710794 "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()
712798 );
713799 }
714 let (current, _, pending) = working_changes(&store, &local)?;
800 let (current, _, pending) = working_changes(&mut *b, &p, &local)?;
715801 if !pending.is_empty() {
716802 return e!("the working directory has changes; record them as a step first");
717803 }
718804 let tip = p.points.last().unwrap();
719 workdir::materialize(&store.objects, &tip.snapshot, &local.root, Some(&current)).map_err(|e| e.to_string())?;
805 b.need(&tip.snapshot)?;
806 workdir::materialize(b.objects(), &tip.snapshot, &local.root, Some(&current)).map_err(err)?;
720807 local.base = tip.n;
721808 local.save()?;
722809 println!("now at @{}", tip.n);
723810 Ok(())
724811}
725812
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
727814/// the change). The change stays linked, so the next `propose` adds a revision built on the tip.
728815fn restart() -> Result<()> {
729816 let cfg = Config::load()?;
730817 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)?;
734821 if !pending.is_empty() {
735822 return e!("the working directory has changes; record them as a step first, so they aren't lost");
736823 }
741828 }
742829 }
743830 let tip = p.points.last().ok_or("no points")?;
744 workdir::materialize(&store.objects, &tip.snapshot, &local.root, Some(&current)).map_err(|e| e.to_string())?;
831 b.need(&tip.snapshot)?;
832 workdir::materialize(b.objects(), &tip.snapshot, &local.root, Some(&current)).map_err(err)?;
745833 local.steps.clear();
746834 local.base = tip.n;
747835 local.save()?;
751839
752840fn group(args: &Args) -> Result<()> {
753841 let cfg = Config::load()?;
754 let mut store = open_store(&cfg)?;
842 let mut b = connect(&cfg)?;
755843 let me = cfg.in_person();
756844 match (args.pos(0), args.pos(1), args.pos(2)) {
757845 (Some("create"), Some(name), None) => {
758 s(store.create_group(&me, name))?;
846 b.create_group(&me, name)?;
759847 println!("group {name} created; members: {}", cfg.person);
760848 }
761849 (Some("add"), Some(name), Some(person)) => {
762 s(store.add_member(&me, name, person))?;
850 b.add_member(&me, name, person)?;
763851 println!("{person} added to {name}");
764852 }
765853 _ => return e!("usage: grove group create NAME | grove group add NAME PERSON"),
771859 let cfg = Config::load()?;
772860 let mut local = Local::find()?;
773861 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)?;
776863 println!("{}/{} is now {to}/{}", local.owner, local.project, local.project);
777864 local.owner = to.to_string();
778865 local.save()
779866}
780867
868// ── On the machine that holds the store ────────────────────
869
870fn 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
883fn 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
781893fn verify() -> Result<()> {
782894 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)?;
785897 println!("{n} objects checked, all match their ids; {} log records", store.state().records);
786898 Ok(())
899}
900
901fn 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)
787909}
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
7use crate::json::{self, Value};
8use std::io::Write;
9use std::process::{Command, Stdio};
10
11pub struct Client {
12 base: String,
13 token: Option<String>,
14}
15
16fn quote(s: &str) -> String {
17 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
18}
19
20impl 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
6use std::io::{self, BufRead, BufReader, Read, Write};
7use std::net::{TcpListener, TcpStream};
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::Arc;
10use std::time::Duration;
11
12const MAX_HEAD: usize = 64 << 10;
13pub const MAX_BODY: usize = 512 << 20;
14const MAX_CONNECTIONS: usize = 128;
15
16pub 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
24impl 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
30pub struct Response {
31 pub status: u16,
32 pub content_type: &'static str,
33 pub body: Vec<u8>,
34}
35
36impl 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
48fn 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
65fn 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.
70fn 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
123fn 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
136pub 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.
139pub 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
11//! grove: where agents build software for people. See VISION.md and docs/model.md.
22
3pub mod api;
4pub mod backend;
35pub mod blake3;
46pub mod cli;
7pub mod client;
58pub mod diff;
69pub mod hex;
10pub mod http;
711pub mod ignore;
812pub mod json;
913pub mod log;
1115pub mod objects;
1216pub mod random;
1317pub mod store;
18pub mod wire;
1419pub mod workdir;
1520
1621#[cfg(test)]
src/model.rsmodified+45 −0
101101 pub at: i64,
102102}
103103
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.
104106#[derive(Clone, Debug)]
107pub 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)]
105116pub struct Agent {
106117 pub id: String,
107118 pub person: String,
219230 pub people: Vec<Person>,
220231 pub agents: Vec<Agent>,
221232 pub groups: Vec<Group>,
233 pub tokens: Vec<Token>,
222234 pub projects: Vec<Project>,
223235 pub records: u64,
224236}
284296 pub fn owns(&self, owner: &str, person: &str) -> bool {
285297 owner == person || self.group(owner).is_some_and(|g| g.members.iter().any(|m| m == person))
286298 }
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 }
287309 pub fn agent(&self, id: &str) -> Option<&Agent> {
288310 self.agents.iter().find(|a| a.id == id)
289311 }
330352 let id = str_field(rec, "id")?;
331353 let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?;
332354 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;
333378 }
334379 "group" => {
335380 let name = str_field(rec, "name")?;
src/objects.rsmodified+49 −0
223223 Ok(())
224224 }
225225
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
226275 /// Confirm every object under a tree exists and matches its id.
227276 pub fn verify_tree(&self, tree: &Id) -> Result<usize> {
228277 let mut count = 1;
src/store.rsmodified+24 −0
6464/// What a piece of evidence holds.
6565pub enum Content<'a> {
6666 Bytes(&'a [u8]),
67 /// A blob already stored (uploaded beforehand).
68 Blob(Id),
6769 Url(&'a str),
6870}
6971
72pub fn token_hash(secret: &str) -> String {
73 crate::hex::encode(&crate::blake3::hash(secret.as_bytes()))
74}
75
7076pub fn now_ms() -> i64 {
7177 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
7278}
156162 Ok(id)
157163 }
158164
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
159179 pub fn revoke_agent(&mut self, person: &str, id: &str) -> Result<()> {
160180 self.write(&Actor::person(person), Self::rec("agent.revoke", vec![("id", id.into())]))
161181 }
291311 pub fn attach(&mut self, by: &Actor, owner: &str, name: &str, change: u64, revision: u32, title: &str, media_type: &str, content: Content) -> Result<()> {
292312 let (blob, url) = match content {
293313 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 }
294318 Content::Url(url) => (Value::Null, Value::from(url)),
295319 };
296320 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
3use crate::hex;
4use crate::json::{obj, Value};
5use crate::model::{Actor, Change, ChangeState, Event, Evidence, Message, Point, Project, Revision, State, Step};
6use crate::objects::Id;
7
8fn id(i: &Id) -> Value {
9 Value::Str(hex::encode(i))
10}
11
12fn actor(a: &Actor) -> Value {
13 a.to_json()
14}
15
16fn step(s: &Step) -> Value {
17 crate::model::step_to_json(s)
18}
19
20pub 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
51fn 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
64fn 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
116fn 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
133type R<T> = Result<T, String>;
134
135fn 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}
138fn os(v: &Value, k: &str) -> Option<String> {
139 v.get(k).and_then(Value::as_str).map(str::to_string)
140}
141fn n(v: &Value, k: &str) -> R<i64> {
142 v.get(k).and_then(Value::as_i64).ok_or_else(|| format!("missing {k}"))
143}
144fn arr<'a>(v: &'a Value, k: &str) -> &'a [Value] {
145 v.get(k).and_then(Value::as_array).unwrap_or(&[])
146}
147fn 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}
150fn 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.
156pub 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`.
251pub 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
260pub 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)]
272mod 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}