A fork of mtelver's day10 project

Implement structured run logging

Adds Run_log module for managing batch run logs:
- Timestamp-based run directories: runs/{YYYY-MM-DD-HHMMSS}/
- summary.json with run statistics and failure details
- Build logs symlinked to runs/{id}/build/{package}.log
- Doc logs symlinked to runs/{id}/docs/{package}.log
- 'latest' symlink updated after each run

Integration:
- Run started at beginning of batch processing
- Build/doc logs collected during summary generation
- Summary written with targets, solutions, successes, failures

Directory structure:
{log_dir}/
├── runs/
│ └── 2026-02-04-120000/
│ ├── summary.json
│ ├── build/*.log
│ └── docs/*.log
└── latest -> runs/2026-02-04-120000

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+458 -4
+41 -2
bin/main.ml
··· 976 976 let log_dir = Path.(config.dir / "logs") in 977 977 Os.set_log_dir log_dir; 978 978 979 + (* Start run logging *) 980 + Day10_lib.Run_log.set_log_base_dir log_dir; 981 + let run_info = Day10_lib.Run_log.start_run () in 982 + 979 983 (* Clean up stale .new/.old directories from interrupted swaps *) 980 984 (match config.html_output with 981 985 | Some html_dir -> Os.Atomic_swap.cleanup_stale_dirs ~html_dir ··· 1113 1117 let build_fail = ref 0 in 1114 1118 let doc_success = ref 0 in 1115 1119 let doc_fail = ref 0 in 1120 + let failures = ref [] in 1116 1121 let () = 1117 1122 try 1118 1123 Sys.readdir layer_dir |> Array.iter (fun name -> ··· 1123 1128 let open Yojson.Safe.Util in 1124 1129 if String.length name > 6 && String.sub name 0 6 = "build-" then begin 1125 1130 (* Build layer *) 1131 + let pkg_name = json |> member "package" |> to_string in 1126 1132 let exit_status = json |> member "exit_status" |> to_int_option |> Option.value ~default:(-1) in 1127 - if exit_status = 0 then incr build_success else incr build_fail 1133 + if exit_status = 0 then begin 1134 + incr build_success; 1135 + (* Add build log to run *) 1136 + let build_log = Path.(layer_dir / name / "build.log") in 1137 + Day10_lib.Run_log.add_build_log run_info ~package:pkg_name ~source_log:build_log 1138 + end else begin 1139 + incr build_fail; 1140 + failures := (pkg_name, Printf.sprintf "build exit code %d" exit_status) :: !failures; 1141 + let build_log = Path.(layer_dir / name / "build.log") in 1142 + Day10_lib.Run_log.add_build_log run_info ~package:pkg_name ~source_log:build_log 1143 + end 1128 1144 end else if String.length name > 4 && String.sub name 0 4 = "doc-" then begin 1129 1145 (* Doc layer - only count blessed ones *) 1146 + let pkg_name = json |> member "package" |> to_string in 1130 1147 let doc = json |> member "doc" in 1131 1148 let blessed = doc |> member "blessed" |> to_bool_option |> Option.value ~default:false in 1132 1149 if blessed then begin 1133 1150 let status = doc |> member "status" |> to_string_option |> Option.value ~default:"" in 1134 - if status = "success" then incr doc_success else incr doc_fail 1151 + if status = "success" then begin 1152 + incr doc_success; 1153 + (* Add doc log to run *) 1154 + let doc_log = Path.(layer_dir / name / "odoc-voodoo-all.log") in 1155 + Day10_lib.Run_log.add_doc_log run_info ~package:pkg_name ~source_log:doc_log 1156 + end else begin 1157 + incr doc_fail; 1158 + let error_msg = doc |> member "error" |> to_string_option |> Option.value ~default:"unknown error" in 1159 + failures := (pkg_name, Printf.sprintf "doc: %s" error_msg) :: !failures; 1160 + let doc_log = Path.(layer_dir / name / "odoc-voodoo-all.log") in 1161 + Day10_lib.Run_log.add_doc_log run_info ~package:pkg_name ~source_log:doc_log 1162 + end 1135 1163 end 1136 1164 end 1137 1165 with _ -> () ··· 1152 1180 ) 0 1153 1181 with _ -> 0 1154 1182 else 0 1183 + in 1184 + (* Write run summary *) 1185 + let _summary = Day10_lib.Run_log.finish_run run_info 1186 + ~targets_requested:(List.length packages) 1187 + ~solutions_found:(List.length solutions) 1188 + ~build_success:!build_success 1189 + ~build_failed:!build_fail 1190 + ~doc_success:!doc_success 1191 + ~doc_failed:!doc_fail 1192 + ~doc_skipped:0 (* TODO: track skipped docs *) 1193 + ~failures:!failures 1155 1194 in 1156 1195 Printf.printf "\nBatch summary:\n%!"; 1157 1196 Printf.printf " Targets requested: %d\n%!" (List.length packages);
+2 -2
lib/dune
··· 1 1 (library 2 2 (name day10_lib) 3 - (libraries unix str) 4 - (modules atomic_swap gc)) 3 + (libraries unix str yojson) 4 + (modules atomic_swap gc run_log))
+176
lib/run_log.ml
··· 1 + (** Run logging for batch processing. 2 + 3 + Manages timestamp-based run directories with structured logs: 4 + - runs/{id}/summary.json 5 + - runs/{id}/build/{package}.log 6 + - runs/{id}/docs/{package}.log 7 + - latest -> runs/{id} (symlink) 8 + *) 9 + 10 + (** Run metadata *) 11 + type t = { 12 + id : string; 13 + start_time : float; 14 + mutable end_time : float option; [@warning "-69"] 15 + run_dir : string; 16 + } 17 + 18 + (** Summary data *) 19 + type summary = { 20 + run_id : string; 21 + start_time : string; 22 + end_time : string; 23 + duration_seconds : float; 24 + targets_requested : int; 25 + solutions_found : int; 26 + build_success : int; 27 + build_failed : int; 28 + doc_success : int; 29 + doc_failed : int; 30 + doc_skipped : int; 31 + failures : (string * string) list; (** (package, error) pairs *) 32 + } 33 + 34 + let log_base_dir = ref "/var/log/day10" 35 + 36 + let set_log_base_dir dir = log_base_dir := dir 37 + 38 + (** Generate a run ID from current time: YYYY-MM-DD-HHMMSS *) 39 + let generate_run_id () = 40 + let t = Unix.gettimeofday () in 41 + let tm = Unix.localtime t in 42 + Printf.sprintf "%04d-%02d-%02d-%02d%02d%02d" 43 + (tm.Unix.tm_year + 1900) 44 + (tm.Unix.tm_mon + 1) 45 + tm.Unix.tm_mday 46 + tm.Unix.tm_hour 47 + tm.Unix.tm_min 48 + tm.Unix.tm_sec 49 + 50 + (** Format Unix timestamp as ISO 8601 string *) 51 + let format_time t = 52 + let tm = Unix.localtime t in 53 + Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02d" 54 + (tm.Unix.tm_year + 1900) 55 + (tm.Unix.tm_mon + 1) 56 + tm.Unix.tm_mday 57 + tm.Unix.tm_hour 58 + tm.Unix.tm_min 59 + tm.Unix.tm_sec 60 + 61 + (** Create directory and parents if needed *) 62 + let mkdir_p path = 63 + let rec create dir = 64 + if not (Sys.file_exists dir) then begin 65 + create (Filename.dirname dir); 66 + try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> () 67 + end 68 + in 69 + create path 70 + 71 + (** Start a new run - creates directory structure *) 72 + let start_run () = 73 + let id = generate_run_id () in 74 + let runs_dir = Filename.concat !log_base_dir "runs" in 75 + let run_dir = Filename.concat runs_dir id in 76 + mkdir_p run_dir; 77 + mkdir_p (Filename.concat run_dir "build"); 78 + mkdir_p (Filename.concat run_dir "docs"); 79 + { 80 + id; 81 + start_time = Unix.gettimeofday (); 82 + end_time = None; 83 + run_dir; 84 + } 85 + 86 + (** Update the 'latest' symlink to point to current run *) 87 + let update_latest_symlink run_info = 88 + let latest = Filename.concat !log_base_dir "latest" in 89 + let target = Filename.concat "runs" run_info.id in 90 + (* Remove existing symlink if present *) 91 + (try Unix.unlink latest with Unix.Unix_error _ -> ()); 92 + (* Create new symlink *) 93 + try Unix.symlink target latest 94 + with Unix.Unix_error (err, _, _) -> 95 + Printf.eprintf "[run_log] Warning: failed to create latest symlink: %s\n%!" 96 + (Unix.error_message err) 97 + 98 + (** Add a build log to the run directory (symlink or copy) *) 99 + let add_build_log run_info ~package ~source_log = 100 + let dest = Filename.concat run_info.run_dir 101 + (Filename.concat "build" (package ^ ".log")) in 102 + if Sys.file_exists source_log then begin 103 + (* Try symlink first, fall back to copy *) 104 + try 105 + Unix.symlink source_log dest 106 + with Unix.Unix_error _ -> 107 + try 108 + let content = In_channel.with_open_text source_log In_channel.input_all in 109 + Out_channel.with_open_text dest (fun oc -> Out_channel.output_string oc content) 110 + with _ -> () 111 + end 112 + 113 + (** Add a doc log to the run directory (symlink or copy) *) 114 + let add_doc_log run_info ~package ~source_log = 115 + let dest = Filename.concat run_info.run_dir 116 + (Filename.concat "docs" (package ^ ".log")) in 117 + if Sys.file_exists source_log then begin 118 + try 119 + Unix.symlink source_log dest 120 + with Unix.Unix_error _ -> 121 + try 122 + let content = In_channel.with_open_text source_log In_channel.input_all in 123 + Out_channel.with_open_text dest (fun oc -> Out_channel.output_string oc content) 124 + with _ -> () 125 + end 126 + 127 + (** Convert summary to JSON *) 128 + let summary_to_json summary = 129 + let failures_json = `List (List.map (fun (pkg, err) -> 130 + `Assoc [("package", `String pkg); ("error", `String err)] 131 + ) summary.failures) in 132 + `Assoc [ 133 + ("run_id", `String summary.run_id); 134 + ("start_time", `String summary.start_time); 135 + ("end_time", `String summary.end_time); 136 + ("duration_seconds", `Float summary.duration_seconds); 137 + ("targets_requested", `Int summary.targets_requested); 138 + ("solutions_found", `Int summary.solutions_found); 139 + ("build_success", `Int summary.build_success); 140 + ("build_failed", `Int summary.build_failed); 141 + ("doc_success", `Int summary.doc_success); 142 + ("doc_failed", `Int summary.doc_failed); 143 + ("doc_skipped", `Int summary.doc_skipped); 144 + ("failures", failures_json); 145 + ] 146 + 147 + (** Write summary.json to run directory *) 148 + let write_summary run_info summary = 149 + let path = Filename.concat run_info.run_dir "summary.json" in 150 + let json = summary_to_json summary in 151 + let content = Yojson.Safe.pretty_to_string json in 152 + Out_channel.with_open_text path (fun oc -> Out_channel.output_string oc content) 153 + 154 + (** Finish a run - write summary and update latest symlink *) 155 + let finish_run (run_info : t) ~targets_requested ~solutions_found 156 + ~build_success ~build_failed ~doc_success ~doc_failed ~doc_skipped 157 + ~failures = 158 + let finish_time = Unix.gettimeofday () in 159 + run_info.end_time <- Some finish_time; 160 + let summary : summary = { 161 + run_id = run_info.id; 162 + start_time = format_time run_info.start_time; 163 + end_time = format_time finish_time; 164 + duration_seconds = finish_time -. run_info.start_time; 165 + targets_requested; 166 + solutions_found; 167 + build_success; 168 + build_failed; 169 + doc_success; 170 + doc_failed; 171 + doc_skipped; 172 + failures; 173 + } in 174 + write_summary run_info summary; 175 + update_latest_symlink run_info; 176 + summary
+49
lib/run_log.mli
··· 1 + (** Run logging for batch processing. 2 + 3 + Manages timestamp-based run directories with structured logs. *) 4 + 5 + (** Run metadata *) 6 + type t 7 + 8 + (** Summary data *) 9 + type summary = { 10 + run_id : string; 11 + start_time : string; 12 + end_time : string; 13 + duration_seconds : float; 14 + targets_requested : int; 15 + solutions_found : int; 16 + build_success : int; 17 + build_failed : int; 18 + doc_success : int; 19 + doc_failed : int; 20 + doc_skipped : int; 21 + failures : (string * string) list; 22 + } 23 + 24 + (** Set the base directory for logs (default: /var/log/day10) *) 25 + val set_log_base_dir : string -> unit 26 + 27 + (** Start a new run - creates directory structure *) 28 + val start_run : unit -> t 29 + 30 + (** Add a build log to the run directory *) 31 + val add_build_log : t -> package:string -> source_log:string -> unit 32 + 33 + (** Add a doc log to the run directory *) 34 + val add_doc_log : t -> package:string -> source_log:string -> unit 35 + 36 + (** Finish a run - write summary and update latest symlink *) 37 + val finish_run : t -> 38 + targets_requested:int -> 39 + solutions_found:int -> 40 + build_success:int -> 41 + build_failed:int -> 42 + doc_success:int -> 43 + doc_failed:int -> 44 + doc_skipped:int -> 45 + failures:(string * string) list -> 46 + summary 47 + 48 + (** Convert summary to JSON *) 49 + val summary_to_json : summary -> Yojson.Safe.t
+4
tests/unit/dune
··· 5 5 (executable 6 6 (name test_gc) 7 7 (libraries day10_lib)) 8 + 9 + (executable 10 + (name test_run_log) 11 + (libraries day10_lib unix yojson))
+186
tests/unit/test_run_log.ml
··· 1 + (** Unit tests for Day10_lib.Run_log module. *) 2 + 3 + let test_dir = ref "" 4 + 5 + let setup () = 6 + let dir = Filename.temp_dir "test-run-log-" "" in 7 + test_dir := dir; 8 + Day10_lib.Run_log.set_log_base_dir dir; 9 + dir 10 + 11 + let teardown () = 12 + if !test_dir <> "" then begin 13 + ignore (Sys.command (Printf.sprintf "rm -rf %s" !test_dir)); 14 + test_dir := "" 15 + end 16 + 17 + let file_exists path = Sys.file_exists path 18 + 19 + let read_file path = 20 + In_channel.with_open_text path In_channel.input_all 21 + 22 + (** Test: start_run creates directory structure *) 23 + let test_start_run_creates_dirs () = 24 + let base_dir = setup () in 25 + let run_info = Day10_lib.Run_log.start_run () in 26 + 27 + (* Check runs directory exists *) 28 + assert (file_exists (Filename.concat base_dir "runs")); 29 + 30 + (* Check run-specific directories exist *) 31 + let runs_dir = Filename.concat base_dir "runs" in 32 + let entries = Sys.readdir runs_dir in 33 + assert (Array.length entries = 1); 34 + 35 + let run_dir = Filename.concat runs_dir entries.(0) in 36 + assert (file_exists (Filename.concat run_dir "build")); 37 + assert (file_exists (Filename.concat run_dir "docs")); 38 + 39 + ignore run_info; 40 + teardown (); 41 + Printf.printf "PASS: test_start_run_creates_dirs\n%!" 42 + 43 + (** Test: finish_run creates summary.json *) 44 + let test_finish_run_creates_summary () = 45 + let base_dir = setup () in 46 + let run_info = Day10_lib.Run_log.start_run () in 47 + 48 + let summary = Day10_lib.Run_log.finish_run run_info 49 + ~targets_requested:10 50 + ~solutions_found:8 51 + ~build_success:7 52 + ~build_failed:1 53 + ~doc_success:5 54 + ~doc_failed:1 55 + ~doc_skipped:1 56 + ~failures:[("bad-pkg.1.0.0", "build failed")] in 57 + 58 + (* Check summary.json exists *) 59 + let runs_dir = Filename.concat base_dir "runs" in 60 + let entries = Sys.readdir runs_dir in 61 + let run_dir = Filename.concat runs_dir entries.(0) in 62 + let summary_file = Filename.concat run_dir "summary.json" in 63 + assert (file_exists summary_file); 64 + 65 + (* Check summary content *) 66 + let content = read_file summary_file in 67 + assert (String.length content > 0); 68 + let json = Yojson.Safe.from_string content in 69 + let open Yojson.Safe.Util in 70 + assert (json |> member "targets_requested" |> to_int = 10); 71 + assert (json |> member "build_success" |> to_int = 7); 72 + assert (json |> member "build_failed" |> to_int = 1); 73 + 74 + ignore summary; 75 + teardown (); 76 + Printf.printf "PASS: test_finish_run_creates_summary\n%!" 77 + 78 + (** Test: finish_run creates latest symlink *) 79 + let test_finish_run_creates_latest () = 80 + let base_dir = setup () in 81 + let run_info = Day10_lib.Run_log.start_run () in 82 + 83 + let _ = Day10_lib.Run_log.finish_run run_info 84 + ~targets_requested:1 85 + ~solutions_found:1 86 + ~build_success:1 87 + ~build_failed:0 88 + ~doc_success:1 89 + ~doc_failed:0 90 + ~doc_skipped:0 91 + ~failures:[] in 92 + 93 + (* Check latest symlink exists *) 94 + let latest = Filename.concat base_dir "latest" in 95 + assert (file_exists latest); 96 + 97 + (* Check it's a symlink *) 98 + let stats = Unix.lstat latest in 99 + assert (stats.Unix.st_kind = Unix.S_LNK); 100 + 101 + teardown (); 102 + Printf.printf "PASS: test_finish_run_creates_latest\n%!" 103 + 104 + (** Test: add_build_log creates symlink *) 105 + let test_add_build_log () = 106 + let base_dir = setup () in 107 + let run_info = Day10_lib.Run_log.start_run () in 108 + 109 + (* Create a source log file *) 110 + let source_log = Filename.concat base_dir "source-build.log" in 111 + Out_channel.with_open_text source_log (fun oc -> 112 + Out_channel.output_string oc "Build output here\n"); 113 + 114 + Day10_lib.Run_log.add_build_log run_info ~package:"test-pkg.1.0.0" ~source_log; 115 + 116 + (* Check log was linked/copied *) 117 + let runs_dir = Filename.concat base_dir "runs" in 118 + let entries = Sys.readdir runs_dir in 119 + let run_dir = Filename.concat runs_dir entries.(0) in 120 + let dest_log = Filename.concat (Filename.concat run_dir "build") "test-pkg.1.0.0.log" in 121 + assert (file_exists dest_log); 122 + 123 + teardown (); 124 + Printf.printf "PASS: test_add_build_log\n%!" 125 + 126 + (** Test: add_doc_log creates symlink *) 127 + let test_add_doc_log () = 128 + let base_dir = setup () in 129 + let run_info = Day10_lib.Run_log.start_run () in 130 + 131 + (* Create a source log file *) 132 + let source_log = Filename.concat base_dir "source-doc.log" in 133 + Out_channel.with_open_text source_log (fun oc -> 134 + Out_channel.output_string oc "Doc output here\n"); 135 + 136 + Day10_lib.Run_log.add_doc_log run_info ~package:"test-pkg.1.0.0" ~source_log; 137 + 138 + (* Check log was linked/copied *) 139 + let runs_dir = Filename.concat base_dir "runs" in 140 + let entries = Sys.readdir runs_dir in 141 + let run_dir = Filename.concat runs_dir entries.(0) in 142 + let dest_log = Filename.concat (Filename.concat run_dir "docs") "test-pkg.1.0.0.log" in 143 + assert (file_exists dest_log); 144 + 145 + teardown (); 146 + Printf.printf "PASS: test_add_doc_log\n%!" 147 + 148 + (** Test: summary_to_json produces valid JSON *) 149 + let test_summary_to_json () = 150 + let summary : Day10_lib.Run_log.summary = { 151 + run_id = "2026-02-04-120000"; 152 + start_time = "2026-02-04T12:00:00"; 153 + end_time = "2026-02-04T12:30:00"; 154 + duration_seconds = 1800.0; 155 + targets_requested = 100; 156 + solutions_found = 95; 157 + build_success = 90; 158 + build_failed = 5; 159 + doc_success = 80; 160 + doc_failed = 5; 161 + doc_skipped = 5; 162 + failures = [("pkg1.1.0", "error1"); ("pkg2.2.0", "error2")]; 163 + } in 164 + 165 + let json = Day10_lib.Run_log.summary_to_json summary in 166 + let str = Yojson.Safe.to_string json in 167 + assert (String.length str > 0); 168 + 169 + (* Parse back and verify *) 170 + let parsed = Yojson.Safe.from_string str in 171 + let open Yojson.Safe.Util in 172 + assert (parsed |> member "run_id" |> to_string = "2026-02-04-120000"); 173 + assert (parsed |> member "targets_requested" |> to_int = 100); 174 + assert (parsed |> member "failures" |> to_list |> List.length = 2); 175 + 176 + Printf.printf "PASS: test_summary_to_json\n%!" 177 + 178 + let () = 179 + Printf.printf "Running Run_log tests...\n%!"; 180 + test_start_run_creates_dirs (); 181 + test_finish_run_creates_summary (); 182 + test_finish_run_creates_latest (); 183 + test_add_build_log (); 184 + test_add_doc_log (); 185 + test_summary_to_json (); 186 + Printf.printf "\nAll Run_log tests passed!\n%!"