55//! provider, keeping this contract independent of any product integration.
66
77use std:: fs:: { File , OpenOptions } ;
8+ use std:: io:: Read ;
89use std:: net:: { TcpListener , TcpStream } ;
910use std:: path:: { Path , PathBuf } ;
1011use std:: process:: { Child , Command , Stdio } ;
@@ -20,7 +21,8 @@ use homeboy_core::process::{
2021use serde_json:: { json, Value } ;
2122
2223use super :: {
23- AgentTaskManagedService , AgentTaskManagedServiceLifecycle , AgentTaskManagedServiceReadinessKind ,
24+ AgentTaskEvidenceRef , AgentTaskManagedService , AgentTaskManagedServiceLifecycle ,
25+ AgentTaskManagedServiceReadinessKind ,
2426} ;
2527
2628#[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize ) ]
@@ -33,6 +35,11 @@ pub struct AgentTaskManagedServiceRecord {
3335 pub local_url : Option < String > ,
3436 pub public_url : Option < String > ,
3537 pub log_path : Option < String > ,
38+ /// The supervisor combines process stdout and stderr in one retained file.
39+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
40+ pub stdout_uri : Option < String > ,
41+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
42+ pub stderr_uri : Option < String > ,
3643 pub pid : Option < u32 > ,
3744 pub cleanup : Option < String > ,
3845 #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
@@ -210,6 +217,8 @@ impl AgentTaskServiceSupervisor {
210217 local_url : local_url. clone ( ) ,
211218 public_url : spec. public_url . clone ( ) ,
212219 log_path : Some ( log_path. display ( ) . to_string ( ) ) ,
220+ stdout_uri : Some ( format ! ( "file://{}" , log_path. display( ) ) ) ,
221+ stderr_uri : Some ( format ! ( "file://{}" , log_path. display( ) ) ) ,
213222 pid : None ,
214223 cleanup : None ,
215224 requested_cleanup_deadline_ms : Some ( spec. cleanup_deadline_ms ) ,
@@ -725,6 +734,97 @@ fn persist_record(run_id: &str, record: &AgentTaskManagedServiceRecord) -> Resul
725734 . map_err ( |error| format ! ( "commit managed service ownership: {error}" ) )
726735}
727736
737+ pub ( super ) fn startup_failure_evidence ( run_id : & str ) -> ( Vec < AgentTaskEvidenceRef > , Value ) {
738+ let records = read_service_records ( run_id) ;
739+ let services = records
740+ . iter ( )
741+ . map ( |record| {
742+ let record_uri = service_record_path ( run_id, & record. id )
743+ . map ( |path| format ! ( "file://{}" , path. display( ) ) ) ;
744+ json ! ( {
745+ "id" : record. id,
746+ "state" : record. state,
747+ "record_uri" : record_uri,
748+ "stdout_uri" : record. stdout_uri,
749+ "stderr_uri" : record. stderr_uri,
750+ "stdout_excerpt" : record. log_path. as_deref( ) . and_then( redacted_log_excerpt) ,
751+ "readiness_attempts" : record. readiness_attempts,
752+ "cleanup" : record. cleanup,
753+ "cleanup_outcome" : record. cleanup_outcome,
754+ } )
755+ } )
756+ . collect :: < Vec < _ > > ( ) ;
757+ let mut evidence_refs = Vec :: new ( ) ;
758+ for record in & records {
759+ if let Ok ( path) = service_record_path ( run_id, & record. id ) {
760+ evidence_refs. push ( AgentTaskEvidenceRef {
761+ kind : "managed-service-record" . to_string ( ) ,
762+ uri : format ! ( "file://{}" , path. display( ) ) ,
763+ label : Some ( format ! ( "managed service '{}' startup record" , record. id) ) ,
764+ } ) ;
765+ }
766+ for ( kind, uri) in [
767+ ( "managed-service-stdout" , record. stdout_uri . as_ref ( ) ) ,
768+ ( "managed-service-stderr" , record. stderr_uri . as_ref ( ) ) ,
769+ ] {
770+ if let Some ( uri) = uri {
771+ evidence_refs. push ( AgentTaskEvidenceRef {
772+ kind : kind. to_string ( ) ,
773+ uri : uri. clone ( ) ,
774+ label : Some ( format ! ( "managed service '{}' {kind}" , record. id) ) ,
775+ } ) ;
776+ }
777+ }
778+ }
779+ (
780+ evidence_refs,
781+ json ! ( {
782+ "run_id" : run_id,
783+ "service_supervisor_state_uri" : service_worker_state_path( run_id)
784+ . ok( )
785+ . map( |path| format!( "file://{}" , path. display( ) ) ) ,
786+ "services" : services,
787+ } ) ,
788+ )
789+ }
790+
791+ fn redacted_log_excerpt ( path : & str ) -> Option < String > {
792+ let mut bytes = Vec :: with_capacity ( 4 * 1024 ) ;
793+ File :: open ( path)
794+ . ok ( ) ?
795+ . take ( 4 * 1024 )
796+ . read_to_end ( & mut bytes)
797+ . ok ( ) ?;
798+ Some ( homeboy_core:: redaction:: redact_string (
799+ & String :: from_utf8_lossy ( & bytes) ,
800+ ) )
801+ }
802+
803+ fn service_record_path ( run_id : & str , service_id : & str ) -> Result < PathBuf , String > {
804+ Ok ( homeboy_core:: paths:: homeboy_data ( )
805+ . map_err ( |error| error. message ) ?
806+ . join ( "agent-task-runs" )
807+ . join ( run_id)
808+ . join ( "services" )
809+ . join ( format ! ( "{service_id}.json" ) ) )
810+ }
811+
812+ fn read_service_records ( run_id : & str ) -> Vec < AgentTaskManagedServiceRecord > {
813+ let directory = match homeboy_core:: paths:: homeboy_data ( ) {
814+ Ok ( path) => path. join ( "agent-task-runs" ) . join ( run_id) . join ( "services" ) ,
815+ Err ( _) => return Vec :: new ( ) ,
816+ } ;
817+ let Ok ( entries) = std:: fs:: read_dir ( directory) else {
818+ return Vec :: new ( ) ;
819+ } ;
820+ entries
821+ . flatten ( )
822+ . filter ( |entry| entry. path ( ) . extension ( ) . and_then ( |value| value. to_str ( ) ) == Some ( "json" ) )
823+ . filter_map ( |entry| std:: fs:: read ( entry. path ( ) ) . ok ( ) )
824+ . filter_map ( |bytes| serde_json:: from_slice ( & bytes) . ok ( ) )
825+ . collect ( )
826+ }
827+
728828fn worker_root ( run_id : & str ) -> Result < PathBuf , String > {
729829 Ok ( homeboy_core:: paths:: homeboy_data ( )
730830 . map_err ( |error| error. message ) ?
@@ -867,6 +967,7 @@ pub fn run_service_worker(request_path: &Path) -> Result<(), String> {
867967 Err ( error) => {
868968 state. state = "failed" . to_string ( ) ;
869969 state. detail = Some ( error) ;
970+ state. services = read_service_records ( & request. run_id ) ;
870971 state. heartbeat_unix_ms = now_unix_ms ( ) ;
871972 return write_json_atomically ( & state_path, & state) ;
872973 }
@@ -1605,7 +1706,8 @@ mod tests {
16051706 service. command = vec ! [
16061707 "sh" . to_string( ) ,
16071708 "-c" . to_string( ) ,
1608- "trap '' TERM; while :; do sleep 1; done" . to_string( ) ,
1709+ "echo token=managed-service-secret >&2; trap '' TERM; while :; do sleep 1; done"
1710+ . to_string( ) ,
16091711 ] ;
16101712 service. readiness . as_mut ( ) . unwrap ( ) . timeout_ms = Some ( 50 ) ;
16111713 service. cleanup_deadline_ms = 100 ;
@@ -1620,6 +1722,11 @@ mod tests {
16201722 assert ! ( error. contains( "readiness probe timed out" ) ) ;
16211723 worker. join ( ) . expect ( "worker joins" ) . expect ( "worker exits" ) ;
16221724 let state = wait_for_worker_state ( run_id, "failed" ) ;
1725+ assert_eq ! (
1726+ state. services. len( ) ,
1727+ 1 ,
1728+ "failed services remain discoverable"
1729+ ) ;
16231730 assert ! ( state
16241731 . detail
16251732 . as_deref( )
@@ -1646,6 +1753,15 @@ mod tests {
16461753 Some ( "deadline_escalation_forced" )
16471754 ) ;
16481755 assert ! ( record. pid. is_some_and( |pid| !super :: process_is_alive( pid) ) ) ;
1756+ assert_eq ! ( record. stdout_uri, record. stderr_uri) ;
1757+
1758+ let ( refs, evidence) = startup_failure_evidence ( run_id) ;
1759+ assert_eq ! ( refs. len( ) , 3 , "record plus stdout and stderr refs" ) ;
1760+ assert_eq ! ( evidence[ "services" ] [ 0 ] [ "state" ] , "failed" ) ;
1761+ assert ! ( evidence[ "services" ] [ 0 ] [ "stdout_uri" ]
1762+ . as_str( )
1763+ . is_some_and( |uri| uri. starts_with( "file://" ) ) ) ;
1764+ assert ! ( !evidence. to_string( ) . contains( "managed-service-secret" ) ) ;
16491765 } ) ;
16501766 }
16511767
0 commit comments