Add the loopd serve command
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
+88
-17
@@ -1,25 +1,49 @@
|
||||
//! `loopd`: the agent loop daemon. Its one command today is `selftest`, which runs the startup
|
||||
//! checks before `loopd` serves anyone.
|
||||
//! `loopd`: the agent loop daemon. It has two commands: `selftest`, which runs the startup checks
|
||||
//! before serving anyone, and `serve`, which runs those checks and then runs the channel server.
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use loopd::channel::{self, Context};
|
||||
use loopd::config::Config;
|
||||
use loopd::llama::Client;
|
||||
use loopd::selftest::run;
|
||||
use loopd::selftest::{SelfTestError, run};
|
||||
use loopd::tools::{FakeTools, Registry};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
match args.as_slice() {
|
||||
["selftest", "--config", path] => run_selftest(path),
|
||||
["serve", "--config", path] => run_serve(path),
|
||||
_ => {
|
||||
eprintln!("usage: loopd selftest --config <path>");
|
||||
eprintln!("usage: loopd serve --config <path>");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The startup checks, with the lines `loopd` prints. Shared by `selftest` and `serve`.
|
||||
fn run_selftest_check(client: &Client) -> Result<(), SelfTestError> {
|
||||
let mut on_step = |step: &str| {
|
||||
eprintln!("selftest: {step}");
|
||||
};
|
||||
match run(client, &mut on_step) {
|
||||
Ok(()) => {
|
||||
eprintln!("selftest: ok");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("selftest: FAILED: {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_selftest(path: &str) -> ExitCode {
|
||||
let cfg = match Config::load(Path::new(path)) {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -29,19 +53,66 @@ fn run_selftest(path: &str) -> ExitCode {
|
||||
}
|
||||
};
|
||||
|
||||
let mut on_step = |step: &str| {
|
||||
eprintln!("selftest: {step}");
|
||||
};
|
||||
let result = run(&Client::new(cfg), &mut on_step);
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
eprintln!("selftest: ok");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("selftest: FAILED: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
let client = Client::new(cfg);
|
||||
match run_selftest_check(&client) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(_) => ExitCode::from(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_serve(path: &str) -> ExitCode {
|
||||
let cfg = match Config::load(Path::new(path)) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
eprintln!("loopd: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
let socket = cfg.channel_socket();
|
||||
if socket.exists()
|
||||
&& let Err(e) = std::fs::remove_file(&socket)
|
||||
{
|
||||
eprintln!(
|
||||
"loopd: cannot remove the old socket at {}: {e}",
|
||||
socket.display()
|
||||
);
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
let client = Client::new(cfg.clone());
|
||||
if run_selftest_check(&client).is_err() {
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
if let Some(parent) = socket.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
eprintln!("loopd: cannot create the socket directory: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
let listener = match UnixListener::bind(&socket) {
|
||||
Ok(listener) => listener,
|
||||
Err(e) => {
|
||||
eprintln!("loopd: cannot bind the socket at {}: {e}", socket.display());
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o600)) {
|
||||
eprintln!("loopd: cannot set the mode of {}: {e}", socket.display());
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
eprintln!("loopd: serving on {}", socket.display());
|
||||
|
||||
let ctx = Arc::new(Context::new(
|
||||
cfg,
|
||||
client,
|
||||
Box::new(FakeTools::new()),
|
||||
Registry::m2b(),
|
||||
));
|
||||
if let Err(e) = channel::serve(listener, ctx) {
|
||||
eprintln!("loopd: the channel server stopped: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Tests for the `loopd serve` command. Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
use support::{FakeServer, Home, Reply};
|
||||
|
||||
fn config_file(home: &Home, server: &FakeServer, expect_slots: u32) -> std::path::PathBuf {
|
||||
let text = format!(
|
||||
r#"
|
||||
[infer]
|
||||
socket = "{}"
|
||||
model = "test-model"
|
||||
[slots]
|
||||
main = 0
|
||||
background = 1
|
||||
[expect]
|
||||
template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
|
||||
n_ctx = 131072
|
||||
slots = {expect_slots}
|
||||
[limits]
|
||||
poll_ms = 40
|
||||
liveness_ms = 500
|
||||
retry_backoff_ms = [10]
|
||||
[paths]
|
||||
home = "{}"
|
||||
"#,
|
||||
server.socket.display(),
|
||||
home.dir.display()
|
||||
);
|
||||
let path = home.dir.join("config.toml");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn healthy_routes(server: &FakeServer) {
|
||||
server.route("/props", vec![Reply::fixture("props")]);
|
||||
server.route(
|
||||
"/v1/chat/completions",
|
||||
vec![
|
||||
Reply::fixture("tool_call"),
|
||||
Reply::fixture("turn1"),
|
||||
Reply::fixture("turn2"),
|
||||
Reply::fixture("plain"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_runs_the_self_test_then_binds_the_socket_with_mode_0600() {
|
||||
let home = Home::new();
|
||||
let server = FakeServer::start();
|
||||
healthy_routes(&server);
|
||||
let config = config_file(&home, &server, 2);
|
||||
let socket = home.dir.join("run").join("loop").join("loop.sock");
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_loopd"))
|
||||
.args(["serve", "--config"])
|
||||
.arg(&config)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut up = false;
|
||||
for _ in 0..200 {
|
||||
if socket.exists() {
|
||||
up = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let mode = std::fs::metadata(&socket).map(|m| m.permissions().mode() & 0o777);
|
||||
let _ = child.kill();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(up, "the socket never appeared; stderr: {stderr}");
|
||||
assert_eq!(mode.unwrap(), 0o600, "{stderr}");
|
||||
assert!(stderr.contains("selftest: ok"), "{stderr}");
|
||||
assert!(stderr.contains("serving on"), "{stderr}");
|
||||
assert_eq!(
|
||||
server.requests_to("/v1/chat/completions").len(),
|
||||
3,
|
||||
"the three self-test completions ran"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_refuses_to_start_when_the_self_test_fails() {
|
||||
let home = Home::new();
|
||||
let server = FakeServer::start();
|
||||
healthy_routes(&server);
|
||||
let config = config_file(&home, &server, 3); // the fixture reports 2 slots
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_loopd"))
|
||||
.args(["serve", "--config"])
|
||||
.arg(&config)
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{stderr}");
|
||||
assert!(stderr.contains("selftest: FAILED"), "{stderr}");
|
||||
assert!(stderr.contains("slot count"), "{stderr}");
|
||||
assert!(
|
||||
!home.dir.join("run").join("loop").join("loop.sock").exists(),
|
||||
"no socket was left behind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_and_a_bad_config_are_reported() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_loopd"))
|
||||
.arg("dance")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("usage"));
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_loopd"))
|
||||
.args(["serve", "--config", "/nonexistent/config.toml"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("config.toml"));
|
||||
}
|
||||
Reference in New Issue
Block a user