diff --git a/.gitignore b/.gitignore index 39e7348..84258b2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ result-* graphify-out/ # lychee's answers during `just md-quality --online` .lycheecache +# The Unison codebase of `just unison-install` (unison/refpulse/README.md) +unison/.codebase/ +unison/refpulse/install.output.md diff --git a/AGENTS.md b/AGENTS.md index 7fed48d..c6ed880 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,8 @@ is cited, and friends under an alias from `PERSONAS.md`. `/note` makes them; `ju is a measure, not a gate: no check fails on a score. - `works/` holds full texts copied as is, only when free to copy in the US and Brazil, indexed in `works/index.md`. The `ouro-get` skill gets them; never trim or edit a file there. +- `unison/refpulse/` is a Unison service the site's Live button calls (`just unison-*`). What it + answers is live, never a fact of the book; the site works when it is down. - A skill in `.claude/skills/` is written here and names its sources; do not copy a skill from another repository. After a change to one, run `scripts/readme.sh --lock`. - The book's colours are black and red, with yellow letters for the title, after Hoppe's diff --git a/REUSE.toml b/REUSE.toml index 8e380fd..df035a1 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -7,7 +7,7 @@ SPDX-FileCopyrightText = "2026 Matheus Hoffmann" SPDX-License-Identifier = "CC-BY-4.0" [[annotations]] -path = ["scripts/**", "tools/**", ".github/**", "justfile", "flake.nix", "flake.lock", "site/**"] +path = ["scripts/**", "tools/**", ".github/**", "justfile", "flake.nix", "flake.lock", "site/**", "unison/**/*.u"] precedence = "closest" SPDX-FileCopyrightText = "2026 Matheus Hoffmann" SPDX-License-Identifier = "AGPL-3.0-only" diff --git a/flake.nix b/flake.nix index 7a8002b..025f134 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,8 @@ site = gleam-website.devShells.${system}.default; # md: the three Markdown analysers of `just md-quality` (.github/workflows/md.yml). md = pkgs.mkShell { packages = [ pkgs.markdownlint-cli2 pkgs.vale pkgs.lychee ]; }; + # unison: the Unison Codebase Manager for unison/ (the refpulse prototype, `just unison-*`). + unison = pkgs.mkShell { packages = [ pkgs.unison-ucm ]; }; }) paper.devShells; }; diff --git a/justfile b/justfile index b8d78cd..2e88e8d 100644 --- a/justfile +++ b/justfile @@ -81,6 +81,20 @@ self-test: md-quality *args: nix develop --extra-experimental-features 'nix-command flakes' --no-update-lock-file .#md --command scripts/md-quality.sh {{args}} +# Unison (unison/refpulse/README.md): make the refpulse project in unison/.codebase/, with its +# libraries from Unison Share. Run it again after a change to refpulse.u. +unison-install: + rm -rf unison/.codebase unison/refpulse/install.output.md + nix develop --no-update-lock-file .#unison --command ucm -C unison/.codebase transcript.in-place unison/refpulse/install.md + +# Serve refpulse on this machine, in Unison Cloud's local runtime; it prints its URL. Enter stops it. +unison-serve: + nix develop --no-update-lock-file .#unison --command ucm -c unison/.codebase run refpulse/main:.refpulse.deploy.local + +# Deploy refpulse to Unison Cloud. Log in once first: `ucm -c unison/.codebase`, then `auth.login`. +unison-deploy: + nix develop --no-update-lock-file .#unison --command ucm -c unison/.codebase run refpulse/main:.refpulse.deploy.cloud + # Fetch the public ouroboric-dev repositories into build/ouroboric/ and list what changed since # OUROBORIC.md recorded them. `/update-ouroboric` (a Claude Code command) then updates the digests. ouroboric-sources: diff --git a/site/src/closed_source_site/app.gleam b/site/src/closed_source_site/app.gleam index 4764bbb..85a9802 100644 --- a/site/src/closed_source_site/app.gleam +++ b/site/src/closed_source_site/app.gleam @@ -7,6 +7,7 @@ import closed_source_site/content.{ import closed_source_site/ffi import gleam/int import gleam/list +import gleam/result import gleam/string import lustre/attribute.{type Attribute} import lustre/effect.{type Effect} @@ -23,7 +24,16 @@ pub type Text { } pub type Model { - Model(text: Text, cite: Int, copied: Bool) + Model(text: Text, cite: Int, copied: Bool, pulse: Pulse) +} + +/// The live DOI check: a request to the refpulse service on Unison Cloud (unison/refpulse/). +pub type Pulse { + Idle + Running + /// The service answered: where, in how many milliseconds, and #(doi, status, ok) per DOI. + Done(endpoint: String, ms: Int, results: List(#(String, Int, Bool))) + Failed(String) } pub type Msg { @@ -31,12 +41,15 @@ pub type Msg { ShowCite(Int) Copy Copied(Bool) + /// Ask the refpulse service about the book's DOIs. + CheckDois + Pulsed(Result(String, String)) /// Scroll the timeline of the thinkers' lives sideways, by this many pixels. ScrollTimeline(Int) } pub fn initial() -> Model { - Model(text: Original, cite: 0, copied: False) + Model(text: Original, cite: 0, copied: False, pulse: Idle) } pub fn init(_: Nil) -> #(Model, Effect(Msg)) { @@ -52,6 +65,15 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ffi.copy(citation(model.cite).1, fn(ok) { dispatch(Copied(ok)) }) }) Copied(ok) -> #(Model(..model, copied: ok), effect.none()) + CheckDois -> #(Model(..model, pulse: Running), { + use dispatch <- effect.from + ffi.refpulse(refpulse_url, book_dois(), fn(r) { dispatch(Pulsed(r)) }) + }) + Pulsed(Ok(text)) -> #( + Model(..model, pulse: parse_pulse(text)), + effect.none(), + ) + Pulsed(Error(e)) -> #(Model(..model, pulse: Failed(e)), effect.none()) ScrollTimeline(dx) -> #(model, { use _ <- effect.from ffi.scroll_by(timeline_id, dx) @@ -95,6 +117,7 @@ pub fn view(model: Model) -> Element(Msg) { authors_section(), fabric_section(), ci_section(), + live_section(model), scores_section(), cite_section(model), ]), @@ -410,6 +433,7 @@ fn jump() -> Element(msg) { link("#authors", "Author"), link("#fabric", "How it is made"), link("#ci", "CI"), + link("#live", "Live"), link("#scores", "Scores"), link("#cite", "Cite"), ]) @@ -689,6 +713,127 @@ fn timeline() -> Element(Msg) { } } +/// The refpulse service's URL on Unison Cloud, printed by `just unison-deploy`. Empty until it is +/// deployed: then the button says so, unless the page is opened with `?refpulse=` (a local +/// `just unison-serve`, for example). +pub const refpulse_url = "" + +/// The book's own DOIs: the concept DOI, then each version's. +pub fn book_dois() -> List(String) { + [content.doi, ..list.map(content.version_dois, fn(v) { v.1 })] + |> list.unique +} + +/// The FFI's answer: "ms\tendpoint", then one "doi\tstatus\tok" line per DOI. +pub fn parse_pulse(text: String) -> Pulse { + case string.split(text, "\n") { + [head, ..rows] -> + case string.split(head, "\t") { + [ms, endpoint] -> + Done( + endpoint, + result.unwrap(int.parse(ms), 0), + list.filter_map(rows, fn(row) { + case string.split(row, "\t") { + [doi, status, ok] -> + Ok(#(doi, result.unwrap(int.parse(status), 0), ok == "true")) + _ -> Error(Nil) + } + }), + ) + _ -> Failed("the service's answer could not be read") + } + [] -> Failed("the service gave an empty answer") + } +} + +fn live_section(model: Model) -> Element(Msg) { + html.section([attribute.aria_labelledby("live")], [ + section_head( + "live", + "Live", + external( + content.repo <> "/blob/main/unison/refpulse/README.md", + "refpulse on Unison", + ), + ), + html.p([attribute.class("sub")], [ + html.text( + "This page is static, on GitHub Pages. The button asks a Unison service, refpulse, to check now that each of the book's DOIs resolves at doi.org, and shows its answer here. A live answer, not a fact of the book.", + ), + ]), + html.button( + [ + attribute.class("pulse-button"), + attribute.disabled(model.pulse == Running), + event.on_click(CheckDois), + ], + [ + html.text(case model.pulse { + Running -> "Checking…" + _ -> "Check the DOIs now" + }), + ], + ), + html.div( + [attribute.class("pulse"), attribute.attribute("aria-live", "polite")], + [pulse_view(model.pulse)], + ), + ]) +} + +fn pulse_view(pulse: Pulse) -> Element(msg) { + case pulse { + Idle -> element.none() + Running -> html.p([], [html.text("Asking refpulse…")]) + Failed(e) -> html.p([attribute.class("pulse-failed")], [html.text(e)]) + Done(endpoint, ms, results) -> { + let ok = list.count(results, fn(r) { r.2 }) + html.div([], [ + html.p([], [ + html.b([], [ + html.text( + int.to_string(ok) + <> " of " + <> int.to_string(list.length(results)) + <> " DOIs resolve", + ), + ]), + html.text( + " · answered by " + <> endpoint + <> " in " + <> int.to_string(ms) + <> " ms", + ), + ]), + html.ul( + [attribute.class("pulse-results")], + list.map(results, fn(r) { + let #(doi, status, good) = r + html.li( + [ + attribute.class(case good { + True -> "is-ok" + False -> "is-dead" + }), + ], + [ + html.text(case good { + True -> "✓ " + False -> "✗ " + }), + external("https://doi.org/" <> doi, doi), + html.text(" · " <> int.to_string(status)), + ], + ) + }), + ), + ]) + } + } +} + fn notes_section() -> Element(msg) { html.section( [attribute.aria_labelledby("notes"), attribute.class("feature")], diff --git a/site/src/closed_source_site/ffi.gleam b/site/src/closed_source_site/ffi.gleam index 9622dde..aa9c41f 100644 --- a/site/src/closed_source_site/ffi.gleam +++ b/site/src/closed_source_site/ffi.gleam @@ -7,3 +7,12 @@ pub fn copy(text: String, done: fn(Bool) -> Nil) -> Nil /// Scroll the element with this id sideways, smoothly unless the reader asks for less motion. @external(javascript, "../closed_source_site_ffi.mjs", "scroll_by") pub fn scroll_by(id: String, dx: Int) -> Nil + +/// Ask the refpulse service (its URL, or the page's `?refpulse=` parameter) about these DOIs. +/// The callback gets "ms\tendpoint" and one "doi\tstatus\tok" line per DOI, or why it failed. +@external(javascript, "../closed_source_site_ffi.mjs", "refpulse") +pub fn refpulse( + url: String, + dois: List(String), + done: fn(Result(String, String)) -> Nil, +) -> Nil diff --git a/site/src/closed_source_site_ffi.mjs b/site/src/closed_source_site_ffi.mjs index 92e0ce5..5e37202 100644 --- a/site/src/closed_source_site_ffi.mjs +++ b/site/src/closed_source_site_ffi.mjs @@ -1,3 +1,4 @@ +import { Ok, Error } from "./gleam.mjs"; // Browser glue the Gleam modules cannot express. export function copy(text, done) { if (!navigator.clipboard) return void done(false); @@ -10,3 +11,20 @@ export function scroll_by(id, dx) { const calm = matchMedia("(prefers-reduced-motion: reduce)").matches; el.scrollBy({ left: dx, behavior: calm ? "auto" : "smooth" }); } + +// The live DOI check (refpulse, unison/refpulse/). A plain GET, so no CORS preflight: the +// service answers with Access-Control-Allow-Origin: *. +export function refpulse(url, dois, done) { + const endpoint = new URLSearchParams(location.search).get("refpulse") || url; + if (!endpoint) return void done(new Error("refpulse is not deployed yet: see unison/refpulse/README.md")); + const query = [...dois].map((d) => "doi=" + encodeURIComponent(d)).join("&"); + const started = performance.now(); + fetch(endpoint.replace(/\/$/, "") + "/check?" + query) + .then((r) => (r.ok ? r.json() : Promise.reject(new globalThis.Error("HTTP " + r.status)))) + .then((j) => { + const ms = Math.round(performance.now() - started); + const rows = (j.results || []).map((x) => [x.doi, x.status, x.ok].join("\t")); + done(new Ok([ms + "\t" + endpoint, ...rows].join("\n"))); + }) + .catch((e) => done(new Error("refpulse did not answer (" + e.message + "). The last checked links are in links.json."))); +} diff --git a/site/static/style.css b/site/static/style.css index 4cb46f6..138ed5c 100644 --- a/site/static/style.css +++ b/site/static/style.css @@ -420,3 +420,17 @@ main section.shelf { padding-top: 48px; } /* A note the owner approved for the book: the cover's black with yellow letters. */ .tag.is-approved { border-color: var(--black); background: var(--black); color: var(--yellow); } + +/* Live: the refpulse button (unison/refpulse/) */ +.pulse-button { + font: 600 14px/1 var(--caps); letter-spacing: .04em; text-transform: uppercase; + color: var(--paper); background: var(--red-text); border: 0; padding: 12px 18px; min-height: 44px; + cursor: pointer; margin-top: 16px; +} +.pulse-button:hover { background: var(--ink); } +.pulse-button[disabled] { opacity: .6; cursor: progress; } +.pulse { margin-top: 12px; font-size: 16px; } +.pulse-results { list-style: none; padding: 0; margin: 8px 0 0; } +.pulse-results li { padding: 4px 0; border-bottom: 1px solid var(--rule); } +.pulse-results .is-ok { color: var(--ok); } +.pulse-results .is-dead, .pulse-failed { color: var(--red-text); } diff --git a/site/test/closed_source_site_test.gleam b/site/test/closed_source_site_test.gleam index 9a898cc..d8e1c0c 100644 --- a/site/test/closed_source_site_test.gleam +++ b/site/test/closed_source_site_test.gleam @@ -228,3 +228,21 @@ pub fn the_timeline_scrolls_and_each_life_takes_focus_test() { assert string.contains(html, "id=\"tl\"") assert string.contains(html, " app.view |> element.to_string + assert list.first(app.book_dois()) == Ok(content.doi) + assert string.contains(html, "Check the DOIs now") + assert string.contains(html, "id=\"live\"") +} diff --git a/unison/refpulse/README.md b/unison/refpulse/README.md new file mode 100644 index 0000000..bf4be66 --- /dev/null +++ b/unison/refpulse/README.md @@ -0,0 +1,112 @@ +--- +title: "refpulse: a Unison service behind a button on a static page" +status: draft +date: 2026-10-15 +related: [refpulse.u, install.md, ../../justfile, ../../site/src/closed_source_site/app.gleam, ../../site/src/closed_source_site_ffi.mjs] +--- + + +# refpulse + +The site is static, on GitHub Pages. Its **Live** section has a button, "Check the DOIs now". +The button asks this Unison service to check that each of the book's DOIs resolves at doi.org, +then shows the answer on the page. The page needs no server of its own: the browser calls the +service directly. + +```text +browser (h0ffmann.github.io) refpulse (Unison) doi.org + click ──► GET /check?doi=…&doi=… ──► for each DOI: GET doi.org/ ──► 302 or 404 + draw ◄── 200 JSON, CORS: * ◄── {"checked","ran","results":[…]} +``` + +## The contract + +| Request | Answer | +|---|---| +| `GET /health` | `{"ok":true,"service":"refpulse"}` | +| `GET /check?doi=10.5281/zenodo.1&doi=…` | `{"checked":n,"ran":"unison","results":[{"doi":…,"status":302,"ok":true}, …]}` | + +- **No preflight.** The request is a plain `GET` with no custom header, so the browser sends it + at once. The answer carries `Access-Control-Allow-Origin: *`. +- **Public and read-only.** It takes DOIs, at most 8 per request (`refpulse.maxDois`). It holds + no secret and writes nothing. +- **Live, never a fact of the book.** The page says so. If the service is down, the button shows + a message and points to `links.json`, the links checked at build time. Nothing else breaks. + +## Run it + +You need only Nix: `nix develop .#unison` gives `ucm`, the Unison Codebase Manager (pinned by +`flake.lock`). + +```sh +just unison-install # makes the refpulse project in unison/.codebase/, with @unison/cloud and @unison/routes +just unison-serve # runs it on this machine; prints the URL; Enter stops it +``` + +Then open the site with the URL in the address, for example +`https://h0ffmann.github.io/closed-source/?refpulse=http://localhost:/#live`, or +open a local build (`just site-build`, then serve `site/dist/`) the same way. + +## Deploy to Unison Cloud + +1. Make an account at (the free tier is enough for a prototype). +2. Log in once from UCM: `nix develop .#unison --command ucm -c unison/.codebase`, then type + `auth.login` and follow the browser. +3. `just unison-deploy`. It prints the service's public URL, a stable name for `refpulse`. A new + deploy moves the name to the new code, so the URL does not change. +4. Put that URL in `refpulse_url` in `site/src/closed_source_site/app.gleam`, and open a pull + request. From then on the button works for every reader, with no `?refpulse=`. + +Unison Cloud's free tier, as its pricing page said on 15 October 2026: 5 services, which +"deactivate every 3 weeks", 50 MB of storage and 5,000 requests a month +(). So the button must survive a service that is down, and it +does. + +## Can it run on my own machine? + +Yes, in three ways, from the smallest to the largest. + +1. **Unison Cloud's local runtime** (`just unison-serve`, `Cloud.main.local.serve`). The same + code, the same `deployHttp`, running on your computer. Use it to develop and to test. The page + you open in your own browser can call it. A reader's browser cannot, because the service is on + your machine and not on the internet. +2. **On your machine, for everyone.** Run it as in 1. Then put it on the internet with HTTPS, + for example through a tunnel (Cloudflare Tunnel, Tailscale Funnel) or a small server of your + own. HTTPS is required: the site is served over HTTPS, and a browser blocks an HTTPS page that + calls a plain HTTP address on the internet. The service then runs only while your machine and + the tunnel are up. +3. **Unison Cloud on your own machines** ("bring your own cloud", BYOC). Unison's own runtime, + on servers you run. It is free for personal or non-commercial use, up to 10 nodes. It needs + S3-compatible storage, and DynamoDB for transactional storage + (). + +One thing to know for 1 and 2. Chrome is adding a permission prompt for a public page that +calls an address on your own network or machine ("local network access"). The page can still +call `localhost`, after the prompt. A tunnel's public HTTPS address does not need the prompt. + +## What was checked, and what was not + +- **Checked (15 October 2026).** The browser half works end to end. A stand-in server with the + same contract asked doi.org for real: the book's DOI came back 302 and a made-up DOI 404, with + the CORS header. A local build of the site, on a different origin from the service, was driven + in headless Chrome. The click went out, the answer came back, and the page drew "1 of 1 DOIs + resolve · 302". With the service down, it showed "refpulse did not answer". With no URL, it + showed "refpulse is not deployed yet". +- **Not yet checked: `refpulse.u` itself.** It was written against the names and types that + Unison Share's API gives for `@unison/cloud` 27.3.1, `@unison/routes` 7.0.2, `@unison/http` + 16.1.0, `@unison/json` 1.4.2 and `@unison/base` 7.20.0. It was not compiled, because the agent's + sandbox could not download the libraries (`lib.install` stopped with `IncompleteHeaders`). + `just unison-install` compiles it; `install.output.md` shows any error. +- **Next.** The checks run one after another, which takes about 0.5 s per DOI. `Remote.fork` and + `Remote.await` could run them at the same time, on many machines, if the forked task may call + `Http`. That is the "distributed" step, after this one compiles. + +## Sources + +- Unison Cloud, "Local development", , read + 15 October 2026: `Cloud.main.local.serve`. +- `@unison/routes` 7.0.2 README, , read through + `api.unison-lang.org` on 15 October 2026: `serveSimple`, `deployHttp` with `Route.run`, + `response.headers.add`, the CORS middleware example. +- `@unison/cloud` 27.3.1 README, , read the same + way: `deployHttp`, `ServiceName.assign`, `parMap`, `Remote`. diff --git a/unison/refpulse/install.md b/unison/refpulse/install.md new file mode 100644 index 0000000..814db55 --- /dev/null +++ b/unison/refpulse/install.md @@ -0,0 +1,15 @@ + + +# refpulse: install + +A UCM transcript (`just unison-install`): it makes the `refpulse` project in the codebase at +`unison/.codebase/` (not committed), installs the libraries from Unison Share and adds +`refpulse.u`. The output, `install.output.md`, shows what UCM said at each step. + +```ucm +scratch/main> project.create-empty refpulse +refpulse/main> lib.install @unison/cloud +refpulse/main> lib.install @unison/routes +refpulse/main> load unison/refpulse/refpulse.u +refpulse/main> add +``` diff --git a/unison/refpulse/refpulse.u b/unison/refpulse/refpulse.u new file mode 100644 index 0000000..c9bbd3f --- /dev/null +++ b/unison/refpulse/refpulse.u @@ -0,0 +1,67 @@ +-- SPDX-License-Identifier: AGPL-3.0-only +-- refpulse: a Unison HTTP service that checks DOIs at doi.org, for the closed-source site's +-- "Check the DOIs now" button (unison/refpulse/README.md). Public and read-only: it takes DOIs +-- in the query, asks doi.org about each, and answers JSON that any page may read (CORS *). +-- +-- GET /health {"ok":true,"service":"refpulse"} +-- GET /check?doi=10.x/y&doi=... {"checked":n,"ran":"unison","results":[{"doi","status","ok"}]} + +-- At most this many DOIs per request, so a public button cannot make it fetch the web for long. +refpulse.maxDois : Nat +refpulse.maxDois = 8 + +-- doi.org answers a registered DOI with a redirect (302) and an unknown one with 404. Any +-- failure to reach doi.org is status 0, not ok. +refpulse.checkDoi : Text ->{Http} Json +refpulse.checkDoi doi = + use Text ++ + result = catch do + uri = IO.net.URI.parse ("https://doi.org/" ++ doi) + response = client.Http.request (HttpRequest.get uri) + HttpResponse.Status.code (HttpResponse.status response) + code = match result with + Right c -> c + Left _ -> 0 + Json.object + [ ("doi", Json.text doi) + , ("status", Json.nat code) + , ("ok", Json.boolean (code > 0 && code < 400)) + ] + +-- Any origin may read the answer: it is public data, and no request carries a credential. +refpulse.cors : '{Route} () +refpulse.cors = do Route.response.headers.add "Access-Control-Allow-Origin" "*" + +refpulse.routes : '{Route, Http} () +refpulse.routes = do + use Parser / + health = do + noCapture GET (s "health") + ok.json (Json.object [("ok", Json.boolean true), ("service", Json.text "refpulse")]) + refpulse.cors() + check = do + noCapture GET (s "check") + dois = List.take refpulse.maxDois (Route.request.queryParameter "doi") + results = List.map refpulse.checkDoi dois + ok.json + (Json.object + [ ("checked", Json.nat (List.size results)) + , ("ran", Json.text "unison") + , ("results", Json.array results) + ]) + refpulse.cors() + health <|> check + +-- On this machine: the Cloud runtime, locally. It prints the service's URL and serves until Enter. +refpulse.deploy.local : '{IO, Exception} () +refpulse.deploy.local = Cloud.main.local.serve do + service = deployHttp Environment.default() (Route.run refpulse.routes) + uri = ServiceName.assign (ServiceName.named "refpulse") service + printLine ("refpulse: " ++ IO.net.URI.toText uri) + +-- On Unison Cloud (after `auth.login`): prints the service's public URL. +refpulse.deploy.cloud : '{IO, Exception} () +refpulse.deploy.cloud = Cloud.main do + service = deployHttp Environment.default() (Route.run refpulse.routes) + uri = ServiceName.assign (ServiceName.named "refpulse") service + printLine ("refpulse: " ++ IO.net.URI.toText uri)