@@ -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} ;
1819use crate :: config:: { DevConfig , TaskUpdateMode } ;
1920use crate :: envfile;
@@ -180,9 +181,164 @@ fn handle_with_state(state: &AppState, command: Command) -> Result<()> {
180181fn 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+
186342fn research_init ( state : & AppState , args : ResearchInitArgs ) -> Result < ( ) > {
187343 let target = if args. directory . is_absolute ( ) {
188344 args. directory . clone ( )
0 commit comments