feat(orchestrator): board-roll.sh - auto-roll LIVE board to LEDGER under byte cap Closes #868
278 lines
12 KiB
Bash
278 lines
12 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, MORE THAN ONE marker pair
|
|
# (multiple zones are refused, not guessed), 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 ----------------------------------------------
|
|
# Exactly ONE marker pair is supported. If a board carries more than one START or
|
|
# END marker we REFUSE (exit 3, zero changes) rather than guess: a naive
|
|
# first-START..last-END span would swallow the curated content and the intermediate
|
|
# markers sitting between two intended zones and silently relocate that pinned text
|
|
# to the LEDGER — the exact data-loss this tool exists to prevent. Refusing matches
|
|
# the "no markers = exit 3" conservative posture.
|
|
start_idx=-1; end_idx=-1; start_count=0; end_count=0
|
|
for i in "${!LINES[@]}"; do
|
|
if [[ "${LINES[$i]}" == "$START_MARK" ]]; then
|
|
if (( start_count == 0 )); then start_idx=$i; fi
|
|
start_count=$(( start_count + 1 ))
|
|
fi
|
|
if [[ "${LINES[$i]}" == "$END_MARK" ]]; then
|
|
end_idx=$i
|
|
end_count=$(( end_count + 1 ))
|
|
fi
|
|
done
|
|
if (( start_count > 1 || end_count > 1 )); then
|
|
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but has ${start_count} START / ${end_count} END" >&2
|
|
echo " markers — only a SINGLE '$START_MARK' … '$END_MARK' roll zone is supported." >&2
|
|
echo " Multiple zones are refused (not guessed) so content between zones is never relocated." >&2
|
|
echo " Consolidate the archival ticks into one zone, or trim manually." >&2
|
|
exit 3
|
|
fi
|
|
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
|