host: process table, shell, coreutils and harness (T8, T9)
T8: sausage:process WIT (spawn, wait, kill), one store per process over the shared root, /proc/processes, /run, pipeline and cancellation groups, Exec. brush and 78 uutils commands; the shared cwd-init object sets the cwd from PWD and keeps numeric exit codes. The corpus matches native bash. T9: V8 (deno_core) runs pi-durable, pi-ai and chord; SQLite storage in <dir>/host/harness.sqlite (WAL, synchronous=FULL); file tools on the VFS, bash through Exec; resumes after SIGKILL.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
//! Rust operations for tools. File access uses the world's shared VFS layer.
|
||||
use crate::exec::{Command, Exec, Options, Stream};
|
||||
use deno_core::{OpState, op2};
|
||||
use deno_error::JsErrorBox;
|
||||
use sausage_vfs::{Layer, Open};
|
||||
use serde_json::{Value, json};
|
||||
use std::{cell::RefCell, io::Write, rc::Rc, sync::Arc};
|
||||
|
||||
pub(super) struct Environment {
|
||||
pub root: Arc<dyn Layer>,
|
||||
pub exec: Arc<dyn Exec>,
|
||||
}
|
||||
|
||||
#[op2]
|
||||
#[serde]
|
||||
pub async fn op_file(
|
||||
state: Rc<RefCell<OpState>>,
|
||||
#[string] kind: String,
|
||||
#[string] path: String,
|
||||
#[serde] bytes: Vec<u8>,
|
||||
) -> Result<serde_json::Value, JsErrorBox> {
|
||||
let root = state.borrow().borrow::<Environment>().root.clone();
|
||||
let result: std::io::Result<Value> = async {
|
||||
match kind.as_str() {
|
||||
"read" => {
|
||||
let file = root.open(&path, Open { follow: true, ..Default::default() }).await?;
|
||||
let size = file.stat().await?.size;
|
||||
Ok(json!(file.read(0, size as usize).await?))
|
||||
}
|
||||
"write" => {
|
||||
let file = root.open(&path, Open { follow: true, write: true, create: true, truncate: true, ..Default::default() }).await?;
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
let count = file.write(offset as u64, bytes[offset..].to_vec()).await?;
|
||||
if count == 0 { return Err(std::io::ErrorKind::WriteZero.into()); }
|
||||
offset += count;
|
||||
}
|
||||
file.sync().await?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"mkdir" => { root.mkdir(&path).await?; Ok(Value::Null) }
|
||||
"stat" => {
|
||||
let s = root.stat(&path, true).await?;
|
||||
Ok(json!({"path": path, "name": path.rsplit('/').next().unwrap_or(""), "kind": if s.directory {"directory"} else {"file"}, "size": s.size, "mtimeMs": 0}))
|
||||
}
|
||||
_ => Err(std::io::ErrorKind::Unsupported.into()),
|
||||
}
|
||||
}.await;
|
||||
Ok(match result {
|
||||
Ok(value) => json!({"ok": true, "value": value}),
|
||||
Err(e) => {
|
||||
json!({"ok": false, "error": {"code": match e.kind() { std::io::ErrorKind::NotFound => "not_found", std::io::ErrorKind::PermissionDenied => "permission_denied", _ => "unknown" }, "message": e.to_string(), "path": path}})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
struct Execution {
|
||||
receiver: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Value>>,
|
||||
task: tokio::task::AbortHandle,
|
||||
}
|
||||
impl deno_core::Resource for Execution {
|
||||
fn close(self: Rc<Self>) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
impl Drop for Execution {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[op2]
|
||||
pub fn op_exec(
|
||||
state: &mut OpState,
|
||||
#[serde] command: serde_json::Value,
|
||||
#[string] cwd: String,
|
||||
timeout: Option<f64>,
|
||||
) -> Result<u32, JsErrorBox> {
|
||||
let exec = state.borrow::<Environment>().exec.clone();
|
||||
let command = if let Some(s) = command.as_str() {
|
||||
Command::Shell(s.to_owned())
|
||||
} else {
|
||||
Command::Argv(
|
||||
serde_json::from_value(command).map_err(|e| JsErrorBox::generic(e.to_string()))?,
|
||||
)
|
||||
};
|
||||
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let result = exec.exec(command, Options { cwd, timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await;
|
||||
let _ = sender.send(match result {
|
||||
Ok(exit) => json!({"exitCode": exit}),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
});
|
||||
});
|
||||
Ok(state.resource_table.add(Execution {
|
||||
receiver: tokio::sync::Mutex::new(receiver),
|
||||
task: task.abort_handle(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[op2]
|
||||
#[serde]
|
||||
pub async fn op_exec_next(
|
||||
state: Rc<RefCell<OpState>>,
|
||||
rid: u32,
|
||||
) -> Result<serde_json::Value, JsErrorBox> {
|
||||
let execution = state
|
||||
.borrow()
|
||||
.resource_table
|
||||
.get::<Execution>(rid)
|
||||
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
|
||||
let value = execution
|
||||
.receiver
|
||||
.lock()
|
||||
.await
|
||||
.recv()
|
||||
.await
|
||||
.unwrap_or(Value::Null);
|
||||
if value.get("stream").is_none() {
|
||||
let resource = state
|
||||
.borrow_mut()
|
||||
.resource_table
|
||||
.take::<Execution>(rid)
|
||||
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
|
||||
deno_core::Resource::close(resource);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[op2]
|
||||
#[string]
|
||||
pub async fn op_input() -> Result<Option<String>, JsErrorBox> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut reader = tokio::io::BufReader::with_capacity(1, tokio::io::stdin());
|
||||
let mut line = String::new();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
|
||||
Ok((n != 0).then_some(line))
|
||||
}
|
||||
|
||||
#[op2(fast)]
|
||||
pub fn op_output(#[string] text: &str) {
|
||||
print!("{text}");
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
+209
-5
@@ -1,8 +1,212 @@
|
||||
//! The durable agent harness: V8 with pi-durable, harness storage, and the tools' environment.
|
||||
//! Owned by task T9.
|
||||
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
|
||||
mod environment;
|
||||
use crate::{exec::Exec, world::World};
|
||||
use deno_core::{JsRuntime, OpState, RuntimeOptions, op2};
|
||||
use deno_error::JsErrorBox;
|
||||
use rusqlite::{Connection, types::Value as SqlValue};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Start the world at `world` with the harness and a console.
|
||||
pub async fn run(_world: &Path) -> anyhow::Result<()> {
|
||||
anyhow::bail!("the harness does not exist yet (T9)")
|
||||
#[op2]
|
||||
#[serde]
|
||||
fn op_sql(
|
||||
state: &mut OpState,
|
||||
#[string] kind: String,
|
||||
#[string] sql: String,
|
||||
#[serde] params: Vec<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, JsErrorBox> {
|
||||
let work = || -> Result<Value, Box<dyn std::error::Error>> {
|
||||
if kind == "open" {
|
||||
let db = Connection::open(sql)?;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||
state.put(db);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if kind == "close" {
|
||||
state.take::<Connection>();
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let db = state.borrow::<Connection>();
|
||||
if kind == "exec" {
|
||||
db.execute_batch(&sql)?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let params: Vec<SqlValue> = params
|
||||
.into_iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => SqlValue::Null,
|
||||
Value::Number(n) => n
|
||||
.as_i64()
|
||||
.map(SqlValue::Integer)
|
||||
.unwrap_or_else(|| SqlValue::Real(n.as_f64().unwrap())),
|
||||
Value::String(s) => SqlValue::Text(s),
|
||||
Value::Array(a) => {
|
||||
SqlValue::Blob(a.into_iter().map(|n| n.as_u64().unwrap() as u8).collect())
|
||||
}
|
||||
_ => panic!("unsupported SQL parameter"),
|
||||
})
|
||||
.collect();
|
||||
let mut stmt = db.prepare_cached(&sql)?;
|
||||
if kind == "run" {
|
||||
let changes = stmt.execute(rusqlite::params_from_iter(params))?;
|
||||
return Ok(json!({"changes": changes, "lastInsertRowid": db.last_insert_rowid()}));
|
||||
}
|
||||
let names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params))?;
|
||||
let mut result = vec![];
|
||||
while let Some(row) = rows.next()? {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let v = match row.get::<_, SqlValue>(i)? {
|
||||
SqlValue::Null => Value::Null,
|
||||
SqlValue::Integer(n) => json!(n),
|
||||
SqlValue::Real(n) => json!(n),
|
||||
SqlValue::Text(s) => json!(s),
|
||||
SqlValue::Blob(b) => json!(b),
|
||||
};
|
||||
obj.insert(name.clone(), v);
|
||||
}
|
||||
result.push(Value::Object(obj));
|
||||
}
|
||||
Ok(json!(result))
|
||||
};
|
||||
work().map_err(|e| JsErrorBox::generic(e.to_string()))
|
||||
}
|
||||
|
||||
deno_core::extension!(
|
||||
storage,
|
||||
ops = [
|
||||
op_sql,
|
||||
environment::op_file,
|
||||
environment::op_exec,
|
||||
environment::op_exec_next,
|
||||
environment::op_input,
|
||||
environment::op_output
|
||||
]
|
||||
);
|
||||
|
||||
/// Start the console with the process implementation supplied by the host.
|
||||
pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> anyhow::Result<()> {
|
||||
// Deno's unsynchronized operations require a current-thread Tokio runtime.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(run_module(world, exec, include_str!("../../js/bundle.js")))
|
||||
})
|
||||
.await?
|
||||
.map_err(|error: anyhow::Error| {
|
||||
let mut text = error.to_string();
|
||||
for key in ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"] {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
if !value.is_empty() {
|
||||
text = text.replace(&value, "[redacted]");
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::anyhow!(text)
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate a bundled harness module with the same storage and tool operations as the console.
|
||||
/// The caller must use a current-thread Tokio runtime.
|
||||
pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyhow::Result<()> {
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
extensions: vec![
|
||||
deno_telemetry::deno_telemetry::init(),
|
||||
deno_webidl::deno_webidl::init(),
|
||||
deno_web::deno_web::init(
|
||||
Arc::new(deno_web::BlobStore::default()),
|
||||
None,
|
||||
false,
|
||||
Default::default(),
|
||||
),
|
||||
deno_crypto::deno_crypto::init(None),
|
||||
deno_net::deno_net::init(None, None),
|
||||
deno_fetch::deno_fetch::init(Default::default()),
|
||||
storage::init(),
|
||||
],
|
||||
extension_transpiler: Some(std::rc::Rc::new(|name, source| {
|
||||
if !name.ends_with(".ts") {
|
||||
return Ok((source, None));
|
||||
}
|
||||
let parsed = deno_ast::parse_module(deno_ast::ParseParams {
|
||||
specifier: deno_core::url::Url::parse(&name).unwrap(),
|
||||
text: source.into(),
|
||||
media_type: deno_ast::MediaType::TypeScript,
|
||||
capture_tokens: false,
|
||||
scope_analysis: false,
|
||||
maybe_syntax: None,
|
||||
})
|
||||
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
|
||||
let emitted = parsed
|
||||
.transpile(
|
||||
&Default::default(),
|
||||
&Default::default(),
|
||||
&deno_ast::EmitOptions {
|
||||
source_map: deno_ast::SourceMapOption::None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|e| JsErrorBox::generic(e.to_string()))?
|
||||
.into_source();
|
||||
Ok((emitted.text.into(), None))
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
runtime
|
||||
.op_state()
|
||||
.borrow_mut()
|
||||
.put(deno_permissions::PermissionsContainer::allow_all(Arc::new(
|
||||
deno_permissions::RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
|
||||
)));
|
||||
runtime.execute_script("bootstrap.js", include_str!("../../js/bootstrap.js"))?;
|
||||
let env: serde_json::Map<String, Value> = ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"]
|
||||
.into_iter()
|
||||
.filter_map(|key| std::env::var(key).ok().map(|v| (key.to_owned(), json!(v))))
|
||||
.collect();
|
||||
runtime.execute_script("env.js", format!("process.env = {};", Value::Object(env)))?;
|
||||
runtime
|
||||
.op_state()
|
||||
.borrow_mut()
|
||||
.put(environment::Environment {
|
||||
root: world.root,
|
||||
exec,
|
||||
});
|
||||
runtime.execute_script(
|
||||
"config.js",
|
||||
format!(
|
||||
"globalThis.storagePath = {};",
|
||||
serde_json::to_string(&world.host_dir.join("harness.sqlite"))?
|
||||
),
|
||||
)?;
|
||||
let specifier = deno_core::ModuleSpecifier::parse("file:///harness.js")?;
|
||||
let module = runtime
|
||||
.load_main_es_module_from_code(&specifier, source.to_owned())
|
||||
.await?;
|
||||
let result = runtime.mod_evaluate(module);
|
||||
runtime
|
||||
.with_event_loop_promise(result, Default::default())
|
||||
.await?;
|
||||
runtime.run_event_loop(Default::default()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Unavailable;
|
||||
#[async_trait::async_trait]
|
||||
impl Exec for Unavailable {
|
||||
async fn exec(
|
||||
&self,
|
||||
_: crate::exec::Command,
|
||||
_: crate::exec::Options,
|
||||
_: &mut (dyn for<'chunk> FnMut(crate::exec::Stream, &'chunk [u8]) + Send),
|
||||
) -> anyhow::Result<i32> {
|
||||
anyhow::bail!("process execution is not wired yet")
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a world and resume its durable root conversation.
|
||||
pub async fn run(path: &Path) -> anyhow::Result<()> {
|
||||
run_with_exec(World::open(path)?, Arc::new(Unavailable)).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user