add live zeroclaw follow dashboard and webhook cost guard
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user