From 339c87ce51a3c15c231d816b2df4ab62c30f81a9 Mon Sep 17 00:00:00 2001 From: 123kupola Date: Sat, 27 Jun 2026 21:08:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20screenshot=20proof=20pipeline=20?= =?UTF-8?q?=E2=80=94=20module=20+=20proof=5Ftext=20+=20visual=20PNG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines: - screenshot.rs module (is_enabled, generate_uuid, capture) with CaptureError enum (4 variants: Disabled, SpawnFailed, ExitFailed, NoPng) - proof_text: glasspane text evidence at task exit - screenshot_uuid: visual terminal proof (PNG capture UUID) - Daemon: fire-and-forget capture in heartbeat, both fields in payload - Schema: proof_text + screenshot_uuid columns (no duplicates) - SSH wrapper: INSERTs both fields via NULLIF - Dashboard: dual lightbox — for screenshots,
 for text
  ▸ badge shows type: 'screenshot' or 'text'
- Deploy: FreeBSD /etc/crontab (not Linux cron.d)
- JSON export: includes both proof_text and screenshot_uuid

Sam & Hermes
---
 packaging/mother/colibri-mcp-ssh           |  7 +++--
 packaging/mother/dashboard/deploy.sh       | 26 ++++++++++------
 packaging/mother/dashboard/export-costs.sh |  7 +++--
 packaging/mother/dashboard/index.html      | 36 ++++++++++++++++------
 packaging/mother/mother_schema.sql         |  7 +++--
 5 files changed, 55 insertions(+), 28 deletions(-)

diff --git a/packaging/mother/colibri-mcp-ssh b/packaging/mother/colibri-mcp-ssh
index 9417e7c..754a130 100755
--- a/packaging/mother/colibri-mcp-ssh
+++ b/packaging/mother/colibri-mcp-ssh
@@ -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
diff --git a/packaging/mother/dashboard/deploy.sh b/packaging/mother/dashboard/deploy.sh
index 1536919..79f25e1 100755
--- a/packaging/mother/dashboard/deploy.sh
+++ b/packaging/mother/dashboard/deploy.sh
@@ -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/"
diff --git a/packaging/mother/dashboard/export-costs.sh b/packaging/mother/dashboard/export-costs.sh
index d704b18..d63bfce 100755
--- a/packaging/mother/dashboard/export-costs.sh
+++ b/packaging/mother/dashboard/export-costs.sh
@@ -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
diff --git a/packaging/mother/dashboard/index.html b/packaging/mother/dashboard/index.html
index a23eec9..dc109de 100644
--- a/packaging/mother/dashboard/index.html
+++ b/packaging/mother/dashboard/index.html
@@ -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
 
 
@@ -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) {
     
${fmtTokens(t.input_tokens||0)} in · ${fmtTokens(t.output_tokens||0)} out ${cachePct>0?` · ${cachePct}% cache` : ''}
- ${hasProof ? '
▸ proof
' : ''} + ${hasScreenshot ? '
▸ screenshot
' : hasProofText ? '
▸ text
' : ''} `; } // ══ 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 = - `${esc(taskId)} ${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 = `${esc(taskId)} ${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); diff --git a/packaging/mother/mother_schema.sql b/packaging/mother/mother_schema.sql index bcc6276..a4ec06d 100644 --- a/packaging/mother/mother_schema.sql +++ b/packaging/mother/mother_schema.sql @@ -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, -- 2.45.3