Merge remote-tracking branch 'origin/main' into rega/research-gallery-embed

This commit is contained in:
doxa-maps-88655c70 2026-09-25 07:40:09 +00:00
commit 145e3c0df8
13 changed files with 89 additions and 27 deletions

View file

@ -209,8 +209,8 @@ iframe at it.
<summary><b>More about method 2 — mobile app</b></summary>
[Capacitor](https://capacitorjs.com) wraps the built maps into an Android app — the same files,
no new code. Shown here: the UPG-100 prayer lists and posters, one poster per row, with print
options that fit a phone.
no new code. Shown here: the UPG-100 prayer lists and posters at phone size, each poster with its
own print options.
```bash
bun run check:mobile # checks the app is wired up correctly — needs no Android tools

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 665 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 828 KiB

View file

@ -1,35 +1,35 @@
{
"cap1-single-element": {
"bundle": "all-people-groups",
"sha256": "db9e293144928477bc7fc1ef50f469db073d81575b29ba394b50d33273c6e1a5"
"sha256": "312b3a0f64b0842b498ae8c0c88491a9225c353a60c0420e05ac18fa0333a0a5"
},
"cap2-adoption": {
"bundle": "doxa-simple-map",
"sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
"sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95"
},
"cap2-engagement": {
"bundle": "doxa-simple-map",
"sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
"sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95"
},
"cap2-prayer": {
"bundle": "doxa-simple-map",
"sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
"sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95"
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
"sha256": "56845dc5bd33198a7dd8fb42895700f21b4bc1c3b7f98f57832e8373c7782a83"
"sha256": "7e3637a635faf8223488a4bc21c1f308c5b3ea94fa6c80d5f1560a04ef7ec9cb"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
"sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d"
},
"cap4-upg100-mfe": {
"bundle": "my-upg-100-list",
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
"sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d"
},
"cap4-upg100-poster-wall": {
"bundle": "my-upg-100-list",
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
"sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d"
},
"cap5-doxa-maps-spa": {
"bundle": "doxa-maps-spa",
@ -41,6 +41,6 @@
},
"deploy2-mobile-upg100": {
"bundle": "my-upg-100-list",
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
"sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d"
}
}

67
internal/browser-slot.sh Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
# browser-slot.sh — run ONE browser command inside a memory-floored, N-slot rendezvous.
#
# WHY A SEMAPHORE AND NOT A LOCK. A single lock made every agent's browser proof wait behind one
# chrome: measured 2026-09-25, the queue sat 10-19 deep for hours. The real constraint is MEMORY,
# not exclusivity — a headless chrome costs ~300-500MB — so the right shape is "as many browsers as
# the box can hold", which is a counting semaphore with a memory floor, not a mutex.
#
# HOW: N slot files. Try each with flock -n (no wait). If all are busy, WAIT on slot 1. Before
# taking any slot, require MemAvailable above the floor — a slot that is free while the box is out
# of memory is an invitation to be OOM-killed mid-measurement, which reads as a flaky gate.
#
# ★ EXIT 75 MEANS "NOT MEASURED", NEVER "FAILED". flock returns 1 on timeout, the same code a
# failing gate uses, and that once turned a busy queue into five red product gates. 75 is
# EX_TEMPFAIL: distinct from 0 and from any gate's own verdict, so a caller can report it as the
# precondition it is.
#
# Usage: internal/browser-slot.sh <command> [args...]
# Env: BROWSER_SLOTS (default 2), BROWSER_SLOT_DIR (default /tmp),
# BROWSER_MEM_FLOOR_MB (default 2500), BROWSER_LOCK_WAIT (default 900),
# BROWSER_MEM_WAIT (default 300)
set -u
SLOTS="${BROWSER_SLOTS:-2}"
DIR="${BROWSER_SLOT_DIR:-/tmp}"
BASE="$DIR/doxa-maps-browser-gate.lock"
FLOOR_MB="${BROWSER_MEM_FLOOR_MB:-2500}"
WAIT_S="${BROWSER_LOCK_WAIT:-900}"
MEM_WAIT_S="${BROWSER_MEM_WAIT:-300}"
BUSY=75
slot_path() { [ "$1" = "1" ] && echo "$BASE" || echo "$BASE$1"; }
mem_available_mb() {
awk '/^MemAvailable:/ { printf "%d", $2 / 1024 }' /proc/meminfo 2>/dev/null || echo 999999
}
# Hold off while the box is below the floor. Proceeding anyway is not an option here the way it is
# for a load average: load makes a measurement slow, memory makes it get killed halfway through.
waited=0
while [ "$(mem_available_mb)" -lt "$FLOOR_MB" ]; do
if [ "$waited" -ge "$MEM_WAIT_S" ]; then
echo "browser-slot: MemAvailable $(mem_available_mb)MB below floor ${FLOOR_MB}MB after ${waited}s — not measuring" >&2
exit "$BUSY"
fi
sleep 10
waited=$((waited + 10))
done
# Fast path: any free slot, in a FIXED order so two processes can never take them in opposite
# orders. -n means "do not wait": if this slot is busy we move on rather than queue behind it.
i=1
while [ "$i" -le "$SLOTS" ]; do
exec 9>"$(slot_path "$i")" 2>/dev/null || true
if flock -n 9; then
exec "$@"
fi
i=$((i + 1))
done
# Everything busy: queue on slot 1, and report a timeout as UNMEASURED rather than as a verdict.
exec 9>"$(slot_path 1)"
if flock -w "$WAIT_S" 9; then
exec "$@"
fi
echo "browser-slot: no slot within ${WAIT_S}s (${SLOTS} slots, floor ${FLOOR_MB}MB) — not measuring" >&2
exit "$BUSY"

View file

@ -84,10 +84,10 @@ const CONCURRENCY = Math.max(
// processes can never take them in opposite orders and deadlock. Either file alone still
// serialises everything that uses it; taking both is what makes gates and hand-run probes queue
// against each other without the repo having to spell a name it must not ship.
const BROWSER_LOCK = '/tmp/doxa-maps-browser-gate.lock'
const EXTRA_LOCK = process.env.BROWSER_GATE_LOCK && process.env.BROWSER_GATE_LOCK !== BROWSER_LOCK
? process.env.BROWSER_GATE_LOCK
: ''
// The slot files live in browser-slot.sh, which owns the rendezvous now. BROWSER_GATE_LOCK from a
// caller's environment is deliberately NOT consulted here any more: a single legacy lock taken
// alongside the slots would collapse two slots back into one, which is the queue this replaced.
// Agents' own wrappers should call internal/browser-slot.sh so everyone shares the same counter.
// ★ THE LOAD WAIT WAS COSTING MORE THAN IT SAVED. It was written at LOAD_MAX 12 when the risk was
// seven agents starting chrome at once; the FLOCK below is what actually prevents that — one
// browser gate at a time, across every agent on the box. The load check is belt-and-braces on top
@ -177,18 +177,13 @@ function buildSteps() {
// wait-for-quiet.sh takes the same lock), so this queues rather than dogpiles. -w 1200
// caps the wait so a crashed holder cannot wedge a wave forever, and static gates —
// parse, sri, hygiene, the twenty that are node-startup — are left completely free.
// flock can exec flock: nesting is how one command waits on both rendezvous points.
// ★ -E 75: A LOCK TIMEOUT IS NOT A GATE FAILURE, AND flock CANNOT SAY SO BY DEFAULT.
// Without it flock exits 1 on timeout — the same code a failing gate uses — and a wave that
// simply could not get the browser lock prints ✗ against five product gates. MEASURED: five
// browser gates each "failed" at exactly 1200064ms, which is the timeout to the millisecond
// and not a verdict about the product at all. 75 (EX_TEMPFAIL) is distinct, so the runner can
// report "could not take the lock" as the precondition it is.
const lockArgs = ['-w', String(LOCK_WAIT_S), '-E', String(LOCK_BUSY_CODE)]
const args = EXTRA_LOCK
? [...lockArgs, BROWSER_LOCK, 'flock', ...lockArgs, EXTRA_LOCK, 'bun', 'run', name]
: [...lockArgs, BROWSER_LOCK, 'bun', 'run', name]
return { ...step, browser: true, cmd: 'flock', args }
// ★ A TWO-SLOT SEMAPHORE, NOT A MUTEX. One lock made every agent's browser proof wait behind
// a single chrome — measured 2026-09-25, the queue sat 10-19 deep for hours. The constraint
// is MEMORY (~300-500MB per headless chrome), not exclusivity, so browser-slot.sh runs as
// many as the box can hold and refuses to start one when MemAvailable is below the floor.
// It keeps the same exit-code contract: 75 means "not measured", never "failed".
const args = [resolve(ROOT, 'internal/browser-slot.sh'), 'bun', 'run', name]
return { ...step, browser: true, cmd: 'sh', args }
})
}
// A bundle is a plugin-designs/<name>/ directory — the same selector build:unlocked used