Add tagged VFS undo journal, host API, and world CLI

This commit is contained in:
2026-11-07 13:01:43 +00:00
parent a26721194e
commit 7ce839e908
26 changed files with 1568 additions and 39 deletions
+9
View File
@@ -4,6 +4,7 @@ mod auth;
mod pages;
mod server;
mod ssh;
mod undo;
pub use server::{Listener, Options, run_with_userland};
use crate::cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys};
@@ -23,6 +24,7 @@ pub struct Api {
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
pub entry: EntryKey,
requests: Mutex<HashMap<u32, (Instant, u32)>>,
journal: Mutex<Option<Arc<sausage_vfs::journal::Journal>>>,
}
impl Api {
/// Own the connection and ceremony state shared by both transports.
@@ -51,8 +53,12 @@ impl Api {
passkeys: Mutex::new(passkeys),
entry,
requests: Mutex::new(HashMap::new()),
journal: Mutex::new(None),
}
}
pub(crate) fn attach_journal(&self, journal: Option<Arc<sausage_vfs::journal::Journal>>) {
*self.journal.lock().unwrap() = journal;
}
fn rate_limit(&self, caller: &Caller) -> Result<()> {
let now = Instant::now();
let mut requests = self.requests.lock().map_err(|_| Error(500, "storage"))?;
@@ -132,6 +138,9 @@ fn request_view(request: cred::requests::Request) -> Result<Value> {
/// Handle a request without a network transport. Errors contain only static error kinds.
pub async fn handle(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
if request.uri().path() == "/undo" || request.uri().path().starts_with("/undo/") {
return undo::handle(ctx, caller, request).await;
}
respond(ctx, caller, request)
}
fn respond(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
+7 -3
View File
@@ -1,5 +1,5 @@
//! The host HTTP listener and console startup share one owned API and credentials store.
use super::{Api, respond};
use super::Api;
use crate::cred::{Caller, Cred};
use std::{net::SocketAddr, path::Path, sync::Arc};
@@ -37,6 +37,10 @@ impl Listener {
);
let serving = server.clone();
let thread = std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("API runtime");
for mut request in serving.incoming_requests() {
let response = (|| -> anyhow::Result<_> {
let mut body = Vec::new();
@@ -45,7 +49,7 @@ impl Listener {
.method(request.method().as_str())
.uri(request.url())
.body(bytes::Bytes::from(body))?;
Ok(respond(
Ok(runtime.block_on(super::handle(
&api,
Caller {
program: "/host/browser".into(),
@@ -53,7 +57,7 @@ impl Listener {
conversations: Vec::new(),
},
request,
))
)))
})();
let response = response.unwrap_or_else(|_| {
http::Response::builder()
+60
View File
@@ -0,0 +1,60 @@
//! Undo routes share the world's journal. They require no credential approval.
use super::*;
use sausage_vfs::journal::{Tag, Undo};
pub(super) async fn handle(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
let journal = ctx.journal.lock().unwrap().clone();
let result = async {
let journal = journal.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "journal unavailable")
})?;
journal.retain().await?;
let query: HashMap<String, String> =
url::form_urlencoded::parse(request.uri().query().unwrap_or("").as_bytes())
.into_owned()
.collect();
match (request.method().as_str(), request.uri().path()) {
("GET", "/undo" | "/undo/list") => Ok((
200,
serde_json::to_value(journal.list(query.get("conversation").cloned()).await?)?,
)),
("GET", "/undo/show") => {
let tag = query.get("tag").ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "tag required")
})?;
Ok((200, serde_json::to_value(journal.show(tag.clone()).await?)?))
}
("POST", "/undo") => {
let request: Undo = serde_json::from_slice(request.body())?;
let result = journal
.undo(
request,
Tag::process(caller.pid, caller.program, caller.conversations)?,
)
.await?;
let status = if result.tag.is_none() { 409 } else { 200 };
Ok((status, serde_json::to_value(result)?))
}
_ => Ok((404, json!({"error":"not_found"}))),
}
}
.await;
let (status, body) = match result {
Ok(result) => result,
Err(error) => {
let error: Box<dyn std::error::Error> = error;
let status = error
.downcast_ref::<std::io::Error>()
.map_or(400, |e| match e.kind() {
std::io::ErrorKind::NotFound => 404,
std::io::ErrorKind::InvalidInput => 400,
_ => 500,
});
(status, json!({"error":error.to_string()}))
}
};
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Bytes::from(serde_json::to_vec(&body).expect("JSON value")))
.expect("valid response")
}