From 271ee25d99e680cce53614ae1dc3f6922c21e779 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Thu, 16 Apr 2026 21:05:30 -0400 Subject: [PATCH] feat: on-demand auto-triage from web UI Adds an "Auto-process queue" button to /admin/triage that lets the user kick off a full LLM triage pass without SSH. Bridges the gap between web UI (in container) and claude CLI (host-only). Architecture: - UI button POSTs to /admin/triage/request-drain - Endpoint writes atocore/config/auto_triage_requested_at flag - Host-side watcher cron (every 2 min) checks for the flag - When found: clears flag, acquires lock, runs auto_triage.py, records progress via atocore/status/* entries - UI polls /admin/triage/drain-status every 10s to show progress, auto-reloads when done Safety: - Lock file prevents concurrent runs on host - Flag cleared before run so duplicate clicks queue at most one re-run - Fail-open: watcher errors just log, don't break anything - Status endpoint stays read-only Installation on host (one-time): */2 * * * * /srv/storage/atocore/app/deploy/dalidou/auto-triage-watcher.sh \ >> /home/papa/atocore-logs/auto-triage-watcher.log 2>&1 Co-Authored-By: Claude Opus 4.6 (1M context) --- deploy/dalidou/auto-triage-watcher.sh | 108 ++++++++++++++++++++++++++ src/atocore/api/routes.py | 51 ++++++++++++ src/atocore/engineering/triage_ui.py | 62 ++++++++++++++- 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 deploy/dalidou/auto-triage-watcher.sh diff --git a/deploy/dalidou/auto-triage-watcher.sh b/deploy/dalidou/auto-triage-watcher.sh new file mode 100644 index 0000000..b2ca146 --- /dev/null +++ b/deploy/dalidou/auto-triage-watcher.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# deploy/dalidou/auto-triage-watcher.sh +# -------------------------------------- +# Host-side watcher for on-demand auto-triage requests from the web UI. +# +# The web UI at /admin/triage has an "Auto-process queue" button that +# POSTs to /admin/triage/request-drain, which writes a timestamp to +# AtoCore project state (atocore/config/auto_triage_requested_at). +# +# This script runs on the Dalidou HOST (where the claude CLI is +# available), polls for the flag, and runs auto_triage.py when seen. +# +# Installed via cron to run every 2 minutes: +# */2 * * * * /srv/storage/atocore/app/deploy/dalidou/auto-triage-watcher.sh +# +# Safety: +# - Lock file prevents concurrent runs +# - Flag is cleared after processing so one request = one run +# - If auto_triage hangs, the lock prevents pileup until manual cleanup + +set -euo pipefail + +ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}" +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +LOCK_FILE="/tmp/atocore-auto-triage.lock" +LOG_DIR="/home/papa/atocore-logs" +mkdir -p "$LOG_DIR" + +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +log() { printf '[%s] %s\n' "$TS" "$*"; } + +# Fetch the request flag via API (read-only, no lock needed) +STATE_JSON=$(curl -sSf --max-time 5 "$ATOCORE_URL/project/state/atocore" 2>/dev/null || echo "{}") +REQUESTED=$(echo "$STATE_JSON" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + for e in d.get('entries', d.get('state', [])): + if e.get('category') == 'config' and e.get('key') == 'auto_triage_requested_at': + print(e.get('value', '')) + break +except Exception: + pass +" 2>/dev/null || echo "") + +if [[ -z "$REQUESTED" ]]; then + # No request — silent exit + exit 0 +fi + +# Acquire lock (non-blocking) +exec 9>"$LOCK_FILE" || exit 0 +if ! flock -n 9; then + log "auto-triage already running, skipping" + exit 0 +fi + +# Record we're starting +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_running\",\"value\":\"1\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_last_started_at\",\"value\":\"$TS\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true + +LOG_FILE="$LOG_DIR/auto-triage-ondemand-$(date -u +%Y%m%d-%H%M%S).log" +log "Starting auto-triage (request: $REQUESTED, log: $LOG_FILE)" + +# Clear the request flag FIRST so duplicate clicks queue at most one re-run +# (the next watcher tick would then see a fresh request, not this one) +curl -sSf -X DELETE "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"config\",\"key\":\"auto_triage_requested_at\"}" \ + >/dev/null 2>&1 || true + +# Run the drain +cd "$APP_DIR" +export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}" +if python3 scripts/auto_triage.py --base-url "$ATOCORE_URL" >> "$LOG_FILE" 2>&1; then + RESULT_LINE=$(tail -5 "$LOG_FILE" | grep "total:" | tail -1 || tail -1 "$LOG_FILE") + RESULT="${RESULT_LINE:-completed}" + log "auto-triage finished: $RESULT" +else + RESULT="ERROR — see $LOG_FILE" + log "auto-triage FAILED — see $LOG_FILE" +fi + +FINISH_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# Mark done +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_running\",\"value\":\"0\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_last_finished_at\",\"value\":\"$FINISH_TS\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true + +# Escape quotes in result for JSON +SAFE_RESULT=$(printf '%s' "$RESULT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])") +curl -sSf -X POST "$ATOCORE_URL/project/state" \ + -H 'Content-Type: application/json' \ + -d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_last_result\",\"value\":\"$SAFE_RESULT\",\"source\":\"host watcher\"}" \ + >/dev/null 2>&1 || true diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 6b3b21b..ef834d3 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -130,6 +130,57 @@ def admin_triage(limit: int = 100) -> HTMLResponse: return HTMLResponse(content=render_triage_page(limit=limit)) +@router.post("/admin/triage/request-drain") +def admin_triage_request_drain() -> dict: + """Request a host-side auto-triage run. + + Writes a flag in project state. A host cron watcher picks it up + within ~2min and runs auto_triage.py, then clears the flag. + This is the bridge between "user clicked button in web UI" and + "claude CLI (on host, not in container) runs". + """ + from datetime import datetime as _dt, timezone as _tz + from atocore.context.project_state import set_state + + now = _dt.now(_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + set_state( + project_name="atocore", + category="config", + key="auto_triage_requested_at", + value=now, + source="admin ui", + ) + return {"requested_at": now, "note": "Host watcher will pick this up within 2 minutes."} + + +@router.get("/admin/triage/drain-status") +def admin_triage_drain_status() -> dict: + """Current state of the auto-triage pipeline (for UI polling).""" + from atocore.context.project_state import get_state + out = { + "requested_at": None, + "last_started_at": None, + "last_finished_at": None, + "last_result": None, + "is_running": False, + } + try: + for e in get_state("atocore"): + if e.category == "config" and e.key == "auto_triage_requested_at": + out["requested_at"] = e.value + elif e.category == "status" and e.key == "auto_triage_last_started_at": + out["last_started_at"] = e.value + elif e.category == "status" and e.key == "auto_triage_last_finished_at": + out["last_finished_at"] = e.value + elif e.category == "status" and e.key == "auto_triage_last_result": + out["last_result"] = e.value + elif e.category == "status" and e.key == "auto_triage_running": + out["is_running"] = (e.value == "1") + except Exception: + pass + return out + + # --- Request/Response models --- diff --git a/src/atocore/engineering/triage_ui.py b/src/atocore/engineering/triage_ui.py index 36fc21c..132d42e 100644 --- a/src/atocore/engineering/triage_ui.py +++ b/src/atocore/engineering/triage_ui.py @@ -74,10 +74,54 @@ async function apiCall(url, method, body) { opts.body = JSON.stringify(body); } const res = await fetch(url, opts); - return { ok: res.ok, status: res.status }; + return { ok: res.ok, status: res.status, json: res.ok ? await res.json().catch(()=>null) : null }; } catch (e) { return { ok: false, status: 0, error: String(e) }; } } +async function requestAutoTriage() { + const btn = document.getElementById('auto-triage-btn'); + const status = document.getElementById('auto-triage-status'); + if (!btn) return; + btn.disabled = true; + btn.textContent = '⏳ Requesting...'; + const r = await apiCall('/admin/triage/request-drain', 'POST'); + if (r.ok) { + status.textContent = '✓ Requested. Host watcher runs every 2 min. Refresh this page in a minute to check progress.'; + status.className = 'auto-triage-msg ok'; + btn.textContent = '✓ Requested'; + pollDrainStatus(); + } else { + status.textContent = '❌ Request failed: ' + r.status; + status.className = 'auto-triage-msg err'; + btn.disabled = false; + btn.textContent = '🤖 Auto-process queue'; + } +} + +async function pollDrainStatus() { + const status = document.getElementById('auto-triage-status'); + const btn = document.getElementById('auto-triage-btn'); + let polls = 0; + const timer = setInterval(async () => { + polls++; + const r = await apiCall('/admin/triage/drain-status', 'GET'); + if (!r.ok || !r.json) return; + const s = r.json; + if (s.is_running) { + status.textContent = '⚙️ Auto-triage running on host... (started ' + (s.last_started_at || '?') + ')'; + status.className = 'auto-triage-msg ok'; + } else if (s.last_finished_at && !s.requested_at) { + status.textContent = '✅ Last run finished: ' + s.last_finished_at + ' → ' + (s.last_result || 'complete'); + status.className = 'auto-triage-msg ok'; + if (btn) { btn.disabled = false; btn.textContent = '🤖 Auto-process queue'; } + clearInterval(timer); + // Reload page to pick up new queue state + setTimeout(() => window.location.reload(), 3000); + } + if (polls > 60) { clearInterval(timer); } // stop after ~10 min of polling + }, 10000); // poll every 10s +} + function setStatus(id, msg, ok) { const el = document.getElementById('status-' + id); if (!el) return; @@ -185,6 +229,13 @@ _TRIAGE_CSS = """ .cand-status.ok { color:#059669; } .cand-status.err { color:#dc2626; } .empty { text-align:center; padding:3rem; opacity:0.6; } + .auto-triage-bar { display:flex; gap:0.8rem; align-items:center; background:var(--card); border:1px solid var(--border); border-radius:6px; padding:0.7rem 1rem; margin-bottom:1.2rem; flex-wrap:wrap; } + .auto-triage-bar button { padding:0.55rem 1.1rem; border:1px solid var(--accent); background:var(--accent); color:white; border-radius:4px; cursor:pointer; font-weight:600; font-size:0.95rem; } + .auto-triage-bar button:hover:not(:disabled) { opacity:0.9; } + .auto-triage-bar button:disabled { opacity:0.5; cursor:not-allowed; } + .auto-triage-msg { flex:1; min-width:200px; font-size:0.85rem; opacity:0.75; } + .auto-triage-msg.ok { color:var(--accent); opacity:1; font-weight:500; } + .auto-triage-msg.err { color:#dc2626; opacity:1; font-weight:500; } """ @@ -221,6 +272,15 @@ def render_triage_page(limit: int = 100) -> str: if needed, then promote or reject. Shortcuts: Y promote · N reject · E edit · S scroll to next. +
+ + + Sends the full queue through LLM triage on the host. Promotes durable facts, + rejects noise, leaves only ambiguous items here for you. + +
{cards_html} """ + _TRIAGE_SCRIPT