add live zeroclaw follow dashboard and webhook cost guard

This commit is contained in:
Clément SAILLANT
2026-02-21 01:49:59 +01:00
parent dcd35339a0
commit 72a6783192
4 changed files with 335 additions and 22 deletions
+36 -3
View File
@@ -68,12 +68,17 @@ This keeps `autonomy.workspace_only = true` effective on a per-repo boundary.
- token sourcing from `gh auth token` at runtime only when `copilot` is selected.
- `tools/ai/zeroclaw_stack_up.sh`
- starts local gateway and local follow server,
- generates local follow page at `http://127.0.0.1:8788/`,
- generates live follow dashboard at `http://127.0.0.1:8788/`,
- dashboard includes live polling panels for `/conversations.jsonl` and `/gateway.log` (1s polling),
- preserves direct raw links: `/conversations.jsonl` and `/gateway.log`,
- stores pair token in `artifacts/zeroclaw/pair_token.txt`.
- `tools/ai/zeroclaw_stack_down.sh`
- stops local gateway/follow processes.
- stops local gateway/follow processes,
- confirms logs remain in `artifacts/zeroclaw/`.
- `tools/ai/zeroclaw_webhook_send.sh`
- sends one webhook message and appends raw request/response trace to `artifacts/zeroclaw/conversations.jsonl`.
- requires `--allow-model-call` before any real webhook send,
- supports `--repo-hint <hint>` metadata tagging,
- appends enriched JSONL traces to `artifacts/zeroclaw/conversations.jsonl`.
### 4.3 Provider/cost strategy
@@ -120,3 +125,31 @@ Behavior:
https://docs.platformio.org/en/latest/plus/remote/unit-testing.html
- PySerial port metadata usage (`serial.tools.list_ports`) for stable device targeting:
https://pyserial.readthedocs.io/en/latest/tools.html
## 8) Live Follow Contract
Follow URL:
- `http://127.0.0.1:8788/` (dashboard)
Raw URLs:
- `http://127.0.0.1:8788/conversations.jsonl`
- `http://127.0.0.1:8788/gateway.log`
Conversation JSONL line schema (append-only):
- `ts` (ISO UTC)
- `repo_hint` (`rtc`, `zacus`, or custom hint; default `unknown`)
- `message`
- `http_status`
- `ok` (boolean)
- `response_raw`
Compatibility rule:
- viewer tolerates legacy lines that only contain `ts`, `message`, `response_raw`.
Credit-protection rule:
- without `--allow-model-call`, `zeroclaw_webhook_send.sh` exits non-zero and does not send/write logs.
+1 -1
View File
@@ -22,4 +22,4 @@ stop_pid_file() {
stop_pid_file "$GW_PID_FILE"
stop_pid_file "$FW_PID_FILE"
echo "ZeroClaw local stack stopped."
echo "ZeroClaw local stack stopped. Logs preserved in artifacts/zeroclaw/."
+209 -11
View File
@@ -18,6 +18,7 @@ INDEX_FILE="$ART_DIR/index.html"
mkdir -p "$ART_DIR"
touch "$CONVO_FILE"
touch "$GW_LOG"
if [[ ! -x "$ZEROCLAW_BIN" ]]; then
if command -v zeroclaw >/dev/null 2>&1; then
@@ -79,19 +80,216 @@ cat >"$INDEX_FILE" <<EOF
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="5" />
<title>ZeroClaw Local Follow</title>
<style>body{font-family:ui-sans-serif,system-ui;margin:20px}code{background:#f4f4f4;padding:2px 4px}</style>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ZeroClaw Live Follow</title>
<style>
:root {
--bg: #f7f8fa;
--panel: #ffffff;
--ink: #161b22;
--muted: #5b6270;
--line: #d7dbe2;
--accent: #1a4f8b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "SF Pro Text", "Segoe UI", Arial, sans-serif;
background: radial-gradient(circle at top, #ffffff 0%, #f2f4f8 48%, #eceff5 100%);
color: var(--ink);
}
main {
max-width: 1280px;
margin: 0 auto;
padding: 20px 14px 28px;
}
h1 {
margin: 0 0 10px;
font-size: 24px;
letter-spacing: 0.2px;
}
.meta {
color: var(--muted);
font-size: 13px;
margin-bottom: 12px;
}
.links {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.links a {
color: var(--accent);
text-decoration: none;
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 5px 10px;
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
min-height: 420px;
box-shadow: 0 2px 10px rgba(17, 26, 41, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel h2 {
margin: 0;
padding: 12px 14px 10px;
border-bottom: 1px solid var(--line);
font-size: 16px;
}
.panel pre {
margin: 0;
padding: 12px 14px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.4;
flex: 1;
}
.status {
margin-top: 10px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
.panel { min-height: 320px; }
}
</style>
</head>
<body>
<h1>ZeroClaw Local Follow</h1>
<ul>
<li>Health: <a href="http://$GATEWAY_HOST:$GATEWAY_PORT/health">http://$GATEWAY_HOST:$GATEWAY_PORT/health</a></li>
<li>Metrics: <a href="http://$GATEWAY_HOST:$GATEWAY_PORT/metrics">http://$GATEWAY_HOST:$GATEWAY_PORT/metrics</a></li>
<li>Gateway log: <a href="/gateway.log">/gateway.log</a></li>
<li>Conversation log: <a href="/conversations.jsonl">/conversations.jsonl</a></li>
</ul>
<p>Follow URL: <code>http://127.0.0.1:$FOLLOW_PORT/</code></p>
<main>
<h1>ZeroClaw Live Follow</h1>
<div class="meta">
Follow URL: <code>http://127.0.0.1:$FOLLOW_PORT/</code> |
Polling interval: <code>1s</code> |
Display cap: <code>500 lines/panel</code>
</div>
<div class="links">
<a href="/conversations.jsonl">/conversations.jsonl</a>
<a href="/gateway.log">/gateway.log</a>
<a href="http://$GATEWAY_HOST:$GATEWAY_PORT/health">/health</a>
<a href="http://$GATEWAY_HOST:$GATEWAY_PORT/metrics">/metrics</a>
</div>
<div class="grid">
<section class="panel">
<h2>Conversations (JSONL live)</h2>
<pre id="conversations">Waiting for data...</pre>
</section>
<section class="panel">
<h2>Gateway log (live)</h2>
<pre id="gateway">Waiting for data...</pre>
</section>
</div>
<div class="status" id="status">Polling started...</div>
</main>
<script>
const MAX_LINES = 500;
const POLL_MS = 1000;
let convoCount = 0;
let gatewayCount = 0;
let convoBuf = [];
let gatewayBuf = [];
let lastOk = null;
function trimTail(lines) {
if (lines.length <= MAX_LINES) return lines;
return lines.slice(lines.length - MAX_LINES);
}
function safeText(input) {
if (input === null || input === undefined) return "";
return String(input);
}
function renderConvo(line) {
try {
const obj = JSON.parse(line);
const ts = safeText(obj.ts || "");
const repo = safeText(obj.repo_hint || "unknown");
const msg = safeText(obj.message || "");
const status = obj.http_status === undefined ? "-" : safeText(obj.http_status);
const ok = obj.ok === undefined ? "-" : safeText(obj.ok);
const raw = safeText(obj.response_raw || "");
return "[" + ts + "] repo=" + repo + " status=" + status + " ok=" + ok + "\n> " + msg + "\n< " + raw;
} catch (error) {
return "[raw] " + line;
}
}
function splitLines(text) {
if (!text) return [];
const lines = text.replace(/\r/g, "").split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
async function pollFile(url, kind) {
const response = await fetch(url + "?t=" + Date.now(), { cache: "no-store" });
if (!response.ok) {
throw new Error(kind + " HTTP " + response.status);
}
const content = await response.text();
const lines = splitLines(content);
if (kind === "conversations") {
if (lines.length < convoCount) {
convoCount = 0;
convoBuf = [];
}
const delta = lines.slice(convoCount).map(renderConvo);
convoCount = lines.length;
convoBuf = trimTail(convoBuf.concat(delta));
document.getElementById("conversations").textContent = convoBuf.length ? convoBuf.join("\n\n") : "(empty)";
} else {
if (lines.length < gatewayCount) {
gatewayCount = 0;
gatewayBuf = [];
}
const delta = lines.slice(gatewayCount);
gatewayCount = lines.length;
gatewayBuf = trimTail(gatewayBuf.concat(delta));
document.getElementById("gateway").textContent = gatewayBuf.length ? gatewayBuf.join("\n") : "(empty)";
}
}
function setStatus(message, isError) {
const now = new Date().toISOString();
lastOk = !isError;
const marker = isError ? "error" : "ok";
document.getElementById("status").textContent = "[" + marker + "] " + message + " | updated " + now;
}
async function tick() {
try {
await Promise.all([
pollFile("/conversations.jsonl", "conversations"),
pollFile("/gateway.log", "gateway"),
]);
setStatus("polling every " + POLL_MS + "ms", false);
} catch (error) {
setStatus(error.message, true);
}
}
tick();
setInterval(tick, POLL_MS);
</script>
</body>
</html>
EOF
+89 -7
View File
@@ -7,12 +7,68 @@ PORT="${ZEROCLAW_GATEWAY_PORT:-3000}"
TOKEN_FILE="$ART_DIR/pair_token.txt"
CONVO_FILE="$ART_DIR/conversations.jsonl"
if [[ $# -lt 1 ]]; then
echo "Usage: $(basename "$0") \"message\"" >&2
usage() {
cat >&2 <<USAGE
Usage:
$(basename "$0") [--repo-hint <hint>] [--allow-model-call] "message"
Options:
--repo-hint <hint> conversation source hint (e.g. rtc, zacus)
--allow-model-call required to actually send webhook requests
-h, --help show this help
USAGE
}
REPO_HINT="unknown"
ALLOW_MODEL_CALL=0
MESSAGE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--repo-hint)
if [[ $# -lt 2 ]]; then
echo "[fail] --repo-hint requires a value." >&2
usage
exit 1
fi
REPO_HINT="$2"
shift 2
;;
--allow-model-call)
ALLOW_MODEL_CALL=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
MESSAGE="$*"
break
;;
-*)
echo "[fail] unknown option: $1" >&2
usage
exit 1
;;
*)
MESSAGE="$*"
break
;;
esac
done
if [[ -z "$MESSAGE" ]]; then
usage
exit 1
fi
MESSAGE="$1"
if [[ "$ALLOW_MODEL_CALL" -ne 1 ]]; then
echo "[guard] dry guard / credit protection: add --allow-model-call to send." >&2
exit 10
fi
TOKEN="${ZEROCLAW_BEARER:-}"
if [[ -z "$TOKEN" && -f "$TOKEN_FILE" ]]; then
TOKEN="$(cat "$TOKEN_FILE")"
@@ -22,16 +78,42 @@ if [[ -z "$TOKEN" ]]; then
exit 2
fi
mkdir -p "$ART_DIR"
touch "$CONVO_FILE"
PAYLOAD="$(python3 -c 'import json,sys;print(json.dumps({"message":sys.argv[1]}))' "$MESSAGE")"
RESPONSE="$(curl -sS -X POST "http://$HOST:$PORT/webhook" \
TMP_BODY="$(mktemp)"
HTTP_STATUS="$(curl -sS -o "$TMP_BODY" -w "%{http_code}" -X POST "http://$HOST:$PORT/webhook" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")"
-d "$PAYLOAD" || true)"
RESPONSE="$(cat "$TMP_BODY" 2>/dev/null || true)"
rm -f "$TMP_BODY"
if [[ "$HTTP_STATUS" =~ ^2[0-9][0-9]$ ]]; then
OK_BOOL="true"
else
OK_BOOL="false"
fi
TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
LINE="$(python3 -c 'import json,sys
ts,msg,resp=sys.argv[1],sys.argv[2],sys.argv[3]
print(json.dumps({"ts":ts,"message":msg,"response_raw":resp},ensure_ascii=False))' "$TS" "$MESSAGE" "$RESPONSE")"
ts,repo,msg,http_status,ok_bool,resp=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4],sys.argv[5],sys.argv[6]
status = int(http_status) if http_status.isdigit() else http_status
ok = ok_bool.lower() == "true"
print(json.dumps({
"ts": ts,
"repo_hint": repo,
"message": msg,
"http_status": status,
"ok": ok,
"response_raw": resp
}, ensure_ascii=False))' "$TS" "$REPO_HINT" "$MESSAGE" "$HTTP_STATUS" "$OK_BOOL" "$RESPONSE")"
printf '%s\n' "$LINE" >>"$CONVO_FILE"
printf '%s\n' "$RESPONSE"
if [[ "$OK_BOOL" != "true" ]]; then
echo "[warn] webhook failed with HTTP status: $HTTP_STATUS" >&2
exit 3
fi