feat: screenshot proof pipeline — module + proof_text + visual PNG #239
5 changed files with 55 additions and 28 deletions
|
|
@ -30,12 +30,14 @@ case "${SSH_ORIGINAL_COMMAND:-}" in
|
|||
# Input: {"node_hostname":"debby","task_id":"abc","provider":"deepseek",
|
||||
# "model":"deepseek-chat","input_tokens":150,"output_tokens":80,
|
||||
# "cache_read_tokens":200,"cache_write_tokens":50,
|
||||
# "cost_usd":0.0042,"success":true,"proof_text":"a1b2c3d4e5f6",
|
||||
# "cost_usd":0.0042,"success":true,
|
||||
# "proof_text":"agent:hermes|cost:0.0042|tokens:150/80",
|
||||
# "screenshot_uuid":"a1b2c3d4e5f6",
|
||||
# "finished_at":"2026-06-27T12:00:00Z"}
|
||||
psql -d mother_hive -tA -v ON_ERROR_STOP=1 <<'PSQL'
|
||||
INSERT INTO task_costs (node_id, task_id, provider, model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
cost_usd, success, proof_text, finished_at)
|
||||
cost_usd, success, proof_text, screenshot_uuid, finished_at)
|
||||
SELECT
|
||||
(SELECT id FROM hive_nodes WHERE hostname = j->>'node_hostname'),
|
||||
j->>'task_id',
|
||||
|
|
@ -48,6 +50,7 @@ SELECT
|
|||
COALESCE((j->>'cost_usd')::DOUBLE PRECISION, 0.0),
|
||||
COALESCE((j->>'success')::BOOLEAN, false),
|
||||
NULLIF(j->>'proof_text', ''),
|
||||
NULLIF(j->>'screenshot_uuid', ''),
|
||||
COALESCE((j->>'finished_at')::TIMESTAMPTZ, now())
|
||||
FROM (SELECT (pg_read_file('/dev/stdin')::JSONB) AS j) AS _;
|
||||
PSQL
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
# Places:
|
||||
# /usr/local/www/clawdie/dashboard/index.html — dashboard page
|
||||
# /usr/local/www/clawdie/dashboard/export-costs.sh — JSON export (cron)
|
||||
# /usr/local/etc/cron.d/clawdie-dashboard — cron job
|
||||
# /etc/crontab entry — cron job (FreeBSD)
|
||||
#
|
||||
# The dashboard reads task_costs.json (exported every 60s by cron) and
|
||||
# links screenshots from ../screenshots/ (tmux-screenshot publish dir).
|
||||
|
|
@ -22,7 +22,7 @@ set -eu
|
|||
|
||||
SRC="$(dirname "$0")"
|
||||
WEBROOT="/usr/local/www/clawdie/dashboard"
|
||||
CRON_FILE="/usr/local/etc/cron.d/clawdie-dashboard"
|
||||
CRON_MARKER="# clawdie-dashboard — auto-managed by deploy.sh"
|
||||
|
||||
echo "=== deploy cost dashboard ==="
|
||||
|
||||
|
|
@ -32,18 +32,24 @@ cp "$SRC/index.html" "$WEBROOT/index.html"
|
|||
cp "$SRC/export-costs.sh" "$WEBROOT/export-costs.sh"
|
||||
chmod +x "$WEBROOT/export-costs.sh"
|
||||
|
||||
# Idempotent cron entry: export every 60s
|
||||
cat > "$CRON_FILE" <<'CRON'
|
||||
# clawdie cost dashboard — export task_costs to JSON every 60s
|
||||
* * * * * root /usr/local/www/clawdie/dashboard/export-costs.sh
|
||||
CRON
|
||||
# FreeBSD cron: add to /etc/crontab (idempotent via marker line).
|
||||
CRON_ENTRY="* * * * * root /usr/local/www/clawdie/dashboard/export-costs.sh ${CRON_MARKER}"
|
||||
if ! grep -qF "${CRON_MARKER}" /etc/crontab 2>/dev/null; then
|
||||
echo "${CRON_ENTRY}" >> /etc/crontab
|
||||
echo " cron → /etc/crontab (added)"
|
||||
else
|
||||
echo " cron → /etc/crontab (already present)"
|
||||
fi
|
||||
|
||||
echo " dashboard → $WEBROOT/index.html"
|
||||
echo " cron → $CRON_FILE"
|
||||
echo ""
|
||||
|
||||
# Run once immediately to seed the data
|
||||
echo "=== initial export ==="
|
||||
"$WEBROOT/export-costs.sh" || echo " (no data yet — tasks will appear as agents complete)"
|
||||
if "$WEBROOT/export-costs.sh"; then
|
||||
echo ""
|
||||
else
|
||||
echo " (no data yet — tasks will appear as agents complete)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Done. Dashboard at: https://mother.clawdie.si/dashboard/"
|
||||
echo "Done. Dashboard at: https://osa.smilepowered.org/dashboard/"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ set -eu
|
|||
OUTDIR="/usr/local/www/clawdie/dashboard"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
psql -d mother_hive -tA <<'SQL' > "${OUTDIR}/task_costs.json"
|
||||
sudo -u postgres psql -d mother_hive -tA <<'SQL' > "${OUTDIR}/task_costs.json"
|
||||
SELECT json_build_object(
|
||||
'updated_at', now(),
|
||||
'summary', json_build_object(
|
||||
|
|
@ -63,14 +63,15 @@ SELECT json_build_object(
|
|||
tc.cost_usd,
|
||||
tc.success,
|
||||
tc.finished_at,
|
||||
tc.proof_text
|
||||
tc.proof_text,
|
||||
tc.screenshot_uuid
|
||||
FROM task_costs tc
|
||||
LEFT JOIN hive_nodes hn ON hn.id = tc.node_id
|
||||
ORDER BY tc.finished_at DESC
|
||||
LIMIT 200
|
||||
) AS t
|
||||
)
|
||||
) AS result;
|
||||
) AS result FROM task_costs;
|
||||
SQL
|
||||
|
||||
echo "dashboard: $(date -Iseconds) — $(jq '.summary.total_tasks' "${OUTDIR}/task_costs.json") tasks" >&2
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ h1 .dot{display:inline-block; width:8px; height:8px; border-radius:50%; margin-r
|
|||
.lightbox{display:none; position:fixed; inset:0; background:rgba(0,0,0,.94); z-index:1000;
|
||||
align-items:center; justify-content:center; padding:1.5rem}
|
||||
.lightbox.open{display:flex}
|
||||
.lightbox img{max-width:94vw; max-height:88vh; border-radius:4px; box-shadow:0 0 40px rgba(0,180,216,.15)}
|
||||
.lightbox pre{max-width:94vw; max-height:88vh; border-radius:4px; box-shadow:0 0 40px rgba(0,180,216,.15)}
|
||||
.lightbox-close{position:fixed; top:1rem; right:1.5rem; background:var(--surface); color:var(--fg2);
|
||||
border:1px solid var(--border); border-radius:4px; padding:.4rem 1rem; font-family:inherit;
|
||||
|
|
@ -189,6 +190,7 @@ h1 .dot{display:inline-block; width:8px; height:8px; border-radius:50%; margin-r
|
|||
<div class="lightbox" id="lightbox">
|
||||
<button class="lightbox-close">Esc to close</button>
|
||||
<div class="lightbox-meta" id="lightbox-meta"></div>
|
||||
<img id="lb-img" src="" alt="screenshot proof" style="display:none">
|
||||
<pre id="lb-text"></pre>
|
||||
</div>
|
||||
|
||||
|
|
@ -304,7 +306,9 @@ function renderCard(t) {
|
|||
const total = (t.input_tokens||0) + (t.cache_read_tokens||0);
|
||||
const cachePct = total > 0 ? Math.round((t.cache_read_tokens||0) / total * 100) : 0;
|
||||
const freshPct = 100 - cachePct;
|
||||
const hasProof = !!t.proof_text;
|
||||
const hasProofText = !!t.proof_text;
|
||||
const hasScreenshot = !!t.screenshot_uuid;
|
||||
const hasProof = hasProofText || hasScreenshot;
|
||||
const cls = hasProof ? 'card has-proof' : 'card';
|
||||
const onClick = hasProof
|
||||
? `onclick="openProof(this,'${esc(t.task_id||'')}')"`
|
||||
|
|
@ -323,21 +327,33 @@ function renderCard(t) {
|
|||
<div class="card-tokens">${fmtTokens(t.input_tokens||0)} in · ${fmtTokens(t.output_tokens||0)} out
|
||||
${cachePct>0?` · ${cachePct}% cache` : ''}
|
||||
</div>
|
||||
${hasProof ? '<div class="card-proof-badge">▸ proof</div>' : ''}
|
||||
${hasScreenshot ? '<div class="card-proof-badge">▸ screenshot</div>' : hasProofText ? '<div class="card-proof-badge">▸ text</div>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ══ lightbox ════════════════════════════════════════════════════════════
|
||||
function openProof(_el, taskId) {
|
||||
const t = (DATA.tasks || []).find(t => t.task_id === taskId);
|
||||
const raw = t ? (t.proof_text || '{}') : '{}';
|
||||
let display;
|
||||
try { display = JSON.stringify(JSON.parse(raw), null, 2); }
|
||||
catch(_) { display = raw; }
|
||||
const lb = document.getElementById('lightbox');
|
||||
document.getElementById('lb-text').textContent = display;
|
||||
document.getElementById('lightbox-meta').innerHTML =
|
||||
`<strong>${esc(taskId)}</strong> ${esc(t?.provider||'')} · $${(t?.cost_usd||0).toFixed(4)}`;
|
||||
const meta = document.getElementById('lightbox-meta');
|
||||
const img = document.getElementById('lb-img');
|
||||
const pre = document.getElementById('lb-text');
|
||||
meta.innerHTML = `<strong>${esc(taskId)}</strong> ${esc(t?.provider||'')} · $${(t?.cost_usd||0).toFixed(4)}`;
|
||||
|
||||
// Hide both, then show the appropriate one.
|
||||
img.style.display = 'none';
|
||||
pre.style.display = 'none';
|
||||
|
||||
if (t?.screenshot_uuid) {
|
||||
img.src = `../screenshots/${t.screenshot_uuid}.png`;
|
||||
img.style.display = 'block';
|
||||
} else if (t?.proof_text) {
|
||||
let display;
|
||||
try { display = JSON.stringify(JSON.parse(t.proof_text), null, 2); }
|
||||
catch(_) { display = t.proof_text; }
|
||||
pre.textContent = display;
|
||||
pre.style.display = 'block';
|
||||
}
|
||||
lb.classList.add('open');
|
||||
}
|
||||
function closeLb() { document.getElementById('lightbox').classList.remove('open'); }
|
||||
|
|
@ -357,7 +373,7 @@ function showJSON() {
|
|||
byNode[n].push({task_id:t.task_id,provider:t.provider,model:t.model,
|
||||
cost:t.cost_usd,success:t.success,finished_at:t.finished_at,
|
||||
tokens:{in:t.input_tokens,out:t.output_tokens,cache_read:t.cache_read_tokens},
|
||||
proof_text:t.proof_text||null});
|
||||
proof_text:t.proof_text||null,screenshot_uuid:t.screenshot_uuid||null});
|
||||
}
|
||||
const output = {updated_at:DATA.updated_at, summary:DATA.summary, nodes:byNode};
|
||||
document.getElementById('json-output').textContent = JSON.stringify(output, null, 2);
|
||||
|
|
|
|||
|
|
@ -65,15 +65,16 @@ CREATE TABLE IF NOT EXISTS task_costs (
|
|||
cache_write_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
success BOOLEAN NOT NULL DEFAULT false,
|
||||
proof_text TEXT, -- tmux-screenshot content hash (12-char UUID)
|
||||
proof_text TEXT, -- glasspane evidence at task exit (agent, state, tokens)
|
||||
screenshot_uuid TEXT, -- visual terminal proof (PNG capture UUID)
|
||||
finished_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
proof_text TEXT -- optional; links to tmux-screenshot capture at task completion
|
||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_costs_node ON task_costs (node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_costs_finished ON task_costs (finished_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_costs_provider ON task_costs (provider, model);
|
||||
ALTER TABLE task_costs ADD COLUMN IF NOT EXISTS proof_text TEXT;
|
||||
ALTER TABLE task_costs ADD COLUMN IF NOT EXISTS screenshot_uuid TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS build_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue