Files
sausage/host/src/harness/mod.rs
T
surma 45d099c3e4 Merge branch 'v3' into v3-t29
# Conflicts:
#	host/Cargo.toml
#	host/src/harness/mod.rs
2026-11-06 23:46:27 +00:00

264 lines
9.3 KiB
Rust

//! Durable V8 harness, host-side SQLite storage, and world tool operations.
mod credentials;
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;
#[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,
credentials::op_credential_requests,
credentials::op_model_proxy,
credentials::op_credential_next,
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<()> {
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
run_with_shared_credentials(world, exec, cred).await
}
async fn run_with_shared_credentials(
world: World,
exec: Arc<dyn Exec>,
cred: Arc<crate::cred::Cred>,
) -> 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_with_credentials(
world,
exec,
cred,
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)
&& !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 cred = Arc::new(crate::cred::store::Cred::open(&world.host_dir)?);
run_module_with_credentials(world, exec, cred, source).await
}
/// Run a harness with the credentials handle shared by the host API.
pub async fn run_module_with_credentials(
world: World,
exec: Arc<dyn Exec>,
cred: Arc<crate::cred::store::Cred>,
source: &str,
) -> anyhow::Result<()> {
let proxy = credentials::proxy(&cred)?;
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"))?;
runtime
.op_state()
.borrow_mut()
.put(credentials::Notifications::new(&cred));
runtime.op_state().borrow_mut().put(cred);
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);
let result = async {
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok::<_, anyhow::Error>(())
}
.await;
result.map_err(|error| {
let mut text = error.to_string();
if let Some(key) = proxy
.as_ref()
.and_then(|v| v.get("api_key"))
.and_then(Value::as_str)
&& !key.is_empty()
{
text = text.replace(key, "[redacted]");
}
anyhow::anyhow!(text)
})
}
/// Open a world and resume its durable root conversation.
pub async fn run(path: &Path) -> anyhow::Result<()> {
run_with_userland(path, None).await
}
/// Start the console with the shared process table and an optional userland bundle.
pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> {
let world = World::open_with_userland(path, userland)?;
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
let runtime = crate::process::Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
crate::process::default_script(),
None,
)?;
let services = crate::process::supervise(runtime.clone());
let result = run_with_shared_credentials(world, Arc::new(runtime), cred).await;
services.shutdown().await;
result
}