feat: screenshot proof pipeline — module + proof_text + visual PNG #239

Merged
clawdie merged 1 commit from merge/screenshot-proof-pipeline into main 2026-06-27 21:10:45 +02:00
5 changed files with 55 additions and 28 deletions

View file

@ -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

View file

@ -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/"

View file

@ -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

View file

@ -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);

View file

@ -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,