···11+# Braid
22+33+Build status tracker for opam overlay repositories. Braid runs [day10](https://github.com/mtelvers/day10) health checks across git commits and provides a queryable manifest of results.
44+55+## Overview
66+77+Braid solves the problem of tracking package build status across multiple commits in an opam overlay repository. It:
88+99+1. **Runs day10 health checks** across a configurable number of commits
1010+2. **Generates a manifest.json** containing all results, build logs, and dependency graphs
1111+3. **Provides query commands** to investigate failures, track package history, and diagnose problems
1212+1313+The manifest format is designed for both human inspection and AI agent consumption.
1414+1515+## Installation
1616+1717+```bash
1818+# Clone the repository
1919+git clone https://github.com/mtelvers/braid.git
2020+cd braid
2121+2222+# Create an opam switch and install dependencies
2323+opam switch create . 5.4.0 --deps-only
2424+eval $(opam env)
2525+opam install . --deps-only
2626+2727+# Build
2828+dune build
2929+3030+# Install (optional)
3131+dune install
3232+```
3333+3434+## Usage
3535+3636+### Running Health Checks
3737+3838+```bash
3939+braid run <REPO_PATH> [OPTIONS]
4040+```
4141+4242+**Arguments:**
4343+- `REPO_PATH` - Path to the overlay opam repository
4444+4545+**Options:**
4646+- `-n, --num-commits N` - Number of commits to process (default: 10)
4747+- `-j, --jobs N` - Number of parallel jobs for solving (default: 40)
4848+- `-o, --output PATH` - Output directory for results (default: results)
4949+- `--opam-repo PATH` - Path to the main opam repository (default: /home/mtelvers/opam-repository)
5050+- `--cache-dir PATH` - Cache directory for day10 (default: /var/cache/day10)
5151+- `--os OS` - Operating system (default: linux)
5252+- `--os-family FAMILY` - OS family (default: debian)
5353+- `--os-distribution DIST` - OS distribution (default: debian)
5454+- `--os-version VERSION` - OS version (default: 13)
5555+- `-v, --verbose` - Increase verbosity
5656+5757+**Example:**
5858+```bash
5959+# Run on the last 57 commits of an overlay repository
6060+braid run /home/mtelvers/aoah-opam-repo -n 57 -o results -v
6161+```
6262+6363+### Merge Testing
6464+6565+Test the cumulative effect of merging multiple overlay repositories without tracking commit history.
6666+6767+```bash
6868+braid merge-test <REPOS>... [OPTIONS]
6969+```
7070+7171+**Arguments:**
7272+- `REPOS` - One or more overlay repository paths (in priority order, first = highest priority)
7373+7474+**Options:**
7575+- `-j, --jobs N` - Number of parallel jobs for solving (default: 40)
7676+- `-o, --output PATH` - Output directory for results (default: results)
7777+- `--opam-repo PATH` - Path to the main opam repository (default: /home/mtelvers/opam-repository)
7878+- `--cache-dir PATH` - Cache directory for day10 (default: /var/cache/day10)
7979+- `--dry-run` - Only solve dependencies, don't actually build
8080+- `--os OS` - Operating system (default: linux)
8181+- `--os-family FAMILY` - OS family (default: debian)
8282+- `--os-distribution DIST` - OS distribution (default: debian)
8383+- `--os-version VERSION` - OS version (default: 13)
8484+- `-v, --verbose` - Increase verbosity
8585+8686+**Examples:**
8787+```bash
8888+# Test a single overlay repository
8989+braid merge-test /home/mtelvers/my-overlay -o results
9090+9191+# Test what happens if 'experimental' is merged into 'stable'
9292+braid merge-test /path/to/experimental /path/to/stable -o merge-results
9393+9494+# Stack multiple overlays (first has highest priority)
9595+braid merge-test /path/to/repo1 /path/to/repo2 /path/to/repo3
9696+9797+# Quick dependency check without building
9898+braid merge-test /path/to/overlay --dry-run
9999+```
100100+101101+**How it works:**
102102+103103+1. Lists packages from all overlay repositories (not from opam-repository)
104104+2. **Stage 1:** Runs `day10 health-check --dry-run --fork N` for fast parallel dependency solving
105105+3. **Stage 2:** For packages that returned "solution" (solvable but not built), runs `day10 health-check` without `--dry-run` to actually build them
106106+107107+With `--dry-run`, only stage 1 runs, showing which packages are solvable without building them.
108108+109109+**Querying merge-test results:**
110110+111111+Query commands work with merge-test manifests using `merge-q` as the commit identifier:
112112+113113+```bash
114114+# Show history for a package
115115+braid history smtpd.dev -m merge-results/manifest.json
116116+# Output: First seen: merge-q, Latest status: failure
117117+118118+# Show build log
119119+braid log merge-q smtpd.dev -m merge-results/manifest.json
120120+121121+# Show dependencies
122122+braid deps merge-q smtpd.dev -m merge-results/manifest.json
123123+```
124124+125125+### Query Commands
126126+127127+All query commands read from a manifest file (default: `manifest.json`). Use `-m PATH` to specify a different manifest.
128128+129129+#### summary
130130+131131+Show overview statistics.
132132+133133+```bash
134134+$ braid summary -m results/manifest.json
135135+Repository: /home/mtelvers/aoah-opam-repo
136136+Generated: 2026-01-19T19:45:16Z
137137+OS: debian-13
138138+Commits: 57
139139+Packages: 55
140140+141141+Latest commit status:
142142+ Success: 12
143143+ Failure: 11
144144+ Dependency failed: 10
145145+ No solution: 22
146146+ Solution (buildable): 0
147147+ Error: 0
148148+```
149149+150150+#### failures
151151+152152+List packages with status 'failure' in the latest commit.
153153+154154+```bash
155155+$ braid failures -m results/manifest.json
156156+Failures in commit 3289824:
157157+ atp.dev
158158+ bytesrw-eio.dev
159159+ claude.dev
160160+ frontmatter.dev
161161+ hermest.dev
162162+ html5rw.dev
163163+ init.dev
164164+ langdetect.dev
165165+ monopam.dev
166166+ owntracks.dev
167167+ srcsetter-cmd.dev
168168+```
169169+170170+#### log
171171+172172+Show the build log for a specific package at a specific commit.
173173+174174+```bash
175175+$ braid log 3289824 bytesrw-eio.dev -m results/manifest.json
176176+Processing: [default: loading data]
177177+[bytesrw-eio.dev: git]
178178+...
179179+-> retrieved bytesrw-eio.dev (git+https://tangled.org/@anil.recoil.org/ocaml-bytesrw-eio.git#main)
180180+[bytesrw-eio: dune subst]
181181++ /home/opam/.opam/default/bin/dune "subst" (CWD=/home/opam/.opam/default/.opam-switch/build/bytesrw-eio.dev)
182182+- File "dune-project", line 25, characters 16-33:
183183+- 25 | (documentation (depends bytesrw)))
184184+- ^^^^^^^^^^^^^^^^^
185185+- Error: Atom or quoted string expected
186186+[ERROR] The compilation of bytesrw-eio.dev failed at "dune subst".
187187+build failed...
188188+```
189189+190190+#### history
191191+192192+Show the status of a package across all commits.
193193+194194+```bash
195195+$ braid history cbort.dev -m results/manifest.json
196196+Package: cbort.dev
197197+First seen: 82661d5
198198+Latest status: success
199199+History:
200200+ 3289824: success
201201+ b92aa39: success
202202+ 2345324: success
203203+ ...
204204+```
205205+206206+#### first-failure
207207+208208+Find when a package first started failing (the commit where it transitioned from success to failure).
209209+210210+```bash
211211+$ braid first-failure atp.dev -m results/manifest.json
212212+First failure: 160dd2e (Add owntracks and owntracks-cli dev packages)
213213+```
214214+215215+#### deps
216216+217217+Show the dependency graph for a package (in DOT format).
218218+219219+```bash
220220+$ braid deps 3289824 cbort.dev -m results/manifest.json
221221+digraph opam {
222222+ "bytesrw.0.3.0" -> {"conf-pkg-config.4" "ocaml.5.3.0" "ocamlbuild.0.16.1" "ocamlfind.1.9.8" "topkg.1.1.1"}
223223+ "cbort.dev" -> {"bytesrw.0.3.0" "dune.3.21.0" "ocaml.5.3.0" "zarith.1.14"}
224224+ ...
225225+}
226226+```
227227+228228+#### result
229229+230230+Get the full JSON result for a package at a commit.
231231+232232+```bash
233233+$ braid result 3289824 cbort.dev -m results/manifest.json
234234+{
235235+ "name": "cbort.dev",
236236+ "status": "success",
237237+ "sha": "32898245e4f7e95e2122f6aa8106c2680c4daffa...",
238238+ "layer": "d41bb6c70aa39c972b04922bb5d9be03",
239239+ "log": "Processing: [default: loading data]...",
240240+ "solution": "digraph opam { ... }"
241241+}
242242+```
243243+244244+#### matrix
245245+246246+Output a terminal-friendly status matrix with vertical package names for better readability.
247247+248248+```bash
249249+$ braid matrix -m results/manifest.json
250250+Build Status Matrix
251251+Legend: S=success, F=failure, D=dependency_failed, -=no_solution, B=solution, (space)=not present
252252+253253+ b
254254+ y
255255+ t
256256+ e
257257+ s
258258+ r
259259+ c w
260260+ b -
261261+ o a e
262262+ r t i
263263+ t p o
264264+---------------------
265265+3289824 S F F ...
266266+b92aa39 S F F ...
267267+```
268268+269269+Package names are displayed vertically and bottom-aligned (with `.dev` suffix stripped), so all names end at the same row just above the data. This makes it easy to read package names of varying lengths.
270270+271271+## Manifest Format
272272+273273+The manifest.json file contains all results in a structured format:
274274+275275+```json
276276+{
277277+ "repo_path": "/home/mtelvers/aoah-opam-repo",
278278+ "opam_repo_path": "/home/mtelvers/opam-repository",
279279+ "os": "debian-13",
280280+ "os_version": "13",
281281+ "generated_at": "2026-01-19T19:45:16Z",
282282+ "commits": ["3289824", "b92aa39", ...],
283283+ "packages": ["atp.dev", "cbort.dev", ...],
284284+ "results": [
285285+ {
286286+ "commit": "3289824...",
287287+ "short_commit": "3289824",
288288+ "message": "activitypub",
289289+ "packages": [
290290+ {
291291+ "name": "cbort.dev",
292292+ "status": "success",
293293+ "sha": "...",
294294+ "layer": "...",
295295+ "log": "...",
296296+ "solution": "..."
297297+ },
298298+ ...
299299+ ]
300300+ },
301301+ ...
302302+ ],
303303+ "mode": "history",
304304+ "overlay_repos": []
305305+}
306306+```
307307+308308+### Manifest Fields
309309+310310+| Field | Description |
311311+|-------|-------------|
312312+| `repo_path` | Primary overlay repository path |
313313+| `opam_repo_path` | Main opam-repository path |
314314+| `os` | Target OS (e.g., "debian-13") |
315315+| `os_version` | OS version |
316316+| `generated_at` | ISO 8601 timestamp |
317317+| `commits` | List of commit hashes (or `["merge-test"]` for merge-test mode; short form: "merge-q") |
318318+| `packages` | List of all package names |
319319+| `results` | Array of per-commit results |
320320+| `mode` | "history" for `run` command, "merge-test" for `merge-test` command |
321321+| `overlay_repos` | For merge-test: list of stacked repos in priority order (first = highest) |
322322+```
323323+324324+### Status Values
325325+326326+| Status | Symbol | Description |
327327+|--------|--------|-------------|
328328+| `success` | S | Package built successfully |
329329+| `failure` | F | Package build failed |
330330+| `dependency_failed` | D | A dependency failed to build |
331331+| `no_solution` | - | Dependencies cannot be solved |
332332+| `solution` | B | Solvable but not yet built (build candidate) |
333333+| not present | (space) | Package does not exist at this commit |
334334+335335+## AI Agent Integration
336336+337337+Braid is designed for easy integration with AI agents. The manifest.json provides:
338338+339339+1. **Structured data** - All results in a single queryable JSON file
340340+2. **Build logs** - Full build output for diagnosing failures
341341+3. **Dependency graphs** - DOT format graphs showing package dependencies
342342+4. **History tracking** - Status of each package across all commits
343343+344344+### Example: Diagnosing a Failure
345345+346346+An AI agent can:
347347+348348+1. Read the manifest to get an overview:
349349+ ```bash
350350+ braid summary -m manifest.json
351351+ ```
352352+353353+2. List current failures:
354354+ ```bash
355355+ braid failures -m manifest.json
356356+ ```
357357+358358+3. Get the build log for a failing package:
359359+ ```bash
360360+ braid log 3289824 atp.dev -m manifest.json
361361+ ```
362362+363363+4. Check when it started failing:
364364+ ```bash
365365+ braid first-failure atp.dev -m manifest.json
366366+ ```
367367+368368+5. View dependencies to understand the failure context:
369369+ ```bash
370370+ braid deps 3289824 atp.dev -m manifest.json
371371+ ```
372372+373373+### Programmatic Access
374374+375375+The manifest can also be read directly as JSON for more complex queries:
376376+377377+```python
378378+import json
379379+380380+with open('manifest.json') as f:
381381+ manifest = json.load(f)
382382+383383+# Find all packages that have ever failed
384384+failed_packages = set()
385385+for result in manifest['results']:
386386+ for pkg in result['packages']:
387387+ if pkg['status'] == 'failure':
388388+ failed_packages.add(pkg['name'])
389389+390390+print(f"Packages that have failed: {failed_packages}")
391391+```
392392+393393+## Tutorial: Merge Testing Workflow
394394+395395+This tutorial demonstrates using `braid merge-test` to validate overlay repositories before merging.
396396+397397+### 1. Create a Test Overlay Repository
398398+399399+Create an opam repository structure with packages to test:
400400+401401+```bash
402402+mkdir -p ~/claude-repo/packages/mypackage/mypackage.dev
403403+```
404404+405405+Create the `repo` file:
406406+```bash
407407+echo 'opam-version: "2.0"' > ~/claude-repo/repo
408408+```
409409+410410+For each package, create an opam file at `packages/<name>/<name>.dev/opam`:
411411+412412+```
413413+opam-version: "2.0"
414414+synopsis: "My package"
415415+depends: [
416416+ "ocaml" {>= "5.0"}
417417+ "dune" {>= "3.0"}
418418+]
419419+build: [
420420+ ["dune" "build" "-p" name "-j" jobs]
421421+]
422422+url {
423423+ src: "git+https://github.com/user/repo.git"
424424+}
425425+```
426426+427427+### 2. Run Initial Merge Test
428428+429429+Test the overlay with a quick dry-run first:
430430+431431+```bash
432432+braid merge-test ~/claude-repo --dry-run -o results
433433+```
434434+435435+This shows which packages are solvable without building. The output includes a "solution" count for packages that can be built.
436436+437437+### 3. Run Full Build Test
438438+439439+Run the actual build:
440440+441441+```bash
442442+braid merge-test ~/claude-repo -o results
443443+```
444444+445445+Example output:
446446+```
447447+Merge test: 1 overlay repos, 3 packages
448448+Overlay repos (priority order):
449449+ /home/user/claude-repo
450450+Results: 1 success, 2 failure, 0 dep_failed, 0 no_solution, 0 error
451451+```
452452+453453+### 4. Diagnose Failures
454454+455455+Check which packages failed:
456456+457457+```bash
458458+braid failures -m results/manifest.json
459459+```
460460+461461+View the build log for a failing package:
462462+463463+```bash
464464+braid log merge-q smtpd.dev -m results/manifest.json
465465+```
466466+467467+Example output showing a missing dependency:
468468+```
469469+pam_stubs.c:7:10: fatal error: security/pam_appl.h: No such file or directory
470470+ 7 | #include <security/pam_appl.h>
471471+ | ^~~~~~~~~~~~~~~~~~~~~
472472+```
473473+474474+### 5. Fix and Retest
475475+476476+In this case, the package needs `conf-pam` as a build dependency. Update the opam file:
477477+478478+```
479479+depends: [
480480+ "ocaml" {>= "5.0"}
481481+ "dune" {>= "3.0"}
482482+ "conf-pam" {build} # Added for PAM headers
483483+ ...
484484+]
485485+```
486486+487487+Rerun the merge test:
488488+489489+```bash
490490+braid merge-test ~/claude-repo -o results
491491+```
492492+493493+```
494494+Results: 3 success, 0 failure, 0 dep_failed, 0 no_solution, 0 error
495495+```
496496+497497+### 6. Test Multiple Stacked Overlays
498498+499499+Test what happens when merging multiple overlays together:
500500+501501+```bash
502502+braid merge-test ~/my-overlay ~/upstream-overlay -o merge-results
503503+```
504504+505505+Overlays are listed in priority order (first = highest priority, "on top"). This tests the combined effect of merging all overlays.
506506+507507+### 7. Query Results
508508+509509+After testing, query the manifest for details:
510510+511511+```bash
512512+# Package history
513513+braid history smtpd.dev -m results/manifest.json
514514+515515+# Dependency graph
516516+braid deps merge-q smtpd.dev -m results/manifest.json
517517+518518+# Full JSON result
519519+braid result merge-q smtpd.dev -m results/manifest.json
520520+```
521521+522522+## Tutorial: Remote Execution via RPC
523523+524524+Braid supports remote execution using Cap'n Proto RPC. This allows you to run health checks on a remote server that has day10 and opam-repository available, from a client that only needs the braid binary and a capability file.
525525+526526+### Use Cases
527527+528528+- **Dev containers**: Run builds from lightweight development environments without day10 or opam-repository
529529+- **Centralised build server**: Share a single build server across multiple developers
530530+- **CI/CD integration**: Submit builds from CI pipelines to a dedicated build infrastructure
531531+532532+### 1. Start the Server
533533+534534+On a machine with day10 and opam-repository:
535535+536536+```bash
537537+braid server --port 5000 \
538538+ --public-addr build.example.com \
539539+ --key-file /var/lib/braid/server.key \
540540+ --cap-file /var/lib/braid/braid.cap \
541541+ --opam-repo /home/user/opam-repository \
542542+ --cache-dir /var/cache/day10
543543+```
544544+545545+**Server options:**
546546+- `--port PORT` - Port to listen on (required)
547547+- `--public-addr HOST` - Public hostname for the capability URI (required)
548548+- `--key-file PATH` - Path to store/load the server's secret key (default: server.key)
549549+- `--cap-file PATH` - Path to write the capability file (default: braid.cap)
550550+- `--opam-repo PATH` - Path to opam-repository
551551+- `--cache-dir PATH` - Cache directory for day10
552552+553553+The `--key-file` option ensures the capability URI remains stable across server restarts. Without it, clients would need a new capability file each time the server restarts.
554554+555555+### 2. Distribute the Capability File
556556+557557+Copy the capability file to any client machine:
558558+559559+```bash
560560+scp build.example.com:/var/lib/braid/braid.cap ~/.config/braid.cap
561561+```
562562+563563+The capability file contains a URI like:
564564+```
565565+capnp://sha-256:abc123...@build.example.com:5000/def456...
566566+```
567567+568568+This URI encodes both the server address and a cryptographic capability token.
569569+570570+### 3. Run Remote Merge Tests
571571+572572+From the client, use `--connect` with a repository URL (not a local path):
573573+574574+```bash
575575+braid merge-test https://github.com/user/overlay-repo \
576576+ --connect ~/.config/braid.cap \
577577+ -o results
578578+```
579579+580580+The server will:
581581+1. Clone the repository to a temporary directory
582582+2. Run day10 health checks
583583+3. Return the manifest JSON
584584+4. Clean up the temporary directory
585585+586586+Example output:
587587+```
588588+Merge test: 1 overlay repos, 4 packages (remote)
589589+Overlay repos (priority order):
590590+ https://github.com/user/overlay-repo
591591+Results: 4 success, 0 failure, 0 dep_failed, 0 no_solution, 0 error
592592+```
593593+594594+### 4. Run Remote History Checks
595595+596596+The `run` command also supports remote execution:
597597+598598+```bash
599599+braid run https://github.com/user/overlay-repo \
600600+ --connect ~/.config/braid.cap \
601601+ -n 10 \
602602+ -o results
603603+```
604604+605605+### 5. Query Results Locally
606606+607607+Once the manifest is downloaded, all query commands work locally:
608608+609609+```bash
610610+braid summary -m results/manifest.json
611611+braid failures -m results/manifest.json
612612+braid log merge-q mypackage.dev -m results/manifest.json
613613+```
614614+615615+### Important Notes for RPC Usage
616616+617617+1. **Repository URLs**: When using `--connect`, pass git URLs instead of local paths. The server clones the repository.
618618+619619+2. **Opam file requirements**: Packages in the overlay repository must have a `url` section in their opam files:
620620+ ```
621621+ url {
622622+ src: "git+https://github.com/user/package-repo.git"
623623+ }
624624+ ```
625625+ This tells day10 where to fetch the package source.
626626+627627+3. **Network requirements**: The server must be able to clone repositories from the URLs you provide.
628628+629629+4. **Capability security**: The capability file grants full access to the braid server. Treat it like a password.
630630+631631+### Example: Complete Workflow
632632+633633+```bash
634634+# On the server (once)
635635+braid server --port 5000 \
636636+ --public-addr basil.caelum.ci.dev \
637637+ --key-file ~/braid-server.key \
638638+ --cap-file ~/braid.cap \
639639+ --opam-repo ~/opam-repository \
640640+ --cache-dir /var/cache/day10
641641+642642+# Distribute capability (once)
643643+scp basil.caelum.ci.dev:~/braid.cap ~/.config/
644644+645645+# From any client - test an overlay
646646+braid merge-test https://github.com/mtelvers/claude-repo \
647647+ --connect ~/.config/braid.cap \
648648+ -o /tmp/results
649649+650650+# Check results
651651+braid summary -m /tmp/results/manifest.json
652652+braid failures -m /tmp/results/manifest.json
653653+```
654654+655655+## Dependencies
656656+657657+- OCaml >= 4.14
658658+- cmdliner >= 1.2
659659+- yojson >= 2.0
660660+- bos >= 0.2
661661+- fmt >= 0.9
662662+- logs >= 0.7
663663+- fpath >= 0.7
664664+- capnp-rpc = 2.1 (for RPC support)
665665+- eio >= 1.2 (for RPC support)
666666+- [day10](https://github.com/mtelvers/day10) (must be in PATH on server)
667667+668668+## License
669669+670670+ISC
···11+(** JSON serialization for braid types *)
22+33+open Types
44+55+let status_to_json status =
66+ `String (string_of_status status)
77+88+let status_of_json = function
99+ | `String s -> status_of_string s
1010+ | _ -> Error
1111+1212+let package_result_to_json r =
1313+ let fields = [
1414+ "name", `String r.name;
1515+ "status", status_to_json r.status;
1616+ ] in
1717+ let fields = match r.sha with
1818+ | Some s -> ("sha", `String s) :: fields
1919+ | None -> fields
2020+ in
2121+ let fields = match r.layer with
2222+ | Some s -> ("layer", `String s) :: fields
2323+ | None -> fields
2424+ in
2525+ let fields = match r.log with
2626+ | Some s -> ("log", `String s) :: fields
2727+ | None -> fields
2828+ in
2929+ let fields = match r.solution with
3030+ | Some s -> ("solution", `String s) :: fields
3131+ | None -> fields
3232+ in
3333+ `Assoc (List.rev fields)
3434+3535+let package_result_of_json json =
3636+ let open Yojson.Basic.Util in
3737+ let name = json |> member "name" |> to_string in
3838+ let status = json |> member "status" |> status_of_json in
3939+ let sha = json |> member "sha" |> to_string_option in
4040+ let layer = json |> member "layer" |> to_string_option in
4141+ let log = json |> member "log" |> to_string_option in
4242+ let solution = json |> member "solution" |> to_string_option in
4343+ { name; status; sha; layer; log; solution }
4444+4545+let commit_result_to_json r =
4646+ `Assoc [
4747+ "commit", `String r.commit;
4848+ "short_commit", `String r.short_commit;
4949+ "message", `String r.message;
5050+ "packages", `List (List.map package_result_to_json r.packages);
5151+ ]
5252+5353+let commit_result_of_json json =
5454+ let open Yojson.Basic.Util in
5555+ let commit = json |> member "commit" |> to_string in
5656+ let short_commit = json |> member "short_commit" |> to_string in
5757+ let message = json |> member "message" |> to_string in
5858+ let packages = json |> member "packages" |> to_list |> List.map package_result_of_json in
5959+ { commit; short_commit; message; packages }
6060+6161+let package_history_to_json h =
6262+ `Assoc [
6363+ "package", `String h.package;
6464+ "first_seen", `String h.first_seen;
6565+ "latest_status", status_to_json h.latest_status;
6666+ "history", `List (List.map (fun (c, s) ->
6767+ `Assoc ["commit", `String c; "status", status_to_json s]
6868+ ) h.history);
6969+ ]
7070+7171+let manifest_to_json m =
7272+ `Assoc [
7373+ "repo_path", `String m.repo_path;
7474+ "opam_repo_path", `String m.opam_repo_path;
7575+ "os", `String m.os;
7676+ "os_version", `String m.os_version;
7777+ "generated_at", `String m.generated_at;
7878+ "commits", `List (List.map (fun c -> `String c) m.commits);
7979+ "packages", `List (List.map (fun p -> `String p) m.packages);
8080+ "results", `List (List.map commit_result_to_json m.results);
8181+ "mode", `String m.mode;
8282+ "overlay_repos", `List (List.map (fun r -> `String r) m.overlay_repos);
8383+ ]
8484+8585+let manifest_of_json json =
8686+ let open Yojson.Basic.Util in
8787+ let repo_path = json |> member "repo_path" |> to_string in
8888+ let opam_repo_path = json |> member "opam_repo_path" |> to_string in
8989+ let os = json |> member "os" |> to_string in
9090+ let os_version = json |> member "os_version" |> to_string in
9191+ let generated_at = json |> member "generated_at" |> to_string in
9292+ let commits = json |> member "commits" |> to_list |> List.map to_string in
9393+ let packages = json |> member "packages" |> to_list |> List.map to_string in
9494+ let results = json |> member "results" |> to_list |> List.map commit_result_of_json in
9595+ (* For backwards compatibility, default mode to "history" and overlay_repos to [] *)
9696+ let mode = match json |> member "mode" with
9797+ | `Null -> "history"
9898+ | j -> to_string j
9999+ in
100100+ let overlay_repos = match json |> member "overlay_repos" with
101101+ | `Null -> []
102102+ | j -> to_list j |> List.map to_string
103103+ in
104104+ { repo_path; opam_repo_path; os; os_version; generated_at; commits; packages; results; mode; overlay_repos }
105105+106106+(** Parse a day10 JSON result file *)
107107+let parse_day10_result json =
108108+ let open Yojson.Basic.Util in
109109+ let name = json |> member "name" |> to_string in
110110+ let status = json |> member "status" |> status_of_json in
111111+ let sha = json |> member "sha" |> to_string_option in
112112+ let layer = json |> member "layer" |> to_string_option in
113113+ let log = json |> member "log" |> to_string_option in
114114+ let solution = json |> member "solution" |> to_string_option in
115115+ { name; status; sha; layer; log; solution }
116116+117117+(** Write manifest to file *)
118118+let write_manifest path manifest =
119119+ let json = manifest_to_json manifest in
120120+ let content = Yojson.Basic.pretty_to_string json in
121121+ Bos.OS.File.write (Fpath.v path) content
122122+123123+(** Read manifest from file *)
124124+let read_manifest path =
125125+ match Bos.OS.File.read (Fpath.v path) with
126126+ | Ok content ->
127127+ let json = Yojson.Basic.from_string content in
128128+ Ok (manifest_of_json json)
129129+ | Error e -> Error e
+159
braid/lib/query.ml
···11+(** Query functions for braid manifest data *)
22+33+open Types
44+55+(** Get all failures (status = Failure) from latest commit *)
66+let failures manifest =
77+ match manifest.results with
88+ | [] -> []
99+ | latest :: _ ->
1010+ latest.packages
1111+ |> List.filter (fun p -> p.status = Failure)
1212+ |> List.map (fun p -> (latest.short_commit, p))
1313+1414+(** Get all packages with a specific status from latest commit *)
1515+let by_status manifest status =
1616+ match manifest.results with
1717+ | [] -> []
1818+ | latest :: _ ->
1919+ latest.packages
2020+ |> List.filter (fun p -> p.status = status)
2121+ |> List.map (fun p -> (latest.short_commit, p))
2222+2323+(** Get log for a specific commit and package *)
2424+let log manifest ~commit ~package =
2525+ let commit_result = List.find_opt (fun r ->
2626+ r.short_commit = commit || r.commit = commit
2727+ ) manifest.results in
2828+ match commit_result with
2929+ | None -> None
3030+ | Some r ->
3131+ let pkg_result = List.find_opt (fun p -> p.name = package) r.packages in
3232+ match pkg_result with
3333+ | None -> None
3434+ | Some p -> p.log
3535+3636+(** Get full result for a specific commit and package *)
3737+let result manifest ~commit ~package =
3838+ let commit_result = List.find_opt (fun r ->
3939+ r.short_commit = commit || r.commit = commit
4040+ ) manifest.results in
4141+ match commit_result with
4242+ | None -> None
4343+ | Some r ->
4444+ List.find_opt (fun p -> p.name = package) r.packages
4545+4646+(** Get history for a package across all commits *)
4747+let history manifest ~package =
4848+ let hist = List.filter_map (fun (r : commit_result) ->
4949+ match List.find_opt (fun p -> p.name = package) r.packages with
5050+ | None -> None
5151+ | Some p -> Some (r.short_commit, p.status)
5252+ ) manifest.results in
5353+ match hist with
5454+ | [] -> None
5555+ | _ ->
5656+ let latest_status = snd (List.hd hist) in
5757+ let first_seen = fst (List.hd (List.rev hist)) in
5858+ Some { package; first_seen; latest_status; history = hist }
5959+6060+(** Get dependencies for a package (from solution graph) *)
6161+let deps manifest ~commit ~package =
6262+ match result manifest ~commit ~package with
6363+ | None -> None
6464+ | Some r -> r.solution
6565+6666+(** Get packages that depend on a given package *)
6767+let rdeps manifest ~commit ~package =
6868+ let commit_result = List.find_opt (fun r ->
6969+ r.short_commit = commit || r.commit = commit
7070+ ) manifest.results in
7171+ match commit_result with
7272+ | None -> []
7373+ | Some r ->
7474+ r.packages
7575+ |> List.filter (fun p ->
7676+ match p.solution with
7777+ | None -> false
7878+ | Some sol ->
7979+ (* Check if the solution graph mentions our package *)
8080+ let pattern = "\"" ^ package in
8181+ String.length sol > 0 &&
8282+ (try let _ = Str.search_forward (Str.regexp_string pattern) sol 0 in true
8383+ with Not_found -> false))
8484+ |> List.map (fun p -> p.name)
8585+8686+(** Get summary statistics *)
8787+let summary manifest =
8888+ match manifest.results with
8989+ | [] -> (0, 0, 0, 0, 0, 0)
9090+ | latest :: _ ->
9191+ let count status = List.length (List.filter (fun p -> p.status = status) latest.packages) in
9292+ (count Success, count Failure, count Dependency_failed, count No_solution, count Solution, count Error)
9393+9494+(** Find when a package first started failing *)
9595+let first_failure manifest ~package =
9696+ let hist = List.filter_map (fun (r : commit_result) ->
9797+ match List.find_opt (fun p -> p.name = package) r.packages with
9898+ | None -> None
9999+ | Some p -> Some (r.short_commit, r.message, p.status)
100100+ ) manifest.results in
101101+ (* Find transition from non-failure to failure (going backwards in time) *)
102102+ let rec find_transition = function
103103+ | [] -> None
104104+ | [(c, m, s)] -> if s = Failure then Some (c, m) else None
105105+ | (c1, m1, s1) :: ((_c2, _m2, s2) :: _ as rest) ->
106106+ if s1 = Failure && s2 <> Failure then Some (c1, m1)
107107+ else find_transition rest
108108+ in
109109+ find_transition (List.rev hist)
110110+111111+(** Generate terminal-friendly matrix with vertical package names *)
112112+let matrix manifest =
113113+ let buf = Buffer.create 4096 in
114114+ Buffer.add_string buf "Build Status Matrix\n";
115115+ Buffer.add_string buf "Legend: S=success, F=failure, D=dependency_failed, -=no_solution, B=solution\n\n";
116116+117117+ let packages = manifest.packages in
118118+ (* Strip .dev suffix for display *)
119119+ let display_names = List.map (fun pkg ->
120120+ if String.length pkg > 4 && String.sub pkg (String.length pkg - 4) 4 = ".dev" then
121121+ String.sub pkg 0 (String.length pkg - 4)
122122+ else pkg
123123+ ) packages in
124124+ let commit_width = 9 in (* "Commit" + padding *)
125125+126126+ (* Find the longest display name for vertical header height *)
127127+ let max_pkg_len = List.fold_left (fun acc pkg -> max acc (String.length pkg)) 0 display_names in
128128+129129+ (* Print vertical package names (bottom-aligned) *)
130130+ for row = 0 to max_pkg_len - 1 do
131131+ Buffer.add_string buf (String.make commit_width ' ');
132132+ List.iter (fun pkg ->
133133+ let pkg_len = String.length pkg in
134134+ let offset = max_pkg_len - pkg_len in
135135+ let ch = if row >= offset then String.make 1 pkg.[row - offset] else " " in
136136+ Buffer.add_string buf (Printf.sprintf " %s " ch)
137137+ ) display_names;
138138+ Buffer.add_char buf '\n'
139139+ done;
140140+141141+ (* Separator line *)
142142+ Buffer.add_string buf (String.make commit_width '-');
143143+ List.iter (fun _ -> Buffer.add_string buf "---") display_names;
144144+ Buffer.add_char buf '\n';
145145+146146+ (* Data rows *)
147147+ List.iter (fun (r : commit_result) ->
148148+ Buffer.add_string buf (Printf.sprintf "%-8s " r.short_commit);
149149+ List.iter (fun pkg_name ->
150150+ let symbol = match List.find_opt (fun p -> p.name = pkg_name) r.packages with
151151+ | None -> " "
152152+ | Some p -> status_symbol p.status
153153+ in
154154+ Buffer.add_string buf (Printf.sprintf " %s " symbol)
155155+ ) packages;
156156+ Buffer.add_char buf '\n'
157157+ ) manifest.results;
158158+159159+ Buffer.contents buf
+41
braid/lib/rpc_client.ml
···11+(** RPC client for connecting to remote BraidService *)
22+33+module Api = Rpc_schema.MakeRPC(Capnp_rpc)
44+55+(** Connect to a remote BraidService using a capability file *)
66+let connect ~sw ~net cap_file =
77+ let vat = Capnp_rpc_unix.client_only_vat ~sw net in
88+ let sr = Capnp_rpc_unix.Cap_file.load vat cap_file |> Result.get_ok in
99+ Capnp_rpc.Sturdy_ref.connect_exn sr
1010+1111+(** Run health checks on a remote server *)
1212+let run_remote ~sw ~net ~cap_file ~repo_url ~num_commits ~fork_jobs
1313+ ~os ~os_family ~os_distribution ~os_version =
1414+ let service = connect ~sw ~net cap_file in
1515+ let open Api.Client.BraidService.Run in
1616+ let request, params = Capnp_rpc.Capability.Request.create Params.init_pointer in
1717+ Params.repo_url_set params repo_url;
1818+ Params.num_commits_set params (Stdint.Uint32.of_int num_commits);
1919+ Params.fork_jobs_set params (Stdint.Uint32.of_int fork_jobs);
2020+ Params.os_set params os;
2121+ Params.os_family_set params os_family;
2222+ Params.os_distribution_set params os_distribution;
2323+ Params.os_version_set params os_version;
2424+ let response = Capnp_rpc.Capability.call_for_value_exn service method_id request in
2525+ Results.manifest_json_get response
2626+2727+(** Run merge test on a remote server *)
2828+let merge_test_remote ~sw ~net ~cap_file ~repo_urls ~dry_run ~fork_jobs
2929+ ~os ~os_family ~os_distribution ~os_version =
3030+ let service = connect ~sw ~net cap_file in
3131+ let open Api.Client.BraidService.MergeTest in
3232+ let request, params = Capnp_rpc.Capability.Request.create Params.init_pointer in
3333+ let _ = Params.repo_urls_set_list params repo_urls in
3434+ Params.dry_run_set params dry_run;
3535+ Params.fork_jobs_set params (Stdint.Uint32.of_int fork_jobs);
3636+ Params.os_set params os;
3737+ Params.os_family_set params os_family;
3838+ Params.os_distribution_set params os_distribution;
3939+ Params.os_version_set params os_version;
4040+ let response = Capnp_rpc.Capability.call_for_value_exn service method_id request in
4141+ Results.manifest_json_get response
···11+(** RPC service implementation for BraidService *)
22+33+module Api = Rpc_schema.MakeRPC(Capnp_rpc)
44+55+(** Clone a git repository to a temporary directory *)
66+let clone_repo ~temp_dir url =
77+ let repo_name =
88+ (* Extract repo name from URL, e.g., "https://github.com/user/repo" -> "repo" *)
99+ let base = Filename.basename url in
1010+ if String.length base > 4 && String.sub base (String.length base - 4) 4 = ".git" then
1111+ String.sub base 0 (String.length base - 4)
1212+ else
1313+ base
1414+ in
1515+ let repo_path = Filename.concat temp_dir repo_name in
1616+ (* GIT_TERMINAL_PROMPT=0 prevents git from prompting for credentials *)
1717+ let cmd_str = Printf.sprintf "GIT_TERMINAL_PROMPT=0 git clone --depth 100 %s %s" url repo_path in
1818+ match Unix.system cmd_str with
1919+ | Unix.WEXITED 0 -> Ok repo_path
2020+ | _ -> Error (`Msg (Printf.sprintf "Failed to clone %s" url))
2121+2222+(** Create a unique temp directory using mktemp *)
2323+let make_temp_dir () =
2424+ let ic = Unix.open_process_in "mktemp -d -t braid.XXXXXX" in
2525+ let temp_dir = input_line ic in
2626+ let _ = Unix.close_process_in ic in
2727+ temp_dir
2828+2929+(** Create the local BraidService implementation *)
3030+let local ~opam_repo_path ~cache_dir =
3131+ let module Service = Api.Service.BraidService in
3232+ Service.local @@ object
3333+ inherit Service.service
3434+3535+ method run_impl params release_param_caps =
3636+ let open Service.Run in
3737+ release_param_caps ();
3838+ let repo_url = Params.repo_url_get params in
3939+ let num_commits = Params.num_commits_get params |> Stdint.Uint32.to_int in
4040+ let fork_jobs = Params.fork_jobs_get params |> Stdint.Uint32.to_int in
4141+ let os = Params.os_get params in
4242+ let os_family = Params.os_family_get params in
4343+ let os_distribution = Params.os_distribution_get params in
4444+ let os_version = Params.os_version_get params in
4545+4646+ (* Create unique temp directory for each request *)
4747+ let temp_dir = make_temp_dir () in
4848+4949+ let result =
5050+ match clone_repo ~temp_dir repo_url with
5151+ | Error (`Msg msg) -> Error msg
5252+ | Ok repo_path ->
5353+ let output_dir = Filename.concat temp_dir "results" in
5454+ (try Unix.mkdir output_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
5555+ match Runner.run ~repo_path ~opam_repo_path ~cache_dir ~output_dir
5656+ ~os ~os_family ~os_distribution ~os_version
5757+ ~fork_jobs ~num_commits with
5858+ | Ok manifest ->
5959+ let json = Json.manifest_to_json manifest in
6060+ Ok (Yojson.Basic.to_string json)
6161+ | Error (`Msg msg) -> Error msg
6262+ in
6363+6464+ (* Clean up temp directory *)
6565+ let _ = Unix.system (Printf.sprintf "rm -rf %s" temp_dir) in
6666+6767+ let response, results = Capnp_rpc.Service.Response.create Results.init_pointer in
6868+ (match result with
6969+ | Ok manifest_json -> Results.manifest_json_set results manifest_json
7070+ | Error msg -> Results.manifest_json_set results (Printf.sprintf "{\"error\": \"%s\"}" msg));
7171+ Capnp_rpc.Service.return response
7272+7373+ method merge_test_impl params release_param_caps =
7474+ let open Service.MergeTest in
7575+ release_param_caps ();
7676+ let repo_urls = Params.repo_urls_get_list params in
7777+ let dry_run = Params.dry_run_get params in
7878+ let fork_jobs = Params.fork_jobs_get params |> Stdint.Uint32.to_int in
7979+ let os = Params.os_get params in
8080+ let os_family = Params.os_family_get params in
8181+ let os_distribution = Params.os_distribution_get params in
8282+ let os_version = Params.os_version_get params in
8383+8484+ (* Create unique temp directory for each request *)
8585+ let temp_dir = make_temp_dir () in
8686+8787+ let result =
8888+ (* Clone all repos *)
8989+ let rec clone_all urls acc =
9090+ match urls with
9191+ | [] -> Ok (List.rev acc)
9292+ | url :: rest ->
9393+ match clone_repo ~temp_dir url with
9494+ | Error e -> Error e
9595+ | Ok path -> clone_all rest (path :: acc)
9696+ in
9797+ match clone_all repo_urls [] with
9898+ | Error (`Msg msg) -> Error msg
9999+ | Ok overlay_repos ->
100100+ let output_dir = Filename.concat temp_dir "results" in
101101+ (try Unix.mkdir output_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
102102+ match Runner.merge_test ~overlay_repos ~opam_repo_path ~cache_dir ~output_dir
103103+ ~os ~os_family ~os_distribution ~os_version ~fork_jobs ~dry_run with
104104+ | Ok manifest ->
105105+ let json = Json.manifest_to_json manifest in
106106+ Ok (Yojson.Basic.to_string json)
107107+ | Error (`Msg msg) -> Error msg
108108+ in
109109+110110+ (* Clean up temp directory *)
111111+ let _ = Unix.system (Printf.sprintf "rm -rf %s" temp_dir) in
112112+113113+ let response, results = Capnp_rpc.Service.Response.create Results.init_pointer in
114114+ (match result with
115115+ | Ok manifest_json -> Results.manifest_json_set results manifest_json
116116+ | Error msg -> Results.manifest_json_set results (Printf.sprintf "{\"error\": \"%s\"}" msg));
117117+ Capnp_rpc.Service.return response
118118+ end
+403
braid/lib/runner.ml
···11+(** Run day10 commands and collect results *)
22+33+open Types
44+55+let ( let* ) = Result.bind
66+77+(** Run a command and return stdout *)
88+let run_cmd args =
99+ let cmd = Bos.Cmd.of_list args in
1010+ Logs.info (fun m -> m "Executing: %a" Bos.Cmd.pp cmd);
1111+ Bos.OS.Cmd.run_out cmd |> Bos.OS.Cmd.out_string
1212+1313+(** Run a command, ignoring output *)
1414+let run_cmd_quiet args =
1515+ let cmd = Bos.Cmd.of_list args in
1616+ match Bos.OS.Cmd.run_out cmd |> Bos.OS.Cmd.out_null with
1717+ | Ok ((), _status) -> Ok ()
1818+ | Error e -> Error e
1919+2020+(** Get list of packages from day10 list *)
2121+let list_packages ~repo_path ~os ~os_family ~os_distribution ~os_version =
2222+ let args = [
2323+ "day10"; "list";
2424+ "--opam-repository"; repo_path;
2525+ "--os"; os;
2626+ "--os-family"; os_family;
2727+ "--os-distribution"; os_distribution;
2828+ "--os-version"; os_version;
2929+ ] in
3030+ let* (output, _) = run_cmd args in
3131+ let packages = String.split_on_char '\n' output
3232+ |> List.filter (fun s -> String.length s > 0)
3333+ in
3434+ Ok packages
3535+3636+(** Get git commits *)
3737+let get_commits ~repo_path ~num_commits =
3838+ let* () = Bos.OS.Dir.set_current (Fpath.v repo_path) in
3939+ let args = ["git"; "log"; "--oneline"; "-n"; string_of_int num_commits; "--format=%H"] in
4040+ let* (output, _) = run_cmd args in
4141+ let commits = String.split_on_char '\n' output
4242+ |> List.filter (fun s -> String.length s > 0)
4343+ in
4444+ Ok commits
4545+4646+(** Get commit message *)
4747+let get_commit_message commit =
4848+ let args = ["git"; "log"; "-1"; "--format=%s"; commit] in
4949+ match run_cmd args with
5050+ | Ok (msg, _) -> String.trim msg
5151+ | Error _ -> ""
5252+5353+(** Checkout a commit *)
5454+let checkout commit =
5555+ let args = ["git"; "checkout"; "-q"; commit] in
5656+ run_cmd_quiet args
5757+5858+(** Run day10 health-check with --dry-run and --json *)
5959+let health_check ~repo_path ~opam_repo_path ~cache_dir ~os ~os_family ~os_distribution ~os_version ~fork_jobs ~output_dir ~packages =
6060+ (* Create packages JSON file *)
6161+ let packages_file = Filename.concat output_dir "packages.json" in
6262+ let packages_json = Printf.sprintf {|{"packages":[%s]}|}
6363+ (String.concat "," (List.map (Printf.sprintf {|"%s"|}) packages))
6464+ in
6565+ let* () = Bos.OS.File.write (Fpath.v packages_file) packages_json in
6666+6767+ let results_dir = Filename.concat output_dir "results" in
6868+ let* _ = Bos.OS.Dir.create (Fpath.v results_dir) in
6969+7070+ let args = [
7171+ "day10"; "health-check";
7272+ "--opam-repository"; repo_path;
7373+ "--opam-repository"; opam_repo_path;
7474+ "--cache-dir"; cache_dir;
7575+ "--os"; os;
7676+ "--os-family"; os_family;
7777+ "--os-distribution"; os_distribution;
7878+ "--os-version"; os_version;
7979+ "--dry-run";
8080+ "--fork"; string_of_int fork_jobs;
8181+ "--json"; results_dir;
8282+ "@" ^ packages_file;
8383+ ] in
8484+ let* _ = run_cmd args in
8585+8686+ (* Parse result files *)
8787+ let* entries = Bos.OS.Dir.contents (Fpath.v results_dir) in
8888+ let results = List.filter_map (fun path ->
8989+ if Fpath.has_ext "json" path then
9090+ match Bos.OS.File.read path with
9191+ | Ok content ->
9292+ (try
9393+ let json = Yojson.Basic.from_string content in
9494+ Some (Json.parse_day10_result json)
9595+ with _ -> None)
9696+ | Error _ -> None
9797+ else None
9898+ ) entries in
9999+ Ok results
100100+101101+(** Process a single commit *)
102102+let process_commit ~repo_path ~opam_repo_path ~cache_dir ~os ~os_family ~os_distribution ~os_version ~fork_jobs ~temp_dir commit =
103103+ let short_commit = String.sub commit 0 7 in
104104+ let message = get_commit_message commit in
105105+106106+ Logs.info (fun m -> m "Processing commit %s: %s" short_commit message);
107107+108108+ let* () = checkout commit in
109109+110110+ let* packages = list_packages ~repo_path ~os ~os_family ~os_distribution ~os_version in
111111+112112+ if packages = [] then
113113+ Ok { commit; short_commit; message; packages = [] }
114114+ else begin
115115+ let output_dir = Filename.concat temp_dir short_commit in
116116+ let* _ = Bos.OS.Dir.create (Fpath.v output_dir) in
117117+118118+ let* results = health_check
119119+ ~repo_path ~opam_repo_path ~cache_dir
120120+ ~os ~os_family ~os_distribution ~os_version
121121+ ~fork_jobs ~output_dir ~packages
122122+ in
123123+124124+ (* Sort results by package name *)
125125+ let sorted_results = List.sort (fun a b -> String.compare a.name b.name) results in
126126+127127+ Ok { commit; short_commit; message; packages = sorted_results }
128128+ end
129129+130130+(** Run the full analysis *)
131131+let run ~repo_path ~opam_repo_path ~cache_dir ~output_dir
132132+ ~os ~os_family ~os_distribution ~os_version
133133+ ~fork_jobs ~num_commits =
134134+135135+ let* () = Bos.OS.Dir.set_current (Fpath.v repo_path) in
136136+137137+ (* Reset to main branch *)
138138+ let* () = checkout "main" in
139139+140140+ (* Get commits to process *)
141141+ let* commits = get_commits ~repo_path ~num_commits in
142142+143143+ Logs.info (fun m -> m "Processing %d commits" (List.length commits));
144144+145145+ (* Create temp directory for intermediate results *)
146146+ let* temp_dir = Bos.OS.Dir.tmp "braid-%s" in
147147+ let temp_dir = Fpath.to_string temp_dir in
148148+149149+ (* Process each commit *)
150150+ let results = List.filter_map (fun commit ->
151151+ match process_commit ~repo_path ~opam_repo_path ~cache_dir
152152+ ~os ~os_family ~os_distribution ~os_version
153153+ ~fork_jobs ~temp_dir commit with
154154+ | Ok result -> Some result
155155+ | Error (`Msg e) ->
156156+ Logs.err (fun m -> m "Error processing %s: %s" commit e);
157157+ None
158158+ ) commits in
159159+160160+ (* Return to main branch *)
161161+ let _ = checkout "main" in
162162+163163+ (* Collect all unique packages *)
164164+ let all_packages = results
165165+ |> List.concat_map (fun (r : commit_result) -> List.map (fun p -> p.name) r.packages)
166166+ |> List.sort_uniq String.compare
167167+ in
168168+169169+ (* Build manifest *)
170170+ let generated_at =
171171+ let t = Unix.gettimeofday () in
172172+ let tm = Unix.gmtime t in
173173+ Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ"
174174+ (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday
175175+ tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec
176176+ in
177177+178178+ let manifest = {
179179+ repo_path;
180180+ opam_repo_path;
181181+ os = Printf.sprintf "%s-%s" os_distribution os_version;
182182+ os_version;
183183+ generated_at;
184184+ commits = List.map (fun c -> String.sub c 0 7) commits;
185185+ packages = all_packages;
186186+ results;
187187+ mode = "history";
188188+ overlay_repos = [];
189189+ } in
190190+191191+ (* Write manifest *)
192192+ let* _ = Bos.OS.Dir.create (Fpath.v output_dir) in
193193+ let manifest_path = Filename.concat output_dir "manifest.json" in
194194+ let* () = Json.write_manifest manifest_path manifest in
195195+196196+ Logs.info (fun m -> m "Manifest written to %s" manifest_path);
197197+198198+ Ok manifest
199199+200200+(** List packages from multiple overlay repos (union of all) *)
201201+let list_packages_multi ~repo_paths ~os ~os_family ~os_distribution ~os_version =
202202+ (* Build args with multiple --opam-repository flags *)
203203+ let repo_args = List.concat_map (fun path ->
204204+ ["--opam-repository"; path]
205205+ ) repo_paths in
206206+ let args = ["day10"; "list"] @ repo_args @ [
207207+ "--os"; os;
208208+ "--os-family"; os_family;
209209+ "--os-distribution"; os_distribution;
210210+ "--os-version"; os_version;
211211+ ] in
212212+ let* (output, _) = run_cmd args in
213213+ let packages = String.split_on_char '\n' output
214214+ |> List.filter (fun s -> String.length s > 0)
215215+ in
216216+ Ok packages
217217+218218+(** Parse results from a directory of JSON files *)
219219+let parse_results_dir results_dir =
220220+ let* entries = Bos.OS.Dir.contents (Fpath.v results_dir) in
221221+ let results = List.filter_map (fun path ->
222222+ if Fpath.has_ext "json" path then
223223+ match Bos.OS.File.read path with
224224+ | Ok content ->
225225+ (try
226226+ let json = Yojson.Basic.from_string content in
227227+ Some (Json.parse_day10_result json)
228228+ with _ -> None)
229229+ | Error _ -> None
230230+ else None
231231+ ) entries in
232232+ Ok results
233233+234234+(** Run day10 health-check with multiple overlay repos (two-stage: solve then build) *)
235235+let health_check_multi ~overlay_repos ~opam_repo_path ~cache_dir ~os ~os_family ~os_distribution ~os_version ~fork_jobs ~dry_run ~output_dir ~packages =
236236+ (* Create packages JSON file *)
237237+ let packages_file = Filename.concat output_dir "packages.json" in
238238+ let packages_json = Printf.sprintf {|{"packages":[%s]}|}
239239+ (String.concat "," (List.map (Printf.sprintf {|"%s"|}) packages))
240240+ in
241241+ let* () = Bos.OS.File.write (Fpath.v packages_file) packages_json in
242242+243243+ let results_dir = Filename.concat output_dir "results" in
244244+ let* _ = Bos.OS.Dir.create (Fpath.v results_dir) in
245245+246246+ (* Build repo args: overlay repos first (highest priority), then opam-repository *)
247247+ let repo_args = List.concat_map (fun path ->
248248+ ["--opam-repository"; path]
249249+ ) overlay_repos in
250250+251251+ (* Stage 1: dry-run with high parallelism to solve dependencies *)
252252+ let args_stage1 = [
253253+ "day10"; "health-check";
254254+ ] @ repo_args @ [
255255+ "--opam-repository"; opam_repo_path;
256256+ "--cache-dir"; cache_dir;
257257+ "--os"; os;
258258+ "--os-family"; os_family;
259259+ "--os-distribution"; os_distribution;
260260+ "--os-version"; os_version;
261261+ "--dry-run";
262262+ "--fork"; string_of_int fork_jobs;
263263+ "--json"; results_dir;
264264+ "@" ^ packages_file;
265265+ ] in
266266+ let* _ = run_cmd args_stage1 in
267267+268268+ (* Parse stage 1 results *)
269269+ let* results_stage1 = parse_results_dir results_dir in
270270+271271+ if dry_run then
272272+ (* If --dry-run, we're done after stage 1 *)
273273+ Ok results_stage1
274274+ else begin
275275+ (* Stage 2: find packages with status "solution" and actually build them *)
276276+ let solution_packages = results_stage1
277277+ |> List.filter (fun r -> r.status = Solution)
278278+ |> List.map (fun r -> r.name)
279279+ in
280280+281281+ if solution_packages = [] then
282282+ (* No packages need building *)
283283+ Ok results_stage1
284284+ else begin
285285+ Logs.info (fun m -> m "Stage 2: building %d packages" (List.length solution_packages));
286286+287287+ (* Create packages file for stage 2 *)
288288+ let packages_file2 = Filename.concat output_dir "packages_stage2.json" in
289289+ let packages_json2 = Printf.sprintf {|{"packages":[%s]}|}
290290+ (String.concat "," (List.map (Printf.sprintf {|"%s"|}) solution_packages))
291291+ in
292292+ let* () = Bos.OS.File.write (Fpath.v packages_file2) packages_json2 in
293293+294294+ (* Stage 2: no --dry-run, no --fork (sequential builds) *)
295295+ let args_stage2 = [
296296+ "day10"; "health-check";
297297+ ] @ repo_args @ [
298298+ "--opam-repository"; opam_repo_path;
299299+ "--cache-dir"; cache_dir;
300300+ "--os"; os;
301301+ "--os-family"; os_family;
302302+ "--os-distribution"; os_distribution;
303303+ "--os-version"; os_version;
304304+ "--json"; results_dir;
305305+ "@" ^ packages_file2;
306306+ ] in
307307+ let* _ = run_cmd args_stage2 in
308308+309309+ (* Parse final results (stage 2 overwrites stage 1 results for built packages) *)
310310+ parse_results_dir results_dir
311311+ end
312312+ end
313313+314314+(** Run merge test on stacked repositories *)
315315+let merge_test ~overlay_repos ~opam_repo_path ~cache_dir ~output_dir
316316+ ~os ~os_family ~os_distribution ~os_version ~fork_jobs ~dry_run =
317317+318318+ (* List packages from overlay repos only (not opam-repository) *)
319319+ let* packages = list_packages_multi ~repo_paths:overlay_repos ~os ~os_family ~os_distribution ~os_version in
320320+321321+ Logs.info (fun m -> m "Found %d packages across %d overlay repos" (List.length packages) (List.length overlay_repos));
322322+323323+ if packages = [] then begin
324324+ Logs.warn (fun m -> m "No packages found in overlay repos");
325325+ let generated_at =
326326+ let t = Unix.gettimeofday () in
327327+ let tm = Unix.gmtime t in
328328+ Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ"
329329+ (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday
330330+ tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec
331331+ in
332332+ let manifest = {
333333+ repo_path = List.hd overlay_repos;
334334+ opam_repo_path;
335335+ os = Printf.sprintf "%s-%s" os_distribution os_version;
336336+ os_version;
337337+ generated_at;
338338+ commits = ["merge-test"];
339339+ packages = [];
340340+ results = [{ commit = "merge-test"; short_commit = "merge-q"; message = "Merge queue snapshot"; packages = [] }];
341341+ mode = "merge-test";
342342+ overlay_repos;
343343+ } in
344344+ let* _ = Bos.OS.Dir.create (Fpath.v output_dir) in
345345+ let manifest_path = Filename.concat output_dir "manifest.json" in
346346+ let* () = Json.write_manifest manifest_path manifest in
347347+ Ok manifest
348348+ end else begin
349349+ (* Create output directory *)
350350+ let* _ = Bos.OS.Dir.create (Fpath.v output_dir) in
351351+352352+ (* Run health check with all overlay repos stacked *)
353353+ let* results = health_check_multi
354354+ ~overlay_repos ~opam_repo_path ~cache_dir
355355+ ~os ~os_family ~os_distribution ~os_version
356356+ ~fork_jobs ~dry_run ~output_dir ~packages
357357+ in
358358+359359+ (* Sort results by package name *)
360360+ let sorted_results = List.sort (fun a b -> String.compare a.name b.name) results in
361361+362362+ (* Build manifest *)
363363+ let generated_at =
364364+ let t = Unix.gettimeofday () in
365365+ let tm = Unix.gmtime t in
366366+ Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ"
367367+ (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday
368368+ tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec
369369+ in
370370+371371+ let all_packages = sorted_results
372372+ |> List.map (fun p -> p.name)
373373+ |> List.sort_uniq String.compare
374374+ in
375375+376376+ let commit_result = {
377377+ commit = "merge-test";
378378+ short_commit = "merge-q";
379379+ message = "Merge queue snapshot";
380380+ packages = sorted_results;
381381+ } in
382382+383383+ let manifest = {
384384+ repo_path = List.hd overlay_repos;
385385+ opam_repo_path;
386386+ os = Printf.sprintf "%s-%s" os_distribution os_version;
387387+ os_version;
388388+ generated_at;
389389+ commits = ["merge-test"];
390390+ packages = all_packages;
391391+ results = [commit_result];
392392+ mode = "merge-test";
393393+ overlay_repos;
394394+ } in
395395+396396+ (* Write manifest *)
397397+ let manifest_path = Filename.concat output_dir "manifest.json" in
398398+ let* () = Json.write_manifest manifest_path manifest in
399399+400400+ Logs.info (fun m -> m "Manifest written to %s" manifest_path);
401401+402402+ Ok manifest
403403+ end
+19
braid/lib/server.ml
···11+(** Cap'n Proto RPC server for BraidService *)
22+33+(** Start the RPC server *)
44+let run ~sw ~net ~fs ~listen_addr ~listen_port ~public_addr ~key_file ~cap_file ~opam_repo_path ~cache_dir =
55+ let service = Rpc_service.local ~opam_repo_path ~cache_dir in
66+ let addr = `TCP (listen_addr, listen_port) in
77+ let public_address = `TCP (public_addr, listen_port) in
88+ let secret_key = `File (Eio.Path.(fs / key_file)) in
99+ let config = Capnp_rpc_unix.Vat_config.create ~secret_key ~public_address ~net addr in
1010+ let service_id = Capnp_rpc_unix.Vat_config.derived_id config "main" in
1111+ let restore = Capnp_rpc_net.Restorer.single service_id service in
1212+ let vat = Capnp_rpc_unix.serve ~sw ~restore config in
1313+ Capnp_rpc_unix.Cap_file.save_service vat service_id cap_file |> Result.get_ok;
1414+ Fmt.pr "Server listening on %s:%d@." listen_addr listen_port;
1515+ Fmt.pr " Public address: %s:%d@." public_addr listen_port;
1616+ Fmt.pr " Key file: %s@." key_file;
1717+ Fmt.pr " Capability file: %s@." cap_file;
1818+ (* Block forever - server runs until killed *)
1919+ Eio.Fiber.await_cancel ()
+73
braid/lib/types.ml
···11+(** Core types for braid *)
22+33+type status =
44+ | Success
55+ | Failure
66+ | Dependency_failed
77+ | No_solution
88+ | Solution (* solvable but not yet built *)
99+ | Error
1010+1111+let status_of_string = function
1212+ | "success" -> Success
1313+ | "failure" -> Failure
1414+ | "dependency_failed" -> Dependency_failed
1515+ | "no_solution" -> No_solution
1616+ | "solution" -> Solution
1717+ | _ -> Error
1818+1919+let string_of_status = function
2020+ | Success -> "success"
2121+ | Failure -> "failure"
2222+ | Dependency_failed -> "dependency_failed"
2323+ | No_solution -> "no_solution"
2424+ | Solution -> "solution"
2525+ | Error -> "error"
2626+2727+let status_symbol = function
2828+ | Success -> "S"
2929+ | Failure -> "F"
3030+ | Dependency_failed -> "D"
3131+ | No_solution -> "-"
3232+ | Solution -> "B"
3333+ | Error -> " "
3434+3535+(** Result of a single package build/check *)
3636+type package_result = {
3737+ name : string;
3838+ status : status;
3939+ sha : string option;
4040+ layer : string option;
4141+ log : string option;
4242+ solution : string option; (* dependency graph in dot format *)
4343+}
4444+4545+(** Results for a single commit *)
4646+type commit_result = {
4747+ commit : string; (* full sha *)
4848+ short_commit : string; (* 7-char prefix *)
4949+ message : string;
5050+ packages : package_result list;
5151+}
5252+5353+(** Package history across commits *)
5454+type package_history = {
5555+ package : string;
5656+ first_seen : string; (* commit where first appeared *)
5757+ latest_status : status;
5858+ history : (string * status) list; (* commit, status pairs, newest first *)
5959+}
6060+6161+(** Summary manifest for the entire run *)
6262+type manifest = {
6363+ repo_path : string;
6464+ opam_repo_path : string;
6565+ os : string;
6666+ os_version : string;
6767+ generated_at : string;
6868+ commits : string list; (* newest first *)
6969+ packages : string list; (* sorted alphabetically *)
7070+ results : commit_result list;
7171+ mode : string; (* "history" for run command, "merge-test" for merge-test command *)
7272+ overlay_repos : string list; (* for merge-test: list of stacked repos in priority order *)
7373+}