@rulemetric/hooks 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/hooks",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: shared event-POST helper with bounded retry on transient failures.
3
+ #
4
+ # Why this exists: the event-capture hooks (post-tool-use, assistant-response,
5
+ # user-prompt) POST to /api/sessions/:id/events fire-and-forget. The always-on
6
+ # API may fast-503 under transient DB-pool load (the admission gate from
7
+ # docs/plans/2026-06-28-db-ingest-load-admission-control.md, Phase 1). Without a
8
+ # retry, a sheddable 503 silently drops the event — the highest-value capture
9
+ # stream. This helper retries transient failures (503 / 429 / network) a few
10
+ # times, honoring a capped Retry-After, and treats 4xx as permanent (no retry),
11
+ # preserving the prior "log on failure, never block the hook" contract.
12
+ #
13
+ # Designed to be called inside the existing backgrounded `( ... ) &` subshell, so
14
+ # it never delays the hook and a non-zero return cannot abort the caller. It is
15
+ # defensively written to be safe under `set -euo pipefail`.
16
+
17
+ # Bounded so the total wait stays inside the hooks' fire-and-forget budget.
18
+ _RM_POST_MAX_ATTEMPTS="${RULEMETRIC_POST_MAX_ATTEMPTS:-3}"
19
+ _RM_POST_RETRY_CAP_S="${RULEMETRIC_POST_RETRY_CAP_S:-2}"
20
+
21
+ # _rulemetric_post_event <url> <auth_header> <payload> <log_tag> <seq>
22
+ # Returns 0 on 2xx; 1 otherwise (after logging the final failure).
23
+ _rulemetric_post_event() {
24
+ local url="$1" auth="$2" payload="$3" tag="$4" seq="$5"
25
+ local attempt=1 http_code="000" body="" hdr_file retry_after sleep_s
26
+
27
+ hdr_file=$(mktemp "${TMPDIR:-/tmp}/rm-post.XXXXXX" 2>/dev/null || true)
28
+
29
+ while [ "$attempt" -le "$_RM_POST_MAX_ATTEMPTS" ]; do
30
+ if [ -n "$hdr_file" ]; then
31
+ body=$(curl -s --noproxy '*' --max-time 4 -D "$hdr_file" -w '\n%{http_code}' \
32
+ -X POST "$url" -H "$auth" -H "Content-Type: application/json" \
33
+ -d "$payload" 2>&1) || true
34
+ else
35
+ body=$(curl -s --noproxy '*' --max-time 4 -w '\n%{http_code}' \
36
+ -X POST "$url" -H "$auth" -H "Content-Type: application/json" \
37
+ -d "$payload" 2>&1) || true
38
+ fi
39
+ http_code=$(printf '%s' "$body" | tail -1)
40
+ body=$(printf '%s' "$body" | sed '$d')
41
+
42
+ if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
43
+ [ -n "$hdr_file" ] && rm -f "$hdr_file"
44
+ return 0
45
+ fi
46
+
47
+ # Transient (service busy / rate-limited / network) → retry unless this was
48
+ # the last allowed attempt. Everything else (4xx) is permanent.
49
+ if [ "$http_code" = "503" ] || [ "$http_code" = "429" ] || [ "$http_code" = "000" ]; then
50
+ if [ "$attempt" -lt "$_RM_POST_MAX_ATTEMPTS" ]; then
51
+ retry_after=""
52
+ if [ -n "$hdr_file" ]; then
53
+ retry_after=$(grep -i '^Retry-After:' "$hdr_file" 2>/dev/null | tail -1 | tr -dc '0-9' || true)
54
+ fi
55
+ if [ -n "$retry_after" ] && [ "$retry_after" -gt 0 ] 2>/dev/null; then
56
+ sleep_s="$retry_after"
57
+ else
58
+ sleep_s="$attempt" # 1s, 2s, ... linear backoff
59
+ fi
60
+ if [ "$sleep_s" -gt "$_RM_POST_RETRY_CAP_S" ] 2>/dev/null; then
61
+ sleep_s="$_RM_POST_RETRY_CAP_S"
62
+ fi
63
+ sleep "$sleep_s" 2>/dev/null || true
64
+ attempt=$((attempt + 1))
65
+ continue
66
+ fi
67
+ fi
68
+
69
+ break
70
+ done
71
+
72
+ _rulemetric_log "$tag" "error" "http_${http_code}_seq_${seq}_attempt_${attempt}: ${body}"
73
+ [ -n "$hdr_file" ] && rm -f "$hdr_file"
74
+ return 1
75
+ }
@@ -16,9 +16,7 @@ trap 'echo "rulemetric assistant-response failed at line $LINENO: $BASH_COMMAND"
16
16
  # Load config (env vars, ~/.config/rulemetric/env, or .env.local)
