A fork of mtelver's day10 project

feat: add JTW (js_top_worker) support for browser REPL artifacts

Enable ohc to produce JavaScript toplevel artifacts alongside
documentation, so every (package, version, universe) triple gets a
working browser-based OCaml REPL with that package pre-loaded.

Adds --with-jtw and --jtw-output flags to health-check and batch
commands. Creates jtw-tools layers (js_of_ocaml + js_top_worker per
OCaml version), per-package jtw layers (.cma.js, .cmi, META,
dynamic_cmis.json), and assembles output with compiler/, u/, and p/
directory structure.

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

+610 -8
+2
bin/config.ml
··· 14 14 dot : string option; 15 15 with_test : bool; 16 16 with_doc : bool; 17 + with_jtw : bool; 17 18 doc_tools_repo : string; 18 19 doc_tools_branch : string; 19 20 html_output : string option; (* Shared HTML output directory for all docs *) 21 + jtw_output : string option; (* Output directory for jtw artifacts *) 20 22 tag : string option; 21 23 log : bool; 22 24 dry_run : bool;
+5
bin/dummy.ml
··· 33 33 34 34 (* Documentation generation not supported in dummy container *) 35 35 let generate_docs ~t:_ ~build_layer_dir:_ ~doc_layer_dir:_ ~dep_doc_hashes:_ ~pkg:_ ~installed_libs:_ ~installed_docs:_ ~phase:_ ~ocaml_version:_ = None 36 + 37 + let jtw_layer_hash ~t:_ ~build_hash:_ ~ocaml_version:_ = "" 38 + 39 + (* JTW generation not supported in dummy container *) 40 + let generate_jtw ~t:_ ~build_layer_dir:_ ~jtw_layer_dir:_ ~dep_build_hashes:_ ~pkg:_ ~installed_libs:_ ~ocaml_version:_ = None
+5
bin/freebsd.ml
··· 252 252 253 253 (* Documentation generation not supported on FreeBSD *) 254 254 let generate_docs ~t:_ ~build_layer_dir:_ ~doc_layer_dir:_ ~dep_doc_hashes:_ ~pkg:_ ~installed_libs:_ ~installed_docs:_ ~phase:_ ~ocaml_version:_ = None 255 + 256 + let jtw_layer_hash ~t:_ ~build_hash:_ ~ocaml_version:_ = "" 257 + 258 + (* JTW generation not supported on FreeBSD *) 259 + let generate_jtw ~t:_ ~build_layer_dir:_ ~jtw_layer_dir:_ ~dep_build_hashes:_ ~pkg:_ ~installed_libs:_ ~ocaml_version:_ = None
+237
bin/jtw_gen.ml
··· 1 + (** JTW generation logic: compile .cma to .cma.js, extract .cmi, META, 2 + generate dynamic_cmis.json, assemble universe output directories. *) 3 + 4 + (** Compute hash for a jtw layer. 5 + Depends on the build hash and jtw-tools layer hash. *) 6 + let compute_jtw_layer_hash ~build_hash ~jtw_tools_hash = 7 + (build_hash ^ " " ^ jtw_tools_hash) |> Digest.string |> Digest.to_hex 8 + 9 + (** Generate the dynamic_cmis.json content for a directory of .cmi files. 10 + [dcs_url] is the URL path prefix for the directory. 11 + Returns the JSON string. *) 12 + let generate_dynamic_cmis_json ~dcs_url cmi_filenames = 13 + (* Strip .cmi extension *) 14 + let all_cmis = List.map (fun s -> 15 + if Filename.check_suffix s ".cmi" 16 + then String.sub s 0 (String.length s - 4) 17 + else s 18 + ) cmi_filenames in 19 + (* Partition into hidden (contains __) and non-hidden modules *) 20 + let hidden, non_hidden = List.partition (fun x -> 21 + try let _ = Str.search_forward (Str.regexp_string "__") x 0 in true 22 + with Not_found -> false 23 + ) all_cmis in 24 + (* Extract prefixes from hidden modules *) 25 + let prefixes = List.filter_map (fun x -> 26 + match String.split_on_char '_' x with 27 + | [] -> None 28 + | _ -> 29 + try 30 + let pos = Str.search_forward (Str.regexp_string "__") x 0 in 31 + Some (String.sub x 0 (pos + 2)) 32 + with Not_found -> None 33 + ) hidden in 34 + let prefixes = List.sort_uniq String.compare prefixes in 35 + let toplevel_modules = List.map String.capitalize_ascii non_hidden in 36 + (* Build JSON manually to avoid dependency on rpclib *) 37 + let json_list xs = "[" ^ String.concat "," (List.map (fun s -> Printf.sprintf "%S" s) xs) ^ "]" in 38 + Printf.sprintf {|{"dcs_url":%S,"dcs_toplevel_modules":%s,"dcs_file_prefixes":%s}|} 39 + dcs_url (json_list toplevel_modules) (json_list prefixes) 40 + 41 + (** Generate findlib_index JSON for a universe. 42 + [meta_paths] is a list of relative META paths (e.g., "hmap/0.8.1/lib/hmap/META"). *) 43 + let generate_findlib_index meta_paths = 44 + let metas = List.map (fun p -> `String p) meta_paths in 45 + Yojson.Safe.to_string (`Assoc [("metas", `List metas)]) 46 + 47 + (** The shell command to compile a .cma to .cma.js inside a container. 48 + Returns a command string suitable for bash -c. *) 49 + let jsoo_compile_command ~cma_path ~output_path ~js_stubs = 50 + let stubs = String.concat " " (List.map Filename.quote js_stubs) in 51 + Printf.sprintf "js_of_ocaml compile --toplevel --include-runtime --effects=disabled %s %s -o %s" 52 + stubs (Filename.quote cma_path) (Filename.quote output_path) 53 + 54 + (** Build the shell script to run inside the container for jtw generation. 55 + This compiles all .cma files found in the package's lib directory. *) 56 + let jtw_container_script ~pkg ~installed_libs = 57 + let pkg_name = OpamPackage.name_to_string pkg in 58 + let lib_base = "/home/opam/.opam/default/lib" in 59 + (* Find .cma files from installed_libs *) 60 + let cma_files = List.filter (fun f -> Filename.check_suffix f ".cma") installed_libs in 61 + if cma_files = [] then 62 + (* No .cma files - just exit success, we'll still copy .cmi and META *) 63 + "true" 64 + else begin 65 + let compile_cmds = List.map (fun cma_rel -> 66 + let cma_path = lib_base ^ "/" ^ cma_rel in 67 + let js_output = "/home/opam/jtw-output/lib/" ^ cma_rel ^ ".js" in 68 + let js_dir = Filename.dirname js_output in 69 + (* Look for jsoo runtime stubs in the same directory as the .cma *) 70 + let cma_dir = Filename.dirname cma_path in 71 + Printf.sprintf "mkdir -p %s && js_stubs=$(find %s -name '*.js' -not -name '*.cma.js' 2>/dev/null | tr '\\n' ' ') && js_of_ocaml compile --toplevel --include-runtime --effects=disabled $js_stubs %s -o %s" 72 + (Filename.quote js_dir) (Filename.quote cma_dir) (Filename.quote cma_path) (Filename.quote js_output) 73 + ) cma_files in 74 + let script = String.concat " && " ( 75 + ["eval $(opam env)"; 76 + Printf.sprintf "echo 'JTW: Compiling %s (%d archives)'" pkg_name (List.length cma_files)] 77 + @ compile_cmds 78 + @ ["echo 'JTW: Done'"] 79 + ) in 80 + script 81 + end 82 + 83 + (** Assemble the jtw output directory structure from completed jtw layers. 84 + 85 + Output structure: 86 + {v 87 + <jtw_output>/ 88 + compiler/<ocaml-version>/ 89 + worker.js 90 + lib/ocaml/ 91 + dynamic_cmis.json 92 + stdlib.cmi, ... 93 + u/<universe-hash>/ 94 + findlib_index 95 + <package>/<version>/ 96 + lib/<findlib-name>/ 97 + META, dynamic_cmis.json, *.cmi, *.cma.js 98 + p/<package>/<version>/ (blessed) 99 + lib/... 100 + v} *) 101 + let assemble_jtw_output ~config ~jtw_output ~ocaml_version ~solutions ~blessed_maps = 102 + let os_key = Config.os_key ~config in 103 + let ocaml_ver = OpamPackage.Version.to_string (OpamPackage.version ocaml_version) in 104 + 105 + (* Step 1: Copy compiler artifacts (worker.js + stdlib) *) 106 + let jtw_tools_dir = Jtw_tools.layer_path ~config ~ocaml_version in 107 + let tools_output = Path.(jtw_tools_dir / "fs" / "home" / "opam" / "jtw-tools-output") in 108 + let compiler_dir = Path.(jtw_output / "compiler" / ocaml_ver) in 109 + Os.mkdir ~parents:true compiler_dir; 110 + let worker_src = Path.(tools_output / "worker.js") in 111 + if Sys.file_exists worker_src then 112 + Os.cp worker_src Path.(compiler_dir / "worker.js"); 113 + (* Copy stdlib lib directory from jtw-tools output *) 114 + let stdlib_src = Path.(tools_output / "lib") in 115 + if Sys.file_exists stdlib_src then begin 116 + let stdlib_dst = Path.(compiler_dir / "lib") in 117 + Os.mkdir ~parents:true stdlib_dst; 118 + ignore (Os.sudo ["cp"; "-a"; "--no-target-directory"; stdlib_src; stdlib_dst]) 119 + end; 120 + 121 + (* Step 2: For each solution, assemble universe directories *) 122 + List.iter (fun (_target_pkg, solution) -> 123 + let ordered = List.map fst (OpamPackage.Map.bindings solution) in 124 + (* Compute universe hash from build hashes of all packages in solution *) 125 + let build_hashes = List.filter_map (fun pkg -> 126 + let pkg_str = OpamPackage.to_string pkg in 127 + let pkg_dir = Path.(config.dir / os_key / "packages" / pkg_str) in 128 + if Sys.file_exists pkg_dir then begin 129 + try 130 + Sys.readdir pkg_dir |> Array.to_list 131 + |> List.find_opt (fun name -> String.length name > 6 && String.sub name 0 6 = "build-") 132 + with _ -> None 133 + end else None 134 + ) ordered in 135 + let universe = Odoc_gen.compute_universe_hash build_hashes in 136 + 137 + (* Collect META paths for findlib_index *) 138 + let meta_paths = ref [] in 139 + 140 + List.iter (fun pkg -> 141 + let pkg_name = OpamPackage.name_to_string pkg in 142 + let pkg_version = OpamPackage.version_to_string pkg in 143 + let pkg_str = OpamPackage.to_string pkg in 144 + 145 + (* Find jtw layer for this package *) 146 + let pkg_layers_dir = Path.(config.dir / os_key / "packages" / pkg_str) in 147 + let jtw_layer_name = 148 + if Sys.file_exists pkg_layers_dir then 149 + try 150 + Sys.readdir pkg_layers_dir |> Array.to_list 151 + |> List.find_opt (fun name -> String.length name > 4 && String.sub name 0 4 = "jtw-") 152 + with _ -> None 153 + else None 154 + in 155 + 156 + match jtw_layer_name with 157 + | None -> () 158 + | Some jtw_name -> 159 + let jtw_layer_dir = Path.(config.dir / os_key / jtw_name) in 160 + let jtw_lib_src = Path.(jtw_layer_dir / "lib") in 161 + if Sys.file_exists jtw_lib_src then begin 162 + (* Determine if blessed *) 163 + let blessed = List.exists (fun (_t, bmap) -> 164 + try OpamPackage.Map.find pkg bmap 165 + with Not_found -> false 166 + ) blessed_maps in 167 + 168 + (* Copy to universe directory *) 169 + let u_pkg_dir = Path.(jtw_output / "u" / universe / pkg_name / pkg_version) in 170 + Os.mkdir ~parents:true u_pkg_dir; 171 + let u_lib_dst = Path.(u_pkg_dir / "lib") in 172 + if not (Sys.file_exists u_lib_dst) then 173 + Os.mkdir ~parents:true u_lib_dst; 174 + ignore (Os.sudo ["cp"; "-a"; "--no-target-directory"; jtw_lib_src; u_lib_dst]); 175 + 176 + (* Collect META paths relative to universe root *) 177 + (try 178 + let rec find_metas base rel = 179 + let full = Path.(base / rel) in 180 + if Sys.is_directory full then 181 + Sys.readdir full |> Array.iter (fun name -> 182 + find_metas base (if rel = "" then name else rel ^ "/" ^ name)) 183 + else if Filename.basename rel = "META" then 184 + meta_paths := (pkg_name ^ "/" ^ pkg_version ^ "/lib/" ^ rel) :: !meta_paths 185 + in 186 + find_metas jtw_lib_src "" 187 + with _ -> ()); 188 + 189 + (* Copy blessed packages to /p/ *) 190 + if blessed then begin 191 + let p_pkg_dir = Path.(jtw_output / "p" / pkg_name / pkg_version) in 192 + Os.mkdir ~parents:true p_pkg_dir; 193 + let p_lib_dst = Path.(p_pkg_dir / "lib") in 194 + if not (Sys.file_exists p_lib_dst) then 195 + Os.mkdir ~parents:true p_lib_dst; 196 + ignore (Os.sudo ["cp"; "-a"; "--no-target-directory"; jtw_lib_src; p_lib_dst]) 197 + end 198 + end 199 + ) ordered; 200 + 201 + (* Write findlib_index for this universe *) 202 + if !meta_paths <> [] then begin 203 + let u_dir = Path.(jtw_output / "u" / universe) in 204 + Os.mkdir ~parents:true u_dir; 205 + let findlib_index = generate_findlib_index !meta_paths in 206 + Os.write_to_file Path.(u_dir / "findlib_index") findlib_index 207 + end 208 + ) solutions 209 + 210 + (** Save jtw layer info to layer.json *) 211 + let save_jtw_layer_info ?jtw_result layer_json_path pkg ~build_hash = 212 + let fields = 213 + [ 214 + ("package", `String (OpamPackage.to_string pkg)); 215 + ("build_hash", `String build_hash); 216 + ("created", `Float (Unix.time ())); 217 + ] 218 + in 219 + let fields = match jtw_result with 220 + | None -> fields 221 + | Some result -> fields @ [ ("jtw", result) ] 222 + in 223 + Yojson.Safe.to_file layer_json_path (`Assoc fields) 224 + 225 + (** JTW result types *) 226 + type jtw_result = 227 + | Jtw_success 228 + | Jtw_failure of string 229 + | Jtw_skipped 230 + 231 + let jtw_result_to_yojson = function 232 + | Jtw_success -> 233 + `Assoc [("status", `String "success")] 234 + | Jtw_failure msg -> 235 + `Assoc [("status", `String "failure"); ("error", `String msg)] 236 + | Jtw_skipped -> 237 + `Assoc [("status", `String "skipped")]
+57
bin/jtw_tools.ml
··· 1 + (** JTW tools layer management for js_of_ocaml + js_top_worker toolchain. 2 + 3 + Per OCaml version: installs js_of_ocaml and js_top_worker, builds worker.js, 4 + and extracts stdlib cmis + dynamic_cmis.json for the compiler version. 5 + 6 + Cached as jtw-tools-{hash}/ per OCaml version. *) 7 + 8 + (** Compute hash for the jtw-tools layer. 9 + Depends on OCaml version (js_of_ocaml must match target compiler). *) 10 + let layer_hash ~(ocaml_version : OpamPackage.t) = 11 + let version = OpamPackage.Version.to_string (OpamPackage.version ocaml_version) in 12 + let components = [ "jtw-tools"; version; "1" (* bump to invalidate cache *) ] in 13 + String.concat "|" components |> Digest.string |> Digest.to_hex 14 + 15 + (** Directory name for the jtw-tools layer *) 16 + let layer_name ~(ocaml_version : OpamPackage.t) = 17 + "jtw-tools-" ^ layer_hash ~ocaml_version 18 + 19 + (** Full path to the jtw-tools layer *) 20 + let layer_path ~(config : Config.t) ~(ocaml_version : OpamPackage.t) = 21 + let os_key = Config.os_key ~config in 22 + Path.(config.dir / os_key / layer_name ~ocaml_version) 23 + 24 + (** Generate build script for the jtw-tools layer. 25 + Installs js_of_ocaml and js_top_worker, then builds worker.js and 26 + extracts stdlib cmis. *) 27 + let build_script ~(ocaml_version : OpamPackage.t) = 28 + let version = OpamPackage.Version.to_string (OpamPackage.version ocaml_version) in 29 + String.concat " && " 30 + [ 31 + Printf.sprintf "opam install -y ocaml-base-compiler.%s" version; 32 + "opam install -y js_of_ocaml js_top_worker"; 33 + (* Verify tools are installed *) 34 + "eval $(opam env) && which js_of_ocaml && which jtw"; 35 + (* Build worker.js using jtw mk-backend *) 36 + "eval $(opam env) && jtw mk-backend -o /home/opam/jtw-tools-output"; 37 + (* Generate stdlib dynamic_cmis.json and copy stdlib cmis *) 38 + "eval $(opam env) && jtw opam -o /home/opam/jtw-tools-output stdlib --no-worker"; 39 + ] 40 + 41 + (** Check if jtw-tools layer exists for this OCaml version *) 42 + let exists ~(config : Config.t) ~(ocaml_version : OpamPackage.t) : bool = 43 + Sys.file_exists (layer_path ~config ~ocaml_version) 44 + 45 + (** Get the hash/name for the jtw-tools layer *) 46 + let get_hash ~(ocaml_version : OpamPackage.t) : string = 47 + layer_name ~ocaml_version 48 + 49 + (** Check if js_of_ocaml is available in the jtw-tools layer *) 50 + let has_jsoo ~(config : Config.t) ~(ocaml_version : OpamPackage.t) : bool = 51 + let jsoo_path = Path.(layer_path ~config ~ocaml_version / "fs" / "home" / "opam" / ".opam" / "default" / "bin" / "js_of_ocaml") in 52 + Sys.file_exists jsoo_path 53 + 54 + (** Check if worker.js was built in the jtw-tools layer *) 55 + let has_worker_js ~(config : Config.t) ~(ocaml_version : OpamPackage.t) : bool = 56 + let worker_path = Path.(layer_path ~config ~ocaml_version / "fs" / "home" / "opam" / "jtw-tools-output" / "worker.js") in 57 + Sys.file_exists worker_path
+167
bin/linux.ml
··· 618 618 ignore (Os.sudo [ "chown"; "-R"; uid_gid; pkg_html_dir ]); 619 619 result 620 620 621 + let jtw_layer_hash ~t:_ ~build_hash ~ocaml_version = 622 + let jtw_tools_hash = Jtw_tools.get_hash ~ocaml_version in 623 + Jtw_gen.compute_jtw_layer_hash ~build_hash ~jtw_tools_hash 624 + 625 + (** Ensure the jtw-tools layer exists and is built. 626 + Contains js_of_ocaml and js_top_worker built with the specified OCaml version. 627 + Returns the layer directory path if successful, None if build failed. *) 628 + let ensure_jtw_tools_layer ~t ~ocaml_version : string option = 629 + let config = t.config in 630 + let layer_dir = Jtw_tools.layer_path ~config ~ocaml_version in 631 + let jtw_tools_layer_name = Jtw_tools.layer_name ~ocaml_version in 632 + let layer_json = Path.(layer_dir / "layer.json") in 633 + let write_layer ~set_temp_log_path target_dir = 634 + let temp_dir = Os.temp_dir ~perms:0o755 ~parent_dir:config.dir "temp-jtw-tools-" "" in 635 + let build_log = Path.(temp_dir / "build.log") in 636 + set_temp_log_path build_log; 637 + let opam_repo_src = List.hd config.opam_repositories in 638 + let opam_repo = Path.(temp_dir / "opam-repository") in 639 + Unix.symlink opam_repo_src opam_repo; 640 + let build_script = Jtw_tools.build_script ~ocaml_version in 641 + let r = build_doc_tools_layer ~t ~temp_dir ~build_script build_log in 642 + let () = Os.safe_rename_dir ~marker_file:layer_json temp_dir target_dir in 643 + let dummy_pkg = OpamPackage.of_string "jtw-tools.0" in 644 + Util.save_layer_info layer_json dummy_pkg [] [] r 645 + in 646 + let ocaml_ver = OpamPackage.Version.to_string (OpamPackage.version ocaml_version) in 647 + let lock_info = Os.{ cache_dir = config.dir; stage = `Tool; package = "jtw-tools"; version = "0"; universe = Some ocaml_ver; layer_name = Some jtw_tools_layer_name } in 648 + let () = 649 + if not (Sys.file_exists layer_json) then Os.create_directory_exclusively ~marker_file:layer_json ~lock_info layer_dir write_layer 650 + in 651 + let exit_status = Util.load_layer_info_exit_status layer_json in 652 + if exit_status = 0 then Some layer_dir else None 653 + 654 + (** Run jtw generation in a container: compile .cma -> .cma.js, copy .cmi, META *) 655 + let run_jtw_in_container ~t ~temp_dir ~build_log ~build_layer_dir ~jtw_layer_dir ~dep_build_hashes ~pkg ~installed_libs ~ocaml_version = 656 + let config = t.config in 657 + let os_key = Config.os_key ~config in 658 + let lowerdir = Path.(temp_dir / "lower") in 659 + let upperdir = Path.(temp_dir / "upper") in 660 + let workdir = Path.(temp_dir / "work") in 661 + let rootfsdir = Path.(temp_dir / "rootfs") in 662 + let () = List.iter Os.mkdir [ lowerdir; upperdir; workdir; rootfsdir ] in 663 + let uid_gid = Printf.sprintf "%d:%d" t.uid t.gid in 664 + let () = ignore (Os.sudo [ "chown"; uid_gid; upperdir; workdir ]) in 665 + (* Build script to compile .cma files *) 666 + let script = Jtw_gen.jtw_container_script ~pkg ~installed_libs in 667 + let argv = [ "/usr/bin/env"; "bash"; "-c"; script ] in 668 + (* Build lower directory from build layer + dependency build layers + jtw-tools layer *) 669 + let target_build_fs = Path.(build_layer_dir / "fs") in 670 + if Sys.file_exists target_build_fs then 671 + ignore (Os.sudo ~stderr:"/dev/null" 672 + ["cp"; "-n"; "--archive"; "--no-dereference"; "--recursive"; "--link"; "--no-target-directory"; target_build_fs; lowerdir]); 673 + (* Copy dependency build layers *) 674 + List.iter (fun hash -> 675 + let layer_fs = Path.(config.dir / os_key / hash / "fs") in 676 + if Sys.file_exists layer_fs then 677 + ignore (Os.sudo ~stderr:"/dev/null" 678 + ["cp"; "-n"; "--archive"; "--no-dereference"; "--recursive"; "--link"; "--no-target-directory"; layer_fs; lowerdir]) 679 + ) dep_build_hashes; 680 + (* Copy jtw-tools layer *) 681 + let jtw_tools_hash = Jtw_tools.get_hash ~ocaml_version in 682 + let jtw_tools_fs = Path.(config.dir / os_key / jtw_tools_hash / "fs") in 683 + if Sys.file_exists jtw_tools_fs then 684 + ignore (Os.sudo ~stderr:"/dev/null" 685 + ["cp"; "-n"; "--archive"; "--no-dereference"; "--recursive"; "--link"; "--no-target-directory"; jtw_tools_fs; lowerdir]); 686 + (* Create output directory in container *) 687 + let jtw_output_host = Path.(temp_dir / "jtw-output") in 688 + Os.mkdir ~parents:true jtw_output_host; 689 + ignore (Os.sudo [ "chown"; uid_gid; jtw_output_host ]); 690 + let etc_hosts = Path.(temp_dir / "hosts") in 691 + let () = Os.write_to_file etc_hosts ("127.0.0.1 localhost " ^ hostname) in 692 + let ld = "lowerdir=" ^ String.concat ":" [ lowerdir; Path.(config.dir / os_key / "base" / "fs") ] in 693 + let ud = "upperdir=" ^ upperdir in 694 + let wd = "workdir=" ^ workdir in 695 + let mount_result = Os.sudo ~stderr:"/dev/null" 696 + [ "mount"; "-t"; "overlay"; "overlay"; rootfsdir; "-o"; String.concat "," [ ld; ud; wd ] ] in 697 + if mount_result <> 0 then 1 698 + else begin 699 + let mounts = [ 700 + { Mount.ty = "bind"; src = jtw_output_host; dst = "/home/opam/jtw-output"; options = [ "rw"; "rbind"; "rprivate" ] }; 701 + { ty = "bind"; src = etc_hosts; dst = "/etc/hosts"; options = [ "ro"; "rbind"; "rprivate" ] }; 702 + ] in 703 + let jtw_env = List.map (fun (k, v) -> 704 + if k = "PATH" then (k, "/home/opam/.opam/default/bin:" ^ v) else (k, v) 705 + ) env in 706 + let config_runc = make ~root:rootfsdir ~cwd:"/home/opam" ~argv ~hostname ~uid:t.uid ~gid:t.gid ~env:jtw_env ~mounts ~network:false in 707 + let () = Os.write_to_file Path.(temp_dir / "config.json") (Yojson.Safe.pretty_to_string config_runc) in 708 + let container_id = "jtw-" ^ Filename.basename temp_dir in 709 + let _ = Os.sudo ~stdout:"/dev/null" ~stderr:"/dev/null" [ "runc"; "delete"; "-f"; container_id ] in 710 + let result = Os.sudo ~stdout:build_log ~stderr:build_log [ "runc"; "run"; "-b"; temp_dir; container_id ] in 711 + let _ = Os.sudo ~stdout:"/dev/null" ~stderr:"/dev/null" [ "runc"; "delete"; "-f"; container_id ] in 712 + let _ = Os.sudo ~stderr:"/dev/null" [ "umount"; rootfsdir ] in 713 + (* Copy output from container to jtw layer *) 714 + let jtw_output_lib = Path.(jtw_output_host / "lib") in 715 + if Sys.file_exists jtw_output_lib then begin 716 + let jtw_layer_lib = Path.(jtw_layer_dir / "lib") in 717 + Os.mkdir ~parents:true (Filename.dirname jtw_layer_lib); 718 + ignore (Os.sudo [ "cp"; "-a"; jtw_output_lib; jtw_layer_lib ]) 719 + end; 720 + (* Also copy .cmi and META from the build layer to the jtw layer *) 721 + let build_lib = Path.(build_layer_dir / "fs" / "home" / "opam" / ".opam" / "default" / "lib") in 722 + List.iter (fun rel_path -> 723 + if Filename.check_suffix rel_path ".cmi" || Filename.basename rel_path = "META" then begin 724 + let src = Path.(build_lib / rel_path) in 725 + let dst = Path.(jtw_layer_dir / "lib" / rel_path) in 726 + if Sys.file_exists src then begin 727 + Os.mkdir ~parents:true (Filename.dirname dst); 728 + (try Os.cp src dst with _ -> ()) 729 + end 730 + end 731 + ) installed_libs; 732 + (* Generate dynamic_cmis.json for each lib subdirectory that has .cmi files *) 733 + let jtw_lib_dir = Path.(jtw_layer_dir / "lib") in 734 + if Sys.file_exists jtw_lib_dir then begin 735 + let rec scan_dirs base rel = 736 + let full = if rel = "" then base else Path.(base / rel) in 737 + if Sys.file_exists full && Sys.is_directory full then begin 738 + let entries = try Sys.readdir full |> Array.to_list with _ -> [] in 739 + let cmi_files = List.filter (fun f -> Filename.check_suffix f ".cmi") entries in 740 + if cmi_files <> [] then begin 741 + let dcs_url = "lib/" ^ rel in 742 + let dcs_json = Jtw_gen.generate_dynamic_cmis_json ~dcs_url cmi_files in 743 + Os.write_to_file Path.(full / "dynamic_cmis.json") dcs_json 744 + end; 745 + (* Recurse into subdirectories *) 746 + List.iter (fun name -> 747 + let sub = if rel = "" then name else rel ^ "/" ^ name in 748 + let sub_full = Path.(base / sub) in 749 + if Sys.file_exists sub_full && Sys.is_directory sub_full then 750 + scan_dirs base sub 751 + ) entries 752 + end 753 + in 754 + scan_dirs jtw_lib_dir "" 755 + end; 756 + (* Clean up *) 757 + let _ = Os.sudo [ "rm"; "-rf"; lowerdir; workdir; rootfsdir; upperdir; jtw_output_host ] in 758 + result 759 + end 760 + 761 + let generate_jtw ~t ~build_layer_dir ~jtw_layer_dir ~dep_build_hashes ~pkg ~installed_libs ~ocaml_version = 762 + let config = t.config in 763 + if not config.with_jtw then None 764 + else 765 + match ensure_jtw_tools_layer ~t ~ocaml_version with 766 + | Some _tools_dir -> 767 + if not (Jtw_tools.has_jsoo ~config ~ocaml_version) then 768 + Some (Jtw_gen.jtw_result_to_yojson (Jtw_gen.Jtw_failure "js_of_ocaml not installed in jtw-tools layer")) 769 + else begin 770 + let temp_dir = Os.temp_dir ~perms:0o755 ~parent_dir:config.dir "temp-jtw-" "" in 771 + let build_log = Path.(temp_dir / "jtw.log") in 772 + let status = 773 + try 774 + run_jtw_in_container ~t ~temp_dir ~build_log ~build_layer_dir ~jtw_layer_dir ~dep_build_hashes ~pkg ~installed_libs ~ocaml_version 775 + with _ -> 1 776 + in 777 + let layer_log = Path.(jtw_layer_dir / "jtw.log") in 778 + (try Os.cp build_log layer_log with _ -> ()); 779 + (try Os.rm ~recursive:true temp_dir with _ -> ()); 780 + if status = 0 then 781 + Some (Jtw_gen.jtw_result_to_yojson Jtw_gen.Jtw_success) 782 + else 783 + Some (Jtw_gen.jtw_result_to_yojson (Jtw_gen.Jtw_failure (Printf.sprintf "jtw generation exited with status %d" status))) 784 + end 785 + | None -> 786 + Some (Jtw_gen.jtw_result_to_yojson Jtw_gen.Jtw_skipped) 787 + 621 788 let generate_docs ~t ~build_layer_dir ~doc_layer_dir ~dep_doc_hashes ~pkg ~installed_libs ~installed_docs ~phase ~ocaml_version = 622 789 let config = t.config in 623 790 if not config.with_doc then None
+106 -7
bin/main.ml
··· 446 446 Some doc_layer_name 447 447 end 448 448 449 + (** Build a jtw layer for a package. 450 + Compiles .cma to .cma.js, copies .cmi and META, generates dynamic_cmis.json. 451 + Returns [Some jtw_layer_name] on success, [None] on failure. *) 452 + let jtw_layer t pkg build_layer_name dep_build_hashes ~ocaml_version = 453 + match ocaml_version with 454 + | None -> None (* No OCaml version means no jtw *) 455 + | Some ocaml_version -> 456 + let pkg_str = OpamPackage.to_string pkg in 457 + let pkg_name = OpamPackage.name_to_string pkg in 458 + let pkg_version = OpamPackage.version_to_string pkg in 459 + Os.log "jtw_layer: starting %s (build=%s, ocaml=%s)" pkg_str build_layer_name (OpamPackage.to_string ocaml_version); 460 + let config = Container.config ~t in 461 + let os_key = Config.os_key ~config in 462 + let jtw_hash = Container.jtw_layer_hash ~t ~build_hash:build_layer_name ~ocaml_version in 463 + let jtw_layer_name = "jtw-" ^ jtw_hash in 464 + let jtw_layer_dir = Path.(config.dir / os_key / jtw_layer_name) in 465 + let build_layer_dir = Path.(config.dir / os_key / build_layer_name) in 466 + let jtw_layer_json = Path.(jtw_layer_dir / "layer.json") in 467 + let universe = Odoc_gen.compute_universe_hash dep_build_hashes in 468 + let write_layer ~set_temp_log_path target_dir = 469 + set_temp_log_path (Path.(target_dir / "jtw.log")); 470 + let build_layer_json = Path.(build_layer_dir / "layer.json") in 471 + let installed_libs = Util.load_layer_info_installed_libs build_layer_json in 472 + let jtw_result = 473 + Container.generate_jtw ~t ~build_layer_dir ~jtw_layer_dir:target_dir ~dep_build_hashes ~pkg ~installed_libs ~ocaml_version 474 + in 475 + Jtw_gen.save_jtw_layer_info ?jtw_result (Path.(target_dir / "layer.json")) pkg ~build_hash:build_layer_name 476 + in 477 + let safe_write_layer ~set_temp_log_path target_dir = 478 + if not (Sys.file_exists target_dir) then Os.mkdir target_dir; 479 + try 480 + write_layer ~set_temp_log_path target_dir 481 + with exn -> 482 + Os.log "jtw_layer: FAILED %s - %s" pkg_str (Printexc.to_string exn); 483 + let target_layer_json = Path.(target_dir / "layer.json") in 484 + if not (Sys.file_exists target_layer_json) then 485 + Jtw_gen.save_jtw_layer_info ~jtw_result:(Jtw_gen.jtw_result_to_yojson (Jtw_gen.Jtw_failure (Printexc.to_string exn))) target_layer_json pkg ~build_hash:build_layer_name; 486 + raise exn 487 + in 488 + let lock_info = Os.{ cache_dir = config.dir; stage = `Build; package = pkg_name; version = pkg_version; universe = Some universe; layer_name = Some jtw_layer_name } in 489 + let () = 490 + if not (Sys.file_exists jtw_layer_json) then 491 + Os.create_directory_exclusively ~marker_file:jtw_layer_json ~lock_info jtw_layer_dir safe_write_layer 492 + in 493 + (* Wait for layer.json *) 494 + let rec wait_for_layer_json retries = 495 + if Sys.file_exists jtw_layer_json then () 496 + else if retries <= 0 then 497 + failwith (Printf.sprintf "JTW layer %s never completed (layer.json missing)" jtw_layer_name) 498 + else begin 499 + Unix.sleepf 0.5; 500 + wait_for_layer_json (retries - 1) 501 + end 502 + in 503 + let () = wait_for_layer_json 600 in 504 + let () = Unix.utimes jtw_layer_json 0.0 0.0 in 505 + (* Create symlink *) 506 + Util.ensure_package_layer_symlink ~cache_dir:config.dir ~os_key ~pkg_str ~layer_name:jtw_layer_name; 507 + Some jtw_layer_name 508 + 449 509 let build config package = 450 510 match solve config package with 451 511 | Ok solution -> ··· 525 585 (Failure build_layer_name, dm)) 526 586 | _ -> (r, dm) 527 587 else (r, dm) 588 + in 589 + (* If build succeeded and with_jtw, create jtw layer *) 590 + let () = 591 + if config.with_jtw then 592 + match r with 593 + | Success _ -> 594 + ignore (jtw_layer t pkg build_layer_name ordered_build_hashes ~ocaml_version) 595 + | _ -> () 528 596 in 529 597 (r, dm) 530 598 in ··· 1260 1328 ) items; 1261 1329 (* Run global deferred doc link pass for x-extra-doc-deps *) 1262 1330 run_global_deferred_doc_link config; 1331 + (* Assemble JTW output if enabled *) 1332 + (match config.with_jtw, config.jtw_output with 1333 + | true, Some jtw_output -> 1334 + Printf.printf "Phase 4: Assembling JTW output...\n%!"; 1335 + (* Find OCaml version from any solution *) 1336 + let ocaml_version = List.find_map (fun (_target, solution) -> extract_ocaml_version solution) solutions in 1337 + (match ocaml_version with 1338 + | Some ocaml_version -> 1339 + Jtw_gen.assemble_jtw_output ~config ~jtw_output ~ocaml_version ~solutions ~blessed_maps:blessing_maps 1340 + | None -> Printf.printf " Warning: no OCaml version found in solutions, skipping JTW assembly\n%!") 1341 + | _ -> ()); 1263 1342 (* Update progress: entering GC phase *) 1264 1343 progress_ref := Day10_lib.Progress.set_phase !progress_ref Day10_lib.Progress.Gc; 1265 1344 Day10_lib.Progress.write ~run_dir:(Day10_lib.Run_log.get_run_dir run_info) !progress_ref; ··· 1294 1373 Printf.printf "\n%!"; 1295 1374 (* Run global deferred doc link pass for x-extra-doc-deps *) 1296 1375 run_global_deferred_doc_link config; 1376 + (* Assemble JTW output if enabled *) 1377 + (match config.with_jtw, config.jtw_output with 1378 + | true, Some jtw_output -> 1379 + Printf.printf "Phase 4: Assembling JTW output...\n%!"; 1380 + let ocaml_version = List.find_map (fun (_target, solution) -> extract_ocaml_version solution) solutions in 1381 + (match ocaml_version with 1382 + | Some ocaml_version -> 1383 + Jtw_gen.assemble_jtw_output ~config ~jtw_output ~ocaml_version ~solutions ~blessed_maps:blessing_maps 1384 + | None -> Printf.printf " Warning: no OCaml version found in solutions, skipping JTW assembly\n%!") 1385 + | _ -> ()); 1297 1386 (* Update progress: entering GC phase *) 1298 1387 progress_ref := Day10_lib.Progress.set_phase !progress_ref Day10_lib.Progress.Gc; 1299 1388 Day10_lib.Progress.write ~run_dir:(Day10_lib.Run_log.get_run_dir run_info) !progress_ref; ··· 1347 1436 let doc = "Shared HTML output directory for all documentation (enables doc generation for all packages)" in 1348 1437 Arg.(value & opt (some string) None & info [ "html-output" ] ~docv:"DIR" ~doc) 1349 1438 1439 + let with_jtw_term = 1440 + let doc = "Generate JTW (js_top_worker) artifacts for browser REPL (default false)" in 1441 + Arg.(value & flag & info [ "with-jtw" ] ~doc) 1442 + 1443 + let jtw_output_term = 1444 + let doc = "Output directory for JTW artifacts (browser REPL support files)" in 1445 + Arg.(value & opt (some string) None & info [ "jtw-output" ] ~docv:"DIR" ~doc) 1446 + 1350 1447 let log_term = 1351 1448 let doc = "Print build logs (default false)" in 1352 1449 Arg.(value & flag & info [ "log" ] ~doc) ··· 1432 1529 dot; 1433 1530 with_test; 1434 1531 with_doc; 1532 + with_jtw = false; 1435 1533 doc_tools_repo; 1436 1534 doc_tools_branch; 1437 1535 html_output; 1536 + jtw_output = None; 1438 1537 tag = None; 1439 1538 log; 1440 1539 dry_run; ··· 1454 1553 in 1455 1554 let health_check_term = 1456 1555 Term.( 1457 - const (fun dir ocaml_version opam_repositories package_arg md json dot with_test with_doc doc_tools_repo doc_tools_branch html_output log dry_run tag arch os os_distribution os_family os_version fork prune_layers blessed_map_file -> 1556 + const (fun dir ocaml_version opam_repositories package_arg md json dot with_test with_doc with_jtw doc_tools_repo doc_tools_branch html_output jtw_output log dry_run tag arch os os_distribution os_family os_version fork prune_layers blessed_map_file -> 1458 1557 let ocaml_version = Option.map OpamPackage.of_string ocaml_version in 1459 1558 let blessed_map = Option.map Blessing.load_blessed_map blessed_map_file in 1460 - run_health_check_multi { dir; ocaml_version; opam_repositories; package = ""; arch; os; os_distribution; os_family; os_version; directory = None; md; json; dot; with_test; with_doc; doc_tools_repo; doc_tools_branch; html_output; tag; log; dry_run; fork; prune_layers; blessed_map } package_arg) 1461 - $ cache_dir_term $ ocaml_version_term $ opam_repository_term $ package_arg $ md_term $ json_term $ dot_term $ with_test_term $ with_doc_term $ doc_tools_repo_term $ doc_tools_branch_term $ html_output_term $ log_term $ dry_run_term $ tag_term $ arch_term $ os_term $ os_distribution_term $ os_family_term $ os_version_term $ fork_term $ prune_layers_term $ blessed_map_term) 1559 + run_health_check_multi { dir; ocaml_version; opam_repositories; package = ""; arch; os; os_distribution; os_family; os_version; directory = None; md; json; dot; with_test; with_doc; with_jtw; doc_tools_repo; doc_tools_branch; html_output; jtw_output; tag; log; dry_run; fork; prune_layers; blessed_map } package_arg) 1560 + $ cache_dir_term $ ocaml_version_term $ opam_repository_term $ package_arg $ md_term $ json_term $ dot_term $ with_test_term $ with_doc_term $ with_jtw_term $ doc_tools_repo_term $ doc_tools_branch_term $ html_output_term $ jtw_output_term $ log_term $ dry_run_term $ tag_term $ arch_term $ os_term $ os_distribution_term $ os_family_term $ os_version_term $ fork_term $ prune_layers_term $ blessed_map_term) 1462 1561 in 1463 1562 let health_check_info = Cmd.info "health-check" ~doc:"Run health check on a package or list of packages" in 1464 1563 Cmd.v health_check_info health_check_term ··· 1469 1568 const (fun ocaml_version opam_repositories all_versions json arch os os_distribution os_family os_version -> 1470 1569 let ocaml_version = Option.map OpamPackage.of_string ocaml_version in 1471 1570 run_list 1472 - { dir = ""; ocaml_version; opam_repositories; package = ""; arch; os; os_distribution; os_family; os_version; directory = None; md = None; json; dot = None; with_test = false; with_doc = false; doc_tools_repo = ""; doc_tools_branch = ""; html_output = None; tag = None; log = false; dry_run = false; fork = None; prune_layers = false; blessed_map = None } 1571 + { dir = ""; ocaml_version; opam_repositories; package = ""; arch; os; os_distribution; os_family; os_version; directory = None; md = None; json; dot = None; with_test = false; with_doc = false; with_jtw = false; doc_tools_repo = ""; doc_tools_branch = ""; html_output = None; jtw_output = None; tag = None; log = false; dry_run = false; fork = None; prune_layers = false; blessed_map = None } 1473 1572 all_versions) 1474 1573 $ ocaml_version_term $ opam_repository_term $ all_versions_term $ json_term $ arch_term $ os_term $ os_distribution_term $ os_family_term $ os_version_term) 1475 1574 in ··· 1563 1662 in 1564 1663 let batch_term = 1565 1664 Term.( 1566 - const (fun dir ocaml_version opam_repositories package_arg md json dot with_test with_doc doc_tools_repo doc_tools_branch html_output log dry_run tag arch os os_distribution os_family os_version fork prune_layers -> 1665 + const (fun dir ocaml_version opam_repositories package_arg md json dot with_test with_doc with_jtw doc_tools_repo doc_tools_branch html_output jtw_output log dry_run tag arch os os_distribution os_family os_version fork prune_layers -> 1567 1666 let ocaml_version = Option.map OpamPackage.of_string ocaml_version in 1568 - run_batch { dir; ocaml_version; opam_repositories; package = ""; arch; os; os_distribution; os_family; os_version; directory = None; md; json; dot; with_test; with_doc; doc_tools_repo; doc_tools_branch; html_output; tag; log; dry_run; fork; prune_layers; blessed_map = None } package_arg) 1569 - $ cache_dir_term $ ocaml_version_term $ opam_repository_term $ package_arg $ md_term $ json_term $ dot_term $ with_test_term $ with_doc_term $ doc_tools_repo_term $ doc_tools_branch_term $ html_output_term $ log_term $ dry_run_term $ tag_term $ arch_term $ os_term $ os_distribution_term $ os_family_term $ os_version_term $ fork_term $ prune_layers_term) 1667 + run_batch { dir; ocaml_version; opam_repositories; package = ""; arch; os; os_distribution; os_family; os_version; directory = None; md; json; dot; with_test; with_doc; with_jtw; doc_tools_repo; doc_tools_branch; html_output; jtw_output; tag; log; dry_run; fork; prune_layers; blessed_map = None } package_arg) 1668 + $ cache_dir_term $ ocaml_version_term $ opam_repository_term $ package_arg $ md_term $ json_term $ dot_term $ with_test_term $ with_doc_term $ with_jtw_term $ doc_tools_repo_term $ doc_tools_branch_term $ html_output_term $ jtw_output_term $ log_term $ dry_run_term $ tag_term $ arch_term $ os_term $ os_distribution_term $ os_family_term $ os_version_term $ fork_term $ prune_layers_term) 1570 1669 in 1571 1670 let batch_info = Cmd.info "batch" ~doc:"Solve all targets, compute blessings, then build with pre-computed blessing maps" in 1572 1671 Cmd.v batch_info batch_term
+24
bin/s.ml
··· 47 47 phase:doc_phase -> 48 48 ocaml_version:OpamPackage.t -> 49 49 Yojson.Safe.t option 50 + 51 + (** Compute hash for a jtw layer. 52 + Depends on the build hash and jtw-tools layer hash. *) 53 + val jtw_layer_hash : 54 + t:t -> 55 + build_hash:string -> 56 + ocaml_version:OpamPackage.t -> 57 + string 58 + 59 + (** JTW generation: compile .cma to .cma.js, extract .cmi, META, generate dynamic_cmis.json. 60 + [build_layer_dir] is the build layer (for .cma/.cmi files). 61 + [jtw_layer_dir] is the output jtw layer. 62 + [dep_build_hashes] are the build layer hashes of dependencies. 63 + [installed_libs] are files installed by this package. 64 + Returns Some json on success/failure, None if not supported. *) 65 + val generate_jtw : 66 + t:t -> 67 + build_layer_dir:string -> 68 + jtw_layer_dir:string -> 69 + dep_build_hashes:string list -> 70 + pkg:OpamPackage.t -> 71 + installed_libs:string list -> 72 + ocaml_version:OpamPackage.t -> 73 + Yojson.Safe.t option 50 74 end
+5
bin/windows.ml
··· 176 176 177 177 (* Documentation generation not supported on Windows *) 178 178 let generate_docs ~t:_ ~build_layer_dir:_ ~doc_layer_dir:_ ~dep_doc_hashes:_ ~pkg:_ ~installed_libs:_ ~installed_docs:_ ~phase:_ ~ocaml_version:_ = None 179 + 180 + let jtw_layer_hash ~t:_ ~build_hash:_ ~ocaml_version:_ = "" 181 + 182 + (* JTW generation not supported on Windows *) 183 + let generate_jtw ~t:_ ~build_layer_dir:_ ~jtw_layer_dir:_ ~dep_build_hashes:_ ~pkg:_ ~installed_libs:_ ~ocaml_version:_ = None
+2 -1
lib/gc.ml
··· 52 52 name = "base" || 53 53 name = "solutions" || 54 54 (String.length name > 11 && String.sub name 0 11 = "doc-driver-") || 55 - (String.length name > 9 && String.sub name 0 9 = "doc-odoc-") 55 + (String.length name > 9 && String.sub name 0 9 = "doc-odoc-") || 56 + (String.length name > 10 && String.sub name 0 10 = "jtw-tools-") 56 57 57 58 (** Perform layer GC. 58 59 [referenced_hashes] should be a list of build-{hash} and doc-{hash}