Files
memona-plugin-aindrive/src/test_host.rs
T
RaymondCho b9f968a7d2 Save and delete files in an aindrive drive without overwriting another change
A token that may write opens a writable folder. A save sends the file
whole through write_file with the revision it was read at, or "none" for
create-only, so aindrive refuses it as a conflict when the file moved on;
saves are capped at one 1 MiB transfer, which a table-driven encoder fits
into one call's fuel. A save whose answer is lost, or that the drive's
device timed out on, is reported with an unknown outcome. Deleting takes
one file; folders are refused because delete_path is recursive and
listings hide entries such as .git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:26:35 +09:00

332 lines
10 KiB
Rust

//! An in-memory aindrive behind the generated synchronous `Guest` API. It
//! answers tool calls the way the drive's MCP endpoint does, as one
//! server-sent event; live QA covers Memona's real HTTP host and the service.
pub use crate::sdk::host::{Header, HostError, Method, Request, Response};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde_json::{Value, json};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
pub struct File {
pub bytes: Vec<u8>,
/// Written into replies verbatim, the way the device's float prints.
pub mtime: String,
}
impl File {
pub fn revision(&self) -> String {
format!("m{}-s{}", self.mtime, self.bytes.len())
}
}
#[derive(Default)]
pub struct Drive {
/// Drive-relative paths. A folder is any prefix of a file, or listed here.
pub files: BTreeMap<String, File>,
pub folders: Vec<String>,
/// The tools `tools/list` offers; every tool by default.
pub tools: Option<Vec<&'static str>>,
/// The status every request answers with, when not 200.
pub status: Option<u16>,
/// Replaces a file between a stat and the read that follows it.
pub change_on_read: Option<File>,
/// A tool that answers with this error text instead of acting.
pub fail: Option<(&'static str, &'static str)>,
/// A tool that acts, but whose reply is lost on the way back.
pub lose_reply: Option<&'static str>,
pub writes: usize,
pub seen: Vec<Seen>,
}
pub struct Seen {
pub url: String,
pub authorization: Option<String>,
pub method: String,
pub tool: Option<String>,
}
thread_local! {
static ACTIVE: RefCell<Option<Rc<RefCell<Drive>>>> =
const { RefCell::new(None) };
}
pub fn install(drive: Drive) -> Rc<RefCell<Drive>> {
let drive = Rc::new(RefCell::new(drive));
ACTIVE.set(Some(drive.clone()));
drive
}
pub fn file(bytes: &[u8], mtime: &str) -> File {
File {
bytes: bytes.to_vec(),
mtime: mtime.to_owned(),
}
}
pub fn http(request: &Request) -> Result<Exchange, HostError> {
if !matches!(request.method, Method::Post) {
return Err(HostError::InvalidInput);
}
let drive = ACTIVE
.with_borrow(Clone::clone)
.ok_or(HostError::Internal)?;
Ok(Exchange {
drive,
request: request.clone(),
body: RefCell::new(Vec::new()),
reply: RefCell::new(None),
})
}
pub struct Exchange {
drive: Rc<RefCell<Drive>>,
request: Request,
body: RefCell<Vec<u8>>,
reply: RefCell<Option<Vec<u8>>>,
}
impl Exchange {
pub fn push(&self, data: &[u8]) -> Result<(), HostError> {
if data.len() > 1024 * 1024 || self.reply.borrow().is_some() {
return Err(HostError::InvalidInput);
}
self.body.borrow_mut().extend_from_slice(data);
Ok(())
}
pub fn respond(&self) -> Result<Response, HostError> {
let call: Value = serde_json::from_slice(&self.body.borrow())
.map_err(|_| HostError::InvalidInput)?;
let mut drive = self.drive.borrow_mut();
let authorization = self
.request
.headers
.iter()
.find(|header| header.name == "authorization")
.map(|header| header.value.clone());
drive.seen.push(Seen {
url: self.request.url.clone(),
authorization,
method: call["method"].as_str().unwrap_or_default().to_owned(),
tool: call["params"]["name"].as_str().map(str::to_owned),
});
let (status, reply) = match drive.status {
Some(status) => (status, b"{\"error\":\"refused\"}".to_vec()),
None => (200, event(&drive.answer(&call))),
};
if drive.lose_reply.is_some()
&& drive.lose_reply == call["params"]["name"].as_str()
{
return Err(HostError::Unavailable);
}
*self.reply.borrow_mut() = Some(reply);
Ok(Response {
status,
headers: Vec::new(),
})
}
/// Hands the reply over in pieces smaller than asked for, so a caller
/// that stops at the first short piece loses the rest.
pub fn pull(&self, limit: u32) -> Result<Vec<u8>, HostError> {
let mut reply = self.reply.borrow_mut();
let reply = reply.as_mut().ok_or(HostError::InvalidInput)?;
let count = reply.len().min(limit as usize).min(4096);
Ok(reply.drain(..count).collect())
}
}
fn event(message: &Value) -> Vec<u8> {
format!("event: message\ndata: {message}\n\n").into_bytes()
}
fn success(structured: Value) -> Value {
json!({
"result": {
"_meta": { "ai.aindrive/a2ui": [] },
"content": [{ "type": "text", "text": "ok" }],
"structuredContent": structured,
},
"jsonrpc": "2.0",
"id": 1,
})
}
fn failure(text: &str) -> Value {
json!({
"result": {
"_meta": {},
"content": [{ "type": "text", "text": text }],
"isError": true,
},
"jsonrpc": "2.0",
"id": 1,
})
}
impl Drive {
/// As `write_file` does: compare the expected revision, then write.
fn write(&mut self, path: &str, arguments: &Value) -> Value {
let Ok(bytes) =
STANDARD.decode(arguments["content"].as_str().unwrap_or("!"))
else {
return failure("[invalid_params] content (string) required");
};
let current = self.files.get(path).map(File::revision);
let folder = current.is_none() && self.is_folder(path);
let conflict = match arguments["base_revision"].as_str() {
None => false,
Some("none") => current.is_some() || folder,
Some(expected) => current.as_deref() != Some(expected),
};
if conflict {
let current = current.as_deref().unwrap_or("none");
return failure(&format!(
"[invalid_params] conflict: the file changed since that revision (current revision: {current})"
));
}
if folder {
return failure("[internal] EISDIR: illegal operation on a directory");
}
self.writes += 1;
let written = file(&bytes, &format!("17918{:08}.5", self.writes));
let revision = written.revision();
self.files.insert(path.to_owned(), written);
success(json!({
"method": "write", "ok": true, "bytes": bytes.len(), "revision": revision,
}))
}
/// As `delete_path` does: a folder goes with everything in it.
fn delete(&mut self, path: &str) -> Value {
let kind = if self.files.remove(path).is_some() {
"file"
} else if !path.is_empty() && self.is_folder(path) {
let prefix = format!("{path}/");
self.files.retain(|file, _| !file.starts_with(&prefix));
self
.folders
.retain(|folder| folder != path && !folder.starts_with(&prefix));
"folder"
} else {
return failure(&format!("[not_found] no entry at {path}"));
};
success(
json!({ "method": "delete", "ok": true, "path": path, "kind": kind }),
)
}
fn answer(&mut self, call: &Value) -> Value {
if call["method"] == "tools/list" {
let tools = self.tools.clone().unwrap_or_else(|| {
vec![
"list_files",
"read_file",
"write_file",
"delete_path",
"stat",
]
});
let tools: Vec<Value> =
tools.iter().map(|name| json!({ "name": name })).collect();
return json!({ "result": { "tools": tools }, "jsonrpc": "2.0", "id": 1 });
}
let arguments = &call["params"]["arguments"];
let path = arguments["path"].as_str().unwrap_or_default().to_owned();
let tool = call["params"]["name"].as_str().unwrap_or_default();
if let Some((_, text)) = self.fail.filter(|(failing, _)| *failing == tool) {
return failure(text);
}
match tool {
"write_file" => self.write(&path, arguments),
"delete_path" => self.delete(&path),
"list_files" => match self.children(&path) {
Some(entries) => success(json!({ "entries": entries })),
None if self.files.contains_key(&path) => {
failure("[internal] ENOTDIR: not a directory, scandir '/Users/me'")
}
None => failure("[internal] ENOENT: no such file or directory"),
},
"stat" if path.is_empty() => failure("[invalid_params] path required"),
"stat" => match self.entry(&path) {
Some(entry) => success(entry),
None => failure(&format!("[not_found] no entry at {path}")),
},
"read_file" => {
if let Some(changed) = self.change_on_read.take() {
self.files.insert(path.clone(), changed);
}
match self.files.get(&path) {
Some(file) => success(json!({
"method": "read",
"content": STANDARD.encode(&file.bytes),
"encoding": "base64",
"truncated": false,
})),
None if self.is_folder(&path) => failure("[internal] is a directory"),
None => failure("[internal] ENOENT: no such file or directory"),
}
}
_ => failure("[internal] unknown tool"),
}
}
fn is_folder(&self, path: &str) -> bool {
path.is_empty()
|| self.folders.iter().any(|folder| folder == path)
|| self
.files
.keys()
.any(|file| file.starts_with(&format!("{path}/")))
}
fn entry(&self, path: &str) -> Option<Value> {
let name = path.rsplit('/').next().unwrap_or(path);
if let Some(file) = self.files.get(path) {
let entry = format!(
r#"{{"name":{},"path":{},"isDir":false,"size":{},"mtimeMs":{}}}"#,
json!(name),
json!(path),
file.bytes.len(),
file.mtime,
);
return serde_json::from_str(&entry).ok();
}
self.is_folder(path).then(|| {
json!({ "name": name, "path": path, "isDir": true, "size": 96,
"mtimeMs": 1_791_726_544_422.142_f64 })
})
}
fn children(&self, path: &str) -> Option<Vec<Value>> {
if !self.is_folder(path) {
return None;
}
let prefix = if path.is_empty() {
String::new()
} else {
format!("{path}/")
};
let mut names: Vec<&str> = self
.files
.keys()
.chain(self.folders.iter())
.filter_map(|item| item.strip_prefix(&prefix))
.filter_map(|rest| rest.split('/').next())
.filter(|name| !name.is_empty())
.collect();
names.dedup();
names.sort_unstable();
names.dedup();
Some(
names
.into_iter()
.filter_map(|name| self.entry(&format!("{prefix}{name}")))
.collect(),
)
}
}