A fork of mtelver's day10 project

refactor: use per-package directories with multiple layer symlinks

Replace single symlink per package with directory structure:
packages/{pkg.version}/
build-{hash} -> ../../build-{hash} # all builds
doc-{hash} -> ../../doc-{hash} # all doc layers
blessed-build -> ../../build-{hash} # canonical if blessed
blessed-docs -> ../../doc-{hash} # canonical if blessed

This handles packages built multiple times with different deps:
- All builds are tracked via symlinks
- blessed-* symlinks mark the canonical build for docs
- Web dashboard checks blessed-build first, falls back to listing

Changes:
- bin/util.ml: New ensure_package_layer_symlink and
ensure_package_blessed_symlink functions
- bin/main.ml: Create doc layer symlinks and blessed symlinks
when doc generation succeeds
- web/data/layer_data.ml: Updated to use new directory structure,
checks blessed-build first

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

+95 -44
+11 -3
bin/main.ml
··· 309 309 let installed_docs = Util.scan_installed_doc_files ~layer_dir:target_dir in 310 310 Util.save_layer_info ~installed_libs ~installed_docs layer_json pkg ordered_deps ordered_build_hashes r; 311 311 (* Create symlink from packages/{pkg} -> ../build-{hash} for easy lookup by package name *) 312 - Util.ensure_package_symlink ~cache_dir:config.dir ~os_key:(Config.os_key ~config) ~pkg_str ~layer_name:build_layer_name 312 + Util.ensure_package_layer_symlink ~cache_dir:config.dir ~os_key:(Config.os_key ~config) ~pkg_str ~layer_name:build_layer_name 313 313 in 314 314 let safe_write_layer target_dir = 315 315 try ··· 322 322 if not (Sys.file_exists target_layer_json) then 323 323 Util.save_layer_info target_layer_json pkg ordered_deps ordered_build_hashes 1; 324 324 (* Create symlink even for failures so we can look up the failure status *) 325 - Util.ensure_package_symlink ~cache_dir:config.dir ~os_key:(Config.os_key ~config) ~pkg_str ~layer_name:build_layer_name; 325 + Util.ensure_package_layer_symlink ~cache_dir:config.dir ~os_key:(Config.os_key ~config) ~pkg_str ~layer_name:build_layer_name; 326 326 raise exn 327 327 in 328 328 (* Check layer.json exists, not just the directory - directory might exist from interrupted build *) ··· 421 421 (* Check if doc generation failed *) 422 422 if Util.load_layer_info_doc_failed doc_layer_json then 423 423 None 424 - else 424 + else begin 425 + (* Create symlink for this doc layer *) 426 + Util.ensure_package_layer_symlink ~cache_dir:config.dir ~os_key ~pkg_str ~layer_name:doc_layer_name; 427 + (* If blessed, create blessed-build and blessed-docs symlinks *) 428 + if blessed then begin 429 + Util.ensure_package_blessed_symlink ~cache_dir:config.dir ~os_key ~pkg_str ~kind:`Build ~layer_name:build_layer_name; 430 + Util.ensure_package_blessed_symlink ~cache_dir:config.dir ~os_key ~pkg_str ~kind:`Docs ~layer_name:doc_layer_name 431 + end; 425 432 Some doc_layer_name 433 + end 426 434 427 435 let build config package = 428 436 match solve config package with
+24 -10
bin/util.ml
··· 41 41 in 42 42 Yojson.Safe.to_file name (`Assoc fields) 43 43 44 - (** Ensure a symlink exists from packages/{pkg_str} -> ../{layer_name} 45 - This enables O(1) lookup of layer info by package name. *) 46 - let ensure_package_symlink ~cache_dir ~os_key ~pkg_str ~layer_name = 47 - let packages_dir = Path.(cache_dir / os_key / "packages") in 48 - let symlink_path = Path.(packages_dir / pkg_str) in 49 - let target = Path.(".." / layer_name) in 50 - (* Create packages directory if needed *) 51 - if not (Sys.file_exists packages_dir) then 52 - Os.mkdir ~parents:true packages_dir; 53 - (* Create or update symlink *) 44 + (** Ensure a symlink exists from packages/{pkg_str}/{layer_name} -> ../../{layer_name} 45 + This enables tracking all builds/docs for a package.version. *) 46 + let ensure_package_layer_symlink ~cache_dir ~os_key ~pkg_str ~layer_name = 47 + let pkg_dir = Path.(cache_dir / os_key / "packages" / pkg_str) in 48 + let symlink_path = Path.(pkg_dir / layer_name) in 49 + let target = Path.(".." / ".." / layer_name) in 50 + (* Create package directory if needed *) 51 + if not (Sys.file_exists pkg_dir) then 52 + Os.mkdir ~parents:true pkg_dir; 53 + (* Create symlink if it doesn't exist *) 54 + if not (Sys.file_exists symlink_path) then 55 + Unix.symlink target symlink_path 56 + 57 + (** Ensure blessed-build or blessed-docs symlink exists for a package. 58 + These point to the layer that produced the blessed (canonical) docs. *) 59 + let ensure_package_blessed_symlink ~cache_dir ~os_key ~pkg_str ~kind ~layer_name = 60 + let pkg_dir = Path.(cache_dir / os_key / "packages" / pkg_str) in 61 + let symlink_name = match kind with `Build -> "blessed-build" | `Docs -> "blessed-docs" in 62 + let symlink_path = Path.(pkg_dir / symlink_name) in 63 + let target = Path.(".." / ".." / layer_name) in 64 + (* Create package directory if needed *) 65 + if not (Sys.file_exists pkg_dir) then 66 + Os.mkdir ~parents:true pkg_dir; 67 + (* Create or update symlink (blessed can change between runs) *) 54 68 (try Unix.unlink symlink_path with Unix.Unix_error (Unix.ENOENT, _, _) -> ()); 55 69 Unix.symlink target symlink_path 56 70
+60 -31
web/data/layer_data.ml
··· 1 1 (** Read layer info for packages from day10's cache directory. 2 - Uses the packages/ symlink directory for O(1) lookup by package name, 3 - with fallback to scanning build-* directories. *) 2 + Uses the packages/{pkg}/ directory structure with symlinks: 3 + - build-{hash} -> ../../build-{hash} (all builds) 4 + - doc-{hash} -> ../../doc-{hash} (all docs) 5 + - blessed-build -> ../../build-{hash} (canonical build if blessed) 6 + - blessed-docs -> ../../doc-{hash} (canonical docs if blessed) 7 + Falls back to scanning build-* directories if no symlinks exist. *) 4 8 5 9 type layer_info = { 6 10 package: string; ··· 26 30 else 27 31 None 28 32 29 - (** Get layer info for a package via symlink *) 33 + (** Follow a symlink and read layer.json from the target directory *) 34 + let read_layer_via_symlink symlink_path = 35 + if Sys.file_exists symlink_path then 36 + try 37 + let target = Unix.readlink symlink_path in 38 + (* Target is relative like "../../build-abc123" *) 39 + let layer_dir = Filename.concat (Filename.dirname symlink_path) target in 40 + let layer_json = Filename.concat layer_dir "layer.json" in 41 + read_layer_json layer_json 42 + with Unix.Unix_error _ -> None 43 + else 44 + None 45 + 46 + (** Get layer info for a package. 47 + Checks blessed-build first, then falls back to most recent build symlink, 48 + then falls back to scanning build-* directories. *) 30 49 let get_package_layer ~cache_dir ~platform ~package = 31 - let symlink_path = Filename.concat cache_dir 50 + let pkg_dir = Filename.concat cache_dir 32 51 (Filename.concat platform 33 52 (Filename.concat "packages" package)) in 34 - if Sys.file_exists symlink_path then 35 - (* Symlink exists - follow it to layer.json *) 36 - let layer_dir = 37 - try 38 - let target = Unix.readlink symlink_path in 39 - (* Target is relative like "../build-abc123" *) 40 - Filename.concat (Filename.dirname symlink_path) target 41 - with Unix.Unix_error _ -> symlink_path 42 - in 43 - let layer_json = Filename.concat layer_dir "layer.json" in 44 - read_layer_json layer_json 45 - else 46 - (* No symlink - fall back to scanning build-* directories *) 47 - let platform_dir = Filename.concat cache_dir platform in 48 - if Sys.file_exists platform_dir && Sys.is_directory platform_dir then 49 - Sys.readdir platform_dir 50 - |> Array.to_list 51 - |> List.filter (fun name -> String.length name > 6 && String.sub name 0 6 = "build-") 52 - |> List.find_map (fun build_dir -> 53 - let layer_json = Filename.concat platform_dir 54 - (Filename.concat build_dir "layer.json") in 55 - match read_layer_json layer_json with 56 - | Some info when info.package = package -> Some info 57 - | _ -> None) 58 - else 59 - None 53 + (* Try blessed-build first *) 54 + let blessed_build = Filename.concat pkg_dir "blessed-build" in 55 + match read_layer_via_symlink blessed_build with 56 + | Some info -> Some info 57 + | None -> 58 + (* Try to find any build-* symlink in the package directory *) 59 + if Sys.file_exists pkg_dir && Sys.is_directory pkg_dir then 60 + let build_symlinks = Sys.readdir pkg_dir 61 + |> Array.to_list 62 + |> List.filter (fun name -> String.length name > 6 && String.sub name 0 6 = "build-") 63 + |> List.sort (fun a b -> String.compare b a) (* Most recent first by hash *) 64 + in 65 + match build_symlinks with 66 + | first :: _ -> 67 + read_layer_via_symlink (Filename.concat pkg_dir first) 68 + | [] -> None 69 + else 70 + (* No package directory - fall back to scanning build-* directories *) 71 + let platform_dir = Filename.concat cache_dir platform in 72 + if Sys.file_exists platform_dir && Sys.is_directory platform_dir then 73 + Sys.readdir platform_dir 74 + |> Array.to_list 75 + |> List.filter (fun name -> String.length name > 6 && String.sub name 0 6 = "build-") 76 + |> List.find_map (fun build_dir -> 77 + let layer_json = Filename.concat platform_dir 78 + (Filename.concat build_dir "layer.json") in 79 + match read_layer_json layer_json with 80 + | Some info when info.package = package -> Some info 81 + | _ -> None) 82 + else 83 + None 60 84 61 85 (** List all packages with layer info (for computing reverse deps). 62 - Returns list of (package_name, layer_info) pairs. *) 86 + Returns list of (package_name, layer_info) pairs. 87 + Uses packages/ directory structure - each subdirectory is a package. *) 63 88 let list_all_packages ~cache_dir ~platform = 64 89 let packages_dir = Filename.concat cache_dir 65 90 (Filename.concat platform "packages") in 66 91 if Sys.file_exists packages_dir && Sys.is_directory packages_dir then 67 92 Sys.readdir packages_dir 68 93 |> Array.to_list 94 + |> List.filter (fun name -> 95 + (* Each entry should be a directory (package.version) *) 96 + let path = Filename.concat packages_dir name in 97 + Sys.is_directory path) 69 98 |> List.filter_map (fun package -> 70 99 match get_package_layer ~cache_dir ~platform ~package with 71 100 | Some info -> Some (package, info)