swactor/crates/ci/src/local_runner.rs
Zachery Aaron Shores-Chmielewski a5bac57b0b feat: deploy CI pipeline with ci-relay and local-runner
Add ci-relay (VPS webhook receiver) and local-runner (Thinkpad CI
executor) crates that communicate over iroh. Includes .ci.yml smoke
test pipeline, simulation tests, and development docs.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-16 12:46:55 +07:00

237 lines
8.3 KiB
Rust

//! LocalRunner actor: executes job commands directly on the host via shell.
//!
//! Short-lived actor, one per job. Spawned by LocalCoordinator when a job
//! is ready to execute.
use std::io::BufRead;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use crate::local_coordinator::LocalCoordinatorMsg;
use crate::{JobComplete, JobFailure, JobProgress, JobSuccess, LocalStartJob};
/// Messages the LocalRunner can receive.
#[derive(Debug, Clone)]
pub enum LocalRunnerMsg {
/// Begin executing the job (sent to self in on_start).
Execute,
/// Simulated: job completed (for testing without real shell).
SimComplete(Result<(), String>),
}
/// LocalRunner actor state.
pub struct LocalRunner {
coordinator_addr: ActorAddress,
start_job: LocalStartJob,
}
impl LocalRunner {
pub fn new(coordinator_addr: ActorAddress, start_job: LocalStartJob) -> Self {
Self {
coordinator_addr,
start_job,
}
}
/// Execute all commands in the job definition, streaming output back.
fn execute(&self, ctx: &Ctx) {
let job_id = &self.start_job.job_id;
let work_dir = &self.start_job.work_dir;
let timeout_secs = self.start_job.job_def.timeout_secs;
for cmd_str in &self.start_job.job_def.run {
// Send progress: command being run.
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobProgress(JobProgress {
job_id: job_id.clone(),
output_line: format!("$ {cmd_str}"),
}),
);
let child_result = Command::new("sh")
.arg("-c")
.arg(cmd_str)
.current_dir(work_dir)
.envs(&self.start_job.env_overrides)
.envs(&self.start_job.job_def.env)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match child_result {
Ok(c) => c,
Err(e) => {
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Err(JobFailure::ExecError(e.to_string())),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
return;
}
};
// Timeout mechanism: share child handle, spawn thread that kills after timeout.
let kill_flag = Arc::new(Mutex::new(false));
let kill_flag_clone = Arc::clone(&kill_flag);
// Use a pipe to signal the timeout thread when the command finishes.
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let timeout_handle = std::thread::spawn(move || {
// Wait for either timeout or command completion.
if done_rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)).is_err() {
*kill_flag_clone.lock().unwrap() = true;
}
});
// Read stdout line-by-line.
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let mut last_lines: Vec<String> = Vec::new();
if let Some(stdout) = stdout {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
if let Ok(line) = line {
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobProgress(JobProgress {
job_id: job_id.clone(),
output_line: line.clone(),
}),
);
last_lines.push(line);
if last_lines.len() > 50 {
last_lines.remove(0);
}
}
}
}
if let Some(stderr) = stderr {
let reader = std::io::BufReader::new(stderr);
for line in reader.lines() {
if let Ok(line) = line {
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobProgress(JobProgress {
job_id: job_id.clone(),
output_line: format!("[stderr] {line}"),
}),
);
last_lines.push(line);
if last_lines.len() > 50 {
last_lines.remove(0);
}
}
}
}
let status = child.wait();
// Signal timeout thread that command finished.
let _ = done_tx.send(());
let _ = timeout_handle.join();
// Check if killed by timeout.
if *kill_flag.lock().unwrap() {
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Err(JobFailure::Timeout),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
return;
}
match status {
Ok(exit) if exit.success() => {
// Command passed, continue to next.
}
Ok(exit) => {
let exit_code = exit.code().unwrap_or(-1);
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Err(JobFailure::CommandFailed {
exit_code,
last_lines,
}),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
return;
}
Err(e) => {
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Err(JobFailure::ExecError(e.to_string())),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
return;
}
}
}
// All commands passed.
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Ok(JobSuccess),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
}
}
impl ActorInterface for LocalRunner {
type Incoming = LocalRunnerMsg;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
let _ = ctx.send(ctx.self_addr(), LocalRunnerMsg::Execute);
}
fn handle(&mut self, ctx: &Ctx, msg: LocalRunnerMsg) {
match msg {
LocalRunnerMsg::Execute => {
self.execute(ctx);
}
LocalRunnerMsg::SimComplete(result) => {
let complete = JobComplete {
job_id: self.start_job.job_id.clone(),
result: match result {
Ok(()) => Ok(JobSuccess),
Err(msg) => Err(JobFailure::CommandFailed {
exit_code: 1,
last_lines: vec![msg],
}),
},
artifacts: Vec::new(),
};
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(complete),
);
ctx.stop_self();
}
}
}
}