jevsnes.git / tools / watch / budget.sh
budget.shannotatedbudget.shsource222 lines · 10.6 KB · raw
1#!/usr/bin/env bash
2# One line of status for a once-a-minute watchdog. See ./README.md.
3#
4# Exit codes: 0 asking works, or is throttled and will clear by itself; 2
5# stopped for good (the lifetime cap, or the vendor said out of credit); 3
6# the window did not answer at all (no session); 4 the window answered but a
7# safety-relevant field (budget, or whether asking is stopped) could not be
8# read - an absent tool on an old build looks exactly like this, and MUST NOT
9# be read as "not stopped" or "$0.00 left". `--self-test` runs the parsing/formatting logic against recorded
10# responses, with no network at all - see `self_test` below for the cases.
11set -euo pipefail
12
13MCP_URL="http://127.0.0.1:7637/mcp"
14
15# The reason the last `parse_tool_response`/`mcp_tool` failure, in a FILE
16# rather than a variable. Every caller that needs it captures the function's
17# STDOUT with `$(...)`, which runs it in a subshell - a variable set there is
18# invisible to the parent shell the instant the subshell exits. A file
19# survives that; this bit the first version of this script (a real `budget`
20# call against an old build, missing the tool, produced "TOOL_ERROR: unbound
21# variable" instead of the message).
22ERROR_FILE="$(mktemp)"
23trap 'rm -f "$ERROR_FILE"' EXIT
24set_error() { printf '%s' "$1" >"$ERROR_FILE"; }
25last_error() { cat "$ERROR_FILE" 2>/dev/null || true; }
26
27# --- pure: parsing and formatting, exercised by --self-test with no network ---
28
29# One top-level field, as text, or the literal string "unknown" - never a
30# silent numeric default. `has($k)` plus an explicit null check is what lets
31# this tell a real `false` (known) apart from a missing or null field
32# (unknown); `jq -e '.foo'` alone cannot, because it treats `false` and
33# `null` identically as "no output".
34get() {  # $1 = json $2 = key -> value as text, or "unknown"
35    jq -r --arg k "$2" \
36        'if (has($k)) and (.[$k] != null) then (.[$k] | tostring) else "unknown" end' \
37        <<<"$1" 2>/dev/null || echo "unknown"
38}
39
40# One raw SSE/JSON-RPC response body -> the tool's own JSON content on
41# stdout (exit 0), or nothing with the reason in `last_error` (exit 1). A
42# JSON-RPC `error` (an unknown tool, on an old build, looks exactly like
43# this), a tool-level `isError`, and a body that is not this shape at all
44# are three different ways to fail and all three are handled - none of them
45# may fall through to being read as a value.
46parse_tool_response() {  # $1 = raw body
47    local line json result
48    # The stream's first event is often a bare keepalive (`data: ` with
49    # nothing after it, seen for real against the live window, 2026-09-20) -
50    # `{` in the pattern is what skips that and finds the actual JSON-RPC
51    # payload, the same way tools/hotpatch/patch.sh's own `mcp_raw` does.
52    line="$(grep -m1 '^data: {' <<<"$1" || true)"
53    [[ -n "$line" ]] || { set_error "no data: {...} line in the response"; return 1; }
54    json="${line#data: }"
55    jq -e . >/dev/null 2>&1 <<<"$json" || { set_error "the data: line was not JSON"; return 1; }
56    if jq -e '.error' >/dev/null 2>&1 <<<"$json"; then
57        set_error "$(jq -r '.error.message // "a JSON-RPC error with no message"' <<<"$json")"
58        return 1
59    fi
60    jq -e '.result' >/dev/null 2>&1 <<<"$json" || { set_error "no result and no error"; return 1; }
61    result="$(jq -c '.result' <<<"$json")"
62    if [[ "$(get "$result" isError)" == "true" ]]; then
63        set_error "$(jq -r '.content[0].text // "the tool reported an error with no text"' <<<"$result")"
64        return 1
65    fi
66    jq -r '.content[0].text // empty' <<<"$result"
67}
68
69format_lifetime() {  # $1 = budget json -> "$X.XX" or "unknown"
70    local v
71    v="$(get "$1" lifetime_remaining_usd)"
72    [[ "$v" == "unknown" ]] && { echo "unknown"; return; }
73    printf '$%.2f\n' "$v"
74}
75
76# "true", "false" or "unknown" - kept apart from the human text below because
77# the caller needs the bare status to decide the exit code, not the sentence.
78# Only a stop that waiting cannot clear is "true"; a throttle heals itself.
79stopped_status() {  # $1 = budget json
80    get "$1" stopped_for_good
81}
82
83# What is stopping questions now, or "no". `stopped` is null when nothing is,
84# which `get` reads as "unknown", so it is only trusted beside a known
85# `stopped_for_good`.
86format_stopped() {  # $1 = budget json
87    local why
88    why="$(get "$1" stopped)"
89    case "$(stopped_status "$1")" in
90        true) echo "for good: $why" ;;
91        false) if [[ "$why" == "unknown" ]]; then echo "no"; else echo "$why"; fi ;;
92        *) echo "unknown" ;;
93    esac
94}
95
96# --- impure: the network round trip ---
97
98mcp_tool() {  # $1 = tool name -> its content on stdout, or sets the error (see last_error) and fails
99    local body extra=()
100    [[ -n "${SESSION:-}" ]] && extra+=(-H "Mcp-Session-Id: $SESSION")
101    body="$(curl -sS --max-time 5 -X POST "$MCP_URL" \
102        -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
103        "${extra[@]}" \
104        -d "$(jq -nc --arg name "$1" \
105            '{jsonrpc:"2.0",id:1,method:"tools/call",params:{name:$name,arguments:{}}}')")" \
106        || { set_error "curl could not reach $MCP_URL"; return 1; }
107    parse_tool_response "$body"
108}
109
110connect() {  # sets SESSION, or exits 3
111    local headers
112    headers="$(mktemp)"
113    trap 'rm -f "$headers"' RETURN
114    curl -sS --max-time 5 -D "$headers" -X POST "$MCP_URL" \
115        -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
116        -d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"budget.sh","version":"0"}}}' \
117        >/dev/null || { echo "budget.sh: could not reach $MCP_URL - is a window running?" >&2; exit 3; }
118    SESSION="$(grep -i '^mcp-session-id:' "$headers" | tr -d '\r' | awk '{print $2}')"
119    [[ -n "$SESSION" ]] || { echo "budget.sh: no Mcp-Session-Id from $MCP_URL - is a window running?" >&2; exit 3; }
120    curl -sS --max-time 5 -X POST "$MCP_URL" -H 'Content-Type: application/json' \
121        -H 'Accept: application/json, text/event-stream' -H "Mcp-Session-Id: $SESSION" \
122        -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null || true
123}
124
125main() {
126    SESSION=""
127    connect
128    local state budget goal exit_code=0
129    if ! state="$(mcp_tool state)"; then
130        echo "budget.sh: state unavailable: $(last_error)" >&2
131        exit 4
132    fi
133    if ! budget="$(mcp_tool budget)"; then
134        echo "budget.sh: budget unavailable: $(last_error)" >&2
135        exit 4
136    fi
137    if goal="$(mcp_tool bot)"; then
138        goal="$(jq -r '.tasks[0] // "-"' <<<"$goal" 2>/dev/null || echo "-")"
139    else
140        goal="-"
141    fi
142    curl -sS -o /dev/null --max-time 5 -X DELETE "$MCP_URL" -H "Mcp-Session-Id: $SESSION" || true
143
144    case "$(stopped_status "$budget")" in
145        true) exit_code=2 ;;
146        false) exit_code=0 ;;
147        *) exit_code=4 ;;
148    esac
149    printf '%s task=%s mode=%s link=%s,%s lifetime_remaining=%s q/min=%s stopped=%s\n' \
150        "$(date -u +%FT%TZ)" "$goal" "$(get "$state" mode)" \
151        "$(get "$state" link_x)" "$(get "$state" link_y)" \
152        "$(format_lifetime "$budget")" "$(get "$budget" questions_last_minute)" \
153        "$(format_stopped "$budget")"
154    exit "$exit_code"
155}
156
157self_test() {
158    local failures=0
159    check() {  # $1 = description $2 = actual $3 = expected
160        if [[ "$2" == "$3" ]]; then
161            echo "ok - $1"
162        else
163            echo "FAIL - $1: got [$2] want [$3]" >&2
164            failures=$((failures + 1))
165        fi
166    }
167    content() {  # $1 = the tool's own JSON, as a shell string -> a recorded SSE body carrying it
168        printf 'event: message\r\ndata: %s\r\n\r\n' \
169            "$(jq -nc --arg text "$1" '{jsonrpc:"2.0",id:1,result:{content:[{type:"text",text:$text}]}}')"
170    }
171
172    local budget_ok budget_throttled budget_capped state_ok
173    budget_ok="$(parse_tool_response "$(content '{"lifetime_remaining_usd":4.0,"stopped":null,"stopped_for_good":false,"questions_last_minute":0}')")"
174    check "a present tool parses" "$(get "$budget_ok" lifetime_remaining_usd)" "4.0"
175    check "an explicit false is KNOWN, not unknown - the bug this exists for" \
176        "$(stopped_status "$budget_ok")" "false"
177    check "a known false with nothing stopping formats as no" "$(format_stopped "$budget_ok")" "no"
178
179    budget_throttled="$(parse_tool_response "$(content '{"lifetime_remaining_usd":3.5,"stopped":"throttled: bucket empty, next question in 12s","stopped_for_good":false,"questions_last_minute":30}')")"
180    check "a throttle is not a stop for good" "$(stopped_status "$budget_throttled")" "false"
181    check "but says why" "$(format_stopped "$budget_throttled")" "throttled: bucket empty, next question in 12s"
182
183    budget_capped="$(parse_tool_response "$(content '{"lifetime_remaining_usd":0.0,"stopped":"lifetime cap reached - stopped for good","stopped_for_good":true,"questions_last_minute":0}')")"
184    check "the cap is a stop for good" "$(stopped_status "$budget_capped")" "true"
185    check "the reason is carried" "$(format_stopped "$budget_capped")" "for good: lifetime cap reached - stopped for good"
186    check "a real low balance still formats as a number" "$(format_lifetime "$budget_capped")" "\$0.00"
187
188    state_ok="$(parse_tool_response "$(content '{"mode":"Overworld","link_x":1000,"link_y":800}')")"
189    check "state parses" "$(get "$state_ok" mode)" "Overworld"
190
191    # Regression, found against the real window 2026-09-20: the stream's
192    # first SSE event is a bare keepalive (`data: ` with nothing after),
193    # before the actual JSON-RPC payload's own `data: {...}` event.
194    local with_keepalive
195    with_keepalive="$(printf 'data: \r\nid: 0\r\nretry: 3000\r\n\r\n')$(content '{"mode":"Overworld","link_x":1000,"link_y":800}')"
196    check "a leading keepalive event is skipped, not read as the payload" \
197        "$(get "$(parse_tool_response "$with_keepalive")" mode)" "Overworld"
198
199    local missing
200    missing='event: message
201data: {"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"no tool named \"budget\""}}
202
203'
204    if parse_tool_response "$missing" >/dev/null 2>&1; then
205        check "a missing tool must be treated as a failure" "parsed" "should have failed"
206    else
207        check "a missing tool's message is surfaced" "$(last_error)" 'no tool named "budget"'
208    fi
209    check "a missing tool never renders as a number" "$(format_lifetime '{}')" "unknown"
210    check "an absent field is unknown, not stopped=no" "$(format_stopped '{}')" "unknown"
211    check "an absent field's exit status is not the false case" "$(stopped_status '{}')" "unknown"
212
213    ((failures == 0)) && { echo "self-test: all ok"; return 0; }
214    echo "self-test: $failures failure(s)" >&2
215    return 1
216}
217
218if [[ "${1:-}" == "--self-test" ]]; then
219    self_test
220else
221    main
222fi