#!/usr/bin/env bash # store.sh — A2 of the wake canon (EPIC #892, W2): the THREE-CURSOR durable # queue (store + drain lib). # # CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md): # §1.2 three-cursor data-flow: observe -> durable store -> coalesce -> drain. # §2.3 classes & durability: ALL classes durable; coalescing is optional. # §2.4 cursor semantics: observed_seq is authoritative; SHAs are descriptors. # # Three cursors: # observed_seq (store-owned SINGLE ALLOCATOR, #908) — monotonic int assigned by # THIS lib at enqueue: next = observed_seq + 1, under an exclusive # lock, committed IFF the durable write succeeds. Authoritative. # There is exactly ONE allocator (this store); the detector and # reconciler no longer allocate independently. Source SHAs are # descriptors, NOT the cursor (§2.4). # pending-inbox (durable) — append store; retains until CONSUMED; survives # pane death / park / restart. # consumed_seq (consumer-owned) — advances ONLY on a CONSUMED ack, over a # CONTIGUOUS gapless prefix <=N. NEVER on sender exit-0 / paste. # # Coalesce (§1.2/§2.3): class=digest REPLACES the pending cumulative-state entry # (newest subsumes prior). Non-coalescible classes (actionable, human, ...) # APPEND. ALL classes are stored durably — durability is NEVER bypassed. # Absent class => treated as `actionable` (fail-safe). # # HMAC: entries carry an `hmac` field left as an UNSIGNED PLACEHOLDER (""). # Signing is W3/A5, explicitly NOT W2. # # Every state mutation is atomic write-tmp+rename (see _wake-common.sh). # # Operator-agnostic: state via XDG/env only; no operator paths/names/secrets. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./_wake-common.sh disable=SC1091 . "$SCRIPT_DIR/_wake-common.sh" STATE_DIR="$(wake_state_dir)" _need_jq() { command -v jq >/dev/null 2>&1 || { echo "store.sh: jq is required" >&2 exit 3 } } usage() { cat >&2 <<'EOF' Usage: store.sh [options] Commands: init Create/repair the XDG state layout. enqueue [--class C] [opts] Record an observation into the durable pending-inbox (coalesce or append) and ALLOCATE its observed_seq. store.sh is the SOLE allocator of observed_seq (#908): under an exclusive enqueue lock it reads the observed_seq cursor, sets next = observed_seq + 1, writes the pending record + observed.set + the cursor as ONE transaction, and PRINTS next to stdout. The cursor advances IFF the durable write succeeds (no burn-before-enqueue). --seq N LEGACY explicit observed_seq (optional). Normal callers (detector, reconciler) OMIT this and let the store allocate. Retained only for tests/tools that must place a SPECIFIC seq (e.g. construct a gap). An explicit seq that is <= consumed_seq is REFUSED loudly (anti-swallow): the idempotent-ignore path can never silently drop a wake. --class C digest|actionable|human|terminal-log|reaction. Absent/empty => actionable (fail-safe). --locators JSON JSON value for the entry's locators (default {}). --emit-ts TS epoch seconds (default: now). drain [--require-idle-cmd CMD] Emit the deliverable set (pending-inbox, coalesced) as JSONL. WHAT to deliver; the actual paste is out of scope. If --require-idle-cmd is given and it exits non-zero, emit nothing (not idle). consume --upto N Advance consumed_seq over the contiguous gapless prefix <=N; drop consumed entries. Rejects a gap (cannot ack N while N-1 is unconsumed). Cumulative & idempotent. cursors Print observed_seq / consumed_seq / depth. Environment: WAKE_STATE_HOME override base (default ${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake) WAKE_AGENT per-agent queue namespace (default: default) EOF } cmd_init() { _wake_init_dir "$STATE_DIR" || { echo "store.sh: failed to init $STATE_DIR" >&2 exit 1 } # Maintenance/daemon-start stale-tmp reap (#927). Cleanup is OFF the per-enqueue # hot path; `init` is a natural once-per-start maintenance point. It is # age-scoped, so even if an operator runs `init` while an enqueue is in flight # it can only reap demonstrably-orphaned (crash-left) tmps, never a live write. _wake_clean_stale_tmp "$STATE_DIR" echo "$STATE_DIR" } cmd_enqueue() { _need_jq local seq='' class='' locators='{}' emit_ts='' while [ $# -gt 0 ]; do case "$1" in --seq) seq="${2:-}" shift 2 ;; --class) class="${2:-}" shift 2 ;; --locators) locators="${2:-}" shift 2 ;; --emit-ts) emit_ts="${2:-}" shift 2 ;; *) echo "store.sh enqueue: unknown option '$1'" >&2 exit 2 ;; esac done # --seq is OPTIONAL (#908): normal callers omit it and the store allocates. # If supplied (legacy/explicit), it must be a non-negative integer. local explicit=0 if [ -n "$seq" ]; then explicit=1 case "$seq" in *[!0-9]*) echo "store.sh enqueue: --seq must be a non-negative integer" >&2 exit 2 ;; esac fi # Absent class => actionable (fail-safe, §2.3). [ -n "$class" ] || class="actionable" case "$class" in digest | actionable | human | terminal-log | reaction) : ;; *) echo "store.sh enqueue: unknown class '$class'" >&2 exit 2 ;; esac [ -n "$emit_ts" ] || emit_ts="$(date +%s)" case "$emit_ts" in *[!0-9]*) echo "store.sh enqueue: --emit-ts must be epoch seconds" >&2 exit 2 ;; esac # Validate locators is well-formed JSON. if ! printf '%s' "$locators" | jq -e . >/dev/null 2>&1; then echo "store.sh enqueue: --locators must be valid JSON" >&2 exit 2 fi # Ensure the layout exists. NB (#927): _wake_init_dir NO LONGER reaps stale # tmps — that would race a concurrent enqueue's live in-flight write (deleting # its tmp mid-rename -> spurious "durable pending write FAILED"). Stale-tmp # reaping is a maintenance action (store.sh init / detector tick), never on this # hot path. _wake_init_dir "$STATE_DIR" # --- serialize the whole allocate+write transaction (#908) ---------------- # A single store-side allocator means the read of observed_seq, the durable # write, and the cursor bump MUST be one critical section: two concurrent # enqueues that both read observed_seq=N would otherwise both allocate N+1 and # collide. The lock (flock where present) makes concurrent enqueues get # DISTINCT seqs. _wake_lock_acquire "$STATE_DIR/.enqueue.lock" || { echo "store.sh enqueue: could not acquire the enqueue lock ($STATE_DIR/.enqueue.lock)" >&2 exit 1 } local consumed observed consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)" observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)" if [ "$explicit" -eq 0 ]; then # ALLOCATE: the store is the sole allocator. next = observed_seq + 1. seq=$((observed + 1)) fi # --- anti-swallow FAIL-LOUD (#908, arrow #3) ------------------------------ # observed_seq >= consumed_seq always (consumed advances only over the gapless # observed prefix), so an ALLOCATED next = observed+1 is ALWAYS > consumed. The # old "seq <= consumed => idempotent no-op" path is therefore UNREACHABLE for an # allocation; if it is ever reached the store is inconsistent (corruption) — we # FAIL LOUD, never a silent no-op. For a LEGACY explicit --seq, a seq inside the # consumed prefix is likewise refused loudly so it can never silently swallow a # fresh obligation (the migration-restart killer must be structurally impossible). if [ "$seq" -le "$consumed" ]; then _wake_lock_release if [ "$explicit" -eq 1 ]; then echo "store.sh enqueue: REFUSED explicit --seq $seq <= consumed_seq $consumed — anti-swallow (#908): a seq inside the consumed prefix is never a fresh obligation and must NEVER be silently ignored." >&2 else echo "store.sh enqueue: FAIL LOUD (#908 allocation invariant) — computed observed_seq $seq <= consumed_seq $consumed (observed_seq cursor=$observed). The store is inconsistent; refusing to allocate rather than silently no-op." >&2 fi exit 1 fi # Build the durable entry. `hmac` is an UNSIGNED PLACEHOLDER (W3/A5 signs it). local entry entry="$(jq -cn \ --argjson seq "$seq" \ --arg class "$class" \ --argjson locators "$locators" \ --argjson emit_ts "$emit_ts" \ '{observed_seq:$seq, locators:$locators, class:$class, emit_ts:$emit_ts, hmac:""}')" || { _wake_lock_release echo "store.sh enqueue: failed to build entry" >&2 exit 1 } # --- durable store write (coalesce vs append) ----------------------------- # digest: REPLACE the single pending cumulative-state entry (drop any existing # digest lines, then append the newest). Newest subsumes prior. # others: APPEND (never replaced). # In BOTH cases the entry lands in the durable store. # # ORDERING (#908, arrow #1 — no burn-before-enqueue): the durable pending write # comes FIRST and its failure ABORTS before the observed.set / observed_seq # cursor writes. The cursor advances IFF the durable pending write succeeds, so # a failed enqueue can never burn a seq that never reached the store. local new_pending if [ "$class" = "digest" ]; then new_pending="$(jq -c 'select(.class != "digest")' "$STATE_DIR/pending.jsonl" 2>/dev/null || true)" new_pending="$(printf '%s\n%s\n' "$new_pending" "$entry" | grep -v '^[[:space:]]*$' || true)" else new_pending="$(cat "$STATE_DIR/pending.jsonl" 2>/dev/null; printf '%s\n' "$entry")" new_pending="$(printf '%s' "$new_pending" | grep -v '^[[:space:]]*$' || true)" fi if ! printf '%s\n' "$new_pending" | grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/pending.jsonl"; then _wake_lock_release echo "store.sh enqueue: durable pending write FAILED for seq $seq — observed_seq cursor NOT advanced (no burned seq, no interior gap)." >&2 exit 1 fi # --- observed.set: record the seq in the live window (gap-detector SoT) ---- # Snapshot observed.set's prior content FIRST: if the final cursor write below # fails AFTER this write advances observed.set, we roll observed.set back to this # snapshot so observed.set can never be left cross-file inconsistent with the # cursor (#917 — observed.set ahead of a cursor that never committed). local prev_observed_set prev_observed_set="$(cat "$STATE_DIR/observed.set" 2>/dev/null || true)" if ! { printf '%s' "$prev_observed_set" printf '\n%s\n' "$seq" } | grep -v '^[[:space:]]*$' | sort -n | uniq | _atomic_write "$STATE_DIR/observed.set"; then _wake_lock_release echo "store.sh enqueue: observed.set write FAILED for seq $seq — observed_seq cursor NOT advanced." >&2 exit 1 fi # --- observed_seq cursor: max monotonic int (COMMIT of the allocation) ----- # Written LAST: only now, after the durable pending + observed.set writes # succeeded, does the cursor advance. This is the atomic commit point of the # allocation. # # #917 (defense-in-depth): this final write is GATED like the pending/observed.set # writes (#908) — its failure is FAIL-LOUD, never silently swallowed (which would # return success while the allocation stayed uncommitted). And because observed.set # was already advanced just above, a cursor-write failure would leave observed.set # ahead of the cursor (cross-file inconsistent); so on failure we ROLL observed.set # BACK to its pre-write snapshot before failing loud. Net: observed.set and the # cursor either BOTH advance or NEITHER does — never a stranded observed.set entry # above the cursor. (The pending write may remain ahead, exactly as #908 already # tolerates on the observed.set-failure path: the cursor is the sole COMMIT point, # so an uncommitted pending entry is re-derived/reconciled, never consumed — the # allocation is simply not committed.) On-disk format is UNCHANGED (read-compatible). if [ "$seq" -gt "$observed" ]; then if ! printf '%s' "$seq" | _atomic_write "$STATE_DIR/observed_seq"; then printf '%s' "$prev_observed_set" | grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/observed.set" || true _wake_lock_release echo "store.sh enqueue: observed_seq cursor write FAILED for seq $seq — allocation NOT committed; rolled observed.set back so it stays consistent with the cursor (#917)." >&2 exit 1 fi fi _wake_lock_release # Print the allocated observed_seq to stdout (sole-allocator contract): the # detector/reconciler capture it for logging/locators/emit. printf '%s\n' "$seq" } cmd_drain() { local idle_cmd='' while [ $# -gt 0 ]; do case "$1" in --require-idle-cmd) idle_cmd="${2:-}" shift 2 ;; *) echo "store.sh drain: unknown option '$1'" >&2 exit 2 ;; esac done # Drain returns the deliverable set ONLY when the pane is idle-at-prompt. The # actual idle detection + paste is out of W2 scope (send-message.sh); the # caller supplies an idle predicate. No predicate => emit unconditionally. if [ -n "$idle_cmd" ]; then if ! sh -c "$idle_cmd" >/dev/null 2>&1; then return 0 # not idle-at-prompt: deliver nothing this cycle. fi fi [ -f "$STATE_DIR/pending.jsonl" ] || return 0 grep -v '^[[:space:]]*$' "$STATE_DIR/pending.jsonl" 2>/dev/null || true } cmd_consume() { local upto='' while [ $# -gt 0 ]; do case "$1" in --upto) upto="${2:-}" shift 2 ;; *) echo "store.sh consume: unknown option '$1'" >&2 exit 2 ;; esac done case "$upto" in '' | *[!0-9]*) echo "store.sh consume: --upto must be a non-negative integer" >&2 exit 2 ;; esac _need_jq _wake_init_dir "$STATE_DIR" local consumed observed consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)" observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)" # Cumulative & idempotent: CONSUMED N implies all <=N. N already covered is a # no-op success (the sender checks cursor-advanced-past-X, not per-wake). if [ "$upto" -le "$consumed" ]; then echo "$consumed" return 0 fi # Never consume beyond what was observed. if [ "$upto" -gt "$observed" ]; then echo "store.sh consume: cannot CONSUMED $upto beyond observed_seq $observed" >&2 exit 1 fi # CONTIGUOUS gapless prefix: every seq in (consumed, upto] must have been # observed. A missing interior seq is a GAP => reject (cannot ack N while N-1 # is unconsumed). observed.set is coalescing-immune, so a subsumed digest seq # still counts as observed and does not read as a gap. local k k=$((consumed + 1)) while [ "$k" -le "$upto" ]; do if ! grep -qxF "$k" "$STATE_DIR/observed.set" 2>/dev/null; then echo "store.sh consume: gap at seq $k — cannot CONSUMED $upto while $k is unobserved/unconsumed" >&2 exit 1 fi k=$((k + 1)) done # Advance the consumer cursor and drop the now-consumed prefix from the # durable store (retain-until-CONSUMED is satisfied). jq -c "select(.observed_seq > $upto)" "$STATE_DIR/pending.jsonl" 2>/dev/null | grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/pending.jsonl" || true awk -v c="$upto" 'NF && $1+0 > c' "$STATE_DIR/observed.set" 2>/dev/null | _atomic_write "$STATE_DIR/observed.set" || true printf '%s' "$upto" | _atomic_write "$STATE_DIR/consumed_seq" echo "$upto" } cmd_cursors() { _wake_init_dir "$STATE_DIR" local observed consumed depth observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)" consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)" depth="$(grep -cv '^[[:space:]]*$' "$STATE_DIR/pending.jsonl" 2>/dev/null || echo 0)" printf 'observed_seq=%s\nconsumed_seq=%s\npending_depth=%s\n' "$observed" "$consumed" "$depth" } main() { [ $# -ge 1 ] || { usage exit 2 } local cmd="$1" shift case "$cmd" in init) cmd_init "$@" ;; enqueue) cmd_enqueue "$@" ;; drain) cmd_drain "$@" ;; consume) cmd_consume "$@" ;; cursors) cmd_cursors "$@" ;; -h | --help | help) usage ;; *) echo "store.sh: unknown command '$cmd'" >&2 usage exit 2 ;; esac } main "$@"