Files
stack/packages/mosaic/framework/tools/orchestrator/board-roll.sh
enhance 406a8464f9
Some checks failed
ci/woodpecker/pr/ci Pipeline was canceled
feat(orchestrator): board-roll.sh — auto-roll oldest LIVE-board entries to LEDGER under byte cap
Coordinator boards (MOS/MS-LEAD LIVE) enforce a <8KB discipline via a board-write
self-guard that ABORTs when over cap; coordinators then hand-trim + retry every
time (38 ABORT-OVER-CAP cycles observed in one 24h window). board-roll.sh
automates that trim mechanically and reversibly: oldest archival ticks move to the
append-only LEDGER instead of being hand-deleted.

Conservative by design — only content inside explicit
<!-- BOARD-ROLL:START -->/<!-- BOARD-ROLL:END --> markers is eligible; pinned
title/blockquote/curated ## sections are never touched. Rolls whole oldest
(bottom-most) ### entry blocks until LIVE is under cap or the zone is empty.
No markers -> exit 3, no change (never guesses). Byte-accurate cap (UTF-8 safe),
atomic writes (temp+mv, LEDGER first), --dry-run, --help exits 0 (#701 discipline).

- board-roll.sh: the tool (self-documented header + usage)
- test-board-roll.sh: 7-group regression harness (under-cap no-op, no-markers
  exit 3, roll-oldest-until-under-cap, LEDGER fidelity, dry-run inertness,
  unsatisfiable-cap exit 3, help/bad-flag exit codes)
- README.md: orchestrator/ tool index + board-roll contract & usage

Shared-runtime helper: authored by enhance lane, STOP at PR-open; merge authority
is the coordinator's after the review gate (AGENTS.md rule 38). Deploy-gap caveat
per #701/#702: SSOT fix lands on web1 only on framework redeploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 04:10:59 -05:00

258 lines
11 KiB
Bash

#!/usr/bin/env bash
#
# board-roll.sh — keep a LIVE orchestration board under its byte cap by rolling
# the oldest archival entries out to its append-only LEDGER.
#
# WHY: coordinator boards (MOS-ORCHESTRATION-BOARD-LIVE.md, MS-LEAD-BOARD-LIVE.md)
# enforce a "< 8 KB LIVE" discipline via a self-guard that ABORTs the board write
# when the file exceeds the cap. In practice the LIVE board keeps bumping the cap,
# so coordinators hand-trim + retry every time (observed: 38 ABORT-OVER-CAP cycles
# in a 24h window on one coordinator). This automates that trim, mechanically and
# reversibly, so the audit trail is preserved in the LEDGER instead of hand-deleted.
#
# CONTRACT (conservative by design — it NEVER guesses what is safe to move):
# The LIVE board must declare an explicit ROLL ZONE with HTML-comment markers:
#
# <!-- BOARD-ROLL:START -->
# ### 2026-07-22 (newest tick — stays longest)
# ...
# ### 2026-07-19 (oldest tick — rolled first)
# ...
# <!-- BOARD-ROLL:END -->
#
# Everything OUTSIDE the markers (title, protocol blockquote, curated always-current
# `##` sections) is PINNED and never touched. Inside the zone, entries are delimited
# by a heading marker (default `### `) and are assumed newest-first (top) → oldest-last
# (bottom), matching board convention. board-roll moves whole oldest (bottom-most)
# entry blocks out of the zone and APPENDS them verbatim to the LEDGER, one block at a
# time, until the LIVE file is back under the cap or the zone is empty.
#
# If no markers are present, it exits 3 without changing anything (safe default —
# adding the markers is a deliberate opt-in by the board owner).
#
# USAGE:
# board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
#
# OPTIONS:
# --live <path> LIVE board file (required)
# --ledger <path> append-only LEDGER file (required; created if absent)
# --cap <bytes> size ceiling for LIVE (default 8192)
# --marker <prefix> entry-heading prefix inside the roll zone (default "### ")
# --dry-run report what would move + resulting size; change nothing
# -h, --help print usage and exit 0
#
# EXIT CODES:
# 0 LIVE is under cap (already, or after rolling); on --dry-run, 0 = a plan exists
# (or nothing to do)
# 2 usage / argument / IO error (bad flag, missing file, unwritable target)
# 3 cannot satisfy the cap: no roll markers present, OR the zone was emptied and
# LIVE is still over cap (curated pinned sections need a manual trim)
#
# NOTE: line endings are normalized to LF on rewrite (boards are LF markdown); a
# trailing newline is always ensured. Writes are atomic (temp file + mv) so a
# failure never leaves LIVE or LEDGER half-written.
set -euo pipefail
START_MARK='<!-- BOARD-ROLL:START -->'
END_MARK='<!-- BOARD-ROLL:END -->'
usage() {
cat <<'EOF'
Usage: board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
Roll the oldest entries out of a LIVE orchestration board into its LEDGER
until the LIVE file is under a byte cap. Conservative: only content inside
explicit <!-- BOARD-ROLL:START -->/<!-- BOARD-ROLL:END --> markers is moved.
Options:
--live <path> LIVE board file (required)
--ledger <path> append-only LEDGER file (required; created if absent)
--cap <bytes> size ceiling for LIVE (default 8192)
--marker <prefix> entry-heading prefix inside the roll zone (default "### ")
--dry-run report what would move; change nothing
-h, --help show this help and exit 0
Exit: 0 under cap (or dry-run plan) · 2 usage/IO error · 3 cannot meet cap
(no markers, or pinned sections alone exceed the cap).
EOF
}
die() { echo "board-roll: $*" >&2; exit 2; }
LIVE=""; LEDGER=""; CAP=8192; MARKER='### '; DRYRUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--live) LIVE="${2:-}"; shift 2 || die "--live needs a value" ;;
--ledger) LEDGER="${2:-}"; shift 2 || die "--ledger needs a value" ;;
--cap) CAP="${2:-}"; shift 2 || die "--cap needs a value" ;;
--marker) MARKER="${2:-}"; shift 2 || die "--marker needs a value" ;;
--dry-run) DRYRUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unknown option: $1" ;;
esac
done
[[ -n "$LIVE" ]] || { usage >&2; die "--live is required"; }
[[ -n "$LEDGER" ]] || { usage >&2; die "--ledger is required"; }
[[ -f "$LIVE" ]] || die "LIVE file not found: $LIVE"
[[ "$CAP" =~ ^[0-9]+$ ]] || die "--cap must be a non-negative integer, got: $CAP"
# --- read LIVE into a line array (newlines stripped; re-added on write) ---------
mapfile -t LINES < "$LIVE"
# byte size of an array rendered as LF-terminated text
render_size() {
if [[ $# -eq 0 ]]; then printf 0; return; fi
printf '%s\n' "$@" | wc -c
}
orig_size=$(render_size "${LINES[@]}")
# --- already under cap → nothing to do -----------------------------------------
if (( orig_size < CAP )); then
echo "board-roll: LIVE is ${orig_size}B (< cap ${CAP}B) — nothing to roll."
exit 0
fi
# --- locate the roll-zone markers ----------------------------------------------
start_idx=-1; end_idx=-1
for i in "${!LINES[@]}"; do
[[ "${LINES[$i]}" == "$START_MARK" && $start_idx -eq -1 ]] && start_idx=$i
[[ "${LINES[$i]}" == "$END_MARK" ]] && end_idx=$i
done
if (( start_idx < 0 || end_idx < 0 || end_idx <= start_idx )); then
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but no usable roll zone" >&2
echo " (need '$START_MARK' then '$END_MARK'). Add the markers around the" >&2
echo " archival tick section to opt this board into automatic rolling." >&2
exit 3
fi
# preamble = lines [0 .. start_idx] (inclusive of START marker)
# zone = lines (start_idx .. end_idx) (exclusive of both markers)
# footer = lines [end_idx .. end] (inclusive of END marker)
preamble=(); zone=(); footer=()
for i in "${!LINES[@]}"; do
if (( i <= start_idx )); then preamble+=("${LINES[$i]}")
elif (( i < end_idx )); then zone+=("${LINES[$i]}")
else footer+=("${LINES[$i]}")
fi
done
# --- split the zone into a fixed head + entry blocks ----------------------------
# zone_head = any zone lines before the first entry marker (kept, never rolled).
# blocks[k] = newline-joined text of entry k (marker line .. line before next marker).
zone_head=(); declare -a block_start=()
first_block=-1
for i in "${!zone[@]}"; do
if [[ "${zone[$i]}" == "$MARKER"* ]]; then
[[ $first_block -eq -1 ]] && first_block=$i
block_start+=("$i")
fi
done
if (( first_block == -1 )); then
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but the roll zone has no" >&2
echo " '${MARKER}' entries to move. Trim the pinned sections manually." >&2
exit 3
fi
for (( i=0; i<first_block; i++ )); do zone_head+=("${zone[$i]}"); done
nblocks=${#block_start[@]}
# block k spans zone[ block_start[k] .. (block_start[k+1]-1 or end-of-zone) ]
block_text() { # $1 = block index → prints the block's lines, LF-joined (no trailing)
local k=$1 s e
s=${block_start[$k]}
if (( k+1 < nblocks )); then e=$(( block_start[$((k+1))] - 1 )); else e=$(( ${#zone[@]} - 1 )); fi
local out=()
for (( j=s; j<=e; j++ )); do out+=("${zone[$j]}"); done
printf '%s\n' "${out[@]}"
}
# --- greedily roll oldest (bottom-most) blocks until under cap ------------------
# keep = number of newest blocks retained; start with all, drop from the bottom.
keep=$nblocks # blocks [keep .. nblocks-1] are the oldest set that gets moved
current_size=$orig_size
build_live_size() { # size of LIVE if we keep blocks [0 .. keep-1]
local acc=("${preamble[@]}" "${zone_head[@]}")
local k s e j
for (( k=0; k<keep; k++ )); do
s=${block_start[$k]}
if (( k+1 < nblocks )); then e=$(( block_start[$((k+1))] - 1 )); else e=$(( ${#zone[@]} - 1 )); fi
for (( j=s; j<=e; j++ )); do acc+=("${zone[$j]}"); done
done
acc+=("${footer[@]}")
render_size "${acc[@]}"
}
while (( current_size >= CAP && keep > 0 )); do
keep=$(( keep - 1 ))
current_size=$(build_live_size)
done
moved_count=$(( nblocks - keep ))
if (( moved_count == 0 )); then
# zone had entries but none movable brought us under (shouldn't happen: keep hits 0)
echo "board-roll: could not reduce LIVE below cap (${current_size}B >= ${CAP}B)." >&2
exit 3
fi
# --- dry-run report -------------------------------------------------------------
plan_headers() {
local k
for (( k=keep; k<nblocks; k++ )); do
# first line of each moved block
printf ' %s\n' "${zone[${block_start[$k]}]}"
done
}
if (( DRYRUN )); then
echo "board-roll: DRY RUN"
echo " LIVE now: ${orig_size}B (cap ${CAP}B) — over by $(( orig_size - CAP ))B"
echo " would roll: ${moved_count} of ${nblocks} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) (oldest first):"
plan_headers
echo " LIVE after: ${current_size}B"
if (( current_size >= CAP )); then
echo " WARNING: still >= cap after emptying the zone; pinned sections need a manual trim." >&2
exit 3
fi
exit 0
fi
# --- commit the roll atomically -------------------------------------------------
live_tmp="$(mktemp "${LIVE}.roll.XXXXXX")" || die "cannot create temp next to LIVE"
ledger_tmp=""
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT`
cleanup() { rm -f "$live_tmp" "$ledger_tmp" 2>/dev/null || true; }
trap cleanup EXIT
# new LIVE = preamble + zone_head + kept blocks + footer
{
printf '%s\n' "${preamble[@]}" "${zone_head[@]}"
for (( k=0; k<keep; k++ )); do block_text "$k"; done
printf '%s\n' "${footer[@]}"
} > "$live_tmp"
# LEDGER gets the moved blocks appended verbatim, in original top→bottom order,
# under a provenance separator. LEDGER is append-only, so we only ever add at EOF.
ledger_tmp="$(mktemp "${LEDGER}.roll.XXXXXX")" || die "cannot create temp next to LEDGER"
if [[ -f "$LEDGER" ]]; then cat "$LEDGER" > "$ledger_tmp"; fi
# ensure a trailing newline on existing content before appending
if [[ -s "$ledger_tmp" && -n "$(tail -c1 "$ledger_tmp")" ]]; then printf '\n' >> "$ledger_tmp"; fi
{
printf '\n<!-- board-roll: %d entr%s rolled from %s -->\n' \
"$moved_count" "$([[ $moved_count -eq 1 ]] && echo y || echo ies)" "$(basename "$LIVE")"
for (( k=keep; k<nblocks; k++ )); do block_text "$k"; done
} >> "$ledger_tmp"
# atomic swap (both, LEDGER first so a crash never drops content that left LIVE)
mv "$ledger_tmp" "$LEDGER"; ledger_tmp=""
mv "$live_tmp" "$LIVE"; live_tmp=""
trap - EXIT
# read the real on-disk size back (truthful, not the predicted value)
final_size=$(wc -c < "$LIVE")
echo "board-roll: rolled ${moved_count} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) to $(basename "$LEDGER"); LIVE ${orig_size}B → ${final_size}B (cap ${CAP}B)."
if (( final_size >= CAP )); then
echo "board-roll: still >= cap after rolling all zone entries; pinned sections need a manual trim." >&2
exit 3
fi
exit 0