Skip to content

Commit df8a10a

Browse files
committed
feat(research): add dashboard start/stop/restart commands
1 parent ec6f082 commit df8a10a

2 files changed

Lines changed: 174 additions & 1 deletion

File tree

crates/dev/src/cli.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,12 @@ pub enum Command {
173173
pub enum ResearchCommand {
174174
/// Scaffold an isolated, project-local research workspace.
175175
Init(ResearchInitArgs),
176+
/// Start the research dashboard API server.
177+
Start(ResearchDashboardArgs),
178+
/// Stop the research dashboard API server.
179+
Stop,
180+
/// Restart the research dashboard API server.
181+
Restart(ResearchDashboardArgs),
176182
}
177183

178184
#[derive(Args, Debug)]
@@ -205,6 +211,17 @@ pub struct ResearchInitArgs {
205211
pub harness_git: String,
206212
}
207213

214+
#[derive(Args, Debug, Clone)]
215+
pub struct ResearchDashboardArgs {
216+
/// Host interface for the dashboard server.
217+
#[arg(long = "host", default_value = "0.0.0.0")]
218+
pub host: String,
219+
220+
/// TCP port for the dashboard server.
221+
#[arg(long = "port", default_value_t = 8022)]
222+
pub port: u16,
223+
}
224+
208225
#[derive(Subcommand, Debug)]
209226
pub enum OsCommand {
210227
/// Switch to Windows configuration (uses npx.cmd, .cmd extensions).

crates/dev/src/runner.rs

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ use crate::cli::{
1313
Cli, Command, ConfigCommand, DockerBuildArgs, DockerCommand, DockerComposeCommand,
1414
DockerComposeUpCommand, DockerComposeUpBuildArgs, DockerInitArgs, EnvArgs, EnvCommand,
1515
GitCommand, InstallArgs, KubeCommand, LanguageCommand, OsCommand, ResearchCommand,
16-
ResearchInitArgs, SetupCommand, StartArgs, VaultCommand, Verb, VersionCommand,
16+
ResearchDashboardArgs, ResearchInitArgs, SetupCommand, StartArgs, VaultCommand, Verb,
17+
VersionCommand,
1718
};
1819
use crate::config::{DevConfig, TaskUpdateMode};
1920
use crate::envfile;
@@ -180,9 +181,164 @@ fn handle_with_state(state: &AppState, command: Command) -> Result<()> {
180181
fn handle_research(state: &AppState, command: ResearchCommand) -> Result<()> {
181182
match command {
182183
ResearchCommand::Init(args) => research_init(state, args),
184+
ResearchCommand::Start(args) => research_dashboard_start(state, args),
185+
ResearchCommand::Stop => research_dashboard_stop(state),
186+
ResearchCommand::Restart(args) => research_dashboard_restart(state, args),
183187
}
184188
}
185189

190+
fn research_dashboard_start(state: &AppState, args: ResearchDashboardArgs) -> Result<()> {
191+
let (cwd, harness_home, pid_path, log_path) = research_dashboard_paths()?;
192+
193+
if let Some(pid) = read_dashboard_pid(&pid_path)? {
194+
if process_is_running(pid) {
195+
println!(
196+
"Research dashboard already running (pid={}, host={}, port={}).",
197+
pid, args.host, args.port
198+
);
199+
println!("Log: {}", log_path.display());
200+
return Ok(());
201+
}
202+
}
203+
204+
fs::create_dir_all(&harness_home)
205+
.with_context(|| format!("creating {}", harness_home.display()))?;
206+
207+
println!("Starting research dashboard on {}:{}...", args.host, args.port);
208+
if state.ctx.dry_run {
209+
println!(
210+
"[dry-run] run: HARNESS_HOME={} uv run uvicorn research_harness.api.main:app --host {} --port {} > {} 2>&1",
211+
harness_home.display(),
212+
args.host,
213+
args.port,
214+
log_path.display()
215+
);
216+
return Ok(());
217+
}
218+
219+
let log_out = fs::File::create(&log_path)
220+
.with_context(|| format!("creating {}", log_path.display()))?;
221+
let log_err = log_out
222+
.try_clone()
223+
.with_context(|| format!("cloning {}", log_path.display()))?;
224+
225+
let child = ProcessCommand::new("setsid")
226+
.current_dir(&cwd)
227+
.env("HARNESS_HOME", harness_home.as_os_str())
228+
.arg("uv")
229+
.arg("run")
230+
.arg("uvicorn")
231+
.arg("research_harness.api.main:app")
232+
.arg("--host")
233+
.arg(&args.host)
234+
.arg("--port")
235+
.arg(args.port.to_string())
236+
.stdin(Stdio::null())
237+
.stdout(Stdio::from(log_out))
238+
.stderr(Stdio::from(log_err))
239+
.spawn()
240+
.context("starting research dashboard process")?;
241+
242+
let pid = child.id();
243+
fs::write(&pid_path, format!("{}\n", pid))
244+
.with_context(|| format!("writing {}", pid_path.display()))?;
245+
246+
std::thread::sleep(std::time::Duration::from_millis(350));
247+
if !process_is_running(pid) {
248+
let _ = fs::remove_file(&pid_path);
249+
bail!(
250+
"research dashboard failed to stay running; check {}",
251+
log_path.display()
252+
);
253+
}
254+
255+
println!("Research dashboard started (pid={}).", pid);
256+
println!("Log: {}", log_path.display());
257+
Ok(())
258+
}
259+
260+
fn research_dashboard_stop(state: &AppState) -> Result<()> {
261+
let (_, _, pid_path, _) = research_dashboard_paths()?;
262+
263+
let Some(pid) = read_dashboard_pid(&pid_path)? else {
264+
println!("Research dashboard is not running (no pid file).");
265+
return Ok(());
266+
};
267+
268+
if !process_is_running(pid) {
269+
if !state.ctx.dry_run {
270+
let _ = fs::remove_file(&pid_path);
271+
}
272+
println!("Research dashboard process {} not running; cleaned stale pid file.", pid);
273+
return Ok(());
274+
}
275+
276+
println!("Stopping research dashboard (pid={})...", pid);
277+
if state.ctx.dry_run {
278+
println!("[dry-run] run: kill -TERM -{}", pid);
279+
return Ok(());
280+
}
281+
282+
let status = ProcessCommand::new("kill")
283+
.arg("-TERM")
284+
.arg(format!("-{}", pid))
285+
.status()
286+
.with_context(|| format!("stopping process group {}", pid))?;
287+
if !status.success() {
288+
let fallback = ProcessCommand::new("kill")
289+
.arg("-TERM")
290+
.arg(pid.to_string())
291+
.status()
292+
.with_context(|| format!("stopping pid {}", pid))?;
293+
if !fallback.success() {
294+
bail!("failed to stop research dashboard (pid={})", pid);
295+
}
296+
}
297+
298+
std::thread::sleep(std::time::Duration::from_millis(250));
299+
let _ = fs::remove_file(&pid_path);
300+
println!("Research dashboard stopped.");
301+
Ok(())
302+
}
303+
304+
fn research_dashboard_restart(state: &AppState, args: ResearchDashboardArgs) -> Result<()> {
305+
research_dashboard_stop(state)?;
306+
research_dashboard_start(state, args)
307+
}
308+
309+
fn research_dashboard_paths() -> Result<(PathBuf, PathBuf, PathBuf, PathBuf)> {
310+
let cwd = std::env::current_dir().context("resolving current working directory")?;
311+
let harness_home = cwd.join(".harness");
312+
let pid_path = harness_home.join("dashboard.pid");
313+
let log_path = harness_home.join("dashboard.log");
314+
Ok((cwd, harness_home, pid_path, log_path))
315+
}
316+
317+
fn read_dashboard_pid(pid_path: &Path) -> Result<Option<u32>> {
318+
if !pid_path.exists() {
319+
return Ok(None);
320+
}
321+
let raw = fs::read_to_string(pid_path)
322+
.with_context(|| format!("reading {}", pid_path.display()))?;
323+
let trimmed = raw.trim();
324+
if trimmed.is_empty() {
325+
return Ok(None);
326+
}
327+
let pid = trimmed
328+
.parse::<u32>()
329+
.with_context(|| format!("invalid pid in {}", pid_path.display()))?;
330+
Ok(Some(pid))
331+
}
332+
333+
fn process_is_running(pid: u32) -> bool {
334+
ProcessCommand::new("kill")
335+
.arg("-0")
336+
.arg(pid.to_string())
337+
.status()
338+
.map(|s| s.success())
339+
.unwrap_or(false)
340+
}
341+
186342
fn research_init(state: &AppState, args: ResearchInitArgs) -> Result<()> {
187343
let target = if args.directory.is_absolute() {
188344
args.directory.clone()

0 commit comments

Comments
 (0)