17
17
  source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
18
18
 
19
- # Bound the read so a hook runner that leaves stdin open cannot wedge the agent.
20
- INPUT=""
21
- IFS= read -r -d '' -t 2 INPUT || true
19
+ INPUT=$(cat)
22
20
  source "$SCRIPT_DIR/_normalize.sh"
23
21
 
24
22
  if [ -z "$HOOK_SESSION_ID" ] || [ -z "${RULEMETRIC_API_URL:-}" ]; then
@@ -76,15 +74,7 @@ TOKEN_USAGE=$(jq -n \
76
74
  # Increment per-session sequence (same locking pattern as post-tool-use)
77
75
  SEQ_FILE="$TEMP_DIR/$HOOK_SESSION_ID.seq"
78
76
  LOCK_DIR="$TEMP_DIR/$HOOK_SESSION_ID.seqlock"
79
- SEQ_LOCK_TRIES=0
80
- while ! mkdir "$LOCK_DIR" 2>/dev/null; do
81
- sleep 0.01
82
- SEQ_LOCK_TRIES=$((SEQ_LOCK_TRIES + 1))
83
- if [ "$SEQ_LOCK_TRIES" -ge 500 ]; then
84
- _rulemetric_log "assistant-response" "skip" "seq_lock_timeout"
85
- exit 0
86
- fi
87
- done
77
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do sleep 0.01; done
88
78
  SEQ=1
89
79
  if [ -f "$SEQ_FILE" ]; then
90
80
  SEQ=$(( $(cat "$SEQ_FILE") + 1 ))
@@ -125,12 +115,9 @@ PAYLOAD=$(jq -n \
125
115
  _rulemetric_log "assistant-response" "success"
126
116
 
127
117
  # Fire-and-forget (same error-logging pattern as post-tool-use)
128
- (CURL_OUT=$(curl -s --noproxy '*' --max-time 4 -w "\n%{http_code}" -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
129
- -H "$AUTH_HEADER" \
130
- -H "Content-Type: application/json" \
131
- -d "$PAYLOAD" 2>&1)
132
- HTTP_CODE=$(echo "$CURL_OUT" | tail -1)
133
- if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
134
- BODY=$(echo "$CURL_OUT" | sed '$d')
135
- _rulemetric_log "assistant-response" "error" "http_${HTTP_CODE}_seq_${SEQ}: ${BODY}"
136
- fi) &
118
+ # Fire-and-forget event POST, with bounded retry on a transient 503/429 so the
119
+ # admission gate (db-wedge plan Phase 1) sheds load without dropping the event.
120
+ source "$SCRIPT_DIR/_post.sh"
121
+ _rulemetric_post_event \
122
+ "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
123
+ "$AUTH_HEADER" "$PAYLOAD" "assistant-response" "$SEQ" &
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: test for the shared event-POST retry helper (_post.sh).
3
+ #
4
+ # Run with: bash packages/hooks/scripts/post-retry.test.sh
5
+ # Exits non-zero on any failed assertion.
6
+ #
7
+ # Proves the Phase-0 contract for the db-wedge admission gate
8
+ # (docs/plans/2026-06-28-db-ingest-load-admission-control.md):
9
+ # 1. a transient 503 + Retry-After is RETRIED and the event lands (not dropped)
10
+ # 2. a permanent 4xx is NOT retried (one request, then give up)
11
+ # 3. sustained 503 gives up after RULEMETRIC_POST_MAX_ATTEMPTS and logs an error
12
+ #
13
+ # Uses a tiny stdlib-only Python stub server; no network, no real API.
14
+
15
+ set -uo pipefail
16
+
17
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+
19
+ if [ -t 1 ]; then C_OK="\033[32m"; C_FAIL="\033[31m"; C_RST="\033[0m"; else C_OK=""; C_FAIL=""; C_RST=""; fi
20
+ FAILURES=0; PASSES=0
21
+ ok() { printf "${C_OK}OK${C_RST} %s\n" "$1"; PASSES=$((PASSES + 1)); }
22
+ fail() { printf "${C_FAIL}FAIL${C_RST} %s\n" "$1"; FAILURES=$((FAILURES + 1)); }
23
+ assert_eq() { if [ "$2" = "$3" ]; then ok "$1"; else fail "$1 (expected [$2], got [$3])"; fi; }
24
+
25
+ PYTHON="$(command -v python3 || command -v python || true)"
26
+ if [ -z "$PYTHON" ]; then echo "SKIP: python3 not found"; exit 0; fi
27
+
28
+ WORK_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/post-retry-test.XXXXXX")
29
+ cleanup() { [ -n "${SERVER_PID:-}" ] && kill "$SERVER_PID" 2>/dev/null; rm -rf "$WORK_ROOT" 2>/dev/null || true; }
30
+ trap cleanup EXIT
31
+
32
+ # --- Stub server: counts requests, programmed by $MODE in a control file --------
33
+ cat > "$WORK_ROOT/stub.py" <<'PY'
34
+ import http.server, json, os, sys, threading
35
+ mode = sys.argv[1] # fail-once | always-503 | always-400
36
+ count_file = sys.argv[2]
37
+ n = 0
38
+ lock = threading.Lock()
39
+ class H(http.server.BaseHTTPRequestHandler):
40
+ def log_message(self, *a): pass
41
+ def do_POST(self):
42
+ global n
43
+ with lock:
44
+ n += 1; cur = n
45
+ open(count_file, "w").write(str(n))
46
+ length = int(self.headers.get('Content-Length', 0) or 0)
47
+ self.rfile.read(length)
48
+ if mode == "always-400":
49
+ self.send_response(400); self.end_headers(); self.wfile.write(b'{"error":"bad"}'); return
50
+ if mode == "always-503":
51
+ self.send_response(503); self.send_header("Retry-After", "1"); self.end_headers()
52
+ self.wfile.write(b'{"error":"busy"}'); return
53
+ # fail-once: 503 on first request, 200 thereafter
54
+ if cur == 1:
55
+ self.send_response(503); self.send_header("Retry-After", "1"); self.end_headers()
56
+ self.wfile.write(b'{"error":"busy"}')
57
+ else:
58
+ self.send_response(200); self.end_headers(); self.wfile.write(b'{"recorded":true}')
59
+ srv = http.server.HTTPServer(("127.0.0.1", 0), H)
60
+ print(srv.server_address[1], flush=True)
61
+ srv.serve_forever()
62
+ PY
63
+
64
+ start_server() {
65
+ # $1 = mode; sets SERVER_PORT, SERVER_PID, COUNT_FILE
66
+ COUNT_FILE="$WORK_ROOT/count.$1"
67
+ : > "$COUNT_FILE"
68
+ exec 3< <("$PYTHON" "$WORK_ROOT/stub.py" "$1" "$COUNT_FILE")
69
+ SERVER_PID=$!
70
+ read -r SERVER_PORT <&3
71
+ }
72
+ stop_server() { [ -n "${SERVER_PID:-}" ] && kill "$SERVER_PID" 2>/dev/null; SERVER_PID=""; }
73
+ req_count() { cat "$COUNT_FILE" 2>/dev/null || echo 0; }
74
+
75
+ # --- Load the helper under test ----------------------------------------------
76
+ LOG_FILE="$WORK_ROOT/log"
77
+ _rulemetric_log() { echo "$*" >> "$LOG_FILE"; } # stub: record log calls
78
+ export RULEMETRIC_POST_MAX_ATTEMPTS=3
79
+ export RULEMETRIC_POST_RETRY_CAP_S=1
80
+ # shellcheck source=/dev/null
81
+ source "$SCRIPT_DIR/_post.sh"
82
+
83
+ AUTH="Authorization: Bearer test"
84
+ PAYLOAD='{"sequence":1,"eventType":"tool_call"}'
85
+
86
+ # --- Test 1: transient 503 + Retry-After is retried, event lands --------------
87
+ start_server fail-once
88
+ : > "$LOG_FILE"
89
+ _rulemetric_post_event "http://127.0.0.1:$SERVER_PORT/events" "$AUTH" "$PAYLOAD" "test" 1
90
+ rc=$?
91
+ assert_eq "fail-once: returns success (0) after retry" "0" "$rc"
92
+ assert_eq "fail-once: server saw exactly 2 requests (fail + retry)" "2" "$(req_count)"
93
+ assert_eq "fail-once: no error logged on eventual success" "0" "$(wc -l < "$LOG_FILE" | tr -d ' ')"
94
+ stop_server
95
+
96
+ # --- Test 2: permanent 4xx is NOT retried ------------------------------------
97
+ start_server always-400
98
+ : > "$LOG_FILE"
99
+ _rulemetric_post_event "http://127.0.0.1:$SERVER_PORT/events" "$AUTH" "$PAYLOAD" "test" 2
100
+ rc=$?
101
+ assert_eq "always-400: returns failure (1)" "1" "$rc"
102
+ assert_eq "always-400: server saw exactly 1 request (no retry on 4xx)" "1" "$(req_count)"
103
+ stop_server
104
+
105
+ # --- Test 3: sustained 503 gives up after MAX_ATTEMPTS and logs ---------------
106
+ start_server always-503
107
+ : > "$LOG_FILE"
108
+ _rulemetric_post_event "http://127.0.0.1:$SERVER_PORT/events" "$AUTH" "$PAYLOAD" "test" 3
109
+ rc=$?
110
+ assert_eq "always-503: returns failure (1) after exhausting attempts" "1" "$rc"
111
+ assert_eq "always-503: server saw exactly MAX_ATTEMPTS (3) requests" "3" "$(req_count)"
112
+ grep -q "http_503" "$LOG_FILE" && ok "always-503: logged an http_503 error" || fail "always-503: expected http_503 in log"
113
+ stop_server
114
+
115
+ # --- Summary -----------------------------------------------------------------
116
+ echo
117
+ echo "Passed: $PASSES Failed: $FAILURES"
118
+ [ "$FAILURES" -eq 0 ]
@@ -9,10 +9,8 @@ trap 'echo "rulemetric post-tool-use failed at line $LINENO: $BASH_COMMAND" >&2;
9
9
  # Load config (env vars, ~/.config/rulemetric/env, or .env.local)
10
10
  source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
11
11
 
12
- # Read hook input from stdin and normalize across tools. Bound the read so a
13
- # hook runner that leaves stdin open cannot wedge the agent.
14
- INPUT=""
15
- IFS= read -r -d '' -t 2 INPUT || true
12
+ # Read hook input from stdin and normalize across tools
13
+ INPUT=$(cat)
16
14
  source "$SCRIPT_DIR/_normalize.sh"
17
15
 
18
16
  # Session paper-trail: snapshot the full working tree to a shadow git ref for
@@ -53,15 +51,7 @@ fi
53
51
  # Increment sequence counter (atomic mkdir lock, works on macOS + Linux)
54
52
  SEQ_FILE="$TEMP_DIR/$HOOK_SESSION_ID.seq"
55
53
  LOCK_DIR="$TEMP_DIR/$HOOK_SESSION_ID.seqlock"
56
- SEQ_LOCK_TRIES=0
57
- while ! mkdir "$LOCK_DIR" 2>/dev/null; do
58
- sleep 0.01
59
- SEQ_LOCK_TRIES=$((SEQ_LOCK_TRIES + 1))
60
- if [ "$SEQ_LOCK_TRIES" -ge 500 ]; then
61
- _rulemetric_log "post-tool-use" "skip" "seq_lock_timeout"
62
- exit 0
63
- fi
64
- done
54
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do sleep 0.01; done
65
55
  SEQ=1
66
56
  if [ -f "$SEQ_FILE" ]; then
67
57
  SEQ=$(( $(cat "$SEQ_FILE") + 1 ))
@@ -80,13 +70,9 @@ PAYLOAD=$(jq -n \
80
70
 
81
71
  _rulemetric_log "post-tool-use" "success"
82
72
 
83
- # Fire-and-forget (capture HTTP errors for debugging)
84
- (CURL_OUT=$(curl -s --noproxy '*' --max-time 4 -w "\n%{http_code}" -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
85
- -H "$AUTH_HEADER" \
86
- -H "Content-Type: application/json" \
87
- -d "$PAYLOAD" 2>&1)
88
- HTTP_CODE=$(echo "$CURL_OUT" | tail -1)
89
- if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
90
- BODY=$(echo "$CURL_OUT" | sed '$d')
91
- _rulemetric_log "post-tool-use" "error" "http_${HTTP_CODE}_seq_${SEQ}: ${BODY}"
92
- fi) &
73
+ # Fire-and-forget event POST, with bounded retry on a transient 503/429 so the
74
+ # admission gate (db-wedge plan Phase 1) sheds load without dropping the event.
75
+ source "$SCRIPT_DIR/_post.sh"
76
+ _rulemetric_post_event \
77
+ "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
78
+ "$AUTH_HEADER" "$PAYLOAD" "post-tool-use" "$SEQ" &
@@ -9,10 +9,8 @@ trap 'echo "rulemetric user-prompt failed at line $LINENO: $BASH_COMMAND" >&2; _
9
9
  # Load config (env vars, ~/.config/rulemetric/env, or .env.local)
10
10
  source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
11
11
 
12
- # Read hook input from stdin and normalize across tools. Bound the read so a
13
- # hook runner that leaves stdin open cannot wedge the agent.
14
- INPUT=""
15
- IFS= read -r -d '' -t 2 INPUT || true
12
+ # Read hook input from stdin and normalize across tools
13
+ INPUT=$(cat)
16
14
  source "$SCRIPT_DIR/_normalize.sh"
17
15
 
18
16
  if [ -z "$HOOK_SESSION_ID" ] || [ -z "${RULEMETRIC_API_URL:-}" ]; then
@@ -41,15 +39,7 @@ fi
41
39
  # Increment sequence counter (atomic mkdir lock, works on macOS + Linux)
42
40
  SEQ_FILE="$TEMP_DIR/$HOOK_SESSION_ID.seq"
43
41
  LOCK_DIR="$TEMP_DIR/$HOOK_SESSION_ID.seqlock"
44
- SEQ_LOCK_TRIES=0
45
- while ! mkdir "$LOCK_DIR" 2>/dev/null; do
46
- sleep 0.01
47
- SEQ_LOCK_TRIES=$((SEQ_LOCK_TRIES + 1))
48
- if [ "$SEQ_LOCK_TRIES" -ge 500 ]; then
49
- _rulemetric_log "user-prompt" "skip" "seq_lock_timeout"
50
- exit 0
51
- fi
52
- done
42
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do sleep 0.01; done
53
43
  SEQ=1
54
44
  if [ -f "$SEQ_FILE" ]; then
55
45
  SEQ=$(( $(cat "$SEQ_FILE") + 1 ))
@@ -57,16 +47,13 @@ fi
57
47
  echo "$SEQ" > "$SEQ_FILE"
58
48
  rmdir "$LOCK_DIR"
59
49
 
60
- # Fire-and-forget: send user message event (capture HTTP errors for debugging)
61
- (CURL_OUT=$(curl -s --noproxy '*' --max-time 4 -w "\n%{http_code}" -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
62
- -H "$AUTH_HEADER" \
63
- -H "Content-Type: application/json" \
64
- -d "{\"sequence\":$SEQ,\"eventType\":\"user_message\",\"content\":$(printf '%s' "$HOOK_PROMPT" | jq -Rs .),\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"}" 2>&1)
65
- HTTP_CODE=$(echo "$CURL_OUT" | tail -1)
66
- if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
67
- BODY=$(echo "$CURL_OUT" | sed '$d')
68
- _rulemetric_log "user-prompt" "error" "http_${HTTP_CODE}_seq_${SEQ}: ${BODY}"
69
- fi) &
50
+ # Fire-and-forget event POST, with bounded retry on a transient 503/429 so the
51
+ # admission gate (db-wedge plan Phase 1) sheds load without dropping the event.
52
+ PAYLOAD="{\"sequence\":$SEQ,\"eventType\":\"user_message\",\"content\":$(printf '%s' "$HOOK_PROMPT" | jq -Rs .),\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"}"
53
+ source "$SCRIPT_DIR/_post.sh"
54
+ _rulemetric_post_event \
55
+ "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
56
+ "$AUTH_HEADER" "$PAYLOAD" "user-prompt" "$SEQ" &
70
57
 
71
58
  _rulemetric_log "user-prompt" "success"
72
59