286 lines
10 KiB
Rust
286 lines
10 KiB
Rust
//! A fresh world's seeded init service serves chat and proxies the real harness API.
|
|
use sausage::{
|
|
cred::{
|
|
Caller, Cred,
|
|
items::{Item, Rule},
|
|
requests::GrantRequest,
|
|
},
|
|
harness::{
|
|
input::{self, Input},
|
|
run_module_with_input,
|
|
},
|
|
process::Runtime,
|
|
world::World,
|
|
};
|
|
use std::{path::Path, sync::Arc, time::Duration};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u16, Vec<u8>)> {
|
|
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
|
|
let method = if body.is_some() { "POST" } else { "GET" };
|
|
let body = body.unwrap_or("");
|
|
stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await?;
|
|
let mut bytes = Vec::new();
|
|
stream.read_to_end(&mut bytes).await?;
|
|
let end = bytes
|
|
.windows(4)
|
|
.position(|s| s == b"\r\n\r\n")
|
|
.ok_or_else(|| anyhow::anyhow!("Missing HTTP headers"))?
|
|
+ 4;
|
|
let header = std::str::from_utf8(&bytes[..end])?;
|
|
let status = header.split_whitespace().nth(1).unwrap().parse()?;
|
|
Ok((status, bytes[end..].to_vec()))
|
|
}
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn seeded_chat() -> anyhow::Result<()> {
|
|
seeded_chat_case(false).await
|
|
}
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn shutdown_with_open_connection() -> anyhow::Result<()> {
|
|
seeded_chat_case(true).await
|
|
}
|
|
async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
|
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
|
let target = crate::common::target_dir();
|
|
crate::common::cwd_init();
|
|
anyhow::ensure!(
|
|
std::process::Command::new("cargo")
|
|
.args([
|
|
"build",
|
|
"--release",
|
|
"--locked",
|
|
"-p",
|
|
"sausage-chat",
|
|
"-p",
|
|
"sausage-init",
|
|
"--target",
|
|
"wasm32-wasip2",
|
|
"--target-dir"
|
|
])
|
|
.arg(&target)
|
|
.current_dir(source.join("userland"))
|
|
.status()?
|
|
.success(),
|
|
"Chat guest build failed"
|
|
);
|
|
let bundle = tempfile::tempdir()?;
|
|
let units = bundle.path().join("etc/systemd/system");
|
|
std::fs::create_dir_all(units.join("multi-user.target.wants"))?;
|
|
std::fs::copy(
|
|
source.join("userland/chat/chat.service"),
|
|
units.join("chat.service"),
|
|
)?;
|
|
std::fs::write(units.join("multi-user.target.wants/chat.service"), "")?;
|
|
std::fs::create_dir_all(bundle.path().join("bin"))?;
|
|
for binary in ["chat", "init"] {
|
|
std::fs::copy(
|
|
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
|
|
bundle.path().join(format!("bin/{binary}.wasm")),
|
|
)?;
|
|
}
|
|
crate::common::copy_tree(
|
|
&source.join("userland/chat"),
|
|
&bundle.path().join("src/sausage/userland/chat"),
|
|
)?;
|
|
let dir = tempfile::tempdir()?;
|
|
drop(World::open_with_userland(dir.path(), Some(bundle.path()))?);
|
|
let port = std::net::TcpListener::bind("127.0.0.1:0")?
|
|
.local_addr()?
|
|
.port();
|
|
let seeded = dir.path().join("world/etc/systemd/system/chat.service");
|
|
let unit = std::fs::read_to_string(&seeded)?.replace(
|
|
"ExecStart=/bin/chat.wasm",
|
|
&format!("ExecStart=/bin/chat.wasm --listen 127.0.0.1:{port}"),
|
|
);
|
|
std::fs::write(&seeded, &unit)?;
|
|
let world = World::open_with_userland(dir.path(), Some(bundle.path()))?;
|
|
assert_eq!(
|
|
std::fs::read_to_string(&seeded)?,
|
|
unit,
|
|
"Reopen must not replace a world's unit"
|
|
);
|
|
assert!(
|
|
dir.path()
|
|
.join("world/src/sausage/userland/chat/Cargo.toml")
|
|
.is_file()
|
|
);
|
|
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
|
let caller = Caller {
|
|
program: "/bin/chat.wasm".into(),
|
|
pid: 1,
|
|
conversations: vec!["1".into()],
|
|
};
|
|
let item = cred.create_item(
|
|
Item {
|
|
id: String::new(),
|
|
name: "Chat test credential".into(),
|
|
version: 0,
|
|
free_use: false,
|
|
rules: vec![Rule {
|
|
port: None,
|
|
scheme: "https".into(),
|
|
hosts: vec!["example.test".into()],
|
|
path_prefixes: vec!["/".into()],
|
|
methods: None,
|
|
}],
|
|
validate_js: None,
|
|
transform_js: String::new(),
|
|
fields: vec![],
|
|
},
|
|
&caller,
|
|
cred.unlocked()?,
|
|
)?;
|
|
let pending = cred.file_request(
|
|
&caller,
|
|
"chat-test",
|
|
GrantRequest {
|
|
items: vec![item.id],
|
|
duration_secs: None,
|
|
max_uses: None,
|
|
validator: None,
|
|
},
|
|
)?;
|
|
let api = Arc::new(sausage::api::Api::open(
|
|
cred.clone(),
|
|
&world.host_dir,
|
|
"localhost",
|
|
"http://localhost:8090",
|
|
)?);
|
|
let (sender, receiver) = input::channel();
|
|
api.attach_harness(sender.clone());
|
|
let runtime = Runtime::new_with_hooks(
|
|
World {
|
|
root: world.root.clone(),
|
|
host_dir: world.host_dir.clone(),
|
|
processes: world.processes.clone(),
|
|
},
|
|
cred.clone(),
|
|
sausage::process::default_script(),
|
|
Some(api),
|
|
)?;
|
|
let services = sausage::process::supervise(runtime.clone());
|
|
let (library, _) = include_str!("../../js/harness-api-test-bundle.js")
|
|
.split_once("// harness-api-test.ts")
|
|
.unwrap();
|
|
let script = format!(
|
|
r#"{library}
|
|
const chatModels=createModels(),chatFaux=fauxProvider({{tokensPerSecond:100000}});
|
|
chatModels.setProvider(chatFaux.provider);chatFaux.setResponses([fauxAssistantMessage('Chat test reply')]);
|
|
const chatRegistry=createRegistry(),chatStorage=await openStorage(globalThis.storagePath);
|
|
const chatHarness=await Harness.open(chatStorage,{{models:chatModels,registry:chatRegistry}},BACKGROUND_CONTEXT);
|
|
await chatHarness.root(BACKGROUND_CONTEXT,{{agent:{{model:{{provider:'faux',modelId:'faux-1'}}}}}});
|
|
const chatApi=harnessApi(chatHarness,chatModels,{{state:()=>({{}})}},BACKGROUND_CONTEXT);
|
|
chatHarness.resume();await serve(chatHarness,BACKGROUND_CONTEXT,undefined,chatApi);
|
|
await chatApi.close();await chatHarness.close(BACKGROUND_CONTEXT);
|
|
"#
|
|
);
|
|
let client = async {
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(60);
|
|
let page = loop {
|
|
if let Ok((200, body)) = request(port, "/", None).await {
|
|
break body;
|
|
}
|
|
anyhow::ensure!(
|
|
std::time::Instant::now() < deadline,
|
|
"Init did not start chat"
|
|
);
|
|
tokio::task::yield_now().await;
|
|
};
|
|
assert!(String::from_utf8_lossy(&page).contains("Sausage chat"));
|
|
let root = loop {
|
|
let (status, body) = request(port, "/api/conversations", None).await?;
|
|
let value: serde_json::Value = serde_json::from_slice(&body)?;
|
|
if status == 200
|
|
&& let Some(id) = value["conversations"][0]["id"].as_u64()
|
|
{
|
|
break id;
|
|
}
|
|
anyhow::ensure!(
|
|
std::time::Instant::now() < deadline,
|
|
"Harness was not ready"
|
|
);
|
|
tokio::task::yield_now().await;
|
|
};
|
|
let submit = serde_json::json!({"content":"Chat test input","requestId":"chat-stable-id"})
|
|
.to_string();
|
|
for _ in 0..2 {
|
|
assert_eq!(
|
|
request(
|
|
port,
|
|
&format!("/api/conversations/{root}/submit"),
|
|
Some(&submit)
|
|
)
|
|
.await?
|
|
.0,
|
|
202
|
|
);
|
|
}
|
|
loop {
|
|
let (status, body) = request(
|
|
port,
|
|
&format!("/api/conversations/{root}/entries?after=0"),
|
|
None,
|
|
)
|
|
.await?;
|
|
assert_eq!(status, 200);
|
|
if String::from_utf8_lossy(&body).contains("Chat test reply") {
|
|
let value: serde_json::Value = serde_json::from_slice(&body)?;
|
|
assert_eq!(
|
|
value["entries"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|e| e["kind"] == "pi.user")
|
|
.count(),
|
|
1
|
|
);
|
|
break;
|
|
}
|
|
anyhow::ensure!(std::time::Instant::now() < deadline, "Reply did not appear");
|
|
tokio::task::yield_now().await;
|
|
}
|
|
let (status, body) = request(port, "/api/credential-requests", None).await?;
|
|
assert_eq!(status, 200);
|
|
let value: serde_json::Value = serde_json::from_slice(&body)?;
|
|
assert!(
|
|
value["requests"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|r| r["id"] == pending
|
|
&& r["approvalUrl"] == format!("http://localhost:8090/approve/{pending}"))
|
|
);
|
|
assert_eq!(request(port, "/api/unknown", None).await?.0, 404);
|
|
sender.send(Input::Stop)?;
|
|
Ok::<_, anyhow::Error>(())
|
|
};
|
|
let result = tokio::time::timeout(Duration::from_secs(90), async {
|
|
tokio::try_join!(
|
|
run_module_with_input(world, Arc::new(runtime.clone()), cred, receiver, &script),
|
|
client
|
|
)
|
|
})
|
|
.await;
|
|
let connection = if open_connection {
|
|
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
|
|
// Leave the request incomplete so the server must wait for more input.
|
|
stream
|
|
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n")
|
|
.await?;
|
|
Some(stream)
|
|
} else {
|
|
None
|
|
};
|
|
let started = std::time::Instant::now();
|
|
services.shutdown().await;
|
|
let elapsed = started.elapsed();
|
|
drop(connection);
|
|
result??;
|
|
println!("Chat shutdown (open_connection={open_connection}): {elapsed:?}");
|
|
anyhow::ensure!(
|
|
elapsed < Duration::from_secs(3),
|
|
"Chat delayed shutdown: {elapsed:?}"
|
|
);
|
|
Ok(())
|
|
}
|