@rulemetric/hooks 0.6.9 → 0.7.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 +1 -1
- package/scripts/_config.sh +19 -2
- package/scripts/_ensure-session.sh +20 -7
- package/scripts/_normalize.sh +15 -0
- package/scripts/_post.sh +128 -4
- package/scripts/post-retry.test.sh +68 -3
- package/scripts/session-end.sh +49 -32
- package/scripts/session-start.sh +7 -13
package/package.json
CHANGED
package/scripts/_config.sh
CHANGED
|
@@ -14,7 +14,19 @@ _rulemetric_config_dir() {
|
|
|
14
14
|
printf '%s' "$RULEMETRIC_CONFIG_DIR"
|
|
15
15
|
return
|
|
16
16
|
fi
|
|
17
|
-
|
|
17
|
+
# XDG-aware, BUT the writers (`rulemetric auth login`, the Python reporter)
|
|
18
|
+
# always use ~/.config/rulemetric. On a Linux host with XDG_CONFIG_HOME set
|
|
19
|
+
# somewhere else, honoring XDG alone means the hooks look where login never
|
|
20
|
+
# wrote — no credentials, capture silently dead. Prefer the XDG dir only if
|
|
21
|
+
# it actually holds a config artifact; otherwise fall back to where the CLI
|
|
22
|
+
# writes.
|
|
23
|
+
local xdg_dir="${XDG_CONFIG_HOME:-$HOME/.config}/rulemetric"
|
|
24
|
+
local default_dir="$HOME/.config/rulemetric"
|
|
25
|
+
if [ "$xdg_dir" != "$default_dir" ] && [ ! -e "$xdg_dir/env" ] && [ ! -e "$xdg_dir/auth.json" ] && [ -d "$default_dir" ]; then
|
|
26
|
+
printf '%s' "$default_dir"
|
|
27
|
+
return
|
|
28
|
+
fi
|
|
29
|
+
printf '%s' "$xdg_dir"
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
# Resolve the active org ID into RULEMETRIC_ORG_ID. Env beats file. A missing
|
|
@@ -62,7 +74,12 @@ _rulemetric_load_config() {
|
|
|
62
74
|
# shellcheck source=/dev/null
|
|
63
75
|
source "$config_dir/env"
|
|
64
76
|
set +a
|
|
65
|
-
if [ -n "${
|
|
77
|
+
if [ -n "${RULEMETRIC_API_KEY:-}" ] || [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; then
|
|
78
|
+
# Credentials are enough — default the API URL like the auth.json branch
|
|
79
|
+
# below. `auth login` against prod doesn't persist RULEMETRIC_API_URL, so
|
|
80
|
+
# requiring it here made every fresh npm install skip capture with
|
|
81
|
+
# "no_config" unless auth.json happened to survive.
|
|
82
|
+
export RULEMETRIC_API_URL="${RULEMETRIC_API_URL:-https://rulemetric.com}"
|
|
66
83
|
_rulemetric_load_active_org
|
|
67
84
|
return 0
|
|
68
85
|
fi
|
|
@@ -159,14 +159,27 @@ _rulemetric_ensure_session() {
|
|
|
159
159
|
--argjson runId "$run_id_arg" \
|
|
160
160
|
'{tool: $tool, externalSessionId: $externalSessionId, projectPath: $projectPath, gitBranch: $gitBranch, metadata: $metadata, orgId: $orgId, runId: $runId}')
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
# Retry transient failures (503/429/network) instead of the old bare `curl -sf`,
|
|
163
|
+
# which silently dropped the whole session — and every event that would have
|
|
164
|
+
# attached to it — on a single transient 502/503 during a load window. Uses the
|
|
165
|
+
# shared bounded-retry helper (echoes the 2xx body so we can read .id). Callers
|
|
166
|
+
# normally source _post.sh already; source it defensively if not.
|
|
167
|
+
if ! command -v _rulemetric_send_capture >/dev/null 2>&1; then
|
|
168
|
+
_RM_ES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
169
|
+
# shellcheck source=/dev/null
|
|
170
|
+
. "$_RM_ES_DIR/_post.sh" 2>/dev/null || true
|
|
171
|
+
fi
|
|
168
172
|
|
|
169
|
-
|
|
173
|
+
local response
|
|
174
|
+
response=$(_rulemetric_send_capture POST "${RULEMETRIC_API_URL}/api/sessions" \
|
|
175
|
+
"$AUTH_HEADER" "$payload" "${1:-ensure-session}" 4)
|
|
176
|
+
|
|
177
|
+
if [ "$?" -ne 0 ] || [ -z "$response" ]; then
|
|
178
|
+
# No local log here — the caller's "skip no_rw_session" must stay the FIRST
|
|
179
|
+
# entry for its script (hooks.test.ts codifies that contract; an extra line
|
|
180
|
+
# here mis-classified the benign skip TWICE now). Remote forensics come from
|
|
181
|
+
# the availability beacon the capture helper fires (statusCode, server-side);
|
|
182
|
+
# local deep-debug remains via RULEMETRIC_HOOK_DEBUG.
|
|
170
183
|
rmdir "$lock_dir" 2>/dev/null
|
|
171
184
|
return 1
|
|
172
185
|
fi
|
package/scripts/_normalize.sh
CHANGED
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
# HOOK_TOOL_OUTPUT - Tool output as JSON
|
|
16
16
|
# HOOK_TRANSCRIPT_PATH - Path to transcript file (empty if unavailable)
|
|
17
17
|
|
|
18
|
+
# Portable short content hash for synthetic session IDs. `shasum` is a perl
|
|
19
|
+
# script — absent on minimal Linux hosts (containers, slim distros), where its
|
|
20
|
+
# failure under `set -euo pipefail` killed Cursor/Copilot-CLI capture entirely.
|
|
21
|
+
# Prefer coreutils sha256sum (ubiquitous on Linux), then shasum (macOS), then
|
|
22
|
+
# cksum (POSIX — weaker, but the IDs only need stable uniqueness per input).
|
|
23
|
+
_rulemetric_short_hash() {
|
|
24
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
25
|
+
printf '%s' "$1" | sha256sum | cut -c1-16
|
|
26
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
27
|
+
printf '%s' "$1" | shasum -a 256 | cut -c1-16
|
|
28
|
+
else
|
|
29
|
+
printf '%s' "$1" | cksum | tr -d ' \t' | cut -c1-16
|
|
30
|
+
fi
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
_rulemetric_normalize() {
|
|
19
34
|
local input="$1"
|
|
20
35
|
|
package/scripts/_post.sh
CHANGED
|
@@ -18,6 +18,44 @@
|
|
|
18
18
|
_RM_POST_MAX_ATTEMPTS="${RULEMETRIC_POST_MAX_ATTEMPTS:-3}"
|
|
19
19
|
_RM_POST_RETRY_CAP_S="${RULEMETRIC_POST_RETRY_CAP_S:-2}"
|
|
20
20
|
|
|
21
|
+
# _rulemetric_beacon <tag> <http_code>
|
|
22
|
+
# Best-effort UNAUTHENTICATED availability beacon (POST /api/telemetry/cli-error,
|
|
23
|
+
# sent DIRECT with --noproxy — a broken local gateway is a prime cause of the
|
|
24
|
+
# failures this reports, so routing through it would suppress the signal). The
|
|
25
|
+
# hooks are the always-on capture component with NO other server-visible failure
|
|
26
|
+
# channel: when their sends exhaust retries, the only trace used to be a line in
|
|
27
|
+
# $TMPDIR — invisible for a remote device. Only availability classes beacon
|
|
28
|
+
# (5xx / network 000 / 408 / 429-exhausted); other 4xx is a rejection, not an
|
|
29
|
+
# outage. Rate-limited to once per hour per tag via a marker file (failures come
|
|
30
|
+
# in storms; one beacon per storm is the signal). Honors the telemetry opt-outs.
|
|
31
|
+
# Fire-and-forget: backgrounded, 2s bound, never fails the caller.
|
|
32
|
+
_rulemetric_beacon() {
|
|
33
|
+
local tag="$1" code="$2"
|
|
34
|
+
case "$code" in
|
|
35
|
+
5??|000|408|429) ;;
|
|
36
|
+
*) return 0 ;;
|
|
37
|
+
esac
|
|
38
|
+
[ "${RULEMETRIC_NO_TELEMETRY:-0}" != "0" ] && return 0
|
|
39
|
+
[ "${DO_NOT_TRACK:-0}" != "0" ] && return 0
|
|
40
|
+
local marker_dir="${TMPDIR:-/tmp}/rulemetric" marker now mtime
|
|
41
|
+
marker="$marker_dir/beacon.$tag"
|
|
42
|
+
mkdir -p "$marker_dir" 2>/dev/null || return 0
|
|
43
|
+
if [ -f "$marker" ]; then
|
|
44
|
+
now=$(date +%s 2>/dev/null || echo 0)
|
|
45
|
+
mtime=$(stat -f %m "$marker" 2>/dev/null || stat -c %Y "$marker" 2>/dev/null || echo 0)
|
|
46
|
+
[ $((now - mtime)) -lt 3600 ] && return 0
|
|
47
|
+
fi
|
|
48
|
+
: > "$marker" 2>/dev/null || true
|
|
49
|
+
local code_int proxied
|
|
50
|
+
code_int=$((10#$code)) 2>/dev/null || code_int=0
|
|
51
|
+
proxied=$([ -n "${HTTPS_PROXY:-}" ] && echo true || echo false)
|
|
52
|
+
( curl -s --noproxy '*' --max-time 2 -X POST \
|
|
53
|
+
"${RULEMETRIC_API_URL:-https://rulemetric.com}/api/telemetry/cli-error" \
|
|
54
|
+
-H 'Content-Type: application/json' \
|
|
55
|
+
--data-binary "{\"event\":\"hook_capture_failed\",\"command\":\"hooks:${tag}\",\"statusCode\":${code_int},\"proxied\":${proxied}}" \
|
|
56
|
+
> /dev/null 2>&1 || true ) &
|
|
57
|
+
}
|
|
58
|
+
|
|
21
59
|
# _rulemetric_post_event <url> <auth_header> <payload> <log_tag> <seq>
|
|
22
60
|
# Returns 0 on 2xx; 1 otherwise (after logging the final failure).
|
|
23
61
|
_rulemetric_post_event() {
|
|
@@ -30,11 +68,11 @@ _rulemetric_post_event() {
|
|
|
30
68
|
if [ -n "$hdr_file" ]; then
|
|
31
69
|
body=$(curl -s --noproxy '*' --max-time 4 -D "$hdr_file" -w '\n%{http_code}' \
|
|
32
70
|
-X POST "$url" -H "$auth" -H "Content-Type: application/json" \
|
|
33
|
-
-
|
|
71
|
+
--data-binary "$payload" 2>&1) || true
|
|
34
72
|
else
|
|
35
73
|
body=$(curl -s --noproxy '*' --max-time 4 -w '\n%{http_code}' \
|
|
36
74
|
-X POST "$url" -H "$auth" -H "Content-Type: application/json" \
|
|
37
|
-
-
|
|
75
|
+
--data-binary "$payload" 2>&1) || true
|
|
38
76
|
fi
|
|
39
77
|
http_code=$(printf '%s' "$body" | tail -1)
|
|
40
78
|
body=$(printf '%s' "$body" | sed '$d')
|
|
@@ -73,6 +111,7 @@ _rulemetric_post_event() {
|
|
|
73
111
|
done
|
|
74
112
|
|
|
75
113
|
_rulemetric_log "$tag" "error" "http_${http_code}_seq_${seq}_attempt_${attempt}: ${body}"
|
|
114
|
+
_rulemetric_beacon "$tag" "$http_code"
|
|
76
115
|
[ -n "$hdr_file" ] && rm -f "$hdr_file"
|
|
77
116
|
return 1
|
|
78
117
|
}
|
|
@@ -94,14 +133,20 @@ _rulemetric_send() {
|
|
|
94
133
|
hdr_file=$(mktemp "${TMPDIR:-/tmp}/rm-send.XXXXXX" 2>/dev/null || true)
|
|
95
134
|
|
|
96
135
|
while [ "$attempt" -le "$max_attempts" ]; do
|
|
136
|
+
# --data-binary (not -d) so a payload of "@/path/to/file" streams the body
|
|
137
|
+
# FROM that file verbatim. Large bodies (session-end reimport can carry a
|
|
138
|
+
# multi-MB transcript) MUST go via @file: an inline argv element that big
|
|
139
|
+
# exceeds the kernel's per-arg exec limit and curl never even starts (E2BIG)
|
|
140
|
+
# — which silently dropped every long session's reimport. Inline JSON
|
|
141
|
+
# payloads (always starting "{") behave identically under --data-binary.
|
|
97
142
|
if [ -n "$hdr_file" ]; then
|
|
98
143
|
body=$(curl -s --noproxy '*' --max-time "$max_time" -D "$hdr_file" -w '\n%{http_code}' \
|
|
99
144
|
-X "$method" "$url" -H "$auth" -H "Content-Type: application/json" \
|
|
100
|
-
-
|
|
145
|
+
--data-binary "$payload" 2>&1) || true
|
|
101
146
|
else
|
|
102
147
|
body=$(curl -s --noproxy '*' --max-time "$max_time" -w '\n%{http_code}' \
|
|
103
148
|
-X "$method" "$url" -H "$auth" -H "Content-Type: application/json" \
|
|
104
|
-
-
|
|
149
|
+
--data-binary "$payload" 2>&1) || true
|
|
105
150
|
fi
|
|
106
151
|
http_code=$(printf '%s' "$body" | tail -1)
|
|
107
152
|
body=$(printf '%s' "$body" | sed '$d')
|
|
@@ -138,6 +183,85 @@ _rulemetric_send() {
|
|
|
138
183
|
done
|
|
139
184
|
|
|
140
185
|
_rulemetric_log "$tag" "error" "http_${http_code}_attempt_${attempt}: ${body}"
|
|
186
|
+
_rulemetric_beacon "$tag" "$http_code"
|
|
187
|
+
[ -n "$hdr_file" ] && rm -f "$hdr_file"
|
|
188
|
+
return 1
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# _rulemetric_send_capture <method> <url> <auth> <payload> <log_tag> [max_time_s] [max_attempts]
|
|
192
|
+
# Like _rulemetric_send, but ECHOES the 2xx response body to stdout so callers
|
|
193
|
+
# that must READ the response can. Its reason to exist: lazy session-create
|
|
194
|
+
# (_ensure-session.sh) needs the new session's `.id` from the response body, and
|
|
195
|
+
# previously used a bare `curl -sf` with NO retry — so a transient 503/429/502
|
|
196
|
+
# during the incident window silently dropped the whole session, and with it every
|
|
197
|
+
# event that would have attached to it (the session row never existed). This gives
|
|
198
|
+
# session-create the SAME bounded 503/429/network retry + capped Retry-After as the
|
|
199
|
+
# event path. 4xx is permanent (no retry). Returns 0 on 2xx (body on stdout); 1
|
|
200
|
+
# otherwise (nothing on stdout). Stays SILENT on failure — the caller inspects
|
|
201
|
+
# the return value and logs its own outcome (e.g. session-create → skip).
|
|
202
|
+
_rulemetric_send_capture() {
|
|
203
|
+
local method="$1" url="$2" auth="$3" payload="$4" tag="$5" max_time="${6:-4}" max_attempts="${7:-$_RM_POST_MAX_ATTEMPTS}"
|
|
204
|
+
local attempt=1 http_code="000" body="" hdr_file retry_after sleep_s
|
|
205
|
+
_RM_CAPTURE_LAST_CODE=""
|
|
206
|
+
|
|
207
|
+
hdr_file=$(mktemp "${TMPDIR:-/tmp}/rm-cap.XXXXXX" 2>/dev/null || true)
|
|
208
|
+
|
|
209
|
+
while [ "$attempt" -le "$max_attempts" ]; do
|
|
210
|
+
if [ -n "$hdr_file" ]; then
|
|
211
|
+
body=$(curl -s --noproxy '*' --max-time "$max_time" -D "$hdr_file" -w '\n%{http_code}' \
|
|
212
|
+
-X "$method" "$url" -H "$auth" -H "Content-Type: application/json" \
|
|
213
|
+
--data-binary "$payload" 2>&1) || true
|
|
214
|
+
else
|
|
215
|
+
body=$(curl -s --noproxy '*' --max-time "$max_time" -w '\n%{http_code}' \
|
|
216
|
+
-X "$method" "$url" -H "$auth" -H "Content-Type: application/json" \
|
|
217
|
+
--data-binary "$payload" 2>&1) || true
|
|
218
|
+
fi
|
|
219
|
+
http_code=$(printf '%s' "$body" | tail -1)
|
|
220
|
+
body=$(printf '%s' "$body" | sed '$d')
|
|
221
|
+
|
|
222
|
+
case "$http_code" in 2??)
|
|
223
|
+
printf '%s' "$body" # give the caller the response (e.g. to read .id)
|
|
224
|
+
[ -n "$hdr_file" ] && rm -f "$hdr_file"
|
|
225
|
+
return 0
|
|
226
|
+
;;
|
|
227
|
+
esac
|
|
228
|
+
|
|
229
|
+
if [ "$http_code" = "503" ] || [ "$http_code" = "429" ] || [ "$http_code" = "000" ]; then
|
|
230
|
+
if [ "$attempt" -lt "$max_attempts" ]; then
|
|
231
|
+
retry_after=""
|
|
232
|
+
if [ -n "$hdr_file" ]; then
|
|
233
|
+
retry_after=$(grep -i '^Retry-After:' "$hdr_file" 2>/dev/null | tail -1 | tr -dc '0-9' || true)
|
|
234
|
+
fi
|
|
235
|
+
if [ -n "$retry_after" ] && [ "$retry_after" -gt 0 ] 2>/dev/null; then
|
|
236
|
+
sleep_s="$retry_after"
|
|
237
|
+
else
|
|
238
|
+
sleep_s="$attempt"
|
|
239
|
+
fi
|
|
240
|
+
if [ "$sleep_s" -gt "$_RM_POST_RETRY_CAP_S" ] 2>/dev/null; then
|
|
241
|
+
sleep_s="$_RM_POST_RETRY_CAP_S"
|
|
242
|
+
fi
|
|
243
|
+
sleep "$sleep_s" 2>/dev/null || true
|
|
244
|
+
attempt=$((attempt + 1))
|
|
245
|
+
continue
|
|
246
|
+
fi
|
|
247
|
+
fi
|
|
248
|
+
|
|
249
|
+
break
|
|
250
|
+
done
|
|
251
|
+
|
|
252
|
+
# Unlike _rulemetric_send / _rulemetric_post_event (fire-and-forget writes whose
|
|
253
|
+
# ONLY failure signal is this log line), _rulemetric_send_capture's caller
|
|
254
|
+
# inspects the return value and logs its OWN outcome — session-create logs
|
|
255
|
+
# skip/no_rw_session when the API is unreachable (see _ensure-session.sh →
|
|
256
|
+
# user-prompt.sh). Logging an "error" here too would prepend a second log entry
|
|
257
|
+
# and mis-classify that benign skip as an error (regressed hooks.test.ts). So
|
|
258
|
+
# the helper stays SILENT on failure; the caller owns the outcome — the final
|
|
259
|
+
# HTTP code is exported in _RM_CAPTURE_LAST_CODE so the caller's log can carry
|
|
260
|
+
# the forensics (a bare "no_rw_session" is undiagnosable from a remote device).
|
|
261
|
+
# The beacon is server-side, not a log entry, so it doesn't break the contract.
|
|
262
|
+
# Deep-debug via RULEMETRIC_HOOK_DEBUG remains available on the caller side.
|
|
263
|
+
_RM_CAPTURE_LAST_CODE="$http_code"
|
|
264
|
+
_rulemetric_beacon "$tag" "$http_code"
|
|
141
265
|
[ -n "$hdr_file" ] && rm -f "$hdr_file"
|
|
142
266
|
return 1
|
|
143
267
|
}
|
|
@@ -31,8 +31,8 @@ trap cleanup EXIT
|
|
|
31
31
|
|
|
32
32
|
# --- Stub server: counts requests, programmed by $MODE in a control file --------
|
|
33
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 | always-202
|
|
34
|
+
import hashlib, http.server, json, os, sys, threading
|
|
35
|
+
mode = sys.argv[1] # fail-once | always-503 | always-400 | always-202 | echo-len
|
|
36
36
|
count_file = sys.argv[2]
|
|
37
37
|
n = 0
|
|
38
38
|
lock = threading.Lock()
|
|
@@ -44,7 +44,10 @@ class H(http.server.BaseHTTPRequestHandler):
|
|
|
44
44
|
n += 1; cur = n
|
|
45
45
|
open(count_file, "w").write(str(n))
|
|
46
46
|
length = int(self.headers.get('Content-Length', 0) or 0)
|
|
47
|
-
self.rfile.read(length)
|
|
47
|
+
body = self.rfile.read(length)
|
|
48
|
+
if mode == "echo-len":
|
|
49
|
+
resp = json.dumps({"len": len(body), "sha": hashlib.sha256(body).hexdigest()})
|
|
50
|
+
self.send_response(200); self.end_headers(); self.wfile.write(resp.encode()); return
|
|
48
51
|
if mode == "always-400":
|
|
49
52
|
self.send_response(400); self.end_headers(); self.wfile.write(b'{"error":"bad"}'); return
|
|
50
53
|
if mode == "always-503":
|
|
@@ -80,6 +83,10 @@ LOG_FILE="$WORK_ROOT/log"
|
|
|
80
83
|
_rulemetric_log() { echo "$*" >> "$LOG_FILE"; } # stub: record log calls
|
|
81
84
|
export RULEMETRIC_POST_MAX_ATTEMPTS=3
|
|
82
85
|
export RULEMETRIC_POST_RETRY_CAP_S=1
|
|
86
|
+
# The failure paths fire an availability beacon at $RULEMETRIC_API_URL — which
|
|
87
|
+
# in these tests is the counting stub server. Disable telemetry so the beacon
|
|
88
|
+
# can't inflate req_count (it's async, which would also make counts racy).
|
|
89
|
+
export RULEMETRIC_NO_TELEMETRY=1
|
|
83
90
|
# shellcheck source=/dev/null
|
|
84
91
|
source "$SCRIPT_DIR/_post.sh"
|
|
85
92
|
|
|
@@ -127,6 +134,64 @@ assert_eq "always-202: server saw exactly 1 request (no retry)" "1" "$(req_count
|
|
|
127
134
|
assert_eq "always-202: no error logged" "0" "$(wc -l < "$LOG_FILE" | tr -d ' ')"
|
|
128
135
|
stop_server
|
|
129
136
|
|
|
137
|
+
# --- Test 5: _rulemetric_send_capture retries a transient 503 AND returns body -
|
|
138
|
+
# Session-create (lazy recovery) uses this to survive a transient 503/502 that
|
|
139
|
+
# previously dropped the whole session. It must retry AND hand back the 2xx body
|
|
140
|
+
# so the caller can read the new session's .id.
|
|
141
|
+
start_server fail-once
|
|
142
|
+
: > "$LOG_FILE"
|
|
143
|
+
cap_body=$(_rulemetric_send_capture POST "http://127.0.0.1:$SERVER_PORT/api/sessions" "$AUTH" "$PAYLOAD" "test" 4)
|
|
144
|
+
rc=$?
|
|
145
|
+
assert_eq "capture/fail-once: returns success (0) after retry" "0" "$rc"
|
|
146
|
+
assert_eq "capture/fail-once: server saw exactly 2 requests (fail + retry)" "2" "$(req_count)"
|
|
147
|
+
assert_eq "capture/fail-once: echoes the 2xx body for the caller to parse .id" '{"recorded":true}' "$cap_body"
|
|
148
|
+
stop_server
|
|
149
|
+
|
|
150
|
+
# --- Test 6: _rulemetric_send_capture gives up on sustained 503, empty stdout --
|
|
151
|
+
start_server always-503
|
|
152
|
+
: > "$LOG_FILE"
|
|
153
|
+
cap_body=$(_rulemetric_send_capture POST "http://127.0.0.1:$SERVER_PORT/api/sessions" "$AUTH" "$PAYLOAD" "test" 4)
|
|
154
|
+
rc=$?
|
|
155
|
+
assert_eq "capture/always-503: returns failure (1) after exhausting attempts" "1" "$rc"
|
|
156
|
+
assert_eq "capture/always-503: server saw exactly MAX_ATTEMPTS (3) requests" "3" "$(req_count)"
|
|
157
|
+
assert_eq "capture/always-503: NO body on stdout on failure (caller sees empty → skips)" "" "$cap_body"
|
|
158
|
+
# The capture helper stays SILENT on failure — the caller (session-create) logs
|
|
159
|
+
# its own outcome (skip/no_rw_session). A stray 'error' entry here would
|
|
160
|
+
# mis-classify that benign skip (regressed hooks.test.ts).
|
|
161
|
+
assert_eq "capture/always-503: helper logs NOTHING on failure (caller owns outcome)" "0" "$(wc -l < "$LOG_FILE" | tr -d ' ')"
|
|
162
|
+
stop_server
|
|
163
|
+
|
|
164
|
+
# --- Test 7: _rulemetric_send_capture does NOT retry a permanent 4xx -----------
|
|
165
|
+
start_server always-400
|
|
166
|
+
: > "$LOG_FILE"
|
|
167
|
+
_rulemetric_send_capture POST "http://127.0.0.1:$SERVER_PORT/api/sessions" "$AUTH" "$PAYLOAD" "test" 4 >/dev/null
|
|
168
|
+
rc=$?
|
|
169
|
+
assert_eq "capture/always-400: returns failure (1)" "1" "$rc"
|
|
170
|
+
assert_eq "capture/always-400: server saw exactly 1 request (no retry on 4xx)" "1" "$(req_count)"
|
|
171
|
+
stop_server
|
|
172
|
+
|
|
173
|
+
# --- Test 8: "@file" payload streams a multi-MB body (reimport E2BIG fix) -----
|
|
174
|
+
# session-end reimport passes "@/tmp/file" so transcripts never ride argv: a
|
|
175
|
+
# single argv element bigger than the kernel per-arg limit meant curl never
|
|
176
|
+
# even exec'd (E2BIG) and long sessions silently NEVER reimported. Assert a
|
|
177
|
+
# 2 MB body arrives complete and byte-identical via _rulemetric_send_capture
|
|
178
|
+
# (same curl invocation shape as _rulemetric_send, but echoes the response
|
|
179
|
+
# so we can read back the server-computed length + sha).
|
|
180
|
+
start_server echo-len
|
|
181
|
+
BIG_FILE="$WORK_ROOT/big-payload.json"
|
|
182
|
+
"$PYTHON" -c "import json; print(json.dumps({'content': 'x' * (2 * 1024 * 1024), 'tool': 'claude_code'}))" > "$BIG_FILE"
|
|
183
|
+
expected_len=$(wc -c < "$BIG_FILE" | tr -d ' ')
|
|
184
|
+
expected_sha=$("$PYTHON" -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$BIG_FILE")
|
|
185
|
+
: > "$LOG_FILE"
|
|
186
|
+
big_body=$(_rulemetric_send_capture POST "http://127.0.0.1:$SERVER_PORT/api/sessions/x/reimport" "$AUTH" "@$BIG_FILE" "test" 10)
|
|
187
|
+
rc=$?
|
|
188
|
+
assert_eq "atfile: returns success (0)" "0" "$rc"
|
|
189
|
+
got_len=$(printf '%s' "$big_body" | "$PYTHON" -c "import json,sys; print(json.load(sys.stdin)['len'])" 2>/dev/null || echo parse-error)
|
|
190
|
+
got_sha=$(printf '%s' "$big_body" | "$PYTHON" -c "import json,sys; print(json.load(sys.stdin)['sha'])" 2>/dev/null || echo parse-error)
|
|
191
|
+
assert_eq "atfile: server received the full 2MB body" "$expected_len" "$got_len"
|
|
192
|
+
assert_eq "atfile: body byte-identical (sha256)" "$expected_sha" "$got_sha"
|
|
193
|
+
stop_server
|
|
194
|
+
|
|
130
195
|
# --- Summary -----------------------------------------------------------------
|
|
131
196
|
echo
|
|
132
197
|
echo "Passed: $PASSES Failed: $FAILURES"
|
package/scripts/session-end.sh
CHANGED
|
@@ -44,33 +44,46 @@ if ! _rulemetric_ensure_session "session-end"; then
|
|
|
44
44
|
fi
|
|
45
45
|
|
|
46
46
|
# Try to reimport with full transcript, fall back to close
|
|
47
|
+
REIMPORT_FILE=""
|
|
47
48
|
if [ -n "$HOOK_TRANSCRIPT_PATH" ] && [ -f "$HOOK_TRANSCRIPT_PATH" ]; then
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
# Build the payload in a TEMP FILE and stream it with curl's @file form. It
|
|
50
|
+
# must never ride argv: a long session's transcript blows the kernel per-arg
|
|
51
|
+
# exec limit (E2BIG) and curl never starts — long sessions silently NEVER
|
|
52
|
+
# reimported. jq builds {content, tool} straight from the transcript so the
|
|
53
|
+
# whole body is also never held in a shell variable.
|
|
54
|
+
REIMPORT_FILE=$(mktemp "${TMPDIR:-/tmp}/rm-reimport.XXXXXX" 2>/dev/null || true)
|
|
55
|
+
if [ -n "$REIMPORT_FILE" ] && jq -Rs --arg tool "$HOOK_TOOL" '{content: ., tool: $tool}' \
|
|
56
|
+
< "$HOOK_TRANSCRIPT_PATH" > "$REIMPORT_FILE" 2>/dev/null; then
|
|
57
|
+
# Retry-on-503; FOREGROUND, blocks the hook, so cap at 2 attempts. max-time 20
|
|
58
|
+
# gives a multi-MB body room to actually transfer on a slow uplink (worst-case
|
|
59
|
+
# hard-hang tail 20+1+20 = 41s, still under Claude Code's 60s hook timeout);
|
|
60
|
+
# the common fast-503 wedge still recovers in <1s. Fall back to close if it
|
|
61
|
+
# still fails after retries — a dropped reimport leaves the session EMPTY.
|
|
62
|
+
# NOTE: under headless `claude -p`, Claude Code's print-mode teardown cancels this
|
|
63
|
+
# hook ("SessionEnd hook … failed: Hook cancelled") regardless of whether this runs
|
|
64
|
+
# in the foreground or is backgrounded — verified 2026-07-05. It is a Claude Code
|
|
65
|
+
# print-mode behavior, not a capture failure (per-turn hooks still land events); do
|
|
66
|
+
# NOT try to "fix" it by detaching this call (adds no benefit, and headless is a
|
|
67
|
+
# deliberately-excluded capture vehicle).
|
|
68
|
+
_rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/reimport" \
|
|
69
|
+
"$AUTH_HEADER" "@$REIMPORT_FILE" "session-end-reimport" 20 2 \
|
|
70
|
+
> /dev/null 2>&1 || {
|
|
71
|
+
_rulemetric_log "session-end" "fallback" "reimport_failed"
|
|
72
|
+
_rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
|
|
73
|
+
"$AUTH_HEADER" '{}' "session-end-close" 4 2 > /dev/null 2>&1 || \
|
|
74
|
+
_rulemetric_log "session-end" "error" "close_failed"
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
_rulemetric_log "session-end" "fallback" "reimport_payload_build_failed"
|
|
78
|
+
_rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
|
|
79
|
+
"$AUTH_HEADER" '{}' "session-end-close" 4 2 > /dev/null 2>&1 || \
|
|
67
80
|
_rulemetric_log "session-end" "error" "close_failed"
|
|
68
|
-
|
|
81
|
+
fi
|
|
82
|
+
[ -n "$REIMPORT_FILE" ] && rm -f "$REIMPORT_FILE" 2>/dev/null || true
|
|
69
83
|
else
|
|
70
84
|
# No transcript available — just close the session
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
> /dev/null 2>&1 || \
|
|
85
|
+
_rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
|
|
86
|
+
"$AUTH_HEADER" '{}' "session-end-close" 4 2 > /dev/null 2>&1 || \
|
|
74
87
|
_rulemetric_log "session-end" "error" "close_failed"
|
|
75
88
|
fi
|
|
76
89
|
|
|
@@ -86,19 +99,23 @@ _rulemetric_log "session-end" "success"
|
|
|
86
99
|
if [ "$HOOK_TOOL" = "claude_code" ]; then
|
|
87
100
|
INSTR_SNAPSHOT=$(rulemetric hooks emit-instructions --project-dir "${HOOK_CWD:-$(pwd)}" 2>/dev/null || true)
|
|
88
101
|
if [ -n "$INSTR_SNAPSHOT" ]; then
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
# Bounded 503/429 retry — this snapshot is the ONLY instruction-linking
|
|
103
|
+
# signal a hooks-only (no-proxy) session ever emits; a one-shot curl made
|
|
104
|
+
# one transient shed permanently unlink the session's instructions.
|
|
105
|
+
_rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/context-snapshots" \
|
|
106
|
+
"$AUTH_HEADER" "$INSTR_SNAPSHOT" "session-end-instr-snapshot" 5 2 \
|
|
107
|
+
> /dev/null 2>&1 || _rulemetric_log "session-end" "warn" "instruction_snapshot_failed"
|
|
93
108
|
fi
|
|
94
109
|
fi
|
|
95
110
|
|
|
96
111
|
# ── Auto-link instructions from context snapshots ──
|
|
97
|
-
# Fire-and-forget
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
# Fire-and-forget (backgrounded), but with the same bounded 503/429 retry as
|
|
113
|
+
# the other session-end writes — one transient shed used to drop it silently.
|
|
114
|
+
# Scoped to THIS session: the API treats an unscoped body as a no-op (the
|
|
115
|
+
# account-wide scan is explicit-backfill only). RW_SESSION_ID is
|
|
116
|
+
# UUID-validated by _ensure-session, so it is safe to interpolate into JSON.
|
|
117
|
+
(_rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/link-instructions" \
|
|
118
|
+
"$AUTH_HEADER" "{\"sessionId\":\"$RW_SESSION_ID\"}" "session-end-link" 8 > /dev/null 2>&1) &
|
|
102
119
|
|
|
103
120
|
# ── Auto-enrich from local session-meta ──
|
|
104
121
|
# For Claude Code: read local session-meta file
|
package/scripts/session-start.sh
CHANGED
|
@@ -59,21 +59,15 @@ _rulemetric_check_auth() {
|
|
|
59
59
|
|
|
60
60
|
_rulemetric_check_auth
|
|
61
61
|
|
|
62
|
-
# ── Gateway auto-recovery ──
|
|
62
|
+
# ── Gateway auto-recovery + stale-addon convergence ──
|
|
63
63
|
# Ensure the gateway proxy is running so Claude Code can always connect.
|
|
64
64
|
# The gateway routes direct when mitmproxy is off, so this is non-breaking.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
rulemetric gateway ensure 2>/dev/null || true
|
|
72
|
-
fi
|
|
73
|
-
else
|
|
74
|
-
# No PID file — try to start gateway
|
|
75
|
-
rulemetric gateway ensure 2>/dev/null || true
|
|
76
|
-
fi
|
|
65
|
+
# UNCONDITIONAL (not gated on the PID quick-check): `gateway ensure` is also
|
|
66
|
+
# the component that detects a HEALTHY proxy running a stale addon after an
|
|
67
|
+
# `npm i -g` upgrade (the gateway↔addon drift restart is circular — both
|
|
68
|
+
# compare versions frozen at their own spawn) and restarts it. Backgrounded so
|
|
69
|
+
# the node boot never delays the hook.
|
|
70
|
+
(rulemetric gateway ensure > /dev/null 2>&1 || true) &
|
|
77
71
|
|
|
78
72
|
# ── Proxy auto-recovery ──
|
|
79
73
|
# Ensure the capture proxy is running so events + context are captured.
|