Files
CASAN/packages/casan-harness/tests/phase-h6-agentops-tests.sh
T
thanhnvandClaude Opus 4.8 36a4812ef3 refactor(structure): promote app to repo root + remove redundant workspace cruft
Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.

- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
  .specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
  active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
  launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
  tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
  README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
  artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
  - .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
    working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
    -> packages/casan-harness/... (.specify/logs state kept)
  - .claude/launch.json, .gitea/*-runbook.md: path prefixes
  - CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
  - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.

Full gate from the new root: PASS=64 FAIL=0 SKIP=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:26:36 +09:00

260 lines
11 KiB
Bash

#!/usr/bin/env bash
set -uo pipefail
# CASAN H6 — AgentOps hardening tests (D1 live alerting · D2 provider-telemetry
# API + reconciliation · D3 hosted dashboard · V15 sliding-window breaker).
#
# All checks run LIVE against real HTTP endpoints started locally (webhook sink,
# mock provider usage API, dashboard server) — the same "live" standard as the
# Vault-dev KMS tests. Deterministic: no model needed.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
S="$CASAN_HARNESS_ROOT/scripts/bash"
WORK="$(mktemp -d)"
SINK_PID=""; API_PID=""
cleanup() {
[[ -n "$SINK_PID" ]] && { kill "$SINK_PID" 2>/dev/null; wait "$SINK_PID" 2>/dev/null; }
[[ -n "$API_PID" ]] && { kill "$API_PID" 2>/dev/null; wait "$API_PID" 2>/dev/null; }
CASAN_AGENTOPS_DIR="$WORK/agentops" bash "$S/dashboard-serve.sh" stop >/dev/null 2>&1
rm -rf "$WORK"
}
trap cleanup EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
expect_rc() {
local want="$1" desc="$2"; shift 2
local got=0; { "$@" >/dev/null 2>&1; } || got=$?
[[ "$got" -eq "$want" ]] && pass "$desc (rc=$got)" || fail "$desc (got rc=$got, want $want)"
}
free_port() {
python - <<'PY'
import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()
PY
}
# ── live webhook sink (records every POST body) ────────────────────────────
cat > "$WORK/sink.py" <<'PY'
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
port, out = int(sys.argv[1]), sys.argv[2]
class H(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(n)
with open(out, "ab") as f:
f.write(body + b"\n")
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"ok":true}')
def log_message(self, *a):
pass
HTTPServer(("127.0.0.1", port), H).serve_forever()
PY
# ── live mock provider usage API (serves a JSON file on GET) ───────────────
cat > "$WORK/mockapi.py" <<'PY'
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
port, src = int(sys.argv[1]), sys.argv[2]
class H(BaseHTTPRequestHandler):
def do_GET(self):
data = open(src, "rb").read()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *a):
pass
HTTPServer(("127.0.0.1", port), H).serve_forever()
PY
wait_http() { # <url> — poll until reachable (any status)
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
curl -sS -m 2 -o /dev/null "$1" 2>/dev/null && return 0
sleep 0.2
done
return 1
}
export CASAN_AGENTOPS_DIR="$WORK/agentops"
mkdir -p "$CASAN_AGENTOPS_DIR"
echo "===== ① H6 live alert dispatch (webhook, dedup, dead-letter) ====="
SINK_PORT="$(free_port)"
SINK_OUT="$WORK/sink-received.jsonl"
: > "$SINK_OUT"
python "$WORK/sink.py" "$SINK_PORT" "$SINK_OUT" &
SINK_PID=$!
wait_http "http://127.0.0.1:$SINK_PORT/" || true
cat > "$WORK/alert1.json" <<'EOF'
{"timestamp":"2026-07-05T00:00:00Z","trace_id":"t-1","severity":"WARN","resource":{"service.name":"demo.agent","service.version":"1.0.0"},"body":{"message":"Alert triggered: execution-failed","alert.type":"execution-failed","step.name":"write_code"},"attributes":{"latency_ms":100,"status":"failed"}}
EOF
export CASAN_ALERT_WEBHOOK="http://127.0.0.1:$SINK_PORT/hook"
expect_rc 0 "alert is DELIVERED live to the webhook (ALERT_DISPATCHED)" \
bash "$S/alert-dispatch.sh" "$WORK/alert1.json"
if grep -q '"alert_type": *"execution-failed"' "$SINK_OUT"; then
pass "webhook sink received the alert payload (severity routed as CRITICAL)"
else
fail "webhook sink did not receive the alert payload"
fi
BEFORE_COUNT="$(grep -c . "$SINK_OUT" || true)"
OUT2="$(bash "$S/alert-dispatch.sh" "$WORK/alert1.json" 2>&1)"
AFTER_COUNT="$(grep -c . "$SINK_OUT" || true)"
if echo "$OUT2" | grep -q "ALERT_DEDUP_SUPPRESSED" && [[ "$BEFORE_COUNT" == "$AFTER_COUNT" ]]; then
pass "duplicate alert inside dedup window is SUPPRESSED (no double page)"
else
fail "duplicate alert was not suppressed (out=$OUT2 before=$BEFORE_COUNT after=$AFTER_COUNT)"
fi
DEAD_PORT="$(free_port)" # nothing listens here
cat > "$WORK/alert2.json" <<'EOF'
{"timestamp":"2026-07-05T00:01:00Z","trace_id":"t-2","severity":"WARN","resource":{"service.name":"demo.agent","service.version":"1.0.0"},"body":{"message":"Alert triggered: cost-spike","alert.type":"cost-spike","step.name":"plan"},"attributes":{"latency_ms":100,"status":"success"}}
EOF
expect_rc 1 "webhook DOWN + strict => fail-loud (ALERT_DELIVERY_FAILED, no silent loss)" \
env CASAN_ALERT_WEBHOOK="http://127.0.0.1:$DEAD_PORT/hook" CASAN_ALERT_STRICT=1 \
bash "$S/alert-dispatch.sh" "$WORK/alert2.json"
if [[ -s "$CASAN_AGENTOPS_DIR/alert-deadletter.jsonl" ]]; then
pass "undelivered alert queued to dead-letter (not lost)"
else
fail "dead-letter queue empty after failed delivery"
fi
FLUSH_OUT="$(bash "$S/alert-dispatch.sh" --flush-deadletter 2>&1)"; FLUSH_RC=$?
if [[ "$FLUSH_RC" -eq 0 ]] && echo "$FLUSH_OUT" | grep -q "redelivered=1 remaining=0"; then
pass "dead-letter flush REDELIVERS the alert once the channel is back"
else
fail "dead-letter flush failed (rc=$FLUSH_RC out=$FLUSH_OUT)"
fi
# end-to-end: a failing pipeline step pushes a live alert through agent-metrics
printf 'input\n' > "$WORK/in.txt"
: > "$SINK_OUT"
CASAN_AGENT_NAME="h6.e2e" CASAN_STEP_NAME="e2e_fail_step" \
bash "$S/agent-metrics.sh" "$WORK/in.txt" "$WORK/out.txt" -- bash -c 'exit 3' >/dev/null 2>&1
if grep -q '"step": *"e2e_fail_step"' "$SINK_OUT"; then
pass "END-TO-END: failing step -> agent-metrics -> live webhook alert received"
else
fail "end-to-end live alert not received by webhook sink"
fi
unset CASAN_ALERT_WEBHOOK
echo "===== ② H6 provider-telemetry API + reconciliation ====="
cat > "$WORK/usage-valid.json" <<'EOF'
[
{"provider":"ollama","model":"ornith:9b","run_id":"r1","step":"plan","input_tokens":200,"output_tokens":300,"total_tokens":500,"cost_usd":0.0,"latency_ms":900,"status":"success"},
{"provider":"ollama","model":"ornith:9b","run_id":"r1","step":"code","input_tokens":400,"output_tokens":600,"total_tokens":1000,"cost_usd":0.0,"latency_ms":1200,"status":"success"}
]
EOF
API_PORT="$(free_port)"
python "$WORK/mockapi.py" "$API_PORT" "$WORK/usage-valid.json" &
API_PID=$!
wait_http "http://127.0.0.1:$API_PORT/usage" || true
FETCH_LOG="$WORK/provider-usage.jsonl"
expect_rc 0 "usage records FETCHED live from provider API (PROVIDER_TELEMETRY_FETCHED)" \
bash "$S/provider-usage-fetch.sh" "http://127.0.0.1:$API_PORT/usage" "$FETCH_LOG"
[[ "$(grep -c . "$FETCH_LOG" 2>/dev/null)" == "2" ]] \
&& pass "both API records imported with api_endpoint provenance" \
|| fail "expected 2 imported records in $FETCH_LOG"
cat > "$WORK/usage-invalid.json" <<'EOF'
[{"provider":"ollama","model":"ornith:9b","step":"plan","total_tokens":500}]
EOF
kill "$API_PID" 2>/dev/null; wait "$API_PID" 2>/dev/null
python "$WORK/mockapi.py" "$API_PORT" "$WORK/usage-invalid.json" &
API_PID=$!
wait_http "http://127.0.0.1:$API_PORT/usage" || true
BAD_LOG="$WORK/provider-usage-bad.jsonl"
expect_rc 1 "invalid API schema is REJECTED all-or-nothing (PROVIDER_USAGE_INVALID)" \
bash "$S/provider-usage-fetch.sh" "http://127.0.0.1:$API_PORT/usage" "$BAD_LOG"
[[ ! -s "$BAD_LOG" ]] \
&& pass "nothing imported from an invalid API response (no dirty telemetry)" \
|| fail "invalid response leaked records into $BAD_LOG"
UNREACH_PORT="$(free_port)"
expect_rc 1 "unreachable provider API => fail-loud (PROVIDER_API_UNREACHABLE)" \
bash "$S/provider-usage-fetch.sh" "http://127.0.0.1:$UNREACH_PORT/usage" "$WORK/never.jsonl"
# reconciliation: local metrics vs provider ground truth
cat > "$WORK/local-ok.jsonl" <<'EOF'
{"step":"plan","total_tokens":495}
{"step":"code","total_tokens":1000}
EOF
expect_rc 0 "local metrics reconcile with provider API (TELEMETRY_RECONCILED)" \
bash "$S/telemetry-reconcile.sh" "$WORK/local-ok.jsonl" "$FETCH_LOG" 10
cat > "$WORK/local-under.jsonl" <<'EOF'
{"step":"plan","total_tokens":50}
{"step":"code","total_tokens":1000}
EOF
expect_rc 1 "local UNDER-REPORTING (cost-hiding) is caught (TELEMETRY_DISCREPANCY)" \
bash "$S/telemetry-reconcile.sh" "$WORK/local-under.jsonl" "$FETCH_LOG" 10
echo "===== ③ H6 hosted dashboard (/healthz stale-aware) ====="
DASH_PORT="$(free_port)"
FRESH_METRICS="$WORK/metrics-fresh.jsonl"
printf '{"trace_id":"t","step":"s","status":"success","latency_ms":10,"total_tokens":5,"cost_estimate":0}\n' > "$FRESH_METRICS"
CASAN_DASHBOARD_METRICS="$FRESH_METRICS" bash "$S/dashboard-serve.sh" start "$DASH_PORT" >/dev/null 2>&1
HEALTH="$(curl -sS -m 3 -w '\n%{http_code}' "http://127.0.0.1:$DASH_PORT/healthz" 2>/dev/null)"
if [[ "${HEALTH##*$'\n'}" == "200" ]] && echo "$HEALTH" | grep -q '"status": *"ok"'; then
pass "dashboard HOSTED: /healthz live returns 200 ok with fresh metrics"
else
fail "healthz not ok (got: $HEALTH)"
fi
if curl -sS -m 3 "http://127.0.0.1:$DASH_PORT/" 2>/dev/null | grep -q "AgentOps Dashboard"; then
pass "dashboard HTML is served live over HTTP (no static-file-only)"
else
fail "dashboard HTML not served over HTTP"
fi
bash "$S/dashboard-serve.sh" stop >/dev/null 2>&1
STALE_METRICS="$WORK/metrics-stale.jsonl"
printf '{"step":"s","total_tokens":5}\n' > "$STALE_METRICS"
touch -t 202601010000 "$STALE_METRICS"
CASAN_DASHBOARD_METRICS="$STALE_METRICS" bash "$S/dashboard-serve.sh" start "$DASH_PORT" >/dev/null 2>&1
HEALTH2="$(curl -sS -m 3 -w '\n%{http_code}' "http://127.0.0.1:$DASH_PORT/healthz" 2>/dev/null)"
if [[ "${HEALTH2##*$'\n'}" == "503" ]] && echo "$HEALTH2" | grep -q '"status": *"stale"'; then
pass "STALE telemetry => /healthz 503 stale (silent telemetry death is page-able)"
else
fail "stale metrics not detected by healthz (got: $HEALTH2)"
fi
bash "$S/dashboard-serve.sh" stop >/dev/null 2>&1
echo "===== ④ H6 sliding-window circuit breaker (V15 interleave evasion) ====="
ALT_LOG="$WORK/alt-usage.jsonl"
: > "$ALT_LOG"
for i in 1 2 3 4 5; do
printf '{"step":"s%s","status":"error","total_tokens":10}\n' "$i" >> "$ALT_LOG"
printf '{"step":"s%s","status":"success","total_tokens":10}\n' "$i" >> "$ALT_LOG"
done
expect_rc 1 "alternating fail/success EVADES consecutive counter but TRIPS window breaker (CIRCUIT_OPEN_WINDOW)" \
env CASAN_PROVIDER_LOG="$ALT_LOG" bash "$S/circuit-breaker-check.sh" --breaker-only
grep -q "CIRCUIT_OPEN_WINDOW" <(env CASAN_PROVIDER_LOG="$ALT_LOG" bash "$S/circuit-breaker-check.sh" --breaker-only 2>&1) \
&& pass "window breaker reason is CIRCUIT_OPEN_WINDOW (rate-based, not consecutive)" \
|| fail "window breaker reason missing"
OK_LOG="$WORK/ok-usage.jsonl"
: > "$OK_LOG"
for i in 1 2 3 4 5 6 7 8 9 10; do
printf '{"step":"s%s","status":"success","total_tokens":10}\n' "$i" >> "$OK_LOG"
done
expect_rc 0 "healthy provider log keeps BOTH breakers closed (no false trip)" \
env CASAN_PROVIDER_LOG="$OK_LOG" bash "$S/circuit-breaker-check.sh" --breaker-only
echo ""
echo "===== H6 AGENTOPS SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1