swactor/crates/mvp-system/src/mvp_chat.rs
Zachery Aaron Shores-Chmielewski ded71289f0 feat(mvp-system): extract mvp_chat library module
Add mvp-system/src/mvp_chat.rs with a run_from_args entrypoint wiring swactor runtime,
dashboard, chat datastream, and a PromptLoop. Add a mock integration test; refresh
mvp-chat and MVP_SYSTEM specs.


Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-07-18 13:40:09 +04:00

317 lines
9.9 KiB
Rust

use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::sync::Arc;
use datastream::{DatastreamEndpoint, Lifetime, StreamDescriptor, StreamId, StreamOrigin};
use swactor::Error;
use tokio::sync::Notify;
type Result<T> = std::result::Result<T, swactor::Error>;
#[derive(Clone)]
enum ChatSignals {}
const MVP_CHAT_DATASTREAM_NODE: &str = "mvp-chat";
const MVP_CHAT_DATASTREAM_LABEL: &str = "mvp chat";
struct MvpChatDatastream {
endpoint: Arc<DatastreamEndpoint>,
wake: Arc<Notify>,
}
impl MvpChatDatastream {
fn new(run_id: u64) -> Self {
let stream = StreamId::new(MVP_CHAT_DATASTREAM_NODE, Lifetime(run_id));
let endpoint = DatastreamEndpoint::with_descriptor(
StreamDescriptor {
stream,
label: Some(MVP_CHAT_DATASTREAM_LABEL.to_owned()),
origin: StreamOrigin::Orchestrator,
},
4096,
1024,
);
Self {
endpoint: Arc::new(endpoint),
wake: Arc::new(Notify::new()),
}
}
fn spawn_handler(&self, handle: &tokio::runtime::Handle) -> tokio::task::JoinHandle<()> {
let endpoint = Arc::clone(&self.endpoint);
let wake = Arc::clone(&self.wake);
handle.spawn(async move {
loop {
wake.notified().await;
while endpoint.tick().drained != 0 {}
}
})
}
}
#[derive(Debug, PartialEq, Eq)]
enum PromptStep {
Response(String),
Ignore,
Exit,
}
fn read_prompt_step<R>(input: &mut R) -> PromptStep
where
R: BufRead,
{
let mut line = String::new();
match input.read_line(&mut line) {
Ok(0) => return PromptStep::Exit,
Ok(_) => {}
Err(_) => return PromptStep::Exit,
}
let prompt = line.trim_end().to_owned();
if prompt.trim().is_empty() {
return PromptStep::Ignore;
}
PromptStep::Response(prompt_response(&prompt))
}
fn prompt_response(prompt: &str) -> String {
format!("Hello, {prompt}!")
}
fn run_prompt_loop() -> Result<()> {
let stdin = io::stdin();
let mut input = stdin.lock();
let stdout = io::stdout();
let mut output = stdout.lock();
run_prompt_loop_with_io(&mut input, &mut output)
}
fn run_prompt_loop_with_io<R, W>(input: &mut R, output: &mut W) -> Result<()>
where
R: BufRead,
W: Write,
{
loop {
write!(output, "prompt:> ")
.map_err(|error| Error::from(format!("write prompt marker: {error}")))?;
output
.flush()
.map_err(|error| Error::from(format!("flush prompt marker: {error}")))?;
match read_prompt_step(input) {
PromptStep::Response(response) => {
writeln!(output, "{response}")
.map_err(|error| Error::from(format!("write prompt response: {error}")))?;
}
PromptStep::Ignore => continue,
PromptStep::Exit => return Ok(()),
}
}
}
enum ChatRuntimeEvent {
PromptExited(Result<()>),
PromptPanicked,
CtrlC(std::io::Result<()>),
}
struct PromptLoop {
join: Option<std::thread::JoinHandle<()>>,
}
impl PromptLoop {
fn spawn(events: tokio::sync::mpsc::UnboundedSender<ChatRuntimeEvent>) -> Result<Self> {
let join = std::thread::Builder::new()
.name("mvp-chat-prompt".to_owned())
.spawn(move || {
let event = match std::panic::catch_unwind(run_prompt_loop) {
Ok(result) => ChatRuntimeEvent::PromptExited(result),
Err(_) => ChatRuntimeEvent::PromptPanicked,
};
let _ = events.send(event);
})
.map_err(|error| Error::from(format!("spawn prompt loop: {error}")))?;
Ok(Self { join: Some(join) })
}
fn join_finished(&mut self) -> Result<()> {
let Some(join) = self.join.take() else {
return Ok(());
};
join.join()
.map_err(|_| Error::from("prompt loop thread panicked".to_owned()))
}
fn detach(mut self) {
let _ = self.join.take();
}
}
fn spawn_ctrl_c_reporter(
handle: &tokio::runtime::Handle,
events: tokio::sync::mpsc::UnboundedSender<ChatRuntimeEvent>,
) {
handle.spawn(async move {
let result = tokio::signal::ctrl_c().await;
let _ = events.send(ChatRuntimeEvent::CtrlC(result));
});
}
fn join_dashboard_http(result: std::result::Result<(), tokio::task::JoinError>) -> Result<()> {
result.map_err(|error| Error::from(format!("dashboard HTTP task failed: {error}")))
}
fn join_datastream_handler(result: std::result::Result<(), tokio::task::JoinError>) -> Result<()> {
result.map_err(|error| Error::from(format!("datastream handler task failed: {error}")))
}
async fn supervise_chat_runtime(
dashboard: &dashboard::DashboardHandle,
mut dashboard_http: tokio::task::JoinHandle<()>,
mut datastream_handler: tokio::task::JoinHandle<()>,
prompt_loop: &mut PromptLoop,
events: &mut tokio::sync::mpsc::UnboundedReceiver<ChatRuntimeEvent>,
) -> Result<()> {
tokio::select! {
result = &mut dashboard_http => {
join_dashboard_http(result)?;
Err("dashboard HTTP server exited before shutdown"
.to_owned()
.into())
}
result = &mut datastream_handler => {
join_datastream_handler(result)?;
Err("datastream handler exited before shutdown"
.to_owned()
.into())
}
event = events.recv() => {
let event = event.ok_or_else(|| Error::from("runtime event channel closed".to_owned()))?;
let run_result = match event {
ChatRuntimeEvent::PromptExited(prompt_result) => {
match prompt_loop.join_finished() {
Ok(()) => prompt_result,
Err(error) => Err(error),
}
}
ChatRuntimeEvent::PromptPanicked => {
match prompt_loop.join_finished() {
Ok(()) => Err("prompt loop panicked".to_owned().into()),
Err(error) => Err(error),
}
}
ChatRuntimeEvent::CtrlC(result) => {
result
.map_err(|error| Error::from(format!("ctrl-c handler failed: {error}")))
.map(|_| ())
}
};
dashboard.shutdown();
join_dashboard_http(dashboard_http.await)?;
run_result
}
}
}
pub fn run_from_args<I>(args: I) -> Result<()>
where
I: IntoIterator<Item = String>,
{
let mut config_path: Option<PathBuf> = None;
let mut provider_selector: Option<&'static str> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--process" | "--docker" | "--vastai" => {
let selected = match arg.as_str() {
"--process" => "process",
"--docker" => "docker",
"--vastai" => "vastai",
_ => unreachable!(),
};
if provider_selector.replace(selected).is_some() {
return Err("conflicting provider selectors; use exactly one of --process, --docker, or --vastai".to_owned().into());
}
}
"--config" => {
let value = args
.next()
.ok_or_else(|| "--config requires a path".to_owned())?;
config_path = Some(PathBuf::from(value));
}
"--yes" | "-y" | "--dump-logs" | "--cached-model" | "--skip-rebuild" => {}
"--pipeline-stages" => {
let value = args
.next()
.ok_or_else(|| "--pipeline-stages requires a value".to_owned())?;
let stages = value
.parse::<u32>()
.map_err(|error| format!("parse --pipeline-stages: {error}"))?;
if stages == 0 {
return Err("--pipeline-stages must be greater than 0".into());
}
}
value if value.starts_with("--dump-logs=") => {
if value["--dump-logs=".len()..].is_empty() {
return Err("--dump-logs= requires a path".into());
}
}
value if value.starts_with("--cached-model=") => {
if value["--cached-model=".len()..].is_empty() {
return Err("--cached-model= requires a path".into());
}
}
value => return Err(format!("unknown mvp-chat argument: {value}").into()),
}
}
let loaded_config = crate::config::TomlConfigOverlay::load(config_path.as_deref())?;
let run_id = loaded_config.overlay.runtime.run_id.unwrap_or(0);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| Error::from(e.to_string()))?;
let chat_datastream = MvpChatDatastream::new(run_id);
let datastream_handler = chat_datastream.spawn_handler(rt.handle());
let _swactor = swactor::runtime::Runtime::new(swactor::runtime::RuntimeConfig {
num_threads: 1,
..Default::default()
});
let _chat_inbox = _swactor.new_inbox::<ChatSignals>()?;
let dashboard = dashboard::DashboardHandle::new(dashboard::DashboardConfig::default());
let dashboard_http = dashboard.spawn_http(rt.handle());
let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
spawn_ctrl_c_reporter(rt.handle(), events_tx.clone());
let mut prompt_loop = PromptLoop::spawn(events_tx)?;
let result = rt.block_on(supervise_chat_runtime(
&dashboard,
dashboard_http,
datastream_handler,
&mut prompt_loop,
&mut events_rx,
));
if result.is_err() {
prompt_loop.detach();
}
result
